fix: 优化公开聊天页登录态恢复与鉴权引导

- 支持复用现有登录态并恢复 refresh token

- 未认证访问时补充跳转登录提示与引导文案
This commit is contained in:
2026-05-06 19:22:21 +08:00
parent 31b0e21d3d
commit ba70fec9a5
3 changed files with 164 additions and 31 deletions

View File

@@ -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",

View File

@@ -50,6 +50,8 @@
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。", "embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
"publicPageBlocked": "该助手未开放匿名访问", "publicPageBlocked": "该助手未开放匿名访问",
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。", "publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
"publicPageLoginRequired": "用户未认证,请登录",
"publicPageLoginRequiredTip": "当前聊天页需要有效登录态或访问令牌后才可继续使用。",
"publicChatTitle": "公开聊天助手", "publicChatTitle": "公开聊天助手",
"publicChatSubtitle": "由 EasyFlow 驱动", "publicChatSubtitle": "由 EasyFlow 驱动",
"publicChatPlaceholder": "输入你的问题,按 Enter 发送", "publicChatPlaceholder": "输入你的问题,按 Enter 发送",

View File

@@ -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;