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 { create, createJSONStorage, devtools, persist, immer } from '@fastgpt/web/common/zustand';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; 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'; import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
type State = { type State = {
...@@ -28,6 +28,30 @@ type State = { ...@@ -28,6 +28,30 @@ type State = {
resetChatCache: () => any; 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 = () => { const createCustomStorage = () => {
// source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage // source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage
const sessionKeys = ['source', 'chatId', 'appId']; const sessionKeys = ['source', 'chatId', 'appId'];
...@@ -82,8 +106,13 @@ export const useChatStore = create<State>()( ...@@ -82,8 +106,13 @@ export const useChatStore = create<State>()(
source: undefined, source: undefined,
setSource(e) { setSource(e) {
set((state) => { set((state) => {
// 首次进入 chat 页面,如果相同的 source,则恢复上一次的 chatId // 分享会话的恢复必须依赖 shareId + outLinkUid,不能只靠 lastChatId 的 source 前缀。
if (!state.chatId && state.lastChatId && state.lastChatId.startsWith(e)) { if (
e !== ChatSourceEnum.share &&
!state.chatId &&
state.lastChatId &&
state.lastChatId.startsWith(e)
) {
state.chatId = state.lastChatId.split('-')[1]; state.chatId = state.lastChatId.split('-')[1];
} else if (e !== get().source) { } else if (e !== get().source) {
// 来源改变,强制重置 chatId // 来源改变,强制重置 chatId
...@@ -99,12 +128,21 @@ export const useChatStore = create<State>()( ...@@ -99,12 +128,21 @@ export const useChatStore = create<State>()(
set((state) => { set((state) => {
if (state.appId !== e) { if (state.appId !== e) {
// 离开当前应用前,记住该应用最近一次会话 const currentCacheKey = getAppChatIdCacheKey({
if (state.appId && state.chatId) { source: state.source,
state.appChatIdMap[state.appId] = state.chatId; appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
if (currentCacheKey && state.chatId) {
state.appChatIdMap[currentCacheKey] = state.chatId;
} }
// 切换到目标应用:优先恢复该应用上次的 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); state.chatId = restoredChatId || getNanoid(24);
if (state.source) { if (state.source) {
state.lastChatId = `${state.source}-${state.chatId}`; state.lastChatId = `${state.source}-${state.chatId}`;
...@@ -122,8 +160,13 @@ export const useChatStore = create<State>()( ...@@ -122,8 +160,13 @@ export const useChatStore = create<State>()(
set((state) => { set((state) => {
state.chatId = id; state.chatId = id;
state.lastChatId = `${state.source}-${id}`; state.lastChatId = `${state.source}-${id}`;
if (state.appId) { const cacheKey = getAppChatIdCacheKey({
state.appChatIdMap[state.appId] = id; source: state.source,
appId: state.appId,
outLinkAuthData: state.outLinkAuthData
});
if (cacheKey) {
state.appChatIdMap[cacheKey] = id;
} }
}); });
}, },
...@@ -142,7 +185,28 @@ export const useChatStore = create<State>()( ...@@ -142,7 +185,28 @@ export const useChatStore = create<State>()(
outLinkAuthData: {}, outLinkAuthData: {},
setOutLinkAuthData(e) { setOutLinkAuthData(e) {
set((state) => { 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; 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() { resetChatCache() {
......
...@@ -84,6 +84,91 @@ describe('useChatStore', () => { ...@@ -84,6 +84,91 @@ describe('useChatStore', () => {
expect(useChatStore.getState().chatId).toBe('chat-b'); 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', () => { it('should keep chatId when setting the same appId', () => {
const store = useChatStore.getState(); const store = useChatStore.getState();
store.setSource(ChatSourceEnum.online); store.setSource(ChatSourceEnum.online);
...@@ -120,7 +205,7 @@ describe('useChatStore', () => { ...@@ -120,7 +205,7 @@ describe('useChatStore', () => {
it('should restore last chat when setting same source and lastChatId with different id', () => { it('should restore last chat when setting same source and lastChatId with different id', () => {
const store = useChatStore.getState(); const store = useChatStore.getState();
const source = ChatSourceEnum.share; const source = ChatSourceEnum.online;
const chatId = 'test'; const chatId = 'test';
useChatStore.setState({ useChatStore.setState({
lastChatId: `${source}-${chatId}`, lastChatId: `${source}-${chatId}`,
...@@ -135,7 +220,7 @@ describe('useChatStore', () => { ...@@ -135,7 +220,7 @@ describe('useChatStore', () => {
// The expected value should be 'test', not 'test-generated-id', since lastChatId is '${source}-test' // 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', () => { it('should restore last chat when setting same source and lastChatId with id that matches getNanoid', () => {
const store = useChatStore.getState(); const store = useChatStore.getState();
const source = ChatSourceEnum.share; const source = ChatSourceEnum.online;
const chatId = 'test'; const chatId = 'test';
useChatStore.setState({ 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