fix: 优化公开聊天页登录态恢复与鉴权引导
- 支持复用现有登录态并恢复 refresh token - 未认证访问时补充跳转登录提示与引导文案
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
|
||||
"publicPageBlocked": "该助手未开放匿名访问",
|
||||
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
|
||||
"publicPageLoginRequired": "用户未认证,请登录",
|
||||
"publicPageLoginRequiredTip": "当前聊天页需要有效登录态或访问令牌后才可继续使用。",
|
||||
"publicChatTitle": "公开聊天助手",
|
||||
"publicChatSubtitle": "由 EasyFlow 驱动",
|
||||
"publicChatPlaceholder": "输入你的问题,按 Enter 发送",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user