fix: 优化公开聊天页登录态恢复与鉴权引导
- 支持复用现有登录态并恢复 refresh token - 未认证访问时补充跳转登录提示与引导文案
This commit is contained in:
@@ -50,6 +50,8 @@
|
|||||||
"embedUsageTip2": "Embed only in trusted sites and combine with rate limiting.",
|
"embedUsageTip2": "Embed only in trusted sites and combine with rate limiting.",
|
||||||
"publicPageBlocked": "Anonymous access is disabled for this bot",
|
"publicPageBlocked": "Anonymous access is disabled for this bot",
|
||||||
"publicPageBlockedTip": "Enable anonymous access in publish settings before using external links.",
|
"publicPageBlockedTip": "Enable anonymous access in publish settings before using external links.",
|
||||||
|
"publicPageLoginRequired": "Authentication required",
|
||||||
|
"publicPageLoginRequiredTip": "This chat page requires a valid login session or access token before it can be used.",
|
||||||
"publicChatTitle": "Public Chat Assistant",
|
"publicChatTitle": "Public Chat Assistant",
|
||||||
"publicChatSubtitle": "Powered by EasyFlow",
|
"publicChatSubtitle": "Powered by EasyFlow",
|
||||||
"publicChatPlaceholder": "Type your question and press Enter to send",
|
"publicChatPlaceholder": "Type your question and press Enter to send",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
|
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
|
||||||
"publicPageBlocked": "该助手未开放匿名访问",
|
"publicPageBlocked": "该助手未开放匿名访问",
|
||||||
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
|
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
|
||||||
|
"publicPageLoginRequired": "用户未认证,请登录",
|
||||||
|
"publicPageLoginRequiredTip": "当前聊天页需要有效登录态或访问令牌后才可继续使用。",
|
||||||
"publicChatTitle": "公开聊天助手",
|
"publicChatTitle": "公开聊天助手",
|
||||||
"publicChatSubtitle": "由 EasyFlow 驱动",
|
"publicChatSubtitle": "由 EasyFlow 驱动",
|
||||||
"publicChatPlaceholder": "输入你的问题,按 Enter 发送",
|
"publicChatPlaceholder": "输入你的问题,按 Enter 发送",
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||||
|
import { LOGIN_PATH } from '@easyflow/constants';
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
import { useAccessStore } from '@easyflow/stores';
|
||||||
import { uuid } from '@easyflow/utils';
|
import { uuid } from '@easyflow/utils';
|
||||||
|
|
||||||
import { useTitle } from '@vueuse/core';
|
import { useTitle } from '@vueuse/core';
|
||||||
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
|
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
|
||||||
|
|
||||||
|
import { refreshTokenApi } from '#/api/core';
|
||||||
import { baseRequestClient, sseClient } from '#/api/request';
|
import { baseRequestClient, sseClient } from '#/api/request';
|
||||||
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||||||
|
|
||||||
@@ -106,12 +109,21 @@ interface PublicChatSessionRestoreResult {
|
|||||||
messages?: PublicChatMessageRecord[];
|
messages?: PublicChatMessageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthResolutionStatus = 'authenticated' | 'error' | 'unauthenticated';
|
||||||
|
|
||||||
|
interface AuthResolutionResult {
|
||||||
|
message?: string;
|
||||||
|
nextToken?: string;
|
||||||
|
status: AuthResolutionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const PUBLIC_CHAT_API_KEY_QUERY_KEYS = ['token', 'apikey', 'apiKey'] as const;
|
const PUBLIC_CHAT_API_KEY_QUERY_KEYS = ['token', 'apikey', 'apiKey'] as const;
|
||||||
const PUBLIC_CHAT_CONTEXT_VERSION = 1;
|
const PUBLIC_CHAT_CONTEXT_VERSION = 1;
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pageTitle = useTitle();
|
const pageTitle = useTitle();
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
const botInfo = ref<BotInfo | null>(null);
|
const botInfo = ref<BotInfo | null>(null);
|
||||||
const conversationId = ref<string>('');
|
const conversationId = ref<string>('');
|
||||||
@@ -149,6 +161,28 @@ const setPageTitle = () => {
|
|||||||
pageTitle.value = botTitle || $t('bot.publicChatTitle');
|
pageTitle.value = botTitle || $t('bot.publicChatTitle');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUnauthenticatedMessage = () => $t('bot.publicPageLoginRequired');
|
||||||
|
|
||||||
|
const normalizePublicChatErrorMessage = (message?: string) => {
|
||||||
|
const trimmed = String(message || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return $t('bot.publicChatInitError');
|
||||||
|
}
|
||||||
|
if (trimmed === '请登录' || trimmed === '用户未认证,请先登录') {
|
||||||
|
return getUnauthenticatedMessage();
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToLogin = async () => {
|
||||||
|
await router.push({
|
||||||
|
path: LOGIN_PATH,
|
||||||
|
query: {
|
||||||
|
redirect: encodeURIComponent(route.fullPath),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isAtBottom = () => {
|
const isAtBottom = () => {
|
||||||
if (!messageContainerRef.value) return true;
|
if (!messageContainerRef.value) return true;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
|
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
|
||||||
@@ -438,34 +472,109 @@ const exchangeApiKeyToAccessToken = async (apiKey: string) => {
|
|||||||
return accessToken;
|
return accessToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveAuthenticatedAccess = async (token: string) => {
|
const extractErrorStatus = (error: any) => {
|
||||||
|
const status = Number(error?.response?.status);
|
||||||
|
return Number.isFinite(status) ? status : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: any) => {
|
||||||
|
const responseData = error?.response?.data ?? {};
|
||||||
|
const message = String(
|
||||||
|
responseData?.message || responseData?.error || error?.message || '',
|
||||||
|
).trim();
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnauthorizedError = (error: any) => {
|
||||||
|
const responseData = error?.response?.data ?? {};
|
||||||
|
return (
|
||||||
|
extractErrorStatus(error) === 401 || Number(responseData?.errorCode) === 401
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeAuthenticatedAccess = async (token: string) => {
|
||||||
|
if (!token.trim()) {
|
||||||
|
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const profileResp = await getResponseBody(
|
const profileResp = await getResponseBody(
|
||||||
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
|
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
|
||||||
headers: buildRequestHeaders(token),
|
headers: buildRequestHeaders(token),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return profileResp.errorCode === 0 && !!profileResp.data;
|
if (profileResp.errorCode === 0 && profileResp.data) {
|
||||||
} catch {
|
return {
|
||||||
return false;
|
nextToken: token,
|
||||||
|
status: 'authenticated',
|
||||||
|
} as AuthResolutionResult;
|
||||||
|
}
|
||||||
|
if (profileResp.errorCode === 401) {
|
||||||
|
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: normalizePublicChatErrorMessage(profileResp.message),
|
||||||
|
status: 'error',
|
||||||
|
} as AuthResolutionResult;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: normalizePublicChatErrorMessage(extractErrorMessage(error)),
|
||||||
|
status: 'error',
|
||||||
|
} as AuthResolutionResult;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tryRefreshCurrentAccessToken = async () => {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshTokenApi();
|
||||||
|
const nextToken = String(refreshed?.data || '').trim();
|
||||||
|
if (!nextToken) {
|
||||||
|
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||||||
|
}
|
||||||
|
accessStore.setAccessToken(nextToken);
|
||||||
|
return await probeAuthenticatedAccess(nextToken);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: normalizePublicChatErrorMessage(extractErrorMessage(error)),
|
||||||
|
status: 'error',
|
||||||
|
} as AuthResolutionResult;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAuthenticatedAccess = async (
|
||||||
|
token: string,
|
||||||
|
options?: { allowRefresh?: boolean },
|
||||||
|
) => {
|
||||||
|
const initialResult = await probeAuthenticatedAccess(token);
|
||||||
|
if (initialResult.status !== 'unauthenticated' || !options?.allowRefresh) {
|
||||||
|
return initialResult;
|
||||||
|
}
|
||||||
|
return await tryRefreshCurrentAccessToken();
|
||||||
|
};
|
||||||
|
|
||||||
const ensureRequestAccessToken = async () => {
|
const ensureRequestAccessToken = async () => {
|
||||||
requestAccessToken.value = '';
|
requestAccessToken.value = '';
|
||||||
authenticatedAccess.value = false;
|
authenticatedAccess.value = false;
|
||||||
const urlApiKey = readUrlApiKey();
|
const urlApiKey = readUrlApiKey();
|
||||||
if (urlApiKey) {
|
if (urlApiKey) {
|
||||||
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
|
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
|
||||||
const authenticated = await resolveAuthenticatedAccess(exchangedToken);
|
const authResult = await resolveAuthenticatedAccess(exchangedToken);
|
||||||
if (!authenticated) {
|
if (authResult.status === 'error') {
|
||||||
|
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||||||
|
}
|
||||||
|
if (authResult.status !== 'authenticated') {
|
||||||
throw new Error($t('bot.publicChatTokenInvalid'));
|
throw new Error($t('bot.publicChatTokenInvalid'));
|
||||||
}
|
}
|
||||||
requestAccessToken.value = exchangedToken;
|
requestAccessToken.value = authResult.nextToken || exchangedToken;
|
||||||
authenticatedAccess.value = true;
|
authenticatedAccess.value = true;
|
||||||
upsertPublicChatContext(
|
upsertPublicChatContext(
|
||||||
{
|
{
|
||||||
accessToken: exchangedToken,
|
accessToken: requestAccessToken.value,
|
||||||
authenticatedAccess: true,
|
authenticatedAccess: true,
|
||||||
},
|
},
|
||||||
{ resetConversation: true },
|
{ resetConversation: true },
|
||||||
@@ -476,36 +585,45 @@ const ensureRequestAccessToken = async () => {
|
|||||||
|
|
||||||
const storedContext = readPublicChatContext(botId.value);
|
const storedContext = readPublicChatContext(botId.value);
|
||||||
if (storedContext?.accessToken) {
|
if (storedContext?.accessToken) {
|
||||||
const tokenValid = await resolveAuthenticatedAccess(
|
const authResult = await resolveAuthenticatedAccess(
|
||||||
storedContext.accessToken,
|
storedContext.accessToken,
|
||||||
);
|
);
|
||||||
if (tokenValid) {
|
if (authResult.status === 'error') {
|
||||||
requestAccessToken.value = storedContext.accessToken;
|
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||||||
authenticatedAccess.value = storedContext.authenticatedAccess;
|
}
|
||||||
|
if (authResult.status === 'authenticated') {
|
||||||
|
requestAccessToken.value =
|
||||||
|
authResult.nextToken || storedContext.accessToken;
|
||||||
|
authenticatedAccess.value = true;
|
||||||
upsertPublicChatContext({
|
upsertPublicChatContext({
|
||||||
accessToken: storedContext.accessToken,
|
accessToken: requestAccessToken.value,
|
||||||
authenticatedAccess: storedContext.authenticatedAccess,
|
authenticatedAccess: true,
|
||||||
conversationId: storedContext.conversationId,
|
conversationId: storedContext.conversationId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearPublicChatContext(botId.value);
|
clearPublicChatContext(botId.value);
|
||||||
}
|
}
|
||||||
|
const currentAccessToken = String(accessStore.accessToken || '').trim();
|
||||||
const tokenResp = await getResponseBody<string>(
|
if (currentAccessToken) {
|
||||||
baseRequestClient.get('/api/temp-token/create'),
|
const authResult = await resolveAuthenticatedAccess(currentAccessToken, {
|
||||||
);
|
allowRefresh: true,
|
||||||
if (tokenResp.errorCode !== 0 || !tokenResp.data) {
|
});
|
||||||
throw new Error($t('bot.publicChatInitError'));
|
if (authResult.status === 'error') {
|
||||||
|
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||||||
|
}
|
||||||
|
if (authResult.status === 'authenticated') {
|
||||||
|
requestAccessToken.value = authResult.nextToken || currentAccessToken;
|
||||||
|
authenticatedAccess.value = true;
|
||||||
|
upsertPublicChatContext(
|
||||||
|
{
|
||||||
|
accessToken: requestAccessToken.value,
|
||||||
|
authenticatedAccess: true,
|
||||||
|
},
|
||||||
|
{ resetConversation: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
requestAccessToken.value = String(tokenResp.data);
|
|
||||||
upsertPublicChatContext(
|
|
||||||
{
|
|
||||||
accessToken: requestAccessToken.value,
|
|
||||||
authenticatedAccess: false,
|
|
||||||
},
|
|
||||||
{ resetConversation: true },
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetConversationState = () => {
|
const resetConversationState = () => {
|
||||||
@@ -1116,7 +1234,7 @@ const initPublicChat = async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
initError.value =
|
initError.value =
|
||||||
error instanceof Error && error.message
|
error instanceof Error && error.message
|
||||||
? error.message
|
? normalizePublicChatErrorMessage(error.message)
|
||||||
: $t('bot.publicChatInitError');
|
: $t('bot.publicChatInitError');
|
||||||
setPageTitle();
|
setPageTitle();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1221,9 +1339,16 @@ onBeforeUnmount(() => {
|
|||||||
class="public-chat-banner public-chat-banner-warning"
|
class="public-chat-banner public-chat-banner-warning"
|
||||||
>
|
>
|
||||||
<p class="public-chat-banner-title">
|
<p class="public-chat-banner-title">
|
||||||
{{ $t('bot.publicPageBlocked') }}
|
{{ $t('bot.publicPageLoginRequired') }}
|
||||||
</p>
|
</p>
|
||||||
<p>{{ $t('bot.publicPageBlockedTip') }}</p>
|
<p>{{ $t('bot.publicPageLoginRequiredTip') }}</p>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
class="public-chat-banner-action"
|
||||||
|
@click="goToLogin"
|
||||||
|
>
|
||||||
|
{{ $t('authentication.goToLogin') }}
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
@@ -1596,6 +1721,10 @@ onBeforeUnmount(() => {
|
|||||||
border: 1px solid #fed7aa;
|
border: 1px solid #fed7aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-chat-banner-action {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.public-chat-message-row {
|
.public-chat-message-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
|
|||||||
Reference in New Issue
Block a user