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.",
"publicPageBlocked": "Anonymous access is disabled for this bot",
"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",
"publicChatSubtitle": "Powered by EasyFlow",
"publicChatPlaceholder": "Type your question and press Enter to send",

View File

@@ -50,6 +50,8 @@
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
"publicPageBlocked": "该助手未开放匿名访问",
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
"publicPageLoginRequired": "用户未认证,请登录",
"publicPageLoginRequiredTip": "当前聊天页需要有效登录态或访问令牌后才可继续使用。",
"publicChatTitle": "公开聊天助手",
"publicChatSubtitle": "由 EasyFlow 驱动",
"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 { ChatThinkingBlock } from '@easyflow/common-ui';
import { LOGIN_PATH } from '@easyflow/constants';
import { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { useAccessStore } from '@easyflow/stores';
import { uuid } from '@easyflow/utils';
import { useTitle } from '@vueuse/core';
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
import { refreshTokenApi } from '#/api/core';
import { baseRequestClient, sseClient } from '#/api/request';
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
@@ -106,12 +109,21 @@ interface PublicChatSessionRestoreResult {
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_CONTEXT_VERSION = 1;
const route = useRoute();
const router = useRouter();
const pageTitle = useTitle();
const accessStore = useAccessStore();
const botInfo = ref<BotInfo | null>(null);
const conversationId = ref<string>('');
@@ -149,6 +161,28 @@ const setPageTitle = () => {
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 = () => {
if (!messageContainerRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
@@ -438,17 +472,89 @@ const exchangeApiKeyToAccessToken = async (apiKey: string) => {
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 {
const profileResp = await getResponseBody(
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
headers: buildRequestHeaders(token),
}),
);
return profileResp.errorCode === 0 && !!profileResp.data;
} catch {
return false;
if (profileResp.errorCode === 0 && profileResp.data) {
return {
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 () => {
@@ -457,15 +563,18 @@ const ensureRequestAccessToken = async () => {
const urlApiKey = readUrlApiKey();
if (urlApiKey) {
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
const authenticated = await resolveAuthenticatedAccess(exchangedToken);
if (!authenticated) {
const authResult = await resolveAuthenticatedAccess(exchangedToken);
if (authResult.status === 'error') {
throw new Error(authResult.message || $t('bot.publicChatInitError'));
}
if (authResult.status !== 'authenticated') {
throw new Error($t('bot.publicChatTokenInvalid'));
}
requestAccessToken.value = exchangedToken;
requestAccessToken.value = authResult.nextToken || exchangedToken;
authenticatedAccess.value = true;
upsertPublicChatContext(
{
accessToken: exchangedToken,
accessToken: requestAccessToken.value,
authenticatedAccess: true,
},
{ resetConversation: true },
@@ -476,36 +585,45 @@ const ensureRequestAccessToken = async () => {
const storedContext = readPublicChatContext(botId.value);
if (storedContext?.accessToken) {
const tokenValid = await resolveAuthenticatedAccess(
const authResult = await resolveAuthenticatedAccess(
storedContext.accessToken,
);
if (tokenValid) {
requestAccessToken.value = storedContext.accessToken;
authenticatedAccess.value = storedContext.authenticatedAccess;
if (authResult.status === 'error') {
throw new Error(authResult.message || $t('bot.publicChatInitError'));
}
if (authResult.status === 'authenticated') {
requestAccessToken.value =
authResult.nextToken || storedContext.accessToken;
authenticatedAccess.value = true;
upsertPublicChatContext({
accessToken: storedContext.accessToken,
authenticatedAccess: storedContext.authenticatedAccess,
accessToken: requestAccessToken.value,
authenticatedAccess: true,
conversationId: storedContext.conversationId,
});
return;
}
clearPublicChatContext(botId.value);
}
const tokenResp = await getResponseBody<string>(
baseRequestClient.get('/api/temp-token/create'),
);
if (tokenResp.errorCode !== 0 || !tokenResp.data) {
throw new Error($t('bot.publicChatInitError'));
const currentAccessToken = String(accessStore.accessToken || '').trim();
if (currentAccessToken) {
const authResult = await resolveAuthenticatedAccess(currentAccessToken, {
allowRefresh: true,
});
if (authResult.status === 'error') {
throw new Error(authResult.message || $t('bot.publicChatInitError'));
}
requestAccessToken.value = String(tokenResp.data);
if (authResult.status === 'authenticated') {
requestAccessToken.value = authResult.nextToken || currentAccessToken;
authenticatedAccess.value = true;
upsertPublicChatContext(
{
accessToken: requestAccessToken.value,
authenticatedAccess: false,
authenticatedAccess: true,
},
{ resetConversation: true },
);
}
}
};
const resetConversationState = () => {
@@ -1116,7 +1234,7 @@ const initPublicChat = async () => {
console.error(error);
initError.value =
error instanceof Error && error.message
? error.message
? normalizePublicChatErrorMessage(error.message)
: $t('bot.publicChatInitError');
setPageTitle();
} finally {
@@ -1221,9 +1339,16 @@ onBeforeUnmount(() => {
class="public-chat-banner public-chat-banner-warning"
>
<p class="public-chat-banner-title">
{{ $t('bot.publicPageBlocked') }}
{{ $t('bot.publicPageLoginRequired') }}
</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>
<template v-else>
<div
@@ -1596,6 +1721,10 @@ onBeforeUnmount(() => {
border: 1px solid #fed7aa;
}
.public-chat-banner-action {
margin-top: 12px;
}
.public-chat-message-row {
display: flex;
margin-bottom: 18px;