Commit ee440d9a by Xianquan Committed by GitHub

fix(chat): isolate share chat cache keys (#6988)

parent 6e95cfb0
import { create, createJSONStorage, devtools, persist, immer } from '@fastgpt/web/common/zustand';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import type { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
type State = {
......@@ -28,6 +28,30 @@ type State = {
resetChatCache: () => any;
};
/**
* 生成按应用恢复会话用的缓存 key。
*
* 普通会话按 source + appId 隔离;分享会话的权限边界是 shareId + outLinkUid,
* 因此分享缓存 key 必须包含完整外链身份,避免同 app 下不同分享链接或外链用户串用 chatId。
*/
const getAppChatIdCacheKey = ({
source,
appId,
outLinkAuthData
}: {
source?: `${ChatSourceEnum}`;
appId?: string;
outLinkAuthData?: OutLinkChatAuthProps;
}) => {
if (!source || !appId) return;
if (source === ChatSourceEnum.share) {
const { shareId, outLinkUid } = outLinkAuthData || {};
if (!shareId || !outLinkUid) return;
return `${source}:${shareId}:${outLinkUid}:${appId}`;
}
return `${source}:${appId}`;
};
const createCustomStorage = () => {
// source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage
const sessionKeys = ['source', 'chatId', 'appId'];
......@@ -82,8 +106,13 @@ export const useChatStore = create<State>()(
source: undefined,
setSource(e) {
set((state) => {
// 首次进入 chat 页面,如果相同的 source,则恢复上一次的 chatId
if (!state.chatId && state.lastChatId && state.lastChatId.startsWith(e)) {
// 分享会话的恢复必须依赖 shareId + outLinkUid,不能只靠 lastChatId 的 source 前缀。
if (
e !== ChatSourceEnum.share &&
!state.chatId &&
state.lastChatId &&
state.lastChatId.startsWith(e)
) {
state.chatId = state.lastChatId.split('-')[1];
} else if (e !== get().source) {
// 来源改变,强制重置 chatId
......@@ -99,12 +128,21 @@ export const useChatStore = create<State>()(
set((state) => {
if (state.appId !== e) {
// 离开当前应用前,记住该应用最近一次会话
if (state.appId && state.chatId) {
state.appChatIdMap[state.appId] = state.chatId;
const currentCacheKey = getAppChatIdCacheKey({
source: state.source,
appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
if (currentCacheKey && state.chatId) {
state.appChatIdMap[currentCacheKey] = state.chatId;
}
// 切换到目标应用:优先恢复该应用上次的 chatId,否则临时生成(待历史列表加载后再对齐)
const restoredChatId = state.appChatIdMap[e];
const nextCacheKey = getAppChatIdCacheKey({
source: state.source,
appId: e,
outLinkAuthData: state.outLinkAuthData
});
const restoredChatId = nextCacheKey ? state.appChatIdMap[nextCacheKey] : undefined;
state.chatId = restoredChatId || getNanoid(24);
if (state.source) {
state.lastChatId = `${state.source}-${state.chatId}`;
......@@ -122,8 +160,13 @@ export const useChatStore = create<State>()(
set((state) => {
state.chatId = id;
state.lastChatId = `${state.source}-${id}`;
if (state.appId) {
state.appChatIdMap[state.appId] = id;
const cacheKey = getAppChatIdCacheKey({
source: state.source,
appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
if (cacheKey) {
state.appChatIdMap[cacheKey] = id;
}
});
},
......@@ -142,7 +185,28 @@ export const useChatStore = create<State>()(
outLinkAuthData: {},
setOutLinkAuthData(e) {
set((state) => {
const currentCacheKey = getAppChatIdCacheKey({
source: state.source,
appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
if (currentCacheKey && state.chatId) {
state.appChatIdMap[currentCacheKey] = state.chatId;
}
state.outLinkAuthData = e;
const nextCacheKey = getAppChatIdCacheKey({
source: state.source,
appId: state.appId,
outLinkAuthData: e
});
if (nextCacheKey) {
const restoredChatId = state.appChatIdMap[nextCacheKey];
state.chatId = restoredChatId || state.chatId || getNanoid(24);
state.lastChatId = `${state.source}-${state.chatId}`;
state.appChatIdMap[nextCacheKey] = state.chatId;
}
});
},
resetChatCache() {
......
......@@ -84,6 +84,91 @@ describe('useChatStore', () => {
expect(useChatStore.getState().chatId).toBe('chat-b');
});
it('should namespace app chat cache by source', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.online);
store.setAppId('app-a');
store.setChatId('online-chat-id');
store.setSource(ChatSourceEnum.api);
store.setAppId('app-b');
store.setChatId('api-chat-id');
expect(useChatStore.getState().appChatIdMap).toMatchObject({
[`${ChatSourceEnum.online}:app-a`]: 'online-chat-id',
[`${ChatSourceEnum.api}:app-b`]: 'api-chat-id'
});
});
it('should not restore app cached chatId for share source', () => {
const store = useChatStore.getState();
useChatStore.setState({
source: ChatSourceEnum.share,
appChatIdMap: {
[`${ChatSourceEnum.online}:app-a`]: 'normal-chat-id',
[`${ChatSourceEnum.share}:app-a`]: 'legacy-share-chat-id',
[`${ChatSourceEnum.share}:share-a:user-a:app-a`]: 'share-chat-id'
}
});
store.setAppId('app-a');
const newState = useChatStore.getState();
expect(newState.chatId).toBe('test-generated-id');
expect(newState.chatId).not.toBe('normal-chat-id');
expect(newState.chatId).not.toBe('legacy-share-chat-id');
expect(newState.chatId).not.toBe('share-chat-id');
});
it('should save share chatId with share identity namespace', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.share);
store.setAppId('app-a');
store.setOutLinkAuthData({ shareId: 'share-a', outLinkUid: 'user-a' });
store.setChatId('share-chat-id');
expect(useChatStore.getState().appChatIdMap).toEqual({
[`${ChatSourceEnum.share}:share-a:user-a:app-a`]: 'share-chat-id'
});
});
it('should restore share chatId only with matched share identity namespace', () => {
const store = useChatStore.getState();
useChatStore.setState({
source: ChatSourceEnum.share,
appId: 'app-a',
chatId: 'new-chat-id',
appChatIdMap: {
[`${ChatSourceEnum.share}:share-a:user-a:app-a`]: 'matched-share-chat',
[`${ChatSourceEnum.share}:share-a:user-b:app-a`]: 'other-user-chat'
}
});
store.setOutLinkAuthData({ shareId: 'share-a', outLinkUid: 'user-a' });
expect(useChatStore.getState().chatId).toBe('matched-share-chat');
});
it('should not restore last chatId for share source', () => {
const store = useChatStore.getState();
useChatStore.setState({
source: undefined,
chatId: '',
lastChatId: `${ChatSourceEnum.share}-cached-share-chat`
});
store.setSource(ChatSourceEnum.share);
const newState = useChatStore.getState();
expect(newState.chatId).toBe('test-generated-id');
expect(newState.chatId).not.toBe('cached-share-chat');
});
it('should keep chatId when setting the same appId', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.online);
......@@ -120,7 +205,7 @@ describe('useChatStore', () => {
it('should restore last chat when setting same source and lastChatId with different id', () => {
const store = useChatStore.getState();
const source = ChatSourceEnum.share;
const source = ChatSourceEnum.online;
const chatId = 'test';
useChatStore.setState({
lastChatId: `${source}-${chatId}`,
......@@ -135,7 +220,7 @@ describe('useChatStore', () => {
// The expected value should be 'test', not 'test-generated-id', since lastChatId is '${source}-test'
it('should restore last chat when setting same source and lastChatId with id that matches getNanoid', () => {
const store = useChatStore.getState();
const source = ChatSourceEnum.share;
const source = ChatSourceEnum.online;
const chatId = 'test';
useChatStore.setState({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment