FastGPT SkillEdit 复用标准 Chat 会话:sourceType/sourceId 双资源模型设计与实践
【免费下载链接】FastGPTFastGPT is a knowledge-based platform built on the LLMs, offers a comprehensive suite of out-of-the-box capabilities such as data processing, RAG retrieval, and visual AI workflow orchestration, letting you easily develop and deploy complex question-answering systems without the need for extensive setup or configuration.项目地址: https://gitcode.com/GitHub_Trending/fa/FastGPT
本篇技术指南围绕 FastGPT 中"Skill Edit(Skill 调试/预览)复用标准 Chat 会话"这一核心改造展开,讲解在保留历史appId物理字段的前提下,通过业务语义层新增sourceType + sourceId双资源模型,让 App 会话与 Skill Edit 会话共用chats、chatitems、chat_item_responses三张标准会话表。读完本文,你将掌握该模型的枚举定义、统一查询/写入 helper、OpenAPI 双层 schema、前端 Chat Target 分层、sandbox/S3/stop-resume 的资源隔离规则,以及索引迁移与历史数据兼容策略,可直接用于理解或复刻类似的"多资源类型复用单套会话存储"改造。
一、背景:为什么不能直接改字段
FastGPT 的标准 Chat 体系由三张表构成:chats(会话)、chatitems(消息)、chat_item_responses(节点响应)。历史上这三张表的物理字段叫appId,所有 App 会话都通过它归属到具体应用。
当 Skill Edit 调试会话需要复用这套标准 Chat 能力(标准 chat service、标准 chat API)时,面临两个约束:
- 历史数据量巨大:历史 App 会话数据量很大,不适合为了接入 Skill Edit 做大规模字段重命名或全量回填;
- 物理字段语义单一:
appId字段名承载不了"资源类型"的语义,直接把 SkillId 塞进appId会导致 Skill 会话与 App 会话在同一字段下混存、无法区分。
因此最终方案是在业务语义层新增sourceType + sourceId:
- App 会话:
sourceType=app,sourceId=appId; - Skill Edit 会话:
sourceType=skillEdit,sourceId=skillId。
Mongo 第一阶段继续保留物理字段appId,但把它视为历史字段名,业务含义统一为sourceId。这样既不动存量数据,又能在语义层完成资源隔离。
二、目标与非目标
目标
- App 和 Skill Edit 共用
chats、chatitems、chat_item_responses; - 标准 chat API 对外继续使用业务字段
appId或skillId,不暴露内部sourceType/sourceId; - API route 使用
parseApiInput和 runtime schema 把appId/skillId转换为sourceType/sourceId; - API handler 之后的业务层统一接收
sourceType/sourceId,禁止继续传 API 原始字段appId/skillId; - App 历史数据在缺失
sourceType的情况下仍可读取、更新和删除; - Skill Edit 的 usage 写入
usage.skillId,不污染usage.appId; - Skill Edit 不写入 App 最近使用、App 统计日志和 App 看板;
- stop、resume、nodeResponse、S3、sandbox 都按
sourceType/sourceId隔离。
非目标(第一阶段的明确边界)
- 不做 Mongo 字段
appId -> sourceId的物理重命名; - 不强制回填几亿历史 App chat 数据;
- 不长期兼容旧 Skill Debug chat,上线初始化阶段清理掉旧数据;
- 不让
ChatSourceEnum承担资源类型语义——它继续表示入口来源(test、api、online、share等); - 不把 Skill Edit 接入 App 最近使用和 App chat logs。
三、核心模型:ChatSourceTypeEnum 与统一 helper
枚举定义
在 packages/global/core/chat/constants.ts 中定义了资源类型枚举,与表示"入口来源"的ChatSourceEnum明确区分:
export enum ChatSourceTypeEnum { app = 'app', skillEdit = 'skillEdit', chatAgentHelper = 'chatAgentHelper' }说明:源码中的枚举比设计初稿多了一个
chatAgentHelper成员(HelperBot 独立命名空间),注释也明确写道:ChatSourceEnum表示对话入口来源(如 test/api/online),ChatSourceTypeEnum表示会话归属资源类型,用于在同一套 chat 表中隔离 App 和 Skill Edit。这正对应设计中"不让 ChatSourceEnum 承担资源类型语义"的约束。
sourceId是所属资源的真实 ObjectId:
sourceType=app时,sourceId是 AppId;sourceType=skillEdit时,sourceId是 SkillId。
统一查询/写入 helper
所有新代码必须通过统一 helper 构造查询和写入字段,实现在 packages/service/core/chat/source.ts:
export function buildChatSourceWriteFields({ sourceType, sourceId }: ChatSourceParams) { return { sourceType, appId: sourceId }; } export function buildChatSourceQuery({ sourceType, sourceId }: ChatSourceParams) { if (sourceType === ChatSourceTypeEnum.app) { return { appId: sourceId, $or: [{ sourceType: ChatSourceTypeEnum.app }, { sourceType: { $exists: false } }] }; } if ( sourceType === ChatSourceTypeEnum.skillEdit || sourceType === ChatSourceTypeEnum.chatAgentHelper ) { return { appId: sourceId, sourceType }; } const exhaustiveCheck: never = sourceType; throw new Error(`Unsupported chat source type: ${exhaustiveCheck}`); }核心语义:
- App 查询默认兼容历史数据:
$or同时匹配sourceType=app与sourceType字段缺失({ $exists: false })的记录,保证存量 App 会话长期可读; - Skill Edit 查询必须精确匹配
sourceType=skillEdit,避免 Skill 与 App 复用同一物理appId字段时串记录; - 非法
sourceType走never穷尽检查并抛错,从编译期和运行期双重防呆。
源码中还额外提供了buildChatSourceAggregateMatch,用于聚合管线场景——因为 Mongoose 不会自动 cast aggregate$match,它会把合法的 ObjectId 字符串显式转换成Types.ObjectId,避免聚合查询命中不了历史appId物理字段(见 source.ts)。
四、API 设计:对外 appId/skillId,对内 sourceType/sourceId
入参互斥规则
标准 chat API 对外只接受appId或skillId二者其一:
{ "appId": "68ad85a7463006c963799a05", "chatId": "chat_xxx" }{ "skillId": "68ad85a7463006c963799a06", "chatId": "chat_xxx" }规则:
appId和skillId必须且只能传一个;- 只传
appId:转换为sourceType=app、sourceId=appId; - 只传
skillId:转换为sourceType=skillEdit、sourceId=skillId; - Zod transform 后的业务层不再保留顶层
appId/skillId; - 不使用字段名
type表示资源类型,避免和已有业务枚举(如ChatSourceEnum)冲突。
OpenAPI schema 分层
withChatTarget一类 runtime schema 带 transform,不能直接用于 OpenAPI 文档生成。因此每个标准 chat API 使用两层 schema:
- Raw schema:不带 transform,对外描述
appId/skillId,用于 OpenAPI path 和前端请求类型; - Runtime schema:基于 raw schema transform,API route 的
parseApiInput使用,输出sourceType/sourceId。
OpenAPI 只注册 raw schema。互斥约束由 raw schema 的superRefine与 API route 的parseApiInput在运行时共同保证。
这一分层在 packages/global/openapi/core/chat/api.ts 中有完整实现,四个 schema 构造器覆盖了必填/可选与外链鉴权两种维度:
| helper | 语义 |
|---|---|
createChatTargetInputSchema(shape) | 必填 target,appId/skillId互斥,用于 OpenAPI path |
createOptionalChatTargetInputSchema(shape) | 可选 target,仅用于可从外链鉴权上下文反推 App 的接口 |
createOutLinkChatTargetInputSchema(shape) | 必填 target + 外链鉴权字段 |
createOptionalOutLinkChatTargetInputSchema(shape) | 可选 target + 外链鉴权字段 |
对应的 runtime 转换 helper 为withChatTarget、withOptionalChatTarget、withOutLinkChatTarget、withOptionalOutLinkChatTarget,它们内部调用transformChatTargetInput/transformChatAuthTargetInput等转换函数。源码注释特别强调:不能用OutLinkChatAuthSchema.extend(createChatTargetInputSchema(...).shape)拼接,否则会丢失 chat target 互斥校验。
互斥校验本身由refineChatAuthTargetInput完成(api.ts),它覆盖了这些规则:
appId和skillId不能同时提供;appId/skillId/share auth三选一;sourceType=chatAgentHelper必须带appId、不能带skillId;- share 模式下
shareId与outLinkUid必须成对出现; skillId不能与 share auth 混用;- 必填模式下三者必须提供其一。
对应的运行时转换transformChatTargetInput(api.ts)逻辑如下:sourceId = appId || skillId;若传了skillId则sourceType=skillEdit,否则按chatAgentHelper/app判断。
标准接口覆盖范围
以下接口统一支持appId/skillIdraw input,并在 route 中解析为sourceType/sourceId:
- 会话初始化与续跑:
/api/core/chat/init、/api/core/chat/resume - 停止:
/api/v2/chat/stop - 记录:
/api/core/chat/record/getRecords_v2、getPaginationRecords、getResData、delete、getQuote、getCollectionQuote - 历史:
/api/core/chat/history/getHistories、getHistoryStatus、markRead、updateHistory、delHistory、clearHistories、batchDelete - 反馈:
/api/core/chat/feedback/updateUserFeedback、updateFeedbackReadStatus、adminUpdate、closeCustom、getFeedbackRecordIds - 文件:
/api/core/chat/file/presignChatFilePostUrl、presignChatFileGetUrl - 语音:
/api/v1/audio/transcriptions
历史 wrapper route 如果保留,必须复用同一个 source-aware handler 和 schema。
App-only 接口(不接入 Skill Edit)
以下接口保持 App-only:outLink init、team init、inputGuide、recentlyUsed、公开 OpenAPI chat completions、/api/core/chat/chatTest、helperBot、App chat logs。
App-only 接口内部如调用标准 chat service,必须显式传sourceType=app和sourceId=appId。
特别说明/api/core/chat/chatTest:它的入参是 App workflow test 协议,依赖nodes/edges/chatConfig/appName和authApp;Skill Edit 调试使用 Skill 专属协议构造 runtime nodes 和编辑沙盒上下文,不应仅通过给chatTest增加skillId来混用两套请求结构。若后续要彻底移除 Skill debug 专属接口,需要单独设计生成入口转换层,而不是把skillId直接塞进现有ChatTestPropsSchema。
五、前端 Chat Target 设计
双层 target
前端分为两层 target(实现见 projects/app/src/web/core/chat/utils.ts):
type ChatSourceTarget = { sourceType: 'app' | 'skillEdit'; sourceId: string; }; type ChatApiTarget = { appId: string } | { skillId: string };规则:
ChatSourceTarget是前端标准 chat 组件内部 target,ChatBox、WorkflowRuntimeContext和标准 chat 请求都以它为准;ChatApiTarget是 OpenAPI/API 边界 raw target,只在请求发出前由toChatApiTarget(sourceTarget)派生;- App 页面传
sourceTarget={{ sourceType: 'app', sourceId: appId }};Skill Preview 传sourceTarget={{ sourceType: 'skillEdit', sourceId: skillId }}; - ChatBox runtime 状态 key 不暴露成 prop,统一用
getChatSourceKey(sourceTarget)生成,形如${sourceType}:${sourceId}; - Skill Preview 下 input guide、TTS、语音识别入口和 ChatBox 内的 App 沙盒入口不展示、不调用;
- 所有标准 chat API 调用只能从
sourceTarget派生ChatApiTarget,不能从真实 App-onlyappId或 Skill ID 推导; - 前端组件不要自行拼 API raw target,统一走
toChatApiTarget(sourceTarget)。
核心转换实现:
export const toChatApiTarget = (target: ChatSourceTarget): ChatTargetInputType => { if (target.sourceType === ChatSourceTypeEnum.skillEdit) { return { skillId: target.sourceId }; } if (target.sourceType === ChatSourceTypeEnum.chatAgentHelper) { return { appId: target.sourceId, sourceType: ChatSourceTypeEnum.chatAgentHelper }; } return { appId: target.sourceId }; };源码中还提供了useChatApiTarget(target)(useMemo 包装的派生 hook)和getChatSourceKey,前者供标准请求统一取 API raw target,后者生成${sourceType}:${sourceId}运行时 key。此外utils.ts还提供了toChatSourceTarget(raw target → 内部 source target,供 SandboxEditor 等 API 边界场景反向使用)和toChatAuthApiTarget(source target → 带 outLinkAuthData 的 raw target,share 模式只传outLinkAuthData)。
ChatBox 最终前端方案
ChatBox不再感知appId/skillId,只接收标准内部 target:
<ChatBox sourceTarget={{ sourceType, sourceId }} features={features} onStartChat={onStartChat} onChatGenerateStatusChange={onChatGenerateStatusChange} />边界划分:
sourceTarget:用于 record/history/feedback/file/quote/resume/stop/delete 等标准 chat 能力;features:只控制功能展示和能力开关,如 feedback、mark、voice、tts、inputGuide、sandbox、workorder、autoResume、markRead、quickReplies、footer actions;onStartChat:保留外部注入,因为 App/Home/Share/ChatTest/Skill Preview 的生成编排不同,暂时不能统一;onChatGenerateStatusChange:只作为事件通知外部页面,ChatBox 不直接读写侧栏 history、最近使用、路由状态等外部模型;onStopChat:移除外部 override,统一走 source-aware/api/v2/chat/stop;onDeleteChatItem:移除外部 override,统一走 source-aware chat item delete 接口;- ChatBox 目录内禁止直接依赖
ChatContext、useChatStore、最近使用等外部页面状态;需要影响外部时通过 props 回调由页面层承接; - App/Home/Share 侧栏历史同步放在页面层 hook 中消费
onChatGenerateStatusChange;Skill Preview 不传该回调。
迁移顺序
- 新增
ChatSourceTarget、getChatSourceKey、toChatApiTarget; WorkflowRuntimeContext改为暴露sourceTarget/sourceKey/appId/chatId,其中appId只表示真实 App-only 能力所需的 AppId;ChatBoxprops 改为sourceTarget + features + onStartChat,不保留feedbackType/showMarkIcon/showVoiceIcon/...等旧 feature props;- 标准 chat 请求统一改用
toChatApiTarget(sourceTarget); - 删除
onStopChat/onDeleteChatItem两个 props,Skill Preview 改走通用 stop/delete; - App-only 功能全部从
appId判断改为features控制; - 外部 history/recently used/router 等状态同步迁到页面层 props 回调,ChatBox 内只保留自身 UI 状态;
- 最后扫
ChatBox目录内appId/skillId/chatTarget/chatTargetId,确保只剩入口页面或 App-only 能力使用。
最大注意点:onStartChat不是 feature,也不是标准 CRUD,先保留。
六、权限设计:source-aware 鉴权入口
新增标准 chat target 鉴权入口(测试见 projects/app/test/service/support/permission/auth/chat.test.ts):
type AuthChatTargetParams = { sourceType: ChatSourceTypeEnum; sourceId: string; chatId?: string; };规则:
sourceType=app:复用现有authChatCrud/authApp;sourceType=skillEdit:走authSkill,并在传入chatId时用source-aware 查询校验 chat 属于当前 skill 和团队(团队不匹配必须拒绝,对应测试用例);- outLink、share、team domain 等 App 专属入口保持
sourceType=app。
所有 chat 存在性校验必须使用 source-aware 查询,禁止裸查{ appId, chatId },否则 Skill 会话会被误判为 App 会话,或在新索引启用前命中错误记录。
七、数据模型:三表字段演进
chats
新增sourceType字段,第一阶段不设置required: true,也不设置 schema default。原因有三:
- 历史 App 数据缺失
sourceType; - 新写入必须通过
buildChatSourceWriteFields显式带sourceType,不能让漏传在 Mongoose 层静默默认成 App; - 待可选回填完成后,再评估是否收紧 schema 校验。
在 packages/global/core/chat/type.ts 的ChatSchema中,appId保留为物理字段名,但 meta 注释明确说明其业务语义是sourceId(可能是 appId 或 skillId);sourceType的 meta 说明"旧数据可能缺失,业务查询层按 app 兼容"。注意这里的 z.default 仅作用于类型层空值归一,Mongo schema 层不设 default,配合source.ts测试断言"sourceType可缺失但无默认值,防止新写入漏传时被静默归为 App"。
chatitems
新增同样的sourceType字段。Human/AI 占位写入、AI 消息更新、软删除、反馈、记录读取都必须带 source-aware 条件。ChatItemDBSchema中同样保留物理appId字段(见 type.ts)。
chat_item_responses
新增同样的sourceType字段。createWorkflowEntryNodeResponseWriter写入和读取都接收sourceType/sourceId,避免 App 与 Skill Edit 在极端 ID 碰撞时串数据(实现见 packages/service/core/chat/nodeResponseStorage.ts)。ChatItemResponseSchema位于 type.ts。
usages
新增skillId字段。写入规则:
- App chat:写
usage.appId,不写usage.skillId; - Skill Edit chat:写
usage.skillId,不写usage.appId。
usages和usage_items是计费审计数据,不能随 chat 删除。
app_chat_logs
不新增sourceType。该表语义是 App 统计日志,Skill Edit 不写入。app_chat_logs不纳入统一 chat 资源删除函数,由 App 删除流程自行处理。
八、Workflow Runtime:runningAppInfo 收敛
runningAppInfo不保留 deprecated 的id或sandboxId字段,最终结构为:
type RunningAppInfo = { sourceType: ChatSourceTypeEnum; sourceId: string; teamId: string; tmbId: string; name: string; isChildApp?: boolean; };使用规则:
- chat 持久化:使用
sourceType/sourceId; - 计费:App 写
usage.appId=sourceId,Skill Edit 写usage.skillId=sourceId; - nodeResponse:使用
sourceType/sourceId写入和读取; - stop/resume:使用
sourceType/sourceId/chatId作为 Redis namespace; - App 专属逻辑只能在
sourceType=app时把sourceId当 AppId 使用; - Skill Edit 专属逻辑只能在
sourceType=skillEdit时把sourceId当 SkillId 使用。
streamAgentSandboxInitStatus不再接收appId或sandboxId,只接收sourceType/sourceId/userId/chatId,内部调用getRunningSandboxId计算实际 sandboxId 后推送状态。相关测试覆盖可见 packages/service/test/core/workflow/workflowStatus.test.ts。
九、Sandbox:id 统一计算
Sandbox id 统一由getRunningSandboxId计算:
function getRunningSandboxId({ sourceType, sourceId, userId, chatId }) { if (sourceType === ChatSourceTypeEnum.app) { return generateSandboxId(sourceId, userId, chatId); } if (sourceType === ChatSourceTypeEnum.skillEdit) { return getEditDebugSandboxId(sourceId); } const exhaustiveCheck: never = sourceType; throw new Error(`Unsupported chat source type: ${exhaustiveCheck}`); }规则:
- App chat:
generateSandboxId(appId, userId, chatId); - Skill Edit:固定
getEditDebugSandboxId(skillId)(编辑态沙盒与 Skill 生命周期绑定,不随 chat 变化); ensureAgentSandboxRuntime内部计算 sandboxId,不从runningAppInfo读取;- 底层 sandbox schema 如仍叫
appId,调用层必须集中封装,避免业务代码把它误认为真实 AppId。
在 packages/global/core/ai/sandbox/constants.ts 中,generateSandboxId的 v2 实现为:${sourceType.toLowerCase()}-${hashStr(${sourceId}-${userId}).slice(0, 16)},即带 sourceType 前缀的稳定物理资源 ID。skillEdit 编辑态实例(SandboxTypeEnum.editDebug)则通过 Skill 删除链路处理。
十、systemVar 与 S3 文件隔离
systemVar
Skill Edit 场景不伪造appId:
- App 场景继续注入
appId=sourceId; - Skill Edit 场景不注入
appId; - 内部运行态可以携带
sourceType/sourceId;是否暴露到变量面板另行评估。
S3 文件 key
新上传统一使用 source-aware key:
chat/${sourceType}/${sourceId}/${uid}/${chatId}/${filename}兼容规则:
- 旧 App 文件 key
chat/${appId}/${uid}/${chatId}/${filename}继续可读; - 新 App 文件使用
chat/app/${appId}/...; - 新 Skill Edit 文件使用
chat/skillEdit/${skillId}/...; - 历史 chat item 中保存的旧 key 不重写;
- legacy key 只允许在
sourceType=app的鉴权上下文中通过; - Skill Edit 不默认读取 legacy App key。
在文件预览接口/api/core/chat/file/presignChatFileGetUrl上,鉴权时同时校验sourceType/sourceId/uid/chatId与 S3 key 归属,错误chatId预览返回unAuthChat(见 projects/app/test/pages/api/core/chat/file/presignChatFileGetUrl.test.ts)。S3 key 构造与解析测试位于 packages/service/test/common/s3/key.test.ts。
十一、stop/resume 的 Redis key 隔离
stop key:
agent_runtime_stopping:${sourceType}:${sourceId}:${chatId}stream resume key:
stream:resume:data:${teamId}:${sourceType}:${sourceId}:${chatId} stream:resume:unavailable:${teamId}:${sourceType}:${sourceId}:${chatId} stream:resume:active:${teamId}:${sourceType}:${sourceId}:${chatId}stop、resume、catchUp、runtime status 的 key 构造必须集中到 helper,禁止各处手写。对应的 key 格式测试在 packages/service/test/core/workflow/workflowStatus.test.ts 中,专门防止 stop key 回退为裸sourceId/chatId。
十二、删除与清理:统一 source-aware 删除函数
标准 chat 会话资源统一由deleteChatResourcesBySource处理(实现见 packages/service/core/chat/delete.ts):
chatschatitemschat_item_responses- chat S3 文件
- chat 绑定的 sandbox 实例和资源
不纳入统一函数:
app_chat_logs:App 日志域,由 App 删除流程处理;usages/usage_items:计费审计域,不能删除;chat_input_guides:App 配置域;- HelperBot chat:独立命名空间。
App 删除调用
await deleteSandboxesByAppId(appId); deleteChatResourcesBySource({ sourceType: ChatSourceTypeEnum.app, sourceId: appId, includeLegacyApp: true, deleteSandboxResources: false });App 删除流程先按 App 维度删除 sandbox,再调用统一 chat 资源删除函数;统一函数此时不再重复删 chat 绑定 sandbox。App 日志仍由 App 删除流程单独删除。
App 日志批量硬删 chat 调用
deleteChatResourcesBySource({ sourceType: ChatSourceTypeEnum.app, sourceId: appId, chatIds, includeLegacyApp: true });该场景会删除指定 chat 绑定的 App sandbox(对应测试 packages/service/test/core/chat/delete.test.ts 与 projects/app/test/api/core/chat/history/batchDelete.test.ts 确认批量删除 Skill Edit chat 不会误删 App chat sandbox)。
Skill 删除调用
deleteChatResourcesBySource({ sourceType: ChatSourceTypeEnum.skillEdit, sourceId: skillId });旧 Skill Debug 初始化清理调用
deleteChatResourcesBySource({ sourceType: ChatSourceTypeEnum.skillEdit, sourceId: skillId, legacySkillDebug: true });legacySkillDebug=true只匹配:
{ appId: skillId, source: ChatSourceEnum.test, sourceType: { $exists: false } }十三、旧 Skill Debug 数据清理策略
旧 Skill Debug chat不做迁移,初始化阶段一次性硬删。原因:
- 旧数据缺少
sourceType,会和历史 App 兼容逻辑冲突; - 旧唯一索引
{ appId: 1, chatId: 1 }存在时,旧 Skill row 会挡住新 Skill Edit row; - Skill Preview 可能从 localStorage 复用旧
chatId,不清理会触发 duplicate key。
清理识别规则:
- 扫描
agentSkills._id(Skill 数量预计不超过 1000); - 用 skillId 集合匹配 legacy chats:
{ appId: { $in: skillIds }, source: 'test', sourceType: { $exists: false } }- 与
apps._id做审计比对,输出重复 ID 报告;第一阶段不把碰撞作为自动剔除条件; - 按 skillId 和 chat 游标分批硬删 chats/items/responses/S3;Skill Edit 编辑沙盒由 Skill 删除链路处理,不由 legacy chat 清理函数处理;
- 脚本支持dry-run、断点续跑和幂等重试。
上线后保留一次 duplicate 兜底:Skill Edit 创建 chat 遇到 duplicate 时,如果确认是 legacy Skill Debug row,则清理该单 chat 后重试一次。清理逻辑测试见 packages/service/test/core/chat/legacySkillDebugCleanup.test.ts。
十四、索引与迁移
上线前先创建新索引。
chats新唯一索引:
db.chats.createIndex( { sourceType: 1, appId: 1, chatId: 1 }, { unique: true, name: 'sourceType_1_appId_1_chatId_1' } );创建前先做重复审计(本地审计通过DUPLICATE_SOURCE_ROWS=0、DUPLICATE_LEGACY_APP_ROWS=0)。旧{ appId: 1, chatId: 1 }唯一索引稳定后再删除。
关键风险:如果不回填历史 App 数据,删除旧唯一索引后 DB 不会阻止以下逻辑重复:
{ appId, chatId, sourceType: { $exists: false } } { appId, chatId, sourceType: 'app' }因此App 写入路径必须先用 source-aware query 命中 legacy row,不能盲插新 App row。这也是为什么buildChatSourceQuery对 App 必须带$or兼容条件——先查询命中已有 legacy row 再做 upsert,避免重复。
chatitems和chat_item_responses需要补 source-aware非唯一复合索引,用于记录读取、分页、删除和 nodeResponse 查询。旧索引先保留,用于 legacy App 查询、rollback 和 explain 对比。schema 索引声明测试见 packages/service/test/core/chat/schema.test.ts,它断言 chat 三表sourceType可缺失但无默认值,并覆盖sourceType_1_appId_1_chatId_1唯一索引声明。
十五、风险清单
历史 App 数据漏查
App 查询必须长期兼容sourceType缺失,直到可选回填完成且确认不再需要 legacy 查询。
旧唯一索引删除后的重复写入
删除旧唯一索引前必须确认 App 写入路径不会在同一appId/chatId下创建 legacy row 和sourceType=approw 两份逻辑重复。
usage 归因错误
Skill Edit 只能写usage.skillId,不能写usage.appId。浏览器集成复验中已确认:Skill Preview 会话的chats/chatitems.sourceType=skillEdit、物理appId=skillId,usage 只写skillId且appId为空;App 会话则相反,usage 写appId且skillId=null。
runningAppInfo 字段误用
不保留runningAppInfo.id和runningAppInfo.sandboxId。所有运行态调用只使用sourceType/sourceId,sandboxId 统一计算。
S3 或删除串源
新文件 key、预览授权和删除前缀都必须按 source-aware 规则处理。App 删除清理 legacy + new App 前缀,Skill 删除只清理chat/skillEdit/${skillId}。
十六、验证体系与测试路径
整个改造有完整的测试与集成验证支撑,主要测试路径如下,可供排查问题时直接复用:
- OpenAPI target schema:packages/global/test/openapi/core/chat/targetSchema.test.ts——覆盖 App/Skill target transform、必填 target 缺失、
appId+skillId同传拒绝、可选 target 缺省/歧义,以及/v1/audio/transcriptionsraw form schema 与 runtime transform 分层; - chat 三表 schema 与删除:packages/service/test/core/chat/schema.test.ts、delete.test.ts、legacySkillDebugCleanup.test.ts;
- chat 主链路:packages/service/test/core/chat/nodeResponseStorage.test.ts、saveChat.test.ts、controller.test.ts、title.test.ts;
- 文件上传/预览与鉴权:projects/app/test/pages/api/core/chat/file/presignChatFilePostUrl.test.ts、presignChatFileGetUrl.test.ts、projects/app/test/service/support/permission/auth/chat.test.ts;
- Skill Debug 回归:projects/app/test/api/core/ai/skill/debugChat.test.ts 与
debugSession/*系列(list/records/delete/stop/chatItemDelete.permission); - sandbox 与 workflow:packages/service/test/core/ai/sandbox/runtime/index.test.ts、packages/service/test/core/workflow/workflowStatus.test.ts。
全量测试结果(文档记录):@fastgpt/global81 个文件、1688 个测试;@fastgpt/app138 个文件、1018 个测试;@fastgpt/service217 个文件、2903 个测试(跳过 2 个外部集成文件、35 个测试),并完成pnpm --filter @fastgpt/app typecheck与git diff --check复核。
浏览器集成复验的关键结论包括:App Chat 标准请求体为 OpenAPI raw{ appId, chatId },Skill Preview 请求体为{ skillId, chatId },均未向前端暴露内部sourceType/sourceId;Skill Preview 下不出现语音、TTS、input guide、ChatBox 内 App 沙盒入口;编辑沙盒 ticket 请求体为{ skillId, chatId: "edit-debug" },keepalive 显示sourceType=skillEdit/sourceId=.../appId=None/chatId=edit-debug;本地 MongoDB 中chats/chatitems/chat_item_responses均写入正确的sourceType,usage 归因正确。
小结
这套sourceType/sourceId双资源模型的核心价值在于:用最小的存储迁移成本(不重命名字段、不回填历史数据),完成了最大的语义隔离收益——App 与 Skill Edit 共享整套标准 Chat 能力,却互不污染 usage、日志、sandbox、S3 与 stop/resume 运行时状态。其设计精髓可归纳为四条原则:对外 API 只暴露appId/skillId业务字段、对内业务层统一sourceType/sourceId、App 查询长期兼容缺失sourceType的 legacy 数据、所有新代码禁止绕过统一 helper 手写查询或 key 构造。对于需要在单套会话存储上承载多种资源类型的类似系统,这套方案具备很高的直接参考价值。
【免费下载链接】FastGPTFastGPT is a knowledge-based platform built on the LLMs, offers a comprehensive suite of out-of-the-box capabilities such as data processing, RAG retrieval, and visual AI workflow orchestration, letting you easily develop and deploy complex question-answering systems without the need for extensive setup or configuration.项目地址: https://gitcode.com/GitHub_Trending/fa/FastGPT
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考