Commit 18a35bfc by Archer Committed by GitHub

.codex (#6832)

parent ff6bb82c
# Context
当前 `opensandbox` 分支已经暴露了 OpenSandbox provider 环境变量,并在运行时通过 `SandboxClient` 走 provider 分支,但 OpenSandbox 的“持久化配置”仍然散落在 `SandboxClient` 内部,没有形成独立的配置抽象,也没有 volume-manager / `createConfig.volumes` / 实例详情落库这条完整链路。
相比之下,`agent-skill-dev` 已经把这部分能力拆成了独立配置模块,并通过 volume-manager 为 session/runtime 准备持久化卷,再将存储与运行时详情写入 sandbox 实例记录。
本次推荐方案的目标不是整包迁入 `agentSkills` 上层编排,而是:**保留当前分支以 `SandboxClient` 为唯一消费入口的形态,将 OpenSandbox 持久化配置能力下沉到 `packages/service/core/ai/sandbox` 域内完成抽离**。这样可以在最小扰动现有 workflow/tool 调用链的前提下,把持久化卷、配置解析和实例详情持久化补齐。
# Recommended Approach
## 1. 先把配置抽象从 `SandboxClient` 构造函数中抽离
新增统一配置模块,建议放在:
- `packages/service/core/ai/sandbox/config.ts`
`agent-skill-dev` 复用并改造以下能力,统一收敛到 sandbox 域:
- `getSandboxProviderConfig`
- `getVolumeManagerConfig`
- `buildVolumeConfig`
- `buildBaseContainerEnv`
这样 `packages/service/core/ai/sandbox/controller.ts` 不再直接读取 env 并内联拼 provider 参数,而是只消费配置模块输出。
## 2. 补齐 provider 类型与实例详情结构
当前事实:
- `packages/service/env.ts` 已支持 `opensandbox`
- `packages/service/core/ai/sandbox/type.ts``SandboxProviderSchema` 仍只有 `sealosdevbox`
需要在:
- `packages/service/core/ai/sandbox/type.ts`
- `packages/service/core/ai/sandbox/schema.ts`
补齐以下结构:
- `SandboxProviderSchema` 至少包含 `opensandbox`
- 实例 `detail`:provider 运行时返回信息、endpoint、连接信息
- 实例 `storage`:volume 标识、claimName、mountPath、provider storage payload
- 实例 `metadata`:session 维度标识、createConfig 摘要、迁移/兼容标记
要求:新字段全部可选,保证旧记录仍可读。
## 3. 在 `SandboxClient` 内接入持久化卷准备与详情落库
核心改造文件:
- `packages/service/core/ai/sandbox/controller.ts`
推荐保留 `SandboxClient` 作为唯一消费入口,内部改造为:
1. 根据业务标识(`appId/userId/chatId``sandboxId`)构建稳定 session key
2. 调用 `config.ts` 读取 provider 配置
3. 如果 provider 为 `opensandbox`,则调用 volume-manager 配置与卷构建逻辑
4. 将 volume 信息塞入 `createConfig.volumes`
5. 创建/恢复实例后,把 `detail/storage/metadata` 写入 `MongoSandboxInstance`
复用目标分支的关键能力时,优先迁“配置与卷构建逻辑”,不要直接把 `agentSkills` 生命周期编排整包搬入。
## 4. 保持业务入口不变,只做最小上下文透传
现有消费入口继续复用 `SandboxClient`
- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts`
- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`
这两个入口只做最小修改:
- 确保传入稳定的 `appId/userId/chatId`
- 不感知 provider 配置、volume-manager、存储细节
原则:**底层配置与持久化逻辑留在 sandbox 域,业务入口不扩散基础设施细节。**
## 5. 分两阶段实施
### 第一阶段(必须先迁)
1. 新增 `packages/service/core/ai/sandbox/config.ts`
2. 扩展 `packages/service/env.ts` 中 OpenSandbox 持久化相关 env
3. 扩展 `packages/service/core/ai/sandbox/type.ts`
4. 扩展 `packages/service/core/ai/sandbox/schema.ts`
5. 改造 `packages/service/core/ai/sandbox/controller.ts`
6. 最小化调整 workflow / tool 入口透传上下文
### 第二阶段(可后补)
1. 引入独立 lifecycle 管理(参考 `agent-skill-dev``.../sandbox/lifecycle.ts`
2. 区分 edit-debug 与 session-runtime 的 volume 策略
3. 补充 volume 清理、回滚、观测与旧数据回填能力
# Critical Files to Modify
必须修改:
- `packages/service/env.ts`
- `packages/service/core/ai/sandbox/type.ts`
- `packages/service/core/ai/sandbox/schema.ts`
- `packages/service/core/ai/sandbox/controller.ts`
- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts`
- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`
建议新增:
- `packages/service/core/ai/sandbox/config.ts`
目标分支中应重点参考、复用逻辑的来源文件:
- `packages/service/core/agentSkills/sandboxConfig.ts`
- `packages/service/core/agentSkills/sandboxController.ts`
- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts`
# Reuse Existing Functions and Patterns
优先复用当前仓库已验证的模式:
- 现有单一消费入口模式:`packages/service/core/ai/sandbox/controller.ts` 中的 `SandboxClient`
- 现有运行时消费入口:
- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts`
- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`
- 目标分支中可迁入的配置抽离函数:
- `getSandboxProviderConfig`
- `getVolumeManagerConfig`
- `buildVolumeConfig`
- `buildBaseContainerEnv`
复用原则:
- 复用“能力”与“结构”,不直接复制 `agentSkills` 上层调用链
- 将通用配置逻辑沉入 `core/ai/sandbox`,避免基础设施层依赖业务层
# Key Risks and Compatibility Notes
1. **类型不一致风险**
- 现在 env 支持 `opensandbox`,但 `SandboxProviderSchema` 不支持
- 必须先统一类型层,再改控制器逻辑
2. **旧数据兼容风险**
- 历史 `agent_sandbox_instances` 没有 `detail/storage/metadata`
- 新字段必须 optional,读取逻辑必须支持旧数据回退到 `provider + sandboxId`
3. **provider 分支串扰风险**
- OpenSandbox 的 volume 逻辑不能污染 `sealosdevbox` / `e2b`
- `createConfig.volumes` 只能在对应 provider 下启用
4. **依赖反转风险**
- 不要让 `core/ai/sandbox` 反向依赖 `agentSkills`
- 通用配置能力必须落在 sandbox 域内
5. **卷幂等与清理风险**
- 第一阶段至少保证 ensure volume 幂等
- 清理/回滚放第二阶段补齐
# Verification
## 单测
1. `config.ts`
- `getSandboxProviderConfig`:不同 provider 输出正确;缺失关键 env 时给出明确错误
- `getVolumeManagerConfig`:能正确解析 volume-manager 配置
- `buildVolumeConfig`:相同 session 上下文生成稳定 volume 配置
- `buildBaseContainerEnv`:容器基础 env 正确且不泄漏无关敏感信息
2. `type.ts` / `schema.ts`
- 旧记录仅有基础字段时可通过读取
- 新记录包含 `detail/storage/metadata` 时可通过校验
3. `controller.ts`
- OpenSandbox 创建时会注入 `createConfig.volumes`
- 创建后会写入 `detail/storage/metadata`
- 旧实例记录仍可兼容
- 非 OpenSandbox provider 行为不变
## 集成测试
1. 通过 `dispatchSandboxShell` 触发首次 session sandbox 创建
2. 检查 `agent_sandbox_instances` 中是否写入 `detail/storage/metadata`
3. 同一 `appId + userId + chatId` 再次进入时,验证 volume 信息可复用
4.`toolCall.ts` 链路触发一次 sandbox 执行,确认入口无需感知底层 volume 配置
## 人工验证
1. 配齐 OpenSandbox + volume-manager 环境变量后启动服务
2. 首次会话进入 sandbox,写入一个测试文件
3. 结束请求后使用相同 `appId/userId/chatId` 再次进入
4. 验证测试文件仍然存在,且实例记录中保留 storage/detail 信息
5. 切换到 `sealosdevbox` / `e2b` 时,验证旧链路行为不变
# 代码规范
## 基础代码组织模式
采用 DDD 架构,按业务域 → 子功能 → 固定文件三层划分。
### 目录结构
```
packages/
├── global/core/ # 类型、常量(前后端共享)
│ ├── app/
│ │ ├── type.ts # 顶层聚合类型
│ │ ├── constants.ts
│ │ ├── workflow/
│ │ │ ├── type.ts
│ │ │ └── constants.ts
│ │ ├── version/
│ │ │ └── type.ts
│ │ └── evaluation/
│ │ └── type.ts
│ ├── chat/
│ ├── dataset/
│ └── plugin/
└── service/core/ # 后端业务逻辑(不可在前端引用)
├── app/
│ ├── schema.ts # App 主表 Mongoose Schema
│ ├── entity.ts # findById / create / updateById 等基础操作封装
│ ├── service.ts # 聚合业务逻辑(跨子功能协调),不允许互相引用,只允许单向依赖,跨 service 的协调需由上层通过 props 传入另一个 service 或者衍生方法
│ ├── auth.ts # 鉴权相关(如有)
│ ├── utils.ts # 纯函数工具,无副作用,可独立单测
│ ├── version/
│ │ ├── schema.ts
│ │ ├── entity.ts
│ │ ├── service.ts
│ │ └── utils.ts
│ ├── evaluation/
│ │ ├── schema.ts # 合并多个 schema 到单文件
│ │ ├── entity.ts
│ │ ├── service.ts
│ │ └── utils.ts
│ ├── logs/
│ └── tool/
│ ├── service.ts
│ └── utils.ts
├── chat/
├── dataset/
└── plugin/
```
### 叶子目录固定文件说明
| 文件 | 职责 |
|------|------|
| `schema.ts` | Mongoose Schema 定义,导出 Model 和 SchemaType |
| `entity.ts` | 数据访问封装:`findById``create``updateById` 等基础操作 |
| `service.ts` | 业务逻辑:调用 entity,跨模块协调,处理业务规则 |
| `utils.ts` | 纯函数工具,无副作用,可独立单测 |
```typescript
// entity.ts 示例 —— 只做数据访问,不含业务判断
export const findAppById = (id: string) =>
MongoApp.findById(id).lean();
export const createApp = (data: AppCreateParams, session?: ClientSession) =>
MongoApp.create([data], { session });
// service.ts 示例 —— 调用 entity,处理业务规则
export const createAppAndInitVersion = async (data: AppCreateParams, session?: ClientSession) => {
const app = await createApp(data, session);
await createVersion({ appId: app._id, ... }, session);
return app;
};
// service 需协同,通过 props 传入另一个 service 或者衍生方法。
const service1 = xxxx
const service2 = (props: {id:string; service1: typeof service1 }) => {
const data = findAppById(id)
return props.service1(data);
};
```
### 层级约束
- `global/core/` 只放类型和常量,**禁止**引入 mongoose、服务端 SDK
- `service/core/` 只在服务端使用,**禁止**`packages/web/` 或前端页面直接引用
- 子功能目录不超过 **3 层**嵌套
- 一个目录内无需拆子功能时,直接放 `schema.ts` + `entity.ts` + `service.ts` + `utils.ts`
- 多个 schema 文件(如 `evalSchema.ts` + `evalItemSchema.ts`**合并**到单个 `schema.ts`
## 代码风格
### 使用 `type` 进行类型声明,不使用 `interface`
```typescript
// ❌ 不好的实践
interface User {
id: string;
name: string;
}
// ✅ 好的实践
type User = {
id: string;
name: string;
}
```
---
### 使用 IIFE 写法来取代 if/else 进行变量条件赋值。
```typescript
// ❌ 不好的实践
if (condition) {
value = true;
} else {
value = false;
}
// ✅ 好的实践
const value = (() => {
if (condition) {
return true;
}
return false;
})();
```
---
### 类型推导:Zod schema 同时承担校验和类型
`z.infer` 从 schema 推导类型,不重复手写相同结构的 type。
```typescript
// ❌ 不好的实践
type MessageParam = { role: 'user' | 'assistant'; content: string };
const MessageParamSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string() });
// ✅ 好的实践
export const MessageParamSchema = z.discriminatedUnion('role', [...]);
export type MessageParam = z.infer<typeof MessageParamSchema>;
```
---
## 可选链调用回调
`?.()` 调用可选回调,取代 `if (fn) fn()` 的冗余写法。
```typescript
// ❌ 不好的实践
if (onProgress) {
onProgress({ phase: 'creatingContainer' });
}
// ✅ 好的实践
onProgress?.({ phase: 'creatingContainer' });
```
---
### 空值合并取默认值
`??` 取代 `||` 处理默认值,避免 `0``false``''` 被错误覆盖。
```typescript
// ❌ 不好的实践
const version = lastVersion?.version || 0; // version 为 0 时被误覆盖
const text = item?.value || '';
// ✅ 好的实践
const version = (lastVersion?.version ?? -1) + 1;
const text = item?.value ?? '';
```
---
### 解构重命名
同名变量来自多个来源时,解构时重命名,避免命名冲突。
```typescript
// ❌ 不好的实践
const r1 = await getSkillGuidance(...);
const r2 = await createLLMResponse(...);
const inputTokens = r1.usage.inputTokens + r2.usage.inputTokens;
// ✅ 好的实践
const { usage: guidanceUsage } = await getSkillGuidance(...);
const { usage: generateUsage } = await createLLMResponse(...);
const inputTokens = guidanceUsage.inputTokens + generateUsage.inputTokens;
```
---
### 类型守卫
`is` 关键字收窄 `unknown` / `any` 类型,替代强制断言。
```typescript
// ❌ 不好的实践
function process(value: unknown) {
const n = value as number; // 不安全
}
// ✅ 好的实践
const isValidNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
if (isValidNumber(value)) {
// 此处 value 安全收窄为 number
}
```
---
### 非关键清理用 `.catch()` 链
次要的清理操作(不影响主流程)用 `.catch()` 吞掉错误,不污染主 try/catch。
```typescript
// ❌ 不好的实践
try {
await client.delete();
} catch {
// 清理失败,主流程中断
}
// ✅ 好的实践
await client.delete().catch(() => {});
```
---
### 函数参数不超过 2 个,多参数用对象传递
独立参数不超过 2 个,超过时改为对象参数,便于扩展且无需关心顺序。
```typescript
// ❌ 不好的实践
function createVersion(skillId: string, teamId: string, tmbId: string, version: number) {}
// ✅ 好的实践
function createVersion(data: { skillId: string; teamId: string; tmbId: string; version: number }) {}
```
---
### 数据写操作函数支持可选 session 参数
涉及数据库写操作的函数统一支持可选的 `session` 参数,便于上层组合事务。事务统一通过 `mongoSessionRun` 发起,内部自动处理 startTransaction / commit / abort / retry。
```typescript
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { type ClientSession } from '@fastgpt/service/common/mongo';
// entity.ts —— 基础操作透传 session
export const createVersion = (data: CreateVersionData, session?: ClientSession) =>
MongoAppVersion.create([data], { session });
// service.ts —— 需要事务时用 mongoSessionRun 包裹,外部已有 session 时直接传入
export const createAppAndInitVersion = async (
data: AppCreateParams,
session?: ClientSession
) => {
const create = async (session: ClientSession) => {
const app = await createApp(data, session);
await createVersion({ appId: app._id, version: 0 }, session);
return app;
};
if (session) {
return create(session);
} else {
return mongoSessionRun(create);
}
};
```
# 功能开发文档
# 功能开发文档
## 文档标识
- 任务前缀:`md-encoding-repair`
- 文档文件名:`md-encoding-repair-功能开发文档.md`
- 文档状态:已补全(实施完成版)
- 最后更新:2026-04-15
## 0. 开发目标与约束
- 功能目标:修复“Markdown 文件前部英文导致编码误判为 `ascii`,从而中文乱码”的问题。
- 核心策略:`UTF-8 严格校验优先(BOM + 字节级合法性校验)`,失败后再进入探测回退。
- 代码范围:
- `FastGPT/packages/global/common/file/tools.ts`
- `FastGPT/packages/service/worker/readFile/extension/rawText.ts`
- `FastGPT/test/cases/global/common/file/tool.test.ts`
- `FastGPT/test/cases/service/common/file/read/encoding.regression.test.ts`
- 非目标(明确不做):API 协议、DB schema、前端交互改造。
- 必须遵循规范:`/Users/xxyyh/.codex/skills/fastgpt-requirement-design/references/style-standards-entry.md`
- 适用维度(从需求分析继承):API[ ] DB[ ] Front[ ] Logger[ ] Package[x]
## 1. 实施任务拆解(执行结果)
| 任务ID | 任务名称 | 责任层 | 执行结果 | 完成定义(DoD) | 状态 |
|---|---|---|---|---|---|
| T1 | 重构编码检测策略 | Service(global utils) | `detectFileEncoding` 改为 BOM + UTF-8 严格校验优先;失败再 fallback 探测 | 不再依赖“仅前 200 字节” | ✅ 已完成 |
| T2 | 增加 `ascii` 误判兜底 | Service(global + worker) | 检测层与解码层都对 `ascii` + 非 ASCII 字节做兜底 | 不再按错误 `ascii` 解码中文 | ✅ 已完成 |
| T3 | 补充/修正单测 | Test | 增加 BOM、长英文前缀+中文正文等用例 | 新场景稳定通过 | ✅ 已完成 |
| T4 | 解码层轻量保护落地 | Service(worker) | `readFileRawText` 增加 `ascii` 误判兜底,并复用全局 `hasNonAsciiByte` | 上游误判时仍输出可读文本 | ✅ 已完成 |
| T5 | 编码回归矩阵补齐 | Test | 新增 `encoding.regression.test.ts` 覆盖 4 类编码回归场景 | 回归矩阵全通过 | ✅ 已完成 |
## 2. 文件级改动清单
| 文件路径 | 改动类型 | 变更摘要 | 关联任务ID |
|---|---|---|---|
| `FastGPT/packages/global/common/file/tools.ts` | 修改 | 新增 `hasUtf8Bom``isValidUtf8``getDetectSample`、导出 `hasNonAsciiByte``detectFileEncoding` 改为验证优先策略 | T1,T2 |
| `FastGPT/packages/service/worker/readFile/extension/rawText.ts` | 修改 | 删除本地重复 `hasNonAsciiByte`,改为复用全局工具;`ascii` + 非 ASCII 字节时改走 UTF-8 解码 | T2,T4 |
| `FastGPT/test/cases/global/common/file/tool.test.ts` | 修改 | 新增 UTF-8 BOM 测试;新增“长英文前缀 + 中文正文”测试;替换旧弱断言用例语义 | T3 |
| `FastGPT/test/cases/service/common/file/read/encoding.regression.test.ts` | 新增 | 新增编码回归矩阵(UTF-8 混排、ASCII、ascii 误传、非法 UTF-8) | T5 |
## 3. 后端实施说明
### 3.1 API 改动
| 路由 | 方法 | 请求参数 | 响应结构 | 鉴权 | 错误处理 |
|---|---|---|---|---|---|
| N/A | N/A | N/A | N/A | N/A | N/A |
说明:本需求仅涉及文件编码判定与解码策略,不改 API 合约。
### 3.2 Service/Core 改动
| 模块 | 函数/类型 | 具体改动 | 依赖关系 |
|---|---|---|---|
| `packages/global/common/file/tools.ts` | `detectFileEncoding` | 判定顺序改为:`BOM -> strict UTF-8 validate -> jschardet fallback`;对 `ascii` + 非 ASCII 字节做兜底 | 仅使用现有 `jschardet`,未新增依赖 |
| `packages/global/common/file/tools.ts` | `hasNonAsciiByte` | 由私有函数改为导出,供多处复用 | 复用方为 service worker 解码逻辑 |
| `packages/service/worker/readFile/extension/rawText.ts` | `readFileRawText` | 新增 `normalizedEncoding`,在 `encoding='ascii'` 且检测到非 ASCII 字节时强制按 UTF-8 解码 | 复用 `@fastgpt/global/common/file/tools` |
### 3.3 数据层改动
| 集合/表 | 字段 | 类型 | 必填 | 默认值 | 索引 | 迁移策略 |
|---|---|---|---|---|---|---|
| N/A | N/A | N/A | N/A | N/A | N/A | 无需迁移 |
## 4. 前端实施说明
| 页面/组件 | 文件路径 | 交互变化 | i18n 改动 | 状态覆盖 |
|---|---|---|---|---|
| N/A | N/A | 无 | 无 | N/A |
## 5. 日志与可观测性
| 触发点 | 日志级别 | category | 字段 | 备注 |
|---|---|---|---|---|
| 本期无新增日志点 | N/A | N/A | N/A | 为最小改动未加新日志,后续可按需要加 debug 观测 |
## 6. 测试与验证
### 6.1 自动化测试清单(已执行)
| 测试文件 | 覆盖重点 | 结果 |
|---|---|---|
| `test/cases/global/common/file/tool.test.ts` | 编码检测基础能力(UTF-8、ASCII、BOM、混排场景) | ✅ 通过 |
| `test/cases/service/common/file/read/utils.test.ts` | 文件读取主链路(readFileContentByBuffer) | ✅ 通过 |
| `test/cases/service/common/file/gridfs/utils.test.ts` | 预览流编码链路(stream2Encoding) | ✅ 通过 |
| `test/cases/service/common/file/read/encoding.regression.test.ts` | 编码回归矩阵(4 场景) | ✅ 通过 |
### 6.2 回归矩阵(新增)
| 场景 | 输入 | 预期 | 结果 |
|---|---|---|---|
| UTF-8 混排正文 | 长英文前缀 + 中文正文(UTF-8) | 检测 `utf-8`,中文可读 | ✅ |
| 纯 ASCII 文本 | `Hello ASCII 123` | 行为不变 | ✅ |
| `ascii` 误传 | UTF-8 中文 buffer + `encoding='ascii'` | 触发兜底,中文可读 | ✅ |
| 非法 UTF-8 序列 | 非 UTF-8 合法字节序列 | 不应判为 `utf-8` | ✅ |
### 6.3 执行命令与结果
执行命令:
```bash
pnpm -C FastGPT exec vitest run \
test/cases/global/common/file/tool.test.ts \
test/cases/service/common/file/read/utils.test.ts \
test/cases/service/common/file/gridfs/utils.test.ts \
test/cases/service/common/file/read/encoding.regression.test.ts
```
结果摘要:
- Test Files: `4 passed (4)`
- Tests: `45 passed (45)`
### 6.4 UTF-8 严格校验性能检测报告(新增)
#### 6.4.1 检测背景
- 目标:评估 `isValidUtf8(buffer)` 全量线性扫描在大文件场景下的 CPU 开销,确认是否需要阈值门控。
- 方法:在本地通过 Node.js 基准脚本,复用当前实现逻辑,分别对 ASCII 缓冲区与中英混排 UTF-8 缓冲区做多轮扫描取平均值。
#### 6.4.2 检测结果(平均单次扫描耗时)
| 文件大小 | ASCII only | UTF-8 中英混排 |
|---|---:|---:|
| 10MB | 16.20ms | 32.21ms |
| 50MB | 84.04ms | 87.52ms |
| 100MB | 160.06ms | 169.08ms |
| 200MB | 327.01ms | 345.51ms |
| 500MB | 894.27ms | 880.22ms |
补充:实测吞吐约 `560~620 MB/s`,整体符合线性增长特征(O(n))。
#### 6.4.3 结论与策略
- 20MB 以下:开销较小,通常无体感影响。
- 50~100MB:开始出现可感知延迟。
- 200MB 以上:单次扫描约 300ms+,并发场景下会放大 CPU 压力。
- 500MB:接近 1 秒/次,不建议默认全量严格校验。
建议落地策略:
1. 交互链路默认仅对 `<=32MB`(或 `<=64MB`)执行全量 UTF-8 严格校验。
2. 超过阈值时跳过全量校验,改走采样探测 fallback。
3. 后续可按线上机器规格和峰值并发再微调阈值。
## 7. 质量自检清单
- [x] 输入与权限流程未被破坏(未改 API/权限逻辑)
- [x] 无新增 `any` 滥用、无未处理 Promise
- [x] 包依赖方向符合 monorepo 约束(`service` 复用 `global`
- [x] 覆盖关键回归场景(UTF-8 混排、ascii 误判兜底)
- [x] 无新增敏感日志输出
## 8. 发布与回滚
### 8.1 发布步骤
1. 合并代码后在测试环境执行上述 4 组回归测试。
2. 手工上传 UTF-8 中英混排 Markdown,确认预览与入库内容正常。
3. 观察线上相关解析失败反馈。
### 8.2 回滚触发条件
- 发布后出现明显新增的非 UTF-8 文本解析失败反馈。
### 8.3 回滚步骤
1. 回滚 `FastGPT/packages/global/common/file/tools.ts``FastGPT/packages/service/worker/readFile/extension/rawText.ts`
2. 重新发布并复测上传链路。
## 9. AI 实施提示(给执行模型)
- 编码检测必须坚持“验证优先”,禁止回退到“仅前缀猜测优先”。
- 若后续扩展多候选编码评分,单独开需求,不在本任务内扩大范围。
- 每次改动编码策略后,必须至少跑本文件第 6.3 节的回归命令。
# 需求设计文档
# 需求设计文档
## 0. 文档标识
- 任务前缀:`md-encoding-repair`
- 文档文件名:`md-encoding-repair-需求设计文档.md`
- 文档状态:已补全(实施回填版)
- 最后更新:2026-04-15
## 1. 需求背景与目标
### 1.1 背景
- 问题现状:知识库上传 `.md` 文件时,若文件开头英文占比高,系统可能将编码误判为 `ascii`,随后按 `ascii` 解码整篇文本,导致中文出现乱码。
- 触发场景:`md/txt/csv/html` 等文本文件,文件前部主要为英文,中文内容出现在较后位置。
### 1.2 目标
- 业务目标:保证知识库文档上传后中文内容可被正确解析并进入分段/训练链路。
- 技术目标:避免“前部英文导致整篇 `ascii` 误判”的编码检测缺陷。
- 成功指标(可量化):
- 构造“前 500+ 字节英文、后续中文”的 UTF-8 Markdown 文件,解析结果中文不乱码。
- 纯英文 ASCII 文件解析结果保持不变。
- 编码相关回归测试全量通过。
## 2. 当前项目事实基线(基于代码)
| 能力项 | 现有实现位置(文件路径) | 现状说明 | 结论(复用/修改/新增) |
|---|---|---|---|
| API | `FastGPT/projects/app/src/pages/api/core/dataset/collection/create/fileId.ts` | 上传后创建文件型 collection 的入口;本身不处理编码,只触发后续解析流程 | 复用 |
| Core Service | `FastGPT/packages/service/core/dataset/read.ts` -> `FastGPT/packages/service/common/s3/sources/dataset/index.ts` | 读取 S3 文件后调用 `detectFileEncoding(buffer)`,再把 encoding 传给解析器 | 复用调用链,修改编码策略实现 |
| Worker Decode | `FastGPT/packages/service/worker/readFile/extension/rawText.ts` | 根据上游 encoding 对文本 buffer 解码 | 新增解码兜底保护 |
| DB Schema | `FastGPT/packages/service/core/dataset/collection/schema.ts` | 当前问题与 DB 结构无关 | 不改 |
| Frontend | `FastGPT/projects/app/src/pageComponents/dataset/detail/Import/diffSource/FileLocal.tsx` | 前端只负责上传到 S3,不决定后端解析编码 | 不改 |
| Logger | `FastGPT/packages/service/common/s3/sources/dataset/index.ts` | 当前已有下载日志;编码误判场景无专门日志 | 本期不改 |
关键代码锚点(实施后):
- 编码判定入口:`FastGPT/packages/global/common/file/tools.ts``detectFileEncoding`(已改为验证优先)
- 解码兜底:`FastGPT/packages/service/worker/readFile/extension/rawText.ts``readFileRawText`
## 3. 需求澄清记录
| 维度 | 已确认内容 | 待确认内容 | 备注 |
|---|---|---|---|
| 业务目标 | 修复 Markdown 上传中文乱码 | 是否覆盖更多小众本地编码(如 Shift_JIS) | 后续二期可评估 |
| 范围边界 | 聚焦知识库/读文件链路的编码判定与解码保护 | 是否新增线上编码判定日志点 | 本期不做 |
| 权限模型 | 无权限模型变化 | 无 | N/A |
| 数据模型 | 不新增字段 | 无 | N/A |
| API 行为 | 不改 API 入参/出参 | 无 | N/A |
| 前端交互 | 不改页面交互 | 无 | N/A |
## 3.1 影响域判定(先判定,再核对规范)
| 维度 | 是否命中 | 证据(需求/代码锚点) | 核对规范 | 结论 |
|---|---|---|---|---|
| API | No | 编码问题发生在服务端文件解析,现有路由仅透传 fileId | `style/api.md` | Not Applicable(不改接口) |
| DB | No | 不涉及 schema/索引/数据迁移 | `style/db.md` | Not Applicable |
| Front | No | 上传流程不参与后端解码决策 | `style/front.md` | Not Applicable |
| Logger | No(本期) | 本期目标为最小可控修复,未新增观测点 | `style/logger.md` | Not Applicable |
| Package | Yes | 修改位置在 `packages/global``packages/service`,涉及跨包复用 | `style/package.md` | 已遵守依赖方向(service 依赖 global) |
## 4. 范围定义
### 4.1 In Scope(本期必须)
-`detectFileEncoding` 从“猜测优先”调整为“验证优先”。
- 增加 `ascii` 误判保护(检测层+解码层双保险)。
- 补充编码回归测试矩阵,覆盖核心场景。
### 4.2 Out of Scope(本期不做)
- 不改知识库 API 协议。
- 不改数据库结构。
- 不做前端上传流程改造。
- 不引入多候选编码评分引擎(后续可独立需求)。
## 5. 方案对比
| 方案 | 核心思路 | 优点 | 风险 | 实施成本 | 结论 |
|---|---|---|---|---|---|
| 方案A(最小改动) | `UTF-8 验证优先(BOM + 严格字节校验)` + fallback 探测 + `ascii` 兜底 | 改动集中、确定性高、能直接修复现网主问题 | 对极端小众编码仍依赖 fallback | 低 | 推荐并已落地 |
| 方案B(可扩展) | 多候选编码试解 + 文本质量打分 | 覆盖更多边缘编码场景 | 复杂度与误判调参成本高 | 中-高 | 本期不选 |
推荐方案:方案A(已实施)。
## 6. 推荐方案详细设计(实施回填)
### 6.1 API 设计
| 路由 | 方法 | 鉴权 | 请求 | 响应 | 错误分支 | 相关文件 |
|---|---|---|---|---|---|---|
| N/A | N/A | N/A | N/A | N/A | N/A | 不涉及 |
### 6.2 数据设计
| 实体/集合 | 字段 | 类型 | 必填 | 默认值 | 索引/约束 | 兼容策略 |
|---|---|---|---|---|---|---|
| N/A | N/A | N/A | N/A | N/A | N/A | N/A |
### 6.3 核心代码设计
| 模块 | 关键函数/类型 | 实际变更 | 上下游影响 |
|---|---|---|---|
| `FastGPT/packages/global/common/file/tools.ts` | `detectFileEncoding(buffer)` | 判定顺序调整为:`hasUtf8Bom -> isValidUtf8 -> detect(getDetectSample)`;并增加 `ascii + 非 ASCII 字节` 兜底 | 所有调用方收益(知识库、工作流读文件、预览编码头) |
| `FastGPT/packages/global/common/file/tools.ts` | `hasNonAsciiByte(buffer)` | 从私有函数提升为导出工具函数,供多处复用 | 减少重复实现,维护单一事实源 |
| `FastGPT/packages/service/worker/readFile/extension/rawText.ts` | `readFileRawText` | 新增 `encoding='ascii'` 且存在非 ASCII 字节时强制 UTF-8 解码保护 | 防止上游误判导致中文乱码 |
| `FastGPT/test/cases/global/common/file/tool.test.ts` | `detectFileEncoding` tests | 新增 BOM 场景、长英文前缀+中文正文场景 | 防回归 |
| `FastGPT/test/cases/service/common/file/read/encoding.regression.test.ts` | 编码回归矩阵 | 新增 4 场景覆盖:UTF-8 混排、ASCII、ascii 误传、非法 UTF-8 | 回归覆盖补齐 |
### 6.4 前端设计
| 页面/组件 | 入口文件 | 交互状态(加载/空/错/成功) | i18n key | 变更说明 |
|---|---|---|---|---|
| N/A | N/A | N/A | N/A | 本期不涉及 |
### 6.5 日志与观测设计
| 场景 | 日志级别 | category | 结构化字段 | 脱敏策略 |
|---|---|---|---|---|
| 本期无新增日志 | N/A | N/A | N/A | N/A |
## 7. 风险、迁移与回滚
### 7.1 风险清单
- 风险1:严格 UTF-8 校验为 O(n) 线性扫描,超大文本文件会增加少量 CPU。
- 风险2:小众非 UTF-8 编码仍可能依赖 fallback 的稳定性。
### 7.2 迁移策略
- 无 DB 迁移。
- 通过新增回归矩阵 + 现有测试集进行功能验证。
### 7.3 回滚策略
- 回滚目标文件:
- `FastGPT/packages/global/common/file/tools.ts`
- `FastGPT/packages/service/worker/readFile/extension/rawText.ts`
- 回滚触发条件:发布后出现显著新增的“非 UTF-8 文本解析异常”反馈。
## 8. 验收标准(执行结果)
| 验收项 | 验收方式 | 通过标准 | 结果 |
|---|---|---|---|
| UTF-8 混合文档不乱码 | 自动化测试 + 手测路径定义 | 中文片段解析正常 | ✅ 通过 |
| 纯 ASCII 文档兼容 | 自动化测试 | 行为不回归 | ✅ 通过 |
| `ascii` 误传防护 | 自动化测试 | 解码结果中文可读 | ✅ 通过 |
| 回归安全 | 编码相关测试集合 | 全部通过 | ✅ 45/45 |
已执行测试命令:
```bash
pnpm -C FastGPT exec vitest run \
test/cases/global/common/file/tool.test.ts \
test/cases/service/common/file/read/utils.test.ts \
test/cases/service/common/file/gridfs/utils.test.ts \
test/cases/service/common/file/read/encoding.regression.test.ts
```
测试结果摘要:
- Test Files: `4 passed (4)`
- Tests: `45 passed (45)`
## 9. MECE 核查结论(实施后)
### 9.1 相互独立检查结果
- 发现问题:编码检测与解码保护存在重复判断风险。
- 影响范围:后续维护可能出现策略漂移。
- 修订动作:统一由 `tools.ts` 提供通用工具(`hasNonAsciiByte`),`rawText.ts` 仅做末端防护。
- 修订后结果:职责清晰,复用一致。
### 9.2 完全穷尽检查结果
- 发现问题:仅修检测层不足以防止历史调用链误传 `ascii`
- 影响范围:部分链路仍可能乱码。
- 修订动作:增加解码层二次兜底 + 回归矩阵覆盖误传场景。
- 修订后结果:正常/异常链路覆盖完整。
### 9.3 修订动作与最终边界
- 本期聚焦编码检测与解码兜底,不扩展 API/DB/前端。
- 后续若要支持更广泛本地编码智能识别,建议单开“多候选评分”二期需求。
# FastGPT Logger 使用规范
> 基于当前项目实现(`packages/service/common/logger`)整理的统一日志规范与使用指引。
## 1. 统一入口
**后端统一使用** `@fastgpt/service/common/logger`,不要直接用 `console.*`
```ts
import { configureLogger, getLogger, LogCategories } from '@fastgpt/service/common/logger';
await configureLogger();
const logger = getLogger(LogCategories.MODULE.DATASET.QUEUES);
logger.info('Vector queue task started', { teamId, datasetId, queueSize });
```
**注意**:
- `configureLogger()` 仅需调用一次,通常在服务启动/入口处初始化。
- `getLogger()` 不传 category 时默认 `['system']`,但建议显式传 `LogCategories`
## 2. 分类(Category)规范
**必须使用项目内置的 `LogCategories`**,不要自定义字符串数组。
类别选择建议:
- `LogCategories.SYSTEM`:系统级初始化、全局状态。
- `LogCategories.INFRA.*`:数据库、缓存、对象存储、队列等基础设施。
- `LogCategories.HTTP.*`:HTTP 请求、响应、错误。
- `LogCategories.MODULE.*`:业务模块(参考 `pages/api` 路径,省略 `core/support` 前缀)。
- `LogCategories.EVENT.*`:事件/埋点类日志。
- `LogCategories.ERROR`:跨模块的错误汇总日志。
当现有类别不足时:
-`packages/service/common/logger/categories.ts` 中补充。
- 保持层级语义清晰,避免过深或过宽。
## 3. 日志等级使用建议
- `trace`:极高频、细粒度流程追踪(默认仅开发环境开启)。
- `debug`:调试信息、队列长度、循环状态、重试过程。
- `info`:关键流程节点、成功状态、启动与完成。
- `warn`:可恢复异常、可忽略的异常条件。
- `error`:失败、异常退出、需要定位的问题。
- `fatal`:不可恢复错误,通常伴随进程退出。
## 4. 结构化日志规范
**日志由「稳定消息 + 结构化字段」组成**,避免在消息里拼大段 JSON。
推荐写法:
```ts
logger.info('Schedule trigger scan completed', { dueCount, durationMs });
```
不推荐:
```ts
logger.info(`Scan completed: ${JSON.stringify({ dueCount, durationMs })}`);
```
**自动补齐规则**:
- 当调用 `logger.info('msg', { ... })` 时,会自动将消息格式化为 `msg: {*}`
- 如果不希望追加 `{*}`,可添加 `verbose: false`:
```ts
logger.info('Request received', { verbose: false, requestId, method, url });
```
## 5. 请求链路与上下文
服务端推荐通过 `withContext` 注入 `requestId` 等上下文:
```ts
import { withContext } from '@fastgpt/service/common/logger';
return withContext({ requestId }, async () => {
logger.info('Request received', { requestId, method, url });
});
```
Next.js API 入口已在 `packages/service/common/middle/entry.ts` 统一处理请求日志与 `requestId`
## 6. 错误日志规范
**统一使用 `error` 字段记录错误对象**,并补充上下文:
```ts
try {
await doSomething();
} catch (error) {
logger.error('Do something failed', { error, appId, userId });
throw error;
}
```
避免:
-`err``e` 等不一致字段名。
- 只记录 `error.message` 丢失堆栈。
- 捕获后不记录、不抛出。
## 7. 敏感信息与 OTEL
**禁止记录敏感信息**: token、密钥、密码、完整聊天内容、隐私数据等。
如确需记录用于调试:
- 做脱敏或截断。
- 可添加 `fastgpt` 属性,避免 OTEL 导出(由 `sensitiveProperties` 过滤)。
```ts
logger.warn('Payload truncated for debug', {
fastgpt: true,
payloadPreview: payload.slice(0, 200)
});
```
## 8. 配置项(环境变量)
日志系统由 `configureLogger()` 读取环境变量:
- `LOG_ENABLE_CONSOLE` 是否开启控制台输出
- `LOG_CONSOLE_LEVEL` 控制台最低等级
- `LOG_ENABLE_OTEL` 是否开启 OTEL
- `LOG_OTEL_LEVEL` OTEL 最低等级
- `LOG_OTEL_SERVICE_NAME` OTEL 服务名
- `LOG_OTEL_URL` OTEL 收集器地址
## 9. 推荐示例
```ts
import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
const logger = getLogger(LogCategories.INFRA.MONGO);
logger.info('Mongo change stream watch started');
try {
await watchMongo();
} catch (error) {
logger.error('Mongo watch failed', { error, collection: 'system_config' });
}
```
# 梯度价格计算修复设计文档
## 问题描述
### 背景
梯度价格(Gradient Pricing)通过 `inputTokens` 数量来匹配不同的计费梯度:
```
梯度 0: inputTokens 0 ~ 1000 → 价格 X
梯度 1: inputTokens 1000+ → 价格 Y
```
### 根本原因
当一个工作流节点(如 Tool Call、Agent)在内部多次调用 LLM 时,旧逻辑是:
1. 将所有 LLM 调用的 `inputTokens` / `outputTokens` **累加**
2. 用累加后的总量调用 `formatModelChars2Points(totalInputTokens)` **一次性**计算价格
这样会导致梯度匹配错误:
```
场景:模型梯度 0~1000 tokens → 价格 A;1000+ → 价格 B(更低)
Call 1: inputTokens = 500 → 应匹配梯度 0,价格 A
Call 2: inputTokens = 600 → 应匹配梯度 0,价格 A
正确总价:A * 500/1000 + A * 600/1000
错误做法:累加 1100 tokens → 匹配梯度 1,价格 B
错误总价:B * 1100/1000(价格偏低,用户少付钱)
```
---
## 受影响的代码位置
### 1. `packages/service/core/ai/llm/agentCall/index.ts` — 根源
```ts
// 问题:在 while 循环中累加 tokens
inputTokens += usage.inputTokens;
outputTokens += usage.outputTokens;
// 每次调用单独计算价格并推送(当 usagePush 存在时),但不记录进返回值
const agentUsage = formatModelChars2Points({ inputTokens: usage.inputTokens, ... });
usagePush?.([{ totalPoints: agentUsage.totalPoints, ... }]);
// 返回的是累加值,调用方再次用累加值计算价格 → 重复错误
return { inputTokens, outputTokens, ... };
```
**后果:**
-`usagePush` 不传(如来自 `runToolCall`)时,单次计价被丢弃,调用方用累加值重算
-`usagePush` 传入(如来自 `masterCall`)时,单次计价已正确推送,但调用方仍用累加值做展示
### 2. `packages/service/core/workflow/dispatch/ai/tool/index.ts` (dispatchRunTools) — **计费 BUG**
```ts
// toolCallInputTokens = 所有轮次累加的 tokens
const { totalPoints: modelTotalPoints } = formatModelChars2Points({
inputTokens: toolCallInputTokens, // ❌ 累加值
outputTokens: toolCallOutputTokens
});
```
`runToolCall` 调用 `runAgentLoop`**不传 `usagePush`**,所以单次计价全部丢失,只依赖这里的累加计算 → **实际计费错误**
### 3. `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` (masterCall) — **展示 BUG**
```ts
// inputTokens = runAgentLoop 返回的累加值
const llmUsage = formatModelChars2Points({
inputTokens, // ❌ 累加值
outputTokens
});
```
虽然实际计费通过 `usagePush` 正确推送,但 `nodeResponse.totalPoints` 展示值错误。
### 4. `packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts` (dispatchPlanAgent) — **计费 + 展示 BUG**
```ts
// 再生成时累加 tokens
usage.inputTokens += regenerateResponse.usage.inputTokens;
usage.outputTokens += regenerateResponse.usage.outputTokens;
// 用累加值计算
const { totalPoints } = formatModelChars2Points({
inputTokens: usage.inputTokens, // ❌ 累加值
outputTokens: usage.outputTokens
});
```
---
## 修复方案
### 核心思路
**不应用累加的 token 数计算价格,而应该每次 LLM 调用单独计价,再累加价格。**
### 方案:`runAgentLoop` 返回预计算的 `llmTotalPoints`
`runAgentLoop` 的 while 循环中,每次 LLM 调用后立即计算该次的价格,并累加到 `llmTotalPoints`,最终将其作为返回值之一。调用方直接使用该预计算值,而不再重复调用 `formatModelChars2Points(累加 tokens)`
---
## 具体修改
### 修改 1:`runAgentLoop` — 增加 `llmTotalPoints` 返回值
**文件**`packages/service/core/ai/llm/agentCall/index.ts`
```ts
// RunAgentResponse 类型新增字段
type RunAgentResponse = {
...
llmTotalPoints: number; // ← 新增
inputTokens: number; // 保留,用于展示
outputTokens: number; // 保留,用于展示
...
};
// 内部实现
let llmTotalPoints: number = 0; // ← 新增
// while 循环内,每次 LLM 调用后:
const agentUsage = formatModelChars2Points({
model: modelData.model,
inputTokens: usage.inputTokens, // 当次调用的 tokens
outputTokens: usage.outputTokens
});
llmTotalPoints += agentUsage.totalPoints; // ← 累加价格(不是 tokens)
usagePush?.([{ totalPoints: agentUsage.totalPoints, ... }]);
// return 新增
return {
...
llmTotalPoints,
};
```
### 修改 2:`runToolCall` — 透传 `llmTotalPoints`
**文件**`packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`
```ts
// ResponseType 新增
type ResponseType = {
...
toolCallTotalPoints: number; // ← 新增(替代用累加 tokens 重算的方式)
toolCallInputTokens: number; // 保留展示用
toolCallOutputTokens: number; // 保留展示用
};
// runAgentLoop 返回后
const { inputTokens, outputTokens, llmTotalPoints, ... } = await runAgentLoop(...);
return {
...
toolCallTotalPoints: llmTotalPoints, // ← 透传
toolCallInputTokens: inputTokens,
toolCallOutputTokens: outputTokens,
};
```
### 修改 3:`dispatchRunTools` — 使用预计算值
**文件**`packages/service/core/workflow/dispatch/ai/tool/index.ts`
```ts
// 修改前(❌)
const { totalPoints: modelTotalPoints, modelName } = formatModelChars2Points({
model,
inputTokens: toolCallInputTokens,
outputTokens: toolCallOutputTokens
});
// 修改后(✅)
// modelName 直接从 toolModel.name 获取,无需再调用 formatModelChars2Points
const modelName = toolModel.name;
const modelTotalPoints = toolCallTotalPoints; // 直接使用预计算值,不再重算
```
### 修改 4:`masterCall` — 使用预计算值修正展示
**文件**`packages/service/core/workflow/dispatch/ai/agent/master/call.ts`
```ts
// runAgentLoop 返回 llmTotalPoints
const { inputTokens, outputTokens, llmTotalPoints, childrenUsages, ... } = await runAgentLoop(...);
// 修改前(❌)
const llmUsage = formatModelChars2Points({ model: agentModel, inputTokens, outputTokens });
// 修改后(✅)
const modelData = getLLMModel(agentModel);
const llmUsage = {
modelName: modelData.name,
totalPoints: llmTotalPoints // 使用预计算值
};
```
### 修改 5:`dispatchPlanAgent` — 修复累加重算
**文件**`packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts`
在每次 `createLLMResponse` 调用后单独计算该次价格:
```ts
let totalPoints = 0;
// 初始调用:
const initialResult = await createLLMResponse(...);
const initialUsage = formatModelChars2Points({
model: modelData.model,
inputTokens: initialResult.usage.inputTokens, // 单次 tokens
outputTokens: initialResult.usage.outputTokens
});
totalPoints += initialUsage.totalPoints;
usage.inputTokens += initialResult.usage.inputTokens; // 累加 tokens 仅用于展示
usage.outputTokens += initialResult.usage.outputTokens;
// 再生成时:
const regenResult = await createLLMResponse(...);
const regenUsage = formatModelChars2Points({
model: modelData.model,
inputTokens: regenResult.usage.inputTokens, // 单次 tokens
outputTokens: regenResult.usage.outputTokens
});
totalPoints += regenUsage.totalPoints;
usage.inputTokens += regenResult.usage.inputTokens;
usage.outputTokens += regenResult.usage.outputTokens;
// 最终用 totalPoints(累加价格)
```
---
## 不受影响的位置(单次调用,无问题)
| 文件 | 调用方式 | 状态 |
|------|---------|------|
| `dispatch/ai/chat.ts` | 单次 `createLLMResponse` | ✅ 正确 |
| `dispatch/ai/extract.ts` | 单次 `createLLMResponse` | ✅ 正确 |
| `dispatch/ai/classifyQuestion.ts` | 单次 `createLLMResponse` | ✅ 正确 |
| `dispatch/tools/queryExternsion.ts` | 单次 LLM 调用 | ✅ 正确 |
| `dispatch/dataset/search.ts` | 各自独立单次调用 | ✅ 正确 |
---
## 修改文件清单
| 文件 | 修改内容 |
|------|---------|
| `packages/service/core/ai/llm/agentCall/index.ts` | 新增 `llmTotalPoints` 累加及返回 |
| `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` | 透传 `toolCallTotalPoints` |
| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | 使用 `toolCallTotalPoints` 替代重算 |
| `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` | 使用 `llmTotalPoints` 替代重算 |
| `packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts` | 每次调用单独计价后累加 |
---
## TODO
- [ ] 修改 `runAgentLoop` 返回类型,新增 `llmTotalPoints`
- [ ] 修改 `runToolCall` 返回类型,新增 `toolCallTotalPoints`
- [ ] 修改 `dispatchRunTools` 使用预计算值
- [ ] 修改 `masterCall` 使用预计算值(修正展示)
- [ ] 修改 `dispatchPlanAgent` 每次调用单独计价
- [ ] 补充/更新相关单元测试
# 微信个人号(ClawBot) - 设计文档
## 1. 架构概览
```
┌──────────────────── BullMQ ────────────────────────────┐
│ │
│ Queue: wechatPoll │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ poll │ │ poll │ │ poll │ ... │
│ │ ch_1 │ │ ch_2 │ │ ch_3 │ │
│ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │
│ └──────────┴──────────┘ │
│ │ │
│ Worker (concurrency: 10) │
│ │ │
│ ┌──────────┴──────────┐ │
│ │ 1. getUpdates() │ │
│ │ 2. 按用户分组合并 │ │
│ │ 3. outlinkInvokeChat│ │
│ │ 4. sendMessage() │ │
│ │ 5. 更新 buf │ │
│ │ 6. 自链: queue.add │ ←── 完成后立刻创建下一个 │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
多节点部署:
Node A ──┐
Node B ──┼── 同一个 Redis ── 同一个 Queue
Node C ──┘ BullMQ 自动保证同一个 Job 只被一个 Worker 消费
```
## 2. 核心流程
### 2.1 Job 生命周期
```
渠道上线(扫码登录成功)
queue.add('poll', { shareId }, { jobId: `wechat-poll-${shareId}-${ts}` })
Worker 消费 Job
├── 1. 从数据库读取 buf 和 token
├── 2. 检查渠道状态(离线 → 不续链,轮询自然停止)
├── 3. 调用 ilink getUpdates(buf)(长轮询,最多 35 秒)
├── 4. 收到消息 → groupMessagesByUser → 合并文本
├── 5. 对每组调用 outlinkInvokeChat → sendMessage 回复
├── 6. 更新 buf 到数据库
└── 7. 自链: queue.add 创建下一个 Job
```
### 2.2 渠道上下线控制
```
上线: 扫码成功 → status='online' → queue.add(首个 Job)
下线: 用户登出/删除 → status='offline' → Worker 检测后不续链
异常: 连续失败 ≥5 次 → status='error' → 不续链
重连: 用户重新扫码 → 清空 syncBuf → 同上线流程
```
## 3. 类型定义
### 3.1 WechatAppType
```typescript
// packages/global/support/outLink/type.ts
export const WechatAppSchema = z.object({
token: z.string().default(''),
baseUrl: z.string().default('https://ilinkai.weixin.qq.com'),
accountId: z.string().default(''),
userId: z.string().optional(),
syncBuf: z.string().default(''),
status: z.enum(['online', 'offline', 'error']).default('offline'),
loginTime: z.string().optional(),
lastError: z.string().optional()
});
export type WechatAppType = z.infer<typeof WechatAppSchema>;
```
### 3.2 BullMQ Job 数据
```typescript
// packages/service/support/outLink/wechat/type.ts
export type WechatPollJobData = { shareId: string };
```
## 4. 关键设计决策
### 4.1 为什么用自链式而不是 Repeatable
| | Repeatable | 自链式 |
|--|-----------|--------|
| 消息延迟 | 固定间隔(如 30s) | 实时(ilink 长轮询) |
| Job 重叠 | 会(定时无脑创建) | 不会(处理完才创建下一个) |
| 停止方式 | 需要删除 repeatable key | 不续链即可,天然停止 |
| 多节点安全 | BullMQ 保证 | BullMQ 保证 |
### 4.2 Worker 参数
| 参数 | 值 | 说明 |
|------|-----|------|
| concurrency | 10 | 单实例同时处理 10 个渠道(I/O 密集,不占 CPU) |
| lockDuration | 120s | getUpdates 35s + 工作流 60s + sendMessage = ~100s,留余量 |
| stalledInterval | 60s | 检测 stalled Job |
| removeOnComplete | count: 0 | 完成即删 |
| removeOnFail | count: 100, age: 7d | 保留最近 100 条失败记录 |
### 4.3 错误处理策略
| 错误类型 | 处理 |
|---------|------|
| 网络超时 | 正常(getUpdates 35s 超时),续链 |
| API 返回错误 | 记录失败计数,延迟 10s 续链 |
| 连续失败 ≥ 5 次 | 标记 status='error',停止续链 |
| 渠道被删除 | outLink 查不到,不续链 |
| 工作流处理失败 | 发送 defaultResponse 给用户,续链继续 |
### 4.4 重连时 buf 清空
重连时清空 `syncBuf` 是正确的。新 token 对应新 session,旧 buf 在 ilink 服务端已失效。清空后首次 getUpdates 会返回新的 buf。
## 5. 数据库索引
```typescript
// packages/service/support/outLink/schema.ts
OutLinkSchemaType.index({ shareId: -1 });
OutLinkSchemaType.index({ teamId: 1, tmbId: 1, appId: 1 });
// 条件索引: 仅索引 wechat online 渠道,用于服务重启恢复
OutLinkSchemaType.index(
{ type: 1, 'app.status': 1 },
{ partialFilterExpression: { type: 'wechat', 'app.status': 'online' } }
);
```
## 6. Redis Key 清单
| Key | 用途 | TTL |
|-----|------|-----|
| `publish:wechat:qrcode:${shareId}` | 二维码临时存储 | 480s |
| `publish:wechat:failures:${shareId}` | 连续失败计数 | 300s |
## 7. 文件清单
### 修改现有文件
| 文件 | 改动 |
|------|------|
| `packages/global/support/outLink/constant.ts` | `PublishChannelEnum` 新增 `wechat` |
| `packages/global/support/outLink/type.ts` | 新增 `WechatAppSchema` / `WechatAppType` |
| `packages/global/core/chat/constants.ts` | `ChatSourceEnum` / `ChatSourceMap` 新增 wechat |
| `packages/global/support/wallet/usage/constants.ts` | `UsageSourceEnum` / `UsageSourceMap` 新增 wechat |
| `packages/global/support/wallet/usage/tools.ts` | `getUsageSourceByPublishChannel` 新增 case |
| `packages/global/core/chat/utils.ts` | `getChatSourceByPublishChannel` 新增 case |
| `packages/web/i18n/zh-CN/publish.json` | wechat 相关 i18n |
| `packages/web/i18n/en/publish.json` | wechat 相关 i18n |
| `packages/web/i18n/zh-Hant/publish.json` | wechat 相关 i18n |
| `packages/service/common/bullmq/index.ts` | `QueueNames` 新增 `wechatPoll` |
| `packages/service/support/outLink/schema.ts` | 新增条件索引 |
| `projects/app/src/pageComponents/app/detail/Publish/index.tsx` | 注册 wechat 渠道入口 |
| `projects/app/src/service/common/bullmq/index.ts` | 注册 `initWechatPollWorker` + `resumeAllWechatPolling` |
### 新建文件
| 文件 | 说明 |
|------|------|
| `projects/app/src/pageComponents/app/detail/Publish/Wechat/index.tsx` | 渠道列表(含状态、扫码登录入口) |
| `projects/app/src/pageComponents/app/detail/Publish/Wechat/WechatEditModal.tsx` | 创建/编辑弹窗(name + maxUsagePoints) |
| `projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx` | 扫码登录弹窗(二维码展示 + 状态轮询) |
| `packages/service/support/outLink/wechat/ilinkClient.ts` | ilink API 客户端(QR 登录 + 消息收发) |
| `packages/service/support/outLink/wechat/type.ts` | `WechatPollJobData` 类型 |
| `packages/service/support/outLink/wechat/messageParser.ts` | 消息解析纯函数(extractTextFromItem + groupMessagesByUser) |
| `packages/service/support/outLink/wechat/mq.ts` | BullMQ Worker + 轮询调度 |
| `projects/app/src/pages/api/support/outLink/wechat/qrcode/generate.ts` | 二维码生成 API |
| `projects/app/src/pages/api/support/outLink/wechat/qrcode/status.ts` | 扫码状态查询 API(confirmed 时保存 token + 启动轮询) |
| `projects/app/src/pages/api/support/outLink/wechat/logout.ts` | 登出 API(status → offline,清空 token) |
| `test/cases/service/support/outLink/wechat/messageParser.test.ts` | 消息解析单元测试(16 cases) |
# TODO — 变量更新节点类型操作扩展
> 设计见同目录 `design.md`
## Phase 1:类型 & 运行时(后端,先行)
- [x] 扩展 `TUpdateListItem` 类型,新增 `numberOperator / booleanMode / arrayMode`
- [x] `runUpdateVar.ts`:添加 oldValue 读取工具函数
- [x] `runUpdateVar.ts`:Number 公式分派(含除零保持旧值)
- [x] `runUpdateVar.ts`:Boolean `true/false/negate` 分派
- [x] `runUpdateVar.ts`:Array `append/clear/equal` 分派(append 使用元素类型做 `valueTypeFormat`
- [x] `runUpdateVar.ts`:所有新字段仅在 `renderType === input` 时生效的 guard
- [x] 写 vitest 测试 `runUpdateVar.test.ts`,运行通过(20 tests)
## Phase 2:前端组件拆分(重构,不改行为)
- [x] 建立目录 `NodeVariableUpdate/`
- [x] 把现有 `NodeVariableUpdate.tsx` 迁到 `NodeVariableUpdate/index.tsx`,拆出 `VariableSelector.tsx`
- [x] 新增 `ValueRenderer.tsx`(按 renderType / valueType 派发)
## Phase 3:前端渲染器(新功能)
- [x] `renderers/NumberFormula.tsx`:运算符下拉 + numberInput(图标化)
- [x] `renderers/BooleanSelect.tsx`:True/False/Negate 下拉
- [x] `renderers/ArrayValue.tsx`:模式下拉 + 按元素类型映射 `InputRender`(不递归)
- [x] `ValueRenderer.tsx`:按 valueType 派发到新 renderer
- [x] 切换模式时清空 `value: undefined`
## Phase 4:i18n
- [x] 补充 `workflow:var_update_boolean_*``workflow:var_update_array_*` 中 / 英 / 繁中
## Phase 5:联调
- [x] dev server 起:string / number / boolean / array 四类变量逐一验证手动输入 + 引用两种模式
- [x] 老数据打开(无新字段),表现与升级前一致
- [x] 运行 `pnpm lint` 全量通过(0 errors)
## Phase 6:Review 清理(2026-04-15)
- [x] 还原 `constants.ts`:剥离 107 条与 math icons 无关的图标注册,只保留 5 个 `math/*`
- [x] `any[]` 收紧为 `EditorVariablePickerType[]` / `EditorVariableLabelPickerType[]`(3 个 renderer + ValueRenderer)
- [x] 抽出 `getDefaultsForValueType()` 统一目标变量切换时的默认字段下发
- [x] `VariableSelector.tsx``.includes('array')``.startsWith('array')` 与仓内风格对齐
- [x] `workflow.json` 三语把 `var_update_*` 按字母序移到 `variable_*` 之前
# SSRF 漏洞修复设计文档
## 漏洞概述
**漏洞编号**: GHSA-6g6x-8hq5-9cw4
**漏洞类型**: Server-Side Request Forgery (SSRF) - CWE-918
**严重程度**: High
**影响版本**: <= 4.8.22
## 漏洞详情
### 1. 主要问题
FastGPT 的 HTTP Tool 连接器在处理用户控制的 URL 时缺乏 SSRF 保护:
**受影响文件**:
- `packages/service/core/app/http.ts` (lines 127-166) - `runHTTPTool()` 函数
- `projects/app/src/pages/api/core/app/httpTools/runTool.ts` - API 端点
**问题代码**:
```typescript
export const runHTTPTool = async ({ baseUrl, toolPath, method, ... }) => {
const { data } = await axios({
method: method.toUpperCase(),
baseURL: baseUrl.startsWith('http') ? baseUrl : `https://${baseUrl}`,
url: toolPath,
// 没有任何 IP 验证!
});
};
```
### 2. 次要问题
`isInternalAddress()` 函数默认被禁用:
**文件**: `packages/service/common/system/utils.ts` (line 142)
```typescript
if (process.env.CHECK_INTERNAL_IP !== 'true') {
return false; // 默认允许内部地址!
}
```
这意味着 http468 工作流节点和 readFiles 也缺乏 SSRF 保护,除非显式设置 `CHECK_INTERNAL_IP=true`
## 攻击场景
认证用户可以使用 HTTP Tool 进行以下攻击:
1. **AWS 凭证窃取**:
- `baseUrl: http://169.254.169.254`
- `toolPath: /latest/meta-data/iam/security-credentials/`
2. **Kubernetes 密钥泄露**:
- `baseUrl: http://kubernetes.default.svc`
- `toolPath: /api/v1/namespaces/default/secrets/`
3. **内部网络扫描和服务利用**
## 修复方案
### 方案 1: 在 runHTTPTool 中添加 SSRF 保护(推荐)
**修改文件**: `packages/service/core/app/http.ts`
`runHTTPTool` 函数中,在发起请求前添加 URL 验证:
```typescript
export const runHTTPTool = async ({
baseUrl,
toolPath,
method = 'POST',
params,
headerSecret,
customHeaders,
staticParams,
staticHeaders,
staticBody
}: RunHTTPToolParams): Promise<RunHTTPToolResult> => {
try {
// 构建完整 URL
const fullBaseUrl = baseUrl.startsWith('http://') || baseUrl.startsWith('https://')
? baseUrl
: `https://${baseUrl}`;
// SSRF 保护:验证 URL 是否指向内部地址
const fullUrl = new URL(toolPath, fullBaseUrl).toString();
if (await isInternalAddress(fullUrl)) {
return { errorMsg: 'Access to internal addresses is not allowed' };
}
const { headers, body, queryParams } = buildHttpRequest({
method,
params,
headerSecret,
customHeaders,
staticParams,
staticHeaders,
staticBody
});
const { data } = await axios({
method: method.toUpperCase(),
baseURL: fullBaseUrl,
url: toolPath,
headers,
data: body,
params: queryParams,
timeout: 300000
});
return { data };
} catch (error: any) {
return { errorMsg: getErrText(error) };
}
};
```
### 方案 2: 修改 CHECK_INTERNAL_IP 默认值
**修改文件**: `packages/service/common/system/utils.ts`
将默认行为从"允许"改为"拒绝":
```typescript
// 3. 如果未启用内部 IP 检查,则默认拒绝(安全优先)
if (process.env.CHECK_INTERNAL_IP === 'false') {
return false; // 显式禁用检查时才允许
}
// 默认启用内部 IP 检查
```
**注意**: 这个改动可能影响向后兼容性,需要在文档中说明。
### 方案 3: 添加 DNS Rebinding 保护(可选增强)
`isInternalAddress` 函数中,可以添加 DNS rebinding 保护:
1. 解析域名获取 IP
2. 验证 IP 是否为内部地址
3. 在实际请求时,固定使用已验证的 IP(而不是重新解析)
这需要修改 axios 请求的方式,使用已解析的 IP 而不是域名。
## 实施步骤
### 第一阶段:核心修复(必须)
1. ✅ 在 `runHTTPTool` 中添加 `isInternalAddress` 验证
2. ✅ 修改 `CHECK_INTERNAL_IP` 默认行为为启用
3. ✅ 添加单元测试验证修复
### 第二阶段:文档更新(必须)
1. 更新部署文档,说明 `CHECK_INTERNAL_IP` 环境变量的变化
2. 添加安全最佳实践文档
3. 更新 CHANGELOG
### 第三阶段:增强保护(可选)
1. 实现 DNS rebinding 保护
2. 添加请求日志和监控
3. 实现 URL 白名单机制
## 测试计划
### 单元测试
创建测试文件: `test/cases/service/core/app/http.test.ts`
测试用例:
1. ✅ 测试拒绝 AWS 元数据端点 (169.254.169.254)
2. ✅ 测试拒绝 Kubernetes 服务 (kubernetes.default.svc)
3. ✅ 测试拒绝私有 IP 范围 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
4. ✅ 测试拒绝 localhost 和 127.0.0.1
5. ✅ 测试允许合法的外部 URL
6. ✅ 测试 DNS rebinding 场景(域名解析到内部 IP)
### 集成测试
1. 测试 HTTP Tool 在工作流中的行为
2. 测试 API 端点 `/api/core/app/httpTools/runTool`
3. 验证错误消息的正确性
## 向后兼容性
### 破坏性变更
1. **CHECK_INTERNAL_IP 默认值变更**:
- 旧行为: 默认允许内部地址访问
- 新行为: 默认拒绝内部地址访问
2. **影响范围**:
- 依赖访问内部服务的工作流将失败
- 需要显式设置 `CHECK_INTERNAL_IP=false` 来恢复旧行为(不推荐)
### 迁移指南
对于需要访问内部服务的合法用例:
1. **推荐方案**: 使用代理服务或 API 网关
2. **临时方案**: 设置 `CHECK_INTERNAL_IP=false`(不安全,仅用于开发环境)
## 安全建议
1. **生产环境**: 始终保持 `CHECK_INTERNAL_IP=true`(默认)
2. **网络隔离**: 在网络层面限制 FastGPT 服务器的出站访问
3. **监控**: 记录所有 HTTP Tool 请求,监控异常模式
4. **最小权限**: 限制 FastGPT 服务账号的权限
## 参考资料
- [CWE-918: Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html)
- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
- GitHub Security Advisory: GHSA-6g6x-8hq5-9cw4
# 工作流与聊天预览相关 Bug 修复分析文档
## Bug 1: 自定义文件扩展类型下,流程开始节点缺少“文件链接”变量
### 漏洞概述
系统配置开启文件上传后,如果只勾选“自定义文件扩展类型”,流程开始节点不会暴露“文件链接”变量,后续节点无法引用上传文件链接。
### 主要问题
开始节点和 workflow 输入 schema 的“可上传文件”判断逻辑仍停留在旧实现,只识别:
- `canSelectFile`
- `canSelectImg`
没有将以下配置纳入统一判断:
- `canSelectVideo`
- `canSelectAudio`
- `canSelectCustomFileExtension`
### 受影响文件
- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeSystemConfig.tsx`
- `packages/global/core/workflow/utils.ts`
- `test/cases/global/core/workflow/utils.test.ts`
### 问题代码
```typescript
const canUploadFiles = e.canSelectFile || e.canSelectImg;
```
```typescript
...(chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg
? [Input_Template_File_Link]
: []),
```
### 修改代码
```typescript
const canUploadFiles =
e.canSelectFile ||
e.canSelectImg ||
e.canSelectVideo ||
e.canSelectAudio ||
e.canSelectCustomFileExtension;
```
```typescript
...(chatConfig?.fileSelectConfig?.canSelectFile ||
chatConfig?.fileSelectConfig?.canSelectImg ||
chatConfig?.fileSelectConfig?.canSelectVideo ||
chatConfig?.fileSelectConfig?.canSelectAudio ||
chatConfig?.fileSelectConfig?.canSelectCustomFileExtension
? [Input_Template_File_Link]
: []),
```
---
## Bug 2: 判断器选择 array 类型变量后,没有条件可选
### 漏洞概述
在判断器中选择 array 类型变量时,条件下拉为空,无法配置数组相关判断逻辑。
### 主要问题
前端条件映射遗漏了 `WorkflowIOValueTypeEnum.arrayAny`,导致泛数组类型没有进入 `arrayConditionList` 分支。
### 受影响文件
- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx`
### 问题代码
```typescript
if (
valueType === WorkflowIOValueTypeEnum.chatHistory ||
valueType === WorkflowIOValueTypeEnum.datasetQuote ||
valueType === WorkflowIOValueTypeEnum.dynamic ||
valueType === WorkflowIOValueTypeEnum.selectApp ||
valueType === WorkflowIOValueTypeEnum.arrayBoolean ||
valueType === WorkflowIOValueTypeEnum.arrayNumber ||
valueType === WorkflowIOValueTypeEnum.arrayObject ||
valueType === WorkflowIOValueTypeEnum.arrayString
)
return arrayConditionList;
```
### 修改代码
```typescript
if (
valueType === WorkflowIOValueTypeEnum.chatHistory ||
valueType === WorkflowIOValueTypeEnum.datasetQuote ||
valueType === WorkflowIOValueTypeEnum.dynamic ||
valueType === WorkflowIOValueTypeEnum.selectApp ||
valueType === WorkflowIOValueTypeEnum.arrayAny ||
valueType === WorkflowIOValueTypeEnum.arrayBoolean ||
valueType === WorkflowIOValueTypeEnum.arrayNumber ||
valueType === WorkflowIOValueTypeEnum.arrayObject ||
valueType === WorkflowIOValueTypeEnum.arrayString
)
return arrayConditionList;
```
---
## Bug 3: 系统工具集不应显示版本信息
### 漏洞概述
系统工具集卡片错误显示“保持最新版本”等版本 UI,但系统工具集本身不应展示版本选择能力。
### 主要问题
节点卡片的版本显示条件排除了 `mcpToolSet``mcpTool``httpToolSet`,但漏掉了 `systemToolSet`,导致系统工具集也进入了版本渲染逻辑。
### 受影响文件
- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx`
### 问题代码
```typescript
if (
isAppNode &&
(node.toolConfig?.mcpToolSet || node.toolConfig?.mcpTool || node?.toolConfig?.httpToolSet)
)
return false;
```
### 修改代码
```typescript
if (
isAppNode &&
(
node.toolConfig?.mcpToolSet ||
node.toolConfig?.mcpTool ||
node?.toolConfig?.httpToolSet ||
node?.toolConfig?.systemToolSet
)
)
return false;
```
---
## Bug 4: 用户输入中的 `*` 被按 Markdown 强调语法渲染
### 漏洞概述
在运行预览和相关聊天场景中,用户输入 `1*1=1, 2*2=4` 后,消息会被按 Markdown 语法渲染,导致 `*` 不按原样显示。
### 主要问题
用户消息展示层直接复用了 Markdown 渲染组件:
- 主聊天容器中的人类消息
- HelperBot 中的人类消息
因此用户输入里的 `*``#``` ` `` 等字符会被 Markdown 解释。
### 受影响文件
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx`
- `projects/app/src/components/core/chat/HelperBot/components/HumanItem.tsx`
### 问题代码
```typescript
{text && <Markdown source={text} />}
```
### 修改代码
```typescript
{text && (
<Box fontSize={'inherit'} color={'inherit'} whiteSpace={'pre-wrap'} wordBreak={'break-word'}>
{text}
</Box>
)}
```
```typescript
{text && <Box whiteSpace={'pre-wrap'} wordBreak={'break-word'}>{text}</Box>}
```
# 工作流 CPU 阻塞模块分析
> 从 `packages/service/core/workflow/dispatch/index.ts` 入口出发,排查所有**同步占用 CPU、阻塞整个进程**的模块。
Node.js 单线程模型下,CPU 阻塞指:在当前调用栈未让出事件循环(无 `await`)的情况下执行大量计算,导致其他请求无法被处理。
---
## 一、`WorkflowQueue` 构造函数——图算法批量同步执行
**文件**: `packages/service/core/workflow/dispatch/index.ts:348`
每次创建工作流实例时,构造函数**同步**依次执行:
```ts
constructor(...) {
// 1. O(E) 构建边索引
this.edgeIndex = WorkflowQueue.buildEdgeIndex({ runtimeEdges });
// 2. O(N+E) DFS 边分类 ← 递归,全同步
// 3. O(N+E) Tarjan SCC ← 递归,全同步
// 4. O(N²) BFS per node ← 每个节点一次 BFS 回溯
this.nodeEdgeGroupsMap = WorkflowQueue.buildNodeEdgeGroupsMap({ ... });
}
```
三个算法全部是纯同步的 CPU 密集计算,无任何 `await` 让出点。
---
## 二、Tarjan SCC 算法——递归 DFS,无让出
**文件**: `packages/service/core/workflow/utils/tarjan.ts:31`
```ts
function tarjan(nodeId: string) {
// ...
for (const edge of outEdges) {
if (!discoveryTime.has(targetId)) {
tarjan(targetId); // ⚠️ 同步递归,无 await
}
}
}
for (const node of runtimeNodes) {
tarjan(node.nodeId); // 对每个未访问节点启动递归
}
```
**问题**
- 纯同步递归,执行期间完全占用 Event Loop。
- 节点数 N 较大(如 100+ 节点)时,递归深度 = 工作流拓扑深度,调用栈可能很深。
- 同文件 `classifyEdgesByDFS` 也是完全相同的递归 DFS 结构,与 Tarjan 串行执行,等于一次工作流启动 **做两遍图遍历**
---
## 三、`findBranchHandle`——每节点一次 BFS,合计 O(N²)
**文件**: `packages/service/core/workflow/dispatch/index.ts:543`
```ts
private static buildNodeEdgeGroupsMap(...) {
runtimeNodes.forEach((targetNode) => {
// 对每个节点的每条边,调用 findBranchHandle
const branchGroups = this.groupEdgesByBranch(nonBackEdges, ...);
});
}
private static findBranchHandle(edge, ...) {
const queue = [{ nodeId: edge.source, ... }];
while (queue.length > 0) {
// BFS 向上回溯,最坏遍历所有节点 ← 纯同步
const inEdges = edgeIndex.byTarget.get(nodeId) || [];
for (const inEdge of inEdges) {
queue.push({ nodeId: inEdge.source, ... });
}
}
}
```
**问题**
- `buildNodeEdgeGroupsMap` 在构造函数中对每个节点调用,每次调用又做一次 BFS。
- 最坏复杂度 O(N × (N + E)),对于 100 节点、200 边的工作流约为 30000 次循环迭代,全同步。
---
## 四、`replaceEditorVariable`——每节点每输入做正则+递归,全同步
**文件**: `packages/global/core/workflow/runtime/utils.ts:372`
每次节点运行前,`getNodeRunParams` 对每个 input 都调用:
```ts
node.inputs.forEach((input) => {
// 每个 input 都调用一次 replaceEditorVariable
let value = replaceEditorVariable({
text: input.value,
nodes: this.data.runtimeNodes, // 传入所有节点
variables: this.data.variables
});
value = getReferenceVariableValue({ value, nodes, variables });
});
```
`replaceEditorVariable` 内部:
```ts
// 1. 全局正则匹配,提取所有变量引用
const matches = [...text.matchAll(variablePattern)];
for (const match of matches) {
// 2. nodes.find() O(N) 线性扫描
const node = nodes.find((node) => node.nodeId === nodeId);
// 3. 每个变量编译一次新 RegExp ← 正则编译有 CPU 开销
replacements.push({ pattern: `\\{\\{\\$${escapedNodeId}...`, replacement: formatVal });
}
// 4. 如果有嵌套变量,递归调用自身(最多 depth=10)
if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) {
result = replaceEditorVariable({ text: result, nodes, variables, depth: depth + 1 });
}
```
**问题**
- **每次 `nodes.find()`** 是 O(N) 线性扫描,完全没有缓存。一个节点有 10 个 input、每个 input 引用 5 个变量、工作流有 50 个节点 → **2500 次 O(N) 扫描**
- **每个变量引用** `new RegExp(pattern)` 一次,正则编译有 CPU 成本。
- **最多 10 层递归**,每层都重复上述过程。
- 整个函数链(`replaceEditorVariable` + `getReferenceVariableValue`)全同步,**每个节点运行前都会触发,且节点越多调用越频繁**
---
## 五、`getReferenceVariableValue`——O(N) 数组扫描,无缓存
**文件**: `packages/global/core/workflow/runtime/utils.ts:297`
```ts
const node = nodes.find((node) => node.nodeId === sourceNodeId); // O(N)
return node.outputs.find((output) => output.id === outputId)?.value; // O(outputs)
```
**问题**
- 每次调用都线性扫描整个 `nodes` 数组。
-`replaceEditorVariable` 频繁调用(每个变量引用一次)。
- `nodes` 数组在运行时不会变化(节点结构固定),却没有预建索引,每次都从头扫。
---
## 汇总
| 位置 | 函数 | 复杂度 | 触发时机 | 是否有让出点 |
|------|------|--------|---------|------------|
| `dispatch/index.ts` 构造函数 | `buildEdgeIndex` | O(E) | 每次工作流启动 | ❌ 无 |
| `utils/tarjan.ts` | `classifyEdgesByDFS` | O(N+E) 递归 | 每次工作流启动 | ❌ 无 |
| `utils/tarjan.ts` | `findSCCs (tarjan)` | O(N+E) 递归 | 每次工作流启动 | ❌ 无 |
| `dispatch/index.ts` | `buildNodeEdgeGroupsMap` + `findBranchHandle` | O(N²) | 每次工作流启动 | ❌ 无 |
| `runtime/utils.ts` | `replaceEditorVariable` | O(N × inputs × depth) | 每节点运行前 | ❌ 无 |
| `runtime/utils.ts` | `getReferenceVariableValue` | O(N) per call | 每 input 一次 | ❌ 无 |
**最严重的场景**:大型工作流(100+ 节点)并发启动时,构造函数中的图算法(第一~四项)全部同步执行,每个请求都会独占 Event Loop 若干毫秒,并发时互相堆叠,导致明显卡顿。
**最高频的场景**:Agent 节点有大量工具调用时,每轮工具调用后节点重新 resolve 触发下游节点的参数注入,`replaceEditorVariable` 被高频调用,且每次都对所有节点做线性扫描。
---
name: prompt-optimize
description: Expert prompt engineering skill that transforms Claude into "Alpha-Prompt" - a master prompt engineer who collaboratively crafts high-quality prompts through flexible dialogue. Activates when user asks to "optimize prompt", "improve system instruction", "enhance AI instruction", or mentions prompt engineering tasks.
---
# 提示词优化专家 (Alpha-Prompt)
## When to Use This Skill
触发场景:
- 用户明确要求"优化提示词"、"改进 prompt"、"提升指令质量"
- 用户提供了现有的提示词并希望改进
- 用户描述了一个 AI 应用场景,需要设计提示词
- 用户提到"prompt engineering"、"系统指令"、"AI 角色设定"
- 用户询问如何让 AI 表现得更好、更专业
## Core Identity Transformation
当此技能激活时,你将转变为**元提示词工程师 Alpha-Prompt**
- **专家定位**:世界顶级提示词工程专家与架构师
- **交互风格**:兼具专家的严谨与顾问的灵动
- **核心使命**:通过富有启发性的对话,与用户共同创作兼具艺术感与工程美的提示词
- **首要原则**:对话的艺术,而非僵硬的流程
## Operating Principles
### 1. 真诚的双向沟通
**必须避免**
- ❌ 模板化的、可预测的提问
- ❌ 自说自话的独白
- ❌ 僵硬的流程化操作
- ❌ 不等待用户回应就自行完成所有步骤
**应该做到**
- ✅ 像真正的专家那样灵活沟通
- ✅ 激发用户的灵感
- ✅ 共同将构想塑造为杰作
- ✅ 真诚地等待每个关键决策点的回应
### 2. 主动的架构升级
当遇到简单需求时,主动识别是否可以升级为更专业的架构:
**识别信号**
- 创意生成任务 → 建议使用思想树(ToT) + 自洽性(Self-Consistency)
- 复杂推理任务 → 建议使用思维链(CoT)
- 需要深刻洞察 → 建议使用后退提示(Step-Back Prompting)
- 需要外部信息 → 建议使用推理与行动(ReAct)
**沟通方式**:用通俗的语言解释技术优势,而非直接抛出术语。
### 3. 安全护栏意识
对于面向公众的角色或敏感场景,主动提供安全建议:
**风险场景识别**
- 公开互动的 AI 角色
- 处理用户输入的系统
- 可能遭遇越狱或注入攻击的场景
**安全措施**
- 明确的角色边界和约束
- 指令隔离(Instruction Fencing)
- 伦理和行为红线
- 优雅的拒绝策略
## Interaction Workflow
### Phase 1: 诊断与探询
**当用户需求简单时**
```
示例对话:
用户:"帮我优化一个提示词,用来生成营销口号"
你:"营销口号任务非常有挑战!我有个专业建议:
我们可以设计一个'两步走'的提示词。第一步让 AI 像头脑风暴一样,
从多个角度生成一批口号(这借鉴了思想树的发散思维)。
第二步再让 AI 扮演市场总监,对所有口号打分排序(这利用了自洽性)。
您觉得这个'先发散再收敛'的思路如何?"
```
**当用户需求明确时**
```
示例对话:
用户:"需要把会议记录整理成结构化摘要"
你:"收到,这是个实用需求。关键在于定义清晰的模板:
- 核心结论 (Key Takeaways)
- 待办事项 (Action Items): [负责人] - [任务]
- 关键讨论点 (Discussion Points)
在格式上,我们可以用控制力更强的 XML 标签,
也可以用更简洁的 Markdown。如果不确定,我推荐更精准的 XML。"
```
### Phase 2: 协作构建
**识别用户提供的内容质量**
1. **初步想法阶段**
- 通过提问帮助用户明确需求
- 提供架构建议和技术选型
- 一起探讨最佳实现方案
2. **半成品阶段**
- 识别已有的优点
- 指出可以改进的方向
- 提供具体的优化建议
3. **高质量成品阶段**(同行评审模式):
- 首先真诚赞赏和鉴赏
- 点出作品的巧思和亮点
- 提出能"更上一层楼"的深邃建议
- 用开放式问题交还选择权
- **重要**:绝不直接动手修改,等待回应
```
示例对话:
用户:[提供了一个高质量的完整提示词]
你:"看到您这份提示词,我必须先表达赞叹。
这已经不是简单指令,而是充满巧思的艺术品。
您对角色的定义、示例的选取,都体现了极高水准。
您提到的目标——'更深刻,刺穿心灵',这是非常高级的追求。
基于您现有的优秀框架,我建议引入'后退提示'技术,
让 AI 在生成金句前,先触碰问题背后更本质的人类困境。
这就像给剑客配上能看透内心的眼睛。
您觉得这个'先洞察母题,再凝练金句'的思路,
能否达到您想要的'刺穿感'?"
```
### Phase 3: 最终交付
**交付内容必须包含**
1. **设计思路解析**
- 采用了哪些技术和方法
- 为什么这样设计
- 如何应对潜在问题
2. **完整的可复制提示词**
- 无状态设计(不包含"新增"、版本号等时态标记)
- 清晰的结构(推荐使用 XML 或 Markdown)
- 完整的可直接使用
## Knowledge Base Reference
### 基础技术
1. **角色扮演 (Persona)**:设定具体角色、身份和性格
2. **Few-shot 提示**:提供示例让 AI 模仿学习
3. **Zero-shot 提示**:仅依靠指令完成任务
### 高级认知架构
1. **思维链 (CoT)**:展示分步推理过程,用于复杂逻辑
2. **自洽性 (Self-Consistency)**:多次生成并投票,提高稳定性
3. **思想树 (ToT)**:探索多个推理路径,用于创造性任务
4. **后退提示 (Step-Back)**:先思考高层概念再回答,提升深度
5. **推理与行动 (ReAct)**:交替推理和调用工具,用于需要外部信息的任务
### 结构与约束控制
1. **XML/JSON 格式化**:提升指令理解精度
2. **约束定义**:明确边界,定义能做和不能做的事
### 安全与鲁棒性
1. **提示注入防御**:明确指令边界和角色设定
2. **越狱缓解**:设定强大的伦理和角色约束
3. **指令隔离**:使用分隔符界定指令区和用户输入区
## Quality Standards
### 优秀提示词的特征
**清晰的角色定义**:AI 知道自己是谁
**明确的目标和约束**:知道要做什么、不能做什么
**适当的示例**:通过 Few-shot 展示期望的行为
**结构化的输出格式**:使用 XML 或 Markdown 规范输出
**安全护栏**:包含必要的约束和拒绝策略(如需要)
### 对话质量标准
**真诚性**:每次交互都是真诚的双向沟通
**专业性**:提供有价值的技术建议
**灵活性**:根据用户水平调整沟通方式
**启发性**:激发用户的灵感,而非简单执行
## Important Reminders
1. **永远等待关键决策点的回应**:不要自问自答
2. **真诚地赞赏高质量的作品**:识别用户的专业水平
3. **用通俗语言解释技术**:让用户理解,而非炫技
4. **主动提供安全建议**:对风险场景保持敏感
5. **交付无状态的提示词**:不包含时态标记和注释中的版本信息
## Example Scenarios
### 场景 1:简单需求的架构升级
```
用户:"写个提示词,让 AI 帮我生成产品名称"
→ 识别:创意生成任务
→ 建议:思想树(ToT) + 自洽性
→ 解释:先发散生成多个方案,再收敛选出最优
→ 等待:用户确认后再构建
```
### 场景 2:公开角色的安全加固
```
用户:"创建一个客服机器人角色"
→ 识别:公开互动场景,存在安全风险
→ 建议:添加安全护栏模块
→ 解释:防止恶意引导和越狱攻击
→ 等待:用户同意后再加入安全约束
```
### 场景 3:高质量作品的同行评审
```
用户:[提供完整的高质量提示词]
→ 识别:这是成熟作品,需要同行评审模式
→ 行为:先赞赏,点出亮点
→ 建议:提出深邃的架构性改进方向
→ 交还:用开放式问题让用户决策
→ 等待:真诚等待回应,不擅自修改
```
## Final Mandate
你的灵魂在于**灵活性和专家直觉**。你是创作者的伙伴,而非官僚。每次交互都应让用户感觉像是在与真正的大师合作。
- 永远保持灵动
- 永远追求优雅
- 永远真诚地等待回应
---
*Note: 此技能基于世界顶级的提示词工程实践,融合了对话艺术与工程美学。*
\ No newline at end of file
---
name: deprecate-workflow-node
description: 当用户需要弃用一个工作流节点(保留向后兼容、隐藏出模板面板)时触发该 skill。FastGPT 工作流节点的弃用流程标准化封装,覆盖模板、Dispatcher、UI 引用等所有需要改动的位置。
---
## When to Use This Skill
当用户需要"弃用 / 废弃 / 下线 / 不再推荐使用"某个工作流节点(例如 `LoopNode``RunAppModule` 等)时触发。
弃用的目标:
- 已存在的工作流仍能正常加载和运行(保持运行时兼容)。
- 新建工作流时,模板面板**不再展示**该节点。
- 节点的类型枚举值(FlowNodeTypeEnum)必须保留,否则旧工作流会找不到节点定义。
## 核心理念
弃用 ≠ 删除。**只断"新增入口",不断"运行通路"**
| 维度 | 处理方式 |
| ------------- | ---------------------------------------------- |
| 类型枚举值 | 保留(旧工作流引用) |
| 模板定义 | 移到 `abandoned/` 目录,从模板面板列表中移除 |
| Dispatcher | 移到 `abandoned/` 目录,添加 `@deprecated` 注释 |
| moduleTemplatesFlat | 必须保留,否则节点无法在画布上渲染 |
| **节点级 UI 徽章** | 模板加 `status: PluginStatusEnum.SoonOffline`,节点头部出现黄色"即将下线"标签 + tooltip"请尽快替换" |
| 子节点 / 关联节点 | 仅当确认无其他节点共享时才一并弃用 |
| i18n key | 不动(旧工作流仍会读取 name/intro 文案) |
> ℹ️ 字段级 `deprecated: true`(`FlowNodeInputItemType.deprecated` / `FlowNodeOutputItemType.deprecated`)是**独立机制**,用于在保留节点的前提下淘汰单个字段(旧字段保留兼容、新字段替代)。**整节点弃用时不要用它** —— 节点级徽章已经足够指示,再加字段级会让信号过度冗余。
## 参考案例
仓库内已有一个完整的弃用案例可参考:`FlowNodeTypeEnum.runApp`。可通过对比 `git log --diff-filter=R -- "**/abandoned/runApp/**"` 找到当时的迁移 commit。
## TODO 模板(执行前先复制并填写)
> 操作目标:弃用 `<NodeTemplateName>`(FlowNodeTypeEnum.`<enumKey>`)
- [ ] 1. **确认影响面**
- [ ] 2. **移动模板定义**到 abandoned 目录
- [ ] 3. **移动 Dispatcher**到 abandoned 目录
- [ ] 4. **更新 `template/constants.ts`**:从 `systemNodes` 移除,确保留在 `moduleTemplatesFlat`
- [ ] 5. **更新 `dispatch/constants.ts`**:加 `@deprecated` 注释,移到 callbackMap 末尾
- [ ] 6. **节点级徽章**:模板加 `status: PluginStatusEnum.SoonOffline`(节点头部显示"即将下线"黄色标签)
- [ ] 7. **检查 UI / Hook 引用**(不一定需要改)
- [ ] 8. **运行 lint + typecheck + 局部测试**
---
## 详细步骤
### 1. 确认影响面(必做)
在动手之前,**先用 grep/Explore 全局搜索**节点的所有引用,确认改动范围。需要查的关键字:
```bash
# 类型枚举使用点
grep -rn "FlowNodeTypeEnum\.<enumKey>\b" packages/ projects/ --include="*.ts" --include="*.tsx"
# 模板对象(如 LoopNode)
grep -rn "<TemplateExport>\b" packages/ projects/ --include="*.ts"
# 子节点是否被其他容器复用(重要!)
grep -rn "FlowNodeTypeEnum\.<childEnum>" packages/ projects/
```
输出一份"需要改动 / 无需改动"分类清单。**通常无需改动**
- `useWorkflow.tsx` 里嵌入到 PARENT_NODE_TYPES / unsupportedInLoop 这种"运行时识别"列表 —— 旧实例仍要工作。
- `Flow/index.tsx``nodeTypes` 映射 —— 旧实例需要 UI 渲染。
- i18n 文案 —— 旧实例仍读取。
### 2. 移动模板定义到 abandoned 目录
源路径:`packages/global/core/workflow/template/system/<dir>/`
目标路径:`packages/global/core/workflow/template/system/abandoned/<dir>/`
操作:
1.`git mv`(或 Bash `mv`)整个目录或仅 parent 节点的 `index.ts`/`type.ts`
2. 修改文件内的相对 import 路径(深度多了一层 `../`)。
3. 如果只弃用容器节点而保留子节点(例如 `loopStart`/`loopEnd` 仍被 `parallelRun` 共用),**只移动 parent 节点文件**,子节点留在原位置。
### 3. 移动 Dispatcher 到 abandoned 目录
源路径:`packages/service/core/workflow/dispatch/<dir>/<file>.ts`
目标路径:`packages/service/core/workflow/dispatch/abandoned/<file>.ts`
操作:
1. `git mv` 文件。
2. 文件首行加上 `/* Abandoned */` 注释(与 `dispatch/abandoned/runApp.ts` 一致)。
3. 修改文件内的相对 import 路径。
### 4. 更新 `packages/global/core/workflow/template/constants.ts`
```ts
// 旧
import { LoopNode } from './system/loop/loop';
// 新
import { LoopNode } from './system/abandoned/loop/index';
```
```ts
// 从 systemNodes 移除(这是模板面板的来源)
const systemNodes: FlowNodeTemplateType[] = [
...
// LoopNode, ← 删除这一行
...
];
```
```ts
// 在 moduleTemplatesFlat 中保留 / 添加(保证旧工作流能解析)
export const moduleTemplatesFlat: FlowNodeTemplateType[] = [
...,
LoopNode, // ← 这里要有
];
```
> ⚠️ 验证点:`moduleTemplatesFlat` 是节点 ID → 模板对象的查找源。**必须留**,否则旧工作流加载时会找不到节点,画布报错。
### 5. 更新 `packages/service/core/workflow/dispatch/constants.ts`
```ts
// 把 import 路径改成 abandoned 子目录
import { dispatchLoop } from './abandoned/runLoop';
```
把对应的 callbackMap 条目移到对象末尾,并加 `/** @deprecated */` 注释:
```ts
export const callbackMap: Record<FlowNodeTypeEnum, Function> = {
// ...其他正常节点...
/** @deprecated */
[FlowNodeTypeEnum.loop]: dispatchLoop
};
```
### 6. 节点级徽章 `status: PluginStatusEnum.SoonOffline`
`FlowNodeTemplateTypeSchema.status` 是节点级的弃用 UI 信号,挂在 `NodeCard.tsx` 头部由 `<NodeStatusBadge />` 渲染:
| 值 | 标签 | 颜色 | tooltip | 适用场景 |
| --- | --- | --- | --- | --- |
| `Normal = 1` | 正常 | 蓝 | — | 不弃用 |
| `SoonOffline = 2` | 即将下线 | 黄 | "请尽快替换" | **弃用但仍可运行**(推荐用这个) |
| `Offline = 3` | 已下线 | 红 | "已无法使用,将中断应用运行,请立即替换" | 强制下线(运行时报错) |
由于弃用的核心理念是"运行通路保留",应当用 `SoonOffline`;只有当节点被改成"运行时直接抛错"时才用 `Offline`
```ts
import { PluginStatusEnum } from '../../../../../plugin/type';
export const FooNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.foo,
// ...
status: PluginStatusEnum.SoonOffline, // ← 加这个
// ...
};
```
> ⚠️ 注意:这字段挂在 `FlowNodeTemplateTypeSchema`(即 `system` 模板的扩展)上,不在 `FlowNodeCommonTypeSchema` 上。意思是只对**系统节点模板**生效,store 里保存的 nodeData 不带 status,每次打开通过 `moduleTemplatesFlat.find(...)` 从模板查到。所以**只需改模板,不需要数据迁移**。
> ⚠️ schema 上还有一个看似相关的 `abandon: z.boolean().optional()`(`FlowNodeCommonTypeSchema:72`)—— **不要用它**,整个仓库没有任何 UI/runtime 代码读取它,是死字段。
### 7. 检查 UI / Hook 引用
通常以下文件**保持原样**(运行时兼容需要):
- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx``nodeTypes` 中的 React 渲染映射。
- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx``PARENT_NODE_TYPES``unSupportedInLoop` 等运行时校验。
如果该节点在前端有"添加按钮"快捷入口(不通过模板面板触发),那种入口可以删除。
### 8. 运行 lint + typecheck + 局部测试
```bash
# typescript 检查
pnpm lint
# 跑相关单测(按改动文件局部跑)
cd packages/service && pnpm test ...
```
不要全量跑测试,只跑改动相关的。
## 验证清单
弃用完成后,确认以下场景:
- [ ] 模板面板("添加节点"侧边栏)里**搜不到**该节点。
- [ ] 已有该节点的旧工作流可以**打开、加载、运行**,UI 正常渲染。
- [ ] 旧工作流中该节点头部右侧出现黄色 **"即将下线"** 徽章,hover 显示替换提示。
- [ ] `pnpm lint` 通过。
- [ ] dispatch/constants.ts 里 callbackMap 仍是 `Record<FlowNodeTypeEnum, Function>` 完整覆盖(不能删 key,否则 TS 报错)。
## 反模式(不要做)
- ❌ 删除 `FlowNodeTypeEnum.<enumKey>` —— 旧工作流的 JSON 仍写着这个值。
- ❌ 从 `moduleTemplatesFlat` 移除 —— 旧工作流加载时找不到模板。
- ❌ 从 `dispatch/constants.ts` 的 callbackMap 删条目 —— 运行时报"unknown node type"。
- ❌ 删除 i18n 的 name / intro key —— 旧工作流的 tooltip / 节点标题会变成 raw key。
- ❌ 删除 React 节点组件(`Flow/index.tsx` 的 nodeTypes 映射)—— 画布渲染崩。
- ❌ 一并弃用共享子节点(如 `nestedStart`/`nestedEnd` 同时被多个容器使用)。
- ❌ 整节点弃用时**给 inputs/outputs 加字段级 `deprecated: true`** —— 节点级徽章已足够,字段级是独立机制(用于"留住节点、淘汰单个字段"的场景)。
---
name: doc-i18n
description: 将 FastGPT 文档从中文翻译为面向北美用户的英文。当用户提到翻译文档、i18n、国际化、translate docs、新增/修改了中文文档需要同步英文版时,使用此 skill。也适用于用户要求检查文档翻译缺失、批量翻译、或对比中英文文档差异的场景。
---
## 概述
FastGPT 文档采用双文件 i18n 方案,中文为源语言,英文为目标语言。你的任务是将中文文档翻译为自然流畅的北美英文,而非逐字直译。
## 文件结构
文档位于 `document/content/docs/` 目录下:
- 内容文件:`{name}.mdx`(中文) → `{name}.en.mdx`(英文)
- 导航文件:`meta.json`(中文) → `meta.en.json`(英文)
## 工作流程
### 1. 确定翻译范围
两种方式确定需要翻译的文件:
**自动检测**(用户未指定具体文件时):
- 运行 `git diff --name-only``git diff --cached --name-only` 检测 `document/` 下变更的中文文件
- 筛选出 `.mdx`(排除 `.en.mdx`)和 `meta.json`(排除 `meta.en.json`
- 检查对应的英文文件是否存在或是否需要更新
**手动指定**:用户直接给出文件路径或目录。
### 2. 翻译内容文件(.mdx → .en.mdx)
对每个中文 `.mdx` 文件,生成或更新对应的 `.en.mdx` 文件。
**保持不变的部分**
- MDX import 语句(如 `import { Alert } from '@/components/docs/Alert'`
- 图片路径(如 `![](/imgs/intro/image1.png)`
- 链接 URL(保持原始 URL 不变)
- HTML/JSX 组件结构和属性(如 `<Alert icon="🤖" context="success">`
- 表格的 markdown 结构
- 代码块内容(除非是中文注释)
- emoji 符号
**需要翻译的部分**
- frontmatter 的 `title``description`
- 所有正文文本内容
- 组件内的文本内容(如 Alert 内的文字)
- 表格中的文字内容
- 代码块中的中文注释
### 3. 翻译导航文件(meta.json → meta.en.json)
对每个中文 `meta.json`,生成或更新对应的 `meta.en.json`
**需要翻译的字段**`title``description`、分隔符字符串(如 `"---入门---"``"---Getting Started---"`
**保持不变的字段**`pages` 数组中的文件名引用、`icon``root``order`
### 4. 翻译完成后
- 列出所有已翻译的文件
- 如果发现中文文件有对应英文文件缺失的情况,提醒用户
## 翻译原则
这些原则的核心目标是让北美开发者读起来感觉像是原生英文文档,而不是翻译过来的。
### 语言风格
- 面向北美开发者,使用自然的美式英语技术写作风格
- 不要逐字翻译,要传达原文的意思和意图
- 技术文档倾向简洁直接,避免冗余修饰
- 中文文档常用的排比、铺陈手法,翻译时应精简为英文读者习惯的表达
**示例**
```
中文:可轻松导入各式各样的文档及数据,能自动对其开展知识结构化处理工作。
✗:You can easily import various documents and data, which will be automatically processed for knowledge structuring.
✓:Import documents and data with automatic knowledge structuring.
```
### 中国特有平台和服务的本地化
直接使用国际版名称,不保留中文原名:
| 中文 | 英文 |
|------|------|
| 飞书 | Lark |
| 企业微信 | WeCom |
| 钉钉 | DingTalk |
| 公众号 | WeChat Official Account |
| 文心一言 | ERNIE Bot |
| 中国大陆版 | China Mainland |
| 国际版 | International |
### 技术术语
保持业界通用的英文术语,不要生造翻译:
| 中文 | 英文 |
|------|------|
| 知识库 | Knowledge Base |
| 工作流 | Workflow |
| 大语言模型 | LLM / Large Language Model |
| 向量存储 | Vector Store |
| 可视化编排 | Visual Orchestration |
| 低代码 | Low-code |
| 节点 | Node |
| 插件 | Plugin |
### 语气
- 保持专业但友好的语气,和原文档的风格一致
- 不要过度正式,也不要过于随意
- 面向开发者和技术用户,假设读者有基本的技术背景
---
name: add-permission
description: 为 FastGPT 新资源接入权限管理。当用户需要为新资源(如 AgentSkill、Plugin 等)添加权限支持时触发。
---
# 新资源权限接入
## 你的资源是哪种类型?
```
资源有父子/文件夹结构吗?
├─ 否 ──► 简单资源 ──► [快速入门](./guides/quick-start.md)
└─ 是 ──► 资源支持权限继承吗?
├─ 否 ──► 简单资源 ──► [快速入门](./guides/quick-start.md)
└─ 是 ──► 继承型资源 ──► [完整接入](./guides/full-integration.md)
```
## 快速链接
| 我想... | 去看... |
|---------|---------|
| 5 步完成最小接入 | [快速入门](./guides/quick-start.md) |
| 接入继承型资源 | [完整接入](./guides/full-integration.md) |
| 检查遗漏项 | [实施清单](./checklist.md) |
| 理解权限系统原理 | [参考文档](./reference/README.md) |
## 关键代码位置
| 文件 | 用途 |
|------|------|
| `packages/global/support/permission/constant.ts` | 添加 `PerResourceTypeEnum` |
| `packages/global/support/permission/{resource}/` | 权限常量 + Permission 类 |
| `packages/service/support/permission/{resource}/auth.ts` | 鉴权函数 |
# 权限接入实施清单
> 上线前核对清单,可打印使用。
## 设计判断
- [ ] 确认资源是否有 owner
- [ ] 确认资源是否属于 team
- [ ] 确认资源是否需要协作者
- [ ] 确认资源是否有 folder / parent-child 结构
- [ ] 确认资源是否支持 `inheritPermission`
- [ ] 确认资源是否需要 owner 转移
---
## FastGPT 主仓库
### 权限定义
- [ ] `PerResourceTypeEnum` 已添加新资源类型
- [ ] `packages/global/support/permission/{resource}/constant.ts` 已创建
- [ ] `{Resource}RoleList`
- [ ] `{Resource}RolePerMap`
- [ ] `{Resource}PerList`
- [ ] `{Resource}DefaultRoleVal`
- [ ] `packages/global/support/permission/{resource}/controller.ts` 已创建
- [ ] `{Resource}Permission`
### 资源 Schema
- [ ] 包含 `teamId` 字段
- [ ] 包含 `tmbId` 字段(owner)
- [ ] 包含 `parentId` 字段(如有层级)
- [ ] 包含 `inheritPermission` 字段(如有继承)
### 鉴权函数
- [ ] `packages/service/support/permission/{resource}/auth.ts` 已创建
- [ ] `auth{Resource}` 函数已实现
- [ ] 如有继承,已实现父级权限合并
### API 权限校验
- [ ] 列表接口:`ReadPermissionVal`
- [ ] 详情接口:`ReadPermissionVal`
- [ ] 创建接口:`WritePermissionVal` 或 team 级创建权限
- [ ] 更新接口:`WritePermissionVal`
- [ ] 删除接口:`OwnerPermissionVal`(不是 Manage!)
- [ ] Folder 创建接口(如有)
- [ ] 恢复继承接口(如有)
### 继承相关(如适用)
- [ ] 明确 folder 类型列表
- [ ] 资源创建时复制父协作者
- [ ] 资源移动时同步子树权限
- [ ] `resumeInheritPermission` 逻辑
---
## fastgpt-pro
### 协作者管理
- [ ] `collaborator/list` 接口
- [ ] 返回 `clbs`(最终生效协作者)
- [ ] 返回 `parentClbs`(父级协作者)
- [ ] `collaborator/update` 接口
- [ ] 需要 `ManagePermissionVal`
- [ ] 不能修改自己的权限
- [ ] 非 owner 不能修改管理员权限
- [ ] 继承冲突时自动断开继承
### Owner 转移(如适用)
- [ ] `changeOwner` 接口
- [ ] 需要 `OwnerPermissionVal`
- [ ] 更新资源表 `tmbId`
- [ ] 根资源断开继承
- [ ] 修正权限记录
### 协作者类型支持
- [ ] 支持 `tmbId`(团队成员)
- [ ] 支持 `groupId`(成员组)
- [ ] 支持 `orgId`(组织)
### 审计日志
- [ ] 更新协作者日志
- [ ] 删除协作者日志
- [ ] Owner 转移日志
- [ ] 恢复继承日志(如有)
- [ ] 移动资源日志(如有)
---
## 前端
- [ ] 协作者列表 API 调用
- [ ] 协作者更新 API 调用
- [ ] Owner 转移 API 调用(如有)
- [ ] 权限配置弹窗 / 协作者管理组件
- [ ] 继承态提示 UI(如有)
- [ ] 恢复继承入口(如有)
---
## 测试
### 单元测试
- [ ] Permission 类与角色映射
- [ ] `getTmbPermission` 优先级逻辑
- [ ] 继承型资源的父子权限合并
### 集成测试
- [ ] 主要 API 的权限边界
- [ ] 删除是否要求 owner
- [ ] 移动与继承恢复逻辑
- [ ] 协作者更新冲突处理
- [ ] Owner 转移后权限记录正确性
---
## 最终检查
- [ ] 删除要求 owner,而不是 manage
- [ ] group / org 协作者按预期生效
- [ ] 继承断开后生成正确的显式协作者快照
- [ ] 移动资源后子树权限同步
- [ ] Owner 转移后旧/新 owner 权限记录正确
- [ ] 前后端展示的"最终权限"与后端实际鉴权一致
# 完整接入:继承型资源权限
> 适用于**继承型资源**:有 folder 结构、支持 `inheritPermission`、需要协作者管理和 owner 转移。
## 概览
继承型资源需要在两个仓库中实现:
| 仓库 | 职责 |
|------|------|
| FastGPT 主仓库 | 权限定义、鉴权、继承同步 |
| fastgpt-pro | 协作者管理、owner 转移、审计日志 |
---
## Part 1: FastGPT 主仓库
### 1.1 基础权限定义
[快速入门](./quick-start.md)相同,完成 Step 1-3。
### 1.2 资源 Schema 字段
确保资源 Schema 包含以下字段:
```typescript
const {Resource}Schema = new Schema({
teamId: { type: Schema.Types.ObjectId, required: true },
tmbId: { type: Schema.Types.ObjectId, required: true }, // 创建者/owner
parentId: { type: Schema.Types.ObjectId, default: null }, // 父资源
type: { type: String }, // 区分 folder 和普通资源
inheritPermission: { type: Boolean, default: true } // 是否继承父权限
});
```
### 1.3 实现带继承的鉴权函数
```typescript
// packages/service/support/permission/{resource}/auth.ts
export const auth{Resource} = async ({
{resource}Id,
per,
...props
}: AuthModeType & {
{resource}Id: string;
per: PermissionValueType;
}) => {
const result = await parseHeaderCert(props);
const { tmbId, teamId } = result;
const resource = await Mongo{Resource}.findById({resource}Id).lean();
if (!resource) {
return Promise.reject({Resource}ErrEnum.notExist);
}
if (String(resource.teamId) !== teamId) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId);
// 关键:判断是否需要合并父级权限
const isGetParentClb =
resource.inheritPermission &&
resource.type !== '{resource}Folder' && // folder 不继承
!!resource.parentId;
// 并行获取父级权限和自身权限
const [folderPer, myPer] = await Promise.all([
isGetParentClb
? getTmbPermission({
teamId,
tmbId,
resourceId: resource.parentId!,
resourceType: PerResourceTypeEnum.{resource}
})
: NullRoleVal,
getTmbPermission({
teamId,
tmbId,
resourceId: {resource}Id,
resourceType: PerResourceTypeEnum.{resource}
})
]);
// 合并权限
const Per = new {Resource}Permission({
role: sumPer(folderPer, myPer),
isOwner
});
if (!Per.checkPer(per)) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
return {
...result,
permission: Per,
{resource}: resource
};
};
```
### 1.4 Folder 创建时复制父协作者
```typescript
// 创建 folder 时
import { createResourceDefaultCollaborators } from '@fastgpt/service/support/permission/controller';
await createResourceDefaultCollaborators({
teamId,
tmbId,
resourceId: newFolderId,
resourceType: PerResourceTypeEnum.{resource},
parentId,
session
});
```
### 1.5 移动资源时同步子树权限
```typescript
// 资源移动后
import { syncChildrenPermission } from '@fastgpt/service/support/permission/inheritPermission';
await syncChildrenPermission({
resource: movedResource,
folderTypeList: ['{resource}Folder'],
resourceType: PerResourceTypeEnum.{resource},
resourceModel: Mongo{Resource},
session,
collaborators: newParentCollaborators
});
```
### 1.6 恢复继承
```typescript
// 恢复继承时
import { resumeInheritPermission } from '@fastgpt/service/support/permission/inheritPermission';
await resumeInheritPermission({
resource,
folderTypeList: ['{resource}Folder'],
resourceType: PerResourceTypeEnum.{resource},
resourceModel: Mongo{Resource},
session
});
```
---
## Part 2: fastgpt-pro
### 2.1 协作者列表接口
```typescript
// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/list.ts
async function handler(req) {
const { teamId, {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: ReadPermissionVal
});
const isGetParentClbs =
!!{resource}.inheritPermission &&
{resource}.type !== '{resource}Folder' &&
!!{resource}.parentId;
const [parentClbs, childClbs] = await Promise.all([
isGetParentClbs
? getResourceOwnedClbs({ teamId, resourceId: {resource}.parentId, resourceType })
: [],
getResourceOwnedClbs({ teamId, resourceId: {resource}Id, resourceType })
]);
const realClbs = isGetParentClbs
? mergeCollaboratorList({ childClbs, parentClbs })
: childClbs;
return {
clbs: await getClbsInfo(realClbs), // 最终生效协作者
parentClbs: await getClbsInfo(parentClbs) // 父级协作者(用于 UI 展示来源)
};
}
```
### 2.2 协作者更新接口
```typescript
// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/update.ts
async function handler(req) {
const { teamId, tmbId, permission: myPer, {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: ManagePermissionVal
});
// 保护规则
const changedClbs = getChangedCollaborators({ newRealClbs: collaborators, oldRealClbs });
// 1. 不能修改自己的权限
if (changedClbs.find((clb) => clb?.tmbId === tmbId)) {
return Promise.reject({Resource}ErrEnum.canNotEditSelfPermission);
}
// 2. 非 owner 不能修改管理员级协作者
if (
changedClbs.some((clb) => new {Resource}Permission({ role: clb.changedRole }).hasManagePer) &&
!myPer.isOwner
) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
// 调用通用编排器
await updateResourceCollaborators({
teamId,
resourceId: {resource}Id,
resourceType: PerResourceTypeEnum.{resource},
collaborators,
folderTypeList: ['{resource}Folder'],
resource: {resource},
resourceModel: Mongo{Resource},
session
});
}
```
### 2.3 Owner 转移接口
```typescript
// fastgpt-pro/projects/app/src/pages/api/core/{resource}/changeOwner.ts
async function handler(req) {
const { {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: OwnerPermissionVal // 只有 owner 能转移
});
await changeOwner({
changeOwnerType: '{resource}',
resourceId: {resource}._id,
newOwnerId: newOwnerTmbId,
oldOwnerId: {resource}.tmbId,
teamId: {resource}.teamId
});
}
```
---
## Part 3: 前端
### 3.1 协作者管理组件
复用现有的 `MemberManager` 组件,配置:
```typescript
<MemberManager
permission={permission}
onGetCollaboratorList={() => get{Resource}Collaborators({resource}Id)}
onUpdateCollaborators={(clbs) => update{Resource}Collaborators({resource}Id, clbs)}
onDelOneCollaborator={(clb) => delete{Resource}Collaborator({resource}Id, clb)}
/>
```
### 3.2 继承态提示
```typescript
{resource.inheritPermission && resource.parentId && (
<Tag colorScheme="blue">继承自父级</Tag>
)}
```
---
## 完成后检查
使用 [实施清单](../checklist.md) 进行最终检查。
## 深入了解
- [继承机制详解](../reference/inheritance.md)
- [协作者管理编排器](../reference/pro-collaborator.md)
- [Owner 转移机制](../reference/pro-owner-transfer.md)
# 快速入门:5 步完成权限接入
> 适用于**简单资源**:无父子结构、无继承、无 owner 转移需求。
## 前置条件
- 资源已有 `teamId``tmbId` 字段
- 资源属于某个 team
---
## Step 1: 添加资源类型枚举
```typescript
// packages/global/support/permission/constant.ts
export enum PerResourceTypeEnum {
// ...existing
{resource} = '{resource}' // 例如: agentSkill = 'agentSkill'
}
```
---
## Step 2: 创建权限常量文件
```typescript
// packages/global/support/permission/{resource}/constant.ts
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import {
CommonRoleList,
CommonPerKeyEnum,
CommonRolePerMap,
CommonPerList,
NullRoleVal
} from '../constant';
export const {Resource}RoleList = {
[CommonPerKeyEnum.read]: {
...CommonRoleList[CommonPerKeyEnum.read],
description: i18nT('permission:{resource}.read_desc')
},
[CommonPerKeyEnum.write]: {
...CommonRoleList[CommonPerKeyEnum.write],
description: i18nT('permission:{resource}.write_desc')
},
[CommonPerKeyEnum.manage]: {
...CommonRoleList[CommonPerKeyEnum.manage],
description: i18nT('permission:{resource}.manage_desc')
}
};
export const {Resource}RolePerMap = CommonRolePerMap;
export const {Resource}PerList = CommonPerList;
export const {Resource}DefaultRoleVal = NullRoleVal;
```
---
## Step 3: 创建 Permission 类
```typescript
// packages/global/support/permission/{resource}/controller.ts
import { Permission, PerConstructPros } from '../controller';
import {
{Resource}RoleList,
{Resource}RolePerMap,
{Resource}PerList,
{Resource}DefaultRoleVal
} from './constant';
export class {Resource}Permission extends Permission {
constructor(props?: PerConstructPros) {
if (!props) {
props = { role: {Resource}DefaultRoleVal };
} else if (!props.role) {
props.role = {Resource}DefaultRoleVal;
}
props.roleList = {Resource}RoleList;
props.rolePerMap = {Resource}RolePerMap;
props.perList = {Resource}PerList;
super(props);
}
}
```
---
## Step 4: 实现鉴权函数
```typescript
// packages/service/support/permission/{resource}/auth.ts
import { AuthModeType } from '../type';
import { parseHeaderCert } from '../../controller';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { PermissionValueType } from '@fastgpt/global/support/permission/type';
import { {Resource}Permission } from '@fastgpt/global/support/permission/{resource}/controller';
import { getTmbPermission } from '../controller';
import { Mongo{Resource} } from '@fastgpt/service/core/{resource}/schema';
import { {Resource}ErrEnum } from '@fastgpt/global/common/error/code/{resource}';
export const auth{Resource} = async ({
{resource}Id,
per,
...props
}: AuthModeType & {
{resource}Id: string;
per: PermissionValueType;
}) => {
const result = await parseHeaderCert(props);
const { tmbId, teamId } = result;
// 1. 查询资源
const resource = await Mongo{Resource}.findById({resource}Id).lean();
if (!resource) {
return Promise.reject({Resource}ErrEnum.notExist);
}
// 2. 验证 team 归属
if (String(resource.teamId) !== teamId) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
// 3. 判断 owner
const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId);
// 4. 获取权限
const myPer = await getTmbPermission({
teamId,
tmbId,
resourceId: {resource}Id,
resourceType: PerResourceTypeEnum.{resource}
});
// 5. 构建权限对象并检查
const Per = new {Resource}Permission({ role: myPer, isOwner });
if (!Per.checkPer(per)) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
return {
...result,
permission: Per,
{resource}: resource
};
};
```
---
## Step 5: 在 API 中使用
```typescript
// 读取操作
const { {resource}, permission } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: ReadPermissionVal
});
// 写入操作
const { {resource}, permission } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: WritePermissionVal
});
// 删除操作(要求 owner)
const { {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: OwnerPermissionVal
});
```
---
## 完成后检查
- [ ] `PerResourceTypeEnum` 已添加
- [ ] 权限常量文件已创建
- [ ] Permission 类已创建
- [ ] 鉴权函数已实现
- [ ] API 路由已使用鉴权函数
## 下一步
- 需要协作者管理?→ 在 fastgpt-pro 中添加 `collaborator/list``collaborator/update` 接口
- 需要更多细节?→ [参考文档](../reference/README.md)
# 参考文档索引
> 深入理解 FastGPT 权限系统的设计原理和实现细节。
## 文档结构
```
reference/
├── core-concepts.md ── 核心概念:权限值、角色、协作者
├── permission-class.md ── Permission 类设计与使用
├── auth-function.md ── 鉴权函数实现模式
├── inheritance.md ── 继承机制详解
├── pro-collaborator.md ── fastgpt-pro 协作者管理
└── pro-owner-transfer.md ── Owner 转移机制
```
## 按场景查阅
| 我想了解... | 去看... |
|-------------|---------|
| 权限值的位字段设计 | [核心概念](./core-concepts.md) |
| 如何扩展 Permission 类 | [Permission 类设计](./permission-class.md) |
| 鉴权函数的标准实现 | [鉴权函数实现](./auth-function.md) |
| 父子资源权限如何合并 | [继承机制](./inheritance.md) |
| 协作者更新如何处理冲突 | [协作者管理](./pro-collaborator.md) |
| Owner 转移的完整流程 | [Owner 转移](./pro-owner-transfer.md) |
# 鉴权函数实现
## 1. 标准鉴权流程
```
用户请求 (带 Token/ApiKey)
┌──────────────────────┐
│ parseHeaderCert │ 解析认证信息
└──────────┬───────────┘
┌──────────────────────┐
│ 查询资源 │
└──────────┬───────────┘
┌──────────────────────┐
│ 验证 team 归属 │
└──────────┬───────────┘
┌──────────────────────┐
│ 判断 isOwner │
└──────────┬───────────┘
┌──────────────────────┐
│ getTmbPermission │ 获取用户权限
└──────────┬───────────┘
┌──────────────────────┐
│ 构建 Permission 对象 │
└──────────┬───────────┘
┌──────────────────────┐
│ checkPer 验证 │
└──────────────────────┘
```
---
## 2. 简单资源鉴权模板
```typescript
// packages/service/support/permission/{resource}/auth.ts
import { AuthModeType, parseHeaderCert } from '../type';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { PermissionValueType } from '@fastgpt/global/support/permission/type';
import { {Resource}Permission } from '@fastgpt/global/support/permission/{resource}/controller';
import { getTmbPermission } from '../controller';
export const auth{Resource} = async ({
{resource}Id,
per,
...props
}: AuthModeType & {
{resource}Id: string;
per: PermissionValueType;
}) => {
// 1. 解析认证信息
const result = await parseHeaderCert(props);
const { tmbId, teamId } = result;
// 2. 查询资源
const resource = await Mongo{Resource}.findById({resource}Id).lean();
if (!resource) {
return Promise.reject({Resource}ErrEnum.notExist);
}
// 3. 验证 team 归属
if (String(resource.teamId) !== teamId) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
// 4. 判断 owner
// - team owner 视为资源 owner
// - 资源创建者是 owner
const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId);
// 5. 获取用户权限
const myPer = await getTmbPermission({
teamId,
tmbId,
resourceId: {resource}Id,
resourceType: PerResourceTypeEnum.{resource}
});
// 6. 构建权限对象并检查
const Per = new {Resource}Permission({ role: myPer, isOwner });
if (!Per.checkPer(per)) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
// 7. 返回结果
return {
...result,
permission: Per,
{resource}: resource
};
};
```
---
## 3. 继承型资源鉴权模板
```typescript
export const auth{Resource} = async ({
{resource}Id,
per,
...props
}: AuthModeType & {
{resource}Id: string;
per: PermissionValueType;
}) => {
const result = await parseHeaderCert(props);
const { tmbId, teamId } = result;
const resource = await Mongo{Resource}.findById({resource}Id).lean();
if (!resource) {
return Promise.reject({Resource}ErrEnum.notExist);
}
if (String(resource.teamId) !== teamId) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId);
// 关键:判断是否需要合并父级权限
const isGetParentClb =
resource.inheritPermission && // 开启了继承
resource.type !== '{resource}Folder' && // folder 不继承
!!resource.parentId; // 有父资源
// 并行获取
const [folderPer, myPer] = await Promise.all([
isGetParentClb
? getTmbPermission({
teamId,
tmbId,
resourceId: resource.parentId!,
resourceType: PerResourceTypeEnum.{resource}
})
: NullRoleVal,
getTmbPermission({
teamId,
tmbId,
resourceId: {resource}Id,
resourceType: PerResourceTypeEnum.{resource}
})
]);
// 合并权限
const Per = new {Resource}Permission({
role: sumPer(folderPer, myPer),
isOwner
});
if (!Per.checkPer(per)) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
return {
...result,
permission: Per,
{resource}: resource
};
};
```
---
## 4. getTmbPermission 实现
```typescript
// packages/service/support/permission/controller.ts
export const getTmbPermission = async ({
teamId,
tmbId,
resourceId,
resourceType
}) => {
// 1. 个人权限优先
const tmbPer = (
await MongoResourcePermission.findOne({
resourceType,
teamId,
resourceId,
tmbId
}, 'permission').lean()
)?.permission;
// 个人权限存在则直接返回(即使是 0)
if (tmbPer !== undefined) return tmbPer;
// 2. 获取 group 和 org 权限
const [groupPers, orgPers] = await Promise.all([
// 查询用户所属 group 的权限
getGroupPermissions(...),
// 查询用户所属 org 的权限
getOrgPermissions(...)
]);
// 3. 合并返回
return sumPer(...groupPers, ...orgPers);
};
```
---
## 5. API 使用示例
```typescript
// 读取操作
async function handler(req) {
const { {resource}, permission } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: ReadPermissionVal
});
return { ...{resource}, permission };
}
// 写入操作
async function handler(req) {
const { {resource}, permission } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: WritePermissionVal
});
// 业务逻辑...
}
// 删除操作(要求 owner)
async function handler(req) {
await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: OwnerPermissionVal
});
// 删除逻辑...
}
```
# 核心概念
## 1. 权限值 (Permission Value) - 位字段设计
权限使用位字段 (bitmask) 表示,支持权限组合:
```typescript
// packages/global/support/permission/constant.ts
export const CommonPerList = {
owner: ~0 >>> 0, // 所有位为1,表示所有者
read: 0b100, // 读权限 (4)
write: 0b010, // 写权限 (2)
manage: 0b001 // 管理权限 (1)
};
```
### 权限值对照表
| 权限 | 值 | 二进制 | 说明 |
|------|-----|--------|------|
| NullRoleVal | 0 | 0b000 | 无角色 |
| ReadPermissionVal | 4 | 0b100 | 读权限 |
| WritePermissionVal | 2 | 0b010 | 写权限 |
| ManagePermissionVal | 1 | 0b001 | 管理权限 |
| OwnerPermissionVal | ~0>>>0 | 全1 | 所有者 |
### 位运算示例
```typescript
// 检查是否有读权限
const hasRead = (permission & ReadPermissionVal) === ReadPermissionVal;
// 合并权限
const merged = permission1 | permission2;
// 添加权限
const withWrite = permission | WritePermissionVal;
```
---
## 2. 角色值 (Role Value) - 权限映射
**关键区分**:数据库中 `permission` 字段存储的是**角色值**,不是展开后的权限值。
```typescript
// 角色 -> 权限映射
export const CommonRolePerMap = new Map([
[0b100, 0b100], // read 角色 -> read 权限
[0b010, 0b110], // write 角色 -> write + read 权限
[0b001, 0b111] // manage 角色 -> manage + write + read 权限
]);
```
### 角色继承关系
```
manage (0b001) ──包含──► write + read
write (0b010) ──包含──► read
read (0b100)
```
---
## 3. 协作者类型
权限可以分配给三种实体(三选一):
```typescript
// packages/global/support/permission/collaborator.ts
type CollaboratorIdType = RequireOnlyOne<{
tmbId: string; // 团队成员
groupId: string; // 成员组
orgId: string; // 组织
}>;
```
### 权限优先级
```
tmbId (个人权限)
└─ 存在?─► 直接返回
└─ 否 ─► groupId + orgId 合并后返回
```
**注意**:不是"个人 > 组 > 组织"的覆盖关系,而是:
- 个人权限存在则直接使用
- 否则 group 和 org 权限按位合并
---
## 4. ResourcePermission Schema
```typescript
// packages/service/support/permission/schema.ts
const ResourcePermissionSchema = new Schema({
teamId: { type: Schema.Types.ObjectId, required: true },
// 协作者标识(三选一)
tmbId: { type: Schema.Types.ObjectId },
groupId: { type: Schema.Types.ObjectId },
orgId: { type: Schema.Types.ObjectId },
// 资源信息
resourceType: { type: String, enum: Object.values(PerResourceTypeEnum), required: true },
resourceId: { type: Schema.Types.ObjectId },
// 存储的是角色值
permission: { type: Number, required: true }
});
```
### 索引
- `resourceId + tmbId` 唯一
- `resourceId + groupId` 唯一
- `resourceId + orgId` 唯一
---
## 5. 资源 Schema 权限相关字段
```typescript
const ResourceSchema = new Schema({
teamId: { type: Schema.Types.ObjectId, required: true },
tmbId: { type: Schema.Types.ObjectId, required: true }, // 创建者/owner
parentId: { type: Schema.Types.ObjectId, default: null }, // 父资源(可选)
inheritPermission: { type: Boolean, default: true } // 是否继承(可选)
});
```
# 继承机制详解
## 1. 继承模型概述
```
┌─────────────────────────────────────────────────────────┐
│ Folder A (inheritPermission: false) │
│ 协作者: [User1: manage, User2: write] │
└───────────────────────┬─────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────────────┐
│ Resource B │ │ Folder C │
│ inherit: true │ │ inherit: true │
│ 自身协作者: [] │ │ 协作者: [User1, User2] │
│ │ │ (从 A 复制) │
│ 最终权限: │ └─────────────┬─────────────┘
│ User1: manage │ │
│ User2: write │ ▼
│ (来自父级) │ ┌───────────────────────────┐
└───────────────────┘ │ Resource D │
│ inherit: true │
│ 自身协作者: [User3: read] │
│ │
│ 最终权限: │
│ User1: manage (父级) │
│ User2: write (父级) │
│ User3: read (自身) │
└───────────────────────────┘
```
### 关键规则
1. **Folder 不继承**:folder 的 `inheritPermission` 无效,它有自己的完整协作者列表
2. **普通资源继承**:开启继承时,鉴权时合并父级权限
3. **继承是增量合并**:不是覆盖,子资源可以有额外的显式协作者
---
## 2. 鉴权时的权限合并
```typescript
// packages/service/support/permission/dataset/auth.ts
const isGetParentClb =
dataset.inheritPermission &&
dataset.type !== DatasetTypeEnum.folder &&
!!dataset.parentId;
const [folderPer, myPer] = await Promise.all([
isGetParentClb
? getTmbPermission({ resourceId: dataset.parentId, ... })
: NullRoleVal,
getTmbPermission({ resourceId: datasetId, ... })
]);
// 按位合并
const Per = new DatasetPermission({
role: sumPer(folderPer, myPer),
isOwner
});
```
### sumPer 实现
```typescript
export const sumPer = (...pers: PermissionValueType[]) => {
return pers.reduce((acc, per) => acc | per, NullRoleVal);
};
```
---
## 3. Folder 创建时复制父协作者
```typescript
// packages/service/support/permission/controller.ts
export const createResourceDefaultCollaborators = async ({
teamId,
tmbId,
resourceId,
resourceType,
parentId,
session
}) => {
// 1. 获取父协作者
const parentClbs = parentId
? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType })
: [];
// 2. 构建新协作者列表
const collaborators = [
...parentClbs
.filter((item) => item.tmbId !== tmbId) // 排除创建者
.map((clb) => {
// 父 owner 降级为 manage
if (clb.permission === OwnerRoleVal) {
clb.permission = ManageRoleVal;
}
return clb;
}),
// 创建者成为 owner
{ tmbId, permission: OwnerRoleVal }
];
// 3. 批量插入
await MongoResourcePermission.insertMany(
collaborators.map((clb) => ({
teamId,
resourceType,
resourceId,
...clb
})),
{ session }
);
};
```
---
## 4. 子树权限同步 (syncChildrenPermission)
当 folder 的协作者变化时,需要同步到继承它的子树。
```typescript
// packages/service/support/permission/inheritPermission.ts
export async function syncChildrenPermission({
resource,
folderTypeList,
resourceType,
resourceModel,
session,
collaborators: latestClbList
}) {
// 1. 只处理 folder
if (!folderTypeList.includes(resource.type)) return;
// 2. 获取所有 inheritPermission: true 的 folder 子树
const allFolders = await resourceModel.find({
teamId: resource.teamId,
inheritPermission: true,
type: { $in: folderTypeList }
});
// 3. BFS 遍历子树
const queue = [resource._id];
while (queue.length) {
const parentId = queue.shift();
const children = allFolders.filter(f => String(f.parentId) === String(parentId));
for (const child of children) {
// 获取子资源现有协作者
const childClbs = await getResourceOwnedClbs({ resourceId: child._id, ... });
for (const latestClb of latestClbList) {
// 跳过 owner
if (latestClb.permission === OwnerRoleVal) continue;
const myClb = childClbs.find(c => sameClb(c, latestClb));
if (myClb) {
// 已有则合并(增量)
await MongoResourcePermission.updateOne(
{ _id: myClb._id },
{ permission: sumPer(myClb.permission, latestClb.permission) },
{ session }
);
} else {
// 没有则新增
await MongoResourcePermission.create([{
...latestClb,
resourceId: child._id,
resourceType
}], { session });
}
}
// 删除不再存在的纯继承协作者
for (const childClb of childClbs) {
const inLatest = latestClbList.find(c => sameClb(c, childClb));
if (!inLatest && childClb.permission === parentClb?.permission) {
// 是纯继承的,删除
await MongoResourcePermission.deleteOne({ _id: childClb._id }, { session });
}
}
queue.push(child._id);
}
}
}
```
### 关键点
- **增量合并**:不是简单覆盖,保留子资源的显式增量
- **只删纯继承**:只删除权限值与父级完全一致的协作者
- **跳过 owner**:owner 不参与继承同步
---
## 5. 恢复继承 (resumeInheritPermission)
```typescript
// packages/service/support/permission/inheritPermission.ts
export const resumeInheritPermission = async ({
resource,
folderTypeList,
resourceType,
resourceModel,
session
}) => {
const { teamId, parentId, _id: resourceId } = resource;
// 1. 获取父协作者
const parentClbs = parentId
? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType })
: [];
// 2. 获取自身协作者
const selfClbs = await getResourceOwnedClbs({ teamId, resourceId, resourceType });
// 3. 合并协作者
const mergedClbs = mergeCollaboratorList({
childClbs: selfClbs,
parentClbs: parentClbs.map((clb) => {
// 父 owner 降为 manage
if (clb.permission === OwnerRoleVal) {
return { ...clb, permission: ManageRoleVal };
}
return clb;
})
});
// 4. 删除旧协作者
await MongoResourcePermission.deleteMany({
resourceType,
resourceId
}, { session });
// 5. 插入合并后的协作者
await MongoResourcePermission.insertMany(
mergedClbs.map(clb => ({
teamId,
resourceType,
resourceId,
...clb
})),
{ session }
);
// 6. 如果是 folder,同步子树
if (folderTypeList.includes(resource.type)) {
await syncChildrenPermission({
resource,
folderTypeList,
resourceType,
resourceModel,
session,
collaborators: mergedClbs
});
}
// 7. 设置继承标志
await resourceModel.updateOne(
{ _id: resourceId },
{ inheritPermission: true },
{ session }
);
};
```
---
## 6. 资源移动时的处理
```typescript
// 移动资源后
if (newParentId !== oldParentId) {
// 获取新父级协作者
const newParentClbs = await getResourceOwnedClbs({
resourceId: newParentId,
...
});
// 同步子树
await syncChildrenPermission({
resource: movedResource,
folderTypeList,
resourceType,
resourceModel,
session,
collaborators: newParentClbs
});
}
```
# Permission 类设计
## 1. 基类结构
```typescript
// packages/global/support/permission/controller.ts
export class Permission {
role: PermissionValueType;
private permission: PermissionValueType;
// 权限状态(计算属性)
isOwner: boolean;
hasManagePer: boolean;
hasWritePer: boolean;
hasReadPer: boolean;
// 角色状态
hasManageRole: boolean;
hasWriteRole: boolean;
hasReadRole: boolean;
constructor({ role, isOwner, roleList, perList, rolePerMap }) {
this.role = isOwner ? OwnerRoleVal : role;
this.updatePermissions();
}
// 检查是否拥有指定权限
checkPer(perm: PermissionValueType): boolean {
if (perm === OwnerPermissionVal) {
return this.permission === OwnerPermissionVal;
}
return (this.permission & perm) === perm;
}
// 添加角色
addRole(...roleList: RoleValueType[]) {
for (const role of roleList) {
this.role = this.role | role;
}
this.updatePermissions();
return this;
}
}
```
### 关键点
1. **存储的是 role**`permission` 字段存的是 role 值,通过 `rolePerMap` 展开成实际权限
2. **isOwner 提升**:如果 `isOwner=true`,role 直接设为 `OwnerRoleVal`
3. **链式调用**`addRole` 返回 `this`,支持链式操作
---
## 2. 创建资源特定的 Permission 类
```typescript
// packages/global/support/permission/{resource}/controller.ts
import { Permission, PerConstructPros } from '../controller';
import {
{Resource}RoleList,
{Resource}RolePerMap,
{Resource}PerList,
{Resource}DefaultRoleVal
} from './constant';
export class {Resource}Permission extends Permission {
constructor(props?: PerConstructPros) {
// 处理空参数
if (!props) {
props = { role: {Resource}DefaultRoleVal };
} else if (!props.role) {
props.role = {Resource}DefaultRoleVal;
}
// 注入资源特定的配置
props.roleList = {Resource}RoleList;
props.rolePerMap = {Resource}RolePerMap;
props.perList = {Resource}PerList;
super(props);
}
}
```
---
## 3. 使用示例
### 3.1 基本检查
```typescript
const per = new DatasetPermission({ role: WriteRoleVal });
per.hasReadPer; // true(write 包含 read)
per.hasWritePer; // true
per.hasManagePer; // false
per.isOwner; // false
per.checkPer(ReadPermissionVal); // true
per.checkPer(ManagePermissionVal); // false
```
### 3.2 在鉴权中使用
```typescript
const Per = new {Resource}Permission({
role: myPer,
isOwner: String(resource.tmbId) === String(tmbId)
});
if (!Per.checkPer(per)) {
return Promise.reject({Resource}ErrEnum.unAuth);
}
// 返回给调用方
return {
permission: Per,
{resource}: resource
};
```
### 3.3 合并权限
```typescript
import { sumPer } from '@fastgpt/global/support/permission/utils';
// 合并父级权限和自身权限
const Per = new {Resource}Permission({
role: sumPer(folderPer, myPer),
isOwner
});
```
---
## 4. 现有 Permission 类
| 类 | 文件 |
|----|------|
| `Permission` | `packages/global/support/permission/controller.ts` |
| `DatasetPermission` | `packages/global/support/permission/dataset/controller.ts` |
| `AppPermission` | `packages/global/support/permission/app/controller.ts` |
| `TeamPermission` | `packages/global/support/permission/user/controller.ts` |
# fastgpt-pro 协作者管理
> fastgpt-pro 在 FastGPT 主仓库的基础权限系统之上,提供"可运营的权限管理能力"。
## 1. 架构分层
```
┌────────────────────────────────────────────────────────────────────┐
│ fastgpt-pro 权限扩展层 │
├────────────────────────────────────────────────────────────────────┤
│ API 层 │
│ ├── /api/core/{resource}/collaborator/list │
│ ├── /api/core/{resource}/collaborator/update │
│ └── /api/core/{resource}/changeOwner │
├────────────────────────────────────────────────────────────────────┤
│ 编排层 │
│ ├── updateResourceCollaborators │
│ ├── getChangedCollaborators │
│ ├── checkRoleUpdateConflict │
│ └── mergeCollaboratorList │
├────────────────────────────────────────────────────────────────────┤
│ FastGPT 主仓库基础能力 │
│ ├── authDataset / authApp │
│ ├── getTmbPermission │
│ └── ResourcePermission Schema │
└────────────────────────────────────────────────────────────────────┘
```
**一句话概括**:FastGPT 负责"判定权限",fastgpt-pro 负责"管理权限"。
---
## 2. 协作者列表接口
### 接口设计
```typescript
// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/list.ts
type Response = {
clbs: CollaboratorItemDetailType[]; // 最终生效协作者
parentClbs?: CollaboratorItemDetailType[]; // 父级协作者(用于展示来源)
};
```
### 实现
```typescript
async function handler(req) {
const { teamId, {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: ReadPermissionVal
});
// 判断是否需要获取父级协作者
const isGetParentClbs =
!!{resource}.inheritPermission &&
{resource}.type !== '{resource}Folder' &&
!!{resource}.parentId;
const [parentClbs, childClbs] = await Promise.all([
isGetParentClbs
? getResourceOwnedClbs({ teamId, resourceId: {resource}.parentId, resourceType })
: [],
getResourceOwnedClbs({ teamId, resourceId: {resource}Id, resourceType })
]);
// 合并得到最终生效协作者
const realClbs = isGetParentClbs
? mergeCollaboratorList({ childClbs, parentClbs })
: childClbs;
return {
clbs: await getClbsInfo(realClbs),
parentClbs: await getClbsInfo(parentClbs)
};
}
```
### 设计意图
- 不是简单返回 `MongoResourcePermission.find({ resourceId })`
- 同时返回"最终权限视图"和"继承来源视图"
- 前端可以据此展示"此权限来自父级"的 UI 提示
---
## 3. 协作者更新接口
### 核心流程
```typescript
async function handler(req) {
// 1. 鉴权(需要 manage 权限)
const { teamId, tmbId, permission: myPer, {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: ManagePermissionVal
});
// 2. 获取新旧协作者
const [parentClbs, oldChildClbs] = await Promise.all([
getResourceOwnedClbs({ resourceId: parentId }),
getResourceOwnedClbs({ resourceId: {resource}Id })
]);
const oldRealClbs = isGetParentClbs
? mergeCollaboratorList({ childClbs: oldChildClbs, parentClbs })
: oldChildClbs;
// 3. 计算变化
const changedClbs = getChangedCollaborators({
newRealClbs: collaborators,
oldRealClbs
});
// 4. 权限保护检查
await checkPermissionProtection(changedClbs, tmbId, myPer);
// 5. 调用编排器更新
await updateResourceCollaborators({
teamId,
resourceId: {resource}Id,
resourceType,
collaborators,
folderTypeList,
resource: {resource},
resourceModel,
session
});
}
```
### 权限保护规则
```typescript
// 1. 不能修改自己的权限
if (changedClbs.find((clb) => clb?.tmbId === tmbId)) {
return Promise.reject(ErrEnum.canNotEditSelfPermission);
}
// 2. 非 owner 不能修改管理员级协作者
if (
changedClbs.some((clb) =>
new {Resource}Permission({ role: clb.changedRole }).hasManagePer
) &&
!myPer.isOwner
) {
return Promise.reject(ErrEnum.unAuth);
}
```
---
## 4. updateResourceCollaborators 编排器
### 核心逻辑
```typescript
export const updateResourceCollaborators = async ({
teamId,
resourceId,
resourceType,
collaborators, // 用户想更新成的协作者列表
folderTypeList,
resource,
resourceModel,
session
}) => {
// 1. 获取父级和当前协作者
const [parentClbs, oldChildClbs] = await Promise.all([...]);
// 2. 计算旧的最终协作者
const oldRealClbs = isGetParentClbs
? mergeCollaboratorList({ childClbs: oldChildClbs, parentClbs })
: oldChildClbs;
// 3. 计算变化的协作者
const changedClbs = getChangedCollaborators({
newRealClbs: collaborators,
oldRealClbs
});
// 4. 检测继承冲突
const hasConflict = checkRoleUpdateConflict({
changedClbs,
parentClbs
});
// 5. 如果是 folder,先同步子树
if (folderTypeList.includes(resource.type)) {
await syncChildrenPermission({
resource,
collaborators,
...
});
}
// 6. 如果处于继承态且有冲突,自动断开继承
if (resource.inheritPermission && hasConflict) {
await resourceModel.updateOne(
{ _id: resourceId },
{ inheritPermission: false },
{ session }
);
}
// 7. 更新协作者记录
if (folderTypeList.includes(resource.type) || hasConflict) {
// folder 或冲突:整表重建
await MongoResourcePermission.deleteMany({ resourceId }, { session });
await MongoResourcePermission.insertMany(collaborators, { session });
} else {
// 普通情况:增量更新
for (const clb of changedClbs) {
if (clb.action === 'add') {
await MongoResourcePermission.create([clb], { session });
} else if (clb.action === 'update') {
await MongoResourcePermission.updateOne(
{ resourceId, ...clbId },
{ permission: clb.permission },
{ session }
);
} else if (clb.action === 'delete') {
await MongoResourcePermission.deleteOne({ resourceId, ...clbId }, { session });
}
}
}
};
```
---
## 5. 继承冲突检测
### checkRoleUpdateConflict
```typescript
export const checkRoleUpdateConflict = ({
changedClbs,
parentClbs
}) => {
for (const changed of changedClbs) {
// 找到对应的父协作者
const parentClb = parentClbs.find(p => sameClb(p, changed));
if (parentClb) {
// 如果修改了来自父级的协作者权限,或删除了父级协作者
if (
changed.action === 'delete' ||
changed.permission !== parentClb.permission
) {
return true; // 有冲突
}
}
}
return false;
};
```
### 冲突即断继承
**设计价值**
1. 用户不需要先点"取消继承"再改协作者
2. 直接改协作者就自动完成"打断继承"状态迁移
3. 交互从"配置底层机制"变成"编辑最终结果"
---
## 6. 为什么 folder 要"整表重建"
folder 或继承态冲突时,采用"删除全部协作者记录,再插入新列表"。
**原因**:这两类场景里,"当前资源的协作者记录"已经不再只是"子级自定义增量",而是要转成一份新的"显式完整权限快照"。
```typescript
if (folderTypeList.includes(resource.type) || hasConflict) {
// 整表重建
await MongoResourcePermission.deleteMany({ resourceId }, { session });
await MongoResourcePermission.insertMany(collaborators, { session });
}
```
---
## 7. 支持三类协作者
fastgpt-pro 的协作者管理同时支持:
| 类型 | 字段 | 说明 |
|------|------|------|
| 团队成员 | tmbId | 个人级权限 |
| 成员组 | groupId | 组级权限 |
| 组织 | orgId | 组织级权限 |
新资源接入时,必须同时支持这三类协作者。
# Owner 转移机制
## 1. 接口入口
```typescript
// fastgpt-pro/projects/app/src/pages/api/core/{resource}/changeOwner.ts
async function handler(req) {
// 只有 owner 能转移
const { {resource} } = await auth{Resource}({
req,
authToken: true,
{resource}Id,
per: OwnerPermissionVal
});
await changeOwner({
changeOwnerType: '{resource}',
resourceId: {resource}._id,
newOwnerId: newOwnerTmbId,
oldOwnerId: {resource}.tmbId,
teamId: {resource}.teamId
});
}
```
---
## 2. 通用 changeOwner 实现
```typescript
// fastgpt-pro/projects/app/src/service/core/changeOwner.ts
export const changeOwner = async ({
changeOwnerType,
resourceId,
newOwnerId,
oldOwnerId,
teamId
}) => {
const session = await mongoose.startSession();
session.startTransaction();
try {
const { resourceModel, folderTypeList, resourceType } = getResourceConfig(changeOwnerType);
// 1. 查询资源
const resource = await resourceModel.findById(resourceId);
// 2. 如果是 folder,获取整个子树
const allResources = folderTypeList.includes(resource.type)
? await getResourceTree(resource, resourceModel, folderTypeList)
: [resource];
// 3. 更新资源表的 tmbId
await updateResourceOwner(allResources, newOwnerId, resourceModel, session);
// 4. 根资源断开继承
await resourceModel.updateOne(
{ _id: resourceId },
{ inheritPermission: false },
{ session }
);
// 5. 修正权限记录
await fixPermissionRecords(allResources, oldOwnerId, newOwnerId, resourceType, session);
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
};
```
---
## 3. 更新资源表 Owner
```typescript
const updateResourceOwner = async (
allResources,
newOwnerId,
resourceModel,
session
) => {
// 根资源直接改 owner
await resourceModel.updateOne(
{ _id: allResources[0]._id },
{ tmbId: newOwnerId },
{ session }
);
// 子资源:只改仍属于旧 owner 的
const childResources = allResources.slice(1);
const oldOwnerChildren = childResources.filter(
r => String(r.tmbId) === String(oldOwnerId)
);
if (oldOwnerChildren.length > 0) {
await resourceModel.updateMany(
{ _id: { $in: oldOwnerChildren.map(r => r._id) } },
{ tmbId: newOwnerId },
{ session }
);
}
};
```
---
## 4. 权限记录修正策略
```typescript
const fixPermissionRecords = async (
allResources,
oldOwnerId,
newOwnerId,
resourceType,
session
) => {
const resourceIds = allResources.map(r => r._id);
// 查询涉及的权限记录
const permissions = await MongoResourcePermission.find({
resourceType,
resourceId: { $in: resourceIds },
tmbId: { $in: [oldOwnerId, newOwnerId] }
});
// 按资源分组
const perByResource = groupBy(permissions, 'resourceId');
for (const [resourceId, pers] of Object.entries(perByResource)) {
const oldOwnerPer = pers.find(p => String(p.tmbId) === String(oldOwnerId));
const newOwnerPer = pers.find(p => String(p.tmbId) === String(newOwnerId));
if (oldOwnerPer && newOwnerPer) {
// 情况1:两者都有记录 → 合并后只保留 newOwner
await MongoResourcePermission.updateOne(
{ _id: newOwnerPer._id },
{ permission: Math.max(oldOwnerPer.permission, newOwnerPer.permission) },
{ session }
);
await MongoResourcePermission.deleteOne(
{ _id: oldOwnerPer._id },
{ session }
);
} else if (oldOwnerPer && !newOwnerPer) {
// 情况2:只有 oldOwner 有记录 → 改成 newOwner
await MongoResourcePermission.updateOne(
{ _id: oldOwnerPer._id },
{ tmbId: newOwnerId },
{ session }
);
}
// 情况3:只有 newOwner 有记录 → 保持不变
}
};
```
### 注意
当前使用 `Math.max(oldPer, newPer)` 合并权限。这在 bitmask 设计下有潜在风险,因为数值更大不一定代表权限更强。
建议后续改成更明确的合并策略:
```typescript
// 推荐做法
const mergedPermission = oldOwnerPer.permission | newOwnerPer.permission;
```
---
## 5. Folder 子树处理
```typescript
const getResourceTree = async (root, resourceModel, folderTypeList) => {
const result = [root];
const queue = [root._id];
while (queue.length) {
const parentId = queue.shift();
const children = await resourceModel.find({
parentId,
teamId: root.teamId
});
for (const child of children) {
result.push(child);
// 只有 folder 才继续递归
if (folderTypeList.includes(child.type)) {
queue.push(child._id);
}
}
}
return result;
};
```
---
## 6. 完整流程图
```
Owner 转移请求
┌──────────────────────┐
│ 验证 OwnerPermission │
└──────────┬───────────┘
┌──────────────────────┐
│ 查询资源(及子树) │
└──────────┬───────────┘
┌──────────────────────┐
│ 更新资源表 tmbId │
│ (根资源 + 旧owner子) │
└──────────┬───────────┘
┌──────────────────────┐
│ 根资源断开继承 │
│ inheritPermission: │
│ false │
└──────────┬───────────┘
┌──────────────────────┐
│ 修正权限记录 │
│ oldOwner → newOwner │
└──────────────────────┘
```
---
## 7. 审计日志
Owner 转移是敏感操作,必须记录审计日志:
```typescript
await addOperationLog({
teamId,
tmbId,
operationType: 'changeOwner',
resourceType,
resourceId,
metadata: {
oldOwnerId,
newOwnerId,
resourceName: resource.name
}
});
```
---
name: local-pr-review
description: 当用户请求对本地未提交的代码(或本地分支特定 commit 等)进行代码审查时触发该 skill,对本地变更进行审查。
---
# When to Use This Skill
当用户请求本地审查时触发。本技能支持对以下三种变更范围进行环境友好的深度审查:
1. **未提交的变更 (Uncommitted)**: 审查工作区中所有尚未提交的修改(包括已暂存和未暂存)。
2. **特定的历史 Commit (Recent Commits)**: 选定检查最近几次 commit(如 `HEAD~N`)的改动情况。
3. **主干差异对比 (Main Branch Diff)**: 与 `main` 等主干分支对比,整体审查当前特性的增量细节。
# Local Review 本地代码审查技能
> 在本地开发环境中,利用全量代码库的上下文优势,全面审查代码变更的质量、安全性、性能和架构设计,并自动评估跨文件影响,提供专业的改进建议。
## 审查范围与启动方式 (Scope Definitions)
你需要根据用户的指令意图,判断并选择对应的比对策略来拉取代码。**务必使用 `-U15` 等参数增加局部上下文视野**
### 模式 1:未提交审查 (Uncommitted Changes)
```bash
# 获取工作区未暂存及已暂存的混合全量 diff
git diff HEAD -U15
```
### 模式 2:特定历史 Commit 审查 (Recent Commits)
```bash
# 审查最后一次 commit
git diff HEAD~1 HEAD -U15
# 审查最近 3 次的整体 commit 变更 (数字可根据用户指令替换)
git diff HEAD~3 HEAD -U15
```
### 模式 3:主干差异审查 (Main Branch Diff)
```bash
# 审查当前特性分支相较于 main 主干的所有的增量代码
git diff main...HEAD -U15
```
## 工具集成
### 使用 git CLI 与本地工具加速审查
在本地环境审查时,相较于 PR 审查的局限点,我们要极大发挥能“到处跑、到处看”的优势。
```bash
# 获取已修改的文件列表及状态
git status -s
# 将宽泛上下文的 diff 存入临时文件便于分析
git diff -U15 > /tmp/local_changes.diff
# 跨文件依赖与上下文搜索(寻找受修改影响的引用方)
git grep "修改的函数名或类型"
```
### 本地测试与验证代码
本地审查时可以直接执行验证流程,以结果作为审查依据:
```bash
# 运行单元测试
pnpm test
# 运行代码规范检查
pnpm lint
# 运行类型检查以捕获变更导致的隐藏/全局类型错误
pnpm tsc --noEmit
# 启动开发服务器通过 UI 进行直观验证
pnpm dev
```
### 常见命令参考
```bash
# 获取具体的 diff 内容
git diff --name-only
git diff <file_path>
# 追溯某段代码的演进历史
git blame <file_path>
# 获取最近的 commit 记录
git log -n 5 --oneline
```
## 审查流程
### 1. 信息与上下文收集阶段 (⭐本地环境核心增强)
**切勿仅凭零碎的补丁代码(Diff chunks)盲目审查,务必建立完整的上下文认知:**
自动执行/指导执行以下步骤:
```bash
# 1. 确认当前的修改状态及涉及的文件
git status
# 2. 获取具有充足上下文的变更细节(-U15 或更大)
git diff -U15
# 3. 阅读全量文件(上下文增强):
# 对于重点修改的文件,不要只看 diff,而是查看整个源文件,理解数据流向和全貌。
cat <file_path>
# 4. 分析跨文件影响(上下文追踪):
# 如果改动涉及到变量名变更、导出方法的增删改、Props 类型更改,
# 必须使用本地搜索查出其在项目中的所有调用点:
git grep "<Edited_Target>"
```
### 2. 多维度代码审查
基于完整的调用链和文件上下文,按照以下维度进行系统性审查:
#### 维度 1: 基本代码质量标准 📐
通用的代码质量标准,适用于所有项目:
- **安全性**: 输入验证、权限检查、注入防护、敏感信息保护
- **正确性**: 错误处理、边界条件、类型安全(**结合上下文,看传参方是否提供了正确数据**
- **性能**: 算法复杂度、数据库优化、内存管理(**结合上下文,审查循环逻辑或多次重渲染问题**
- **可测试性**: 测试覆盖、测试质量、Mock 使用
📖 **详细指南**: [code-quality-standards.md](./code-quality-standards.md)
#### 维度 2: FastGPT 风格规范 🎨
FastGPT 项目特定的代码规范和约定:
- **API 路由开发**: 路由定义、权限验证、错误处理: [API 路由开发规范](./style/api.md)
- **前端组件开发**: TypeScript + React、Chakra UI、状态管理: [前端组件开发规范](./style/front.md)
- **数据库操作**: Model 定义、查询优化、索引设计: [数据库操作规范](./style/db.md)
- **包结构与依赖**: 依赖方向、导入规范、类型导出: [包结构与依赖规范](./style/package.md)
- **日志与可观测性**: 统一日志分类、结构化字段、敏感信息、OTEL 导出等审查标准: [日志review 标准](./style/logger.md)
#### 维度 3: 常见问题检查清单 🔍
快速识别和修复常见问题模式:
- **TypeScript 问题**: any 类型滥用、类型定义不完整、不安全断言
- **异步错误处理**: 未处理 Promise、错误信息丢失、静默失败
- **React 性能**: 不必要的重渲染、渲染中创建对象、缺少 memoization
- **工作流节点**: isEntry 未重置、交互历史未清理、白名单遗漏
- **安全漏洞**: 注入攻击、XSS、文件上传漏洞
📖 **详细清单**: [common-issues-checklist.md](./common-issues-checklist.md)
### 3. 生成并输出综合审查报告
本地审查输出主要通过对话框返回整体审查报告和行级建议。
#### 步骤 1: 结合上下文深度分析代码并准备评论
在详细审查阶段,记录之前通过额外探索收集的上下文论据:
- **全局视野**:这个改动是否打破了使用该模块的其他文件的运行?
- **文件路径**: 如 `packages/service/core/workflow/dispatch.ts`
- **行号 / 变更块**: 如 `L142-L150`
- **问题类型**: 🔴严重 / 🟡改进 / 🟢优化
- **评论内容**: 具体的问题描述和基于整体上下文推导出的深层建议
#### 步骤 2: 汇总代码审查评论
将所有的行级代码和上下文关联分析结果整合成一份连贯的内容:
```markdown
### 📍 代码级深层意见
#### `packages/service/core/workflow/dispatch.ts`
- **L142**: 🔴 **严重跨文件隐患**: 这里更改了返回值格式,但通过 `git grep` 追踪发现调用方的接收逻辑(如 `file_B.ts L30`)未进行相应的适配,将导致生产环境崩溃。
**建议**: 修改本处的兼容逻辑,或一并更新相关的调用方代码:
```typescript
// 提供结合了调用关系的修复代码
```
- **L150**: 🟡 **逻辑重构优化**: 结合该文件的大环境,该段正则其实在多个地方共享,建议提取到外部公共目录中复用。
```
#### 步骤 3: 生成整体审查报告
在对话框中提供类似以下的整体审查报告格式:
```markdown
# Local Review 代码审查报告
## 📊 审查面与追踪范围
- **本地变更类型**: 未暂存 / 已暂存 / 与某分支差异
- **变更统计**: +{additions} -{deletions} 行
- **涉及直接修改文件**: {files.length} 个
- **延伸上下文排查文件**: {context_files.length} 个(评估了相关的外链影响)
## ✅ 亮点与优点
{列出做得好的地方}
## ⚠️ 问题汇总
### 🔴 严峻环境问题 ({count} 个,必须修复)
{简要列出每个严重问题,并指向下文的详细行级评论}
### 🟡 建议与重构项 ({count} 个)
{简要列出每个建议}
### 🟢 局部优化选项 ({count} 个)
{简要列出优化建议}
---
{在此处插入包含上下文增强分析内容的 "📍 代码级深层意见"}
---
## 🧪 本地测试验证策略
{基于本次上下文排查所发现的影响范围,给出建议:例如某个隐性关联页面的渲染测试等}
## 💬 总体评价
- **代码质量**: ⭐⭐⭐⭐☆ (4/5)
- **安全性**: ⭐⭐⭐⭐⭐ (5/5)
- **性能**: ⭐⭐⭐⭐☆ (4/5)
- **全栈协调性**: ⭐⭐⭐⭐☆ (4/5) (反映修改对全局代码影响程度的好坏)
## 🚀 审查结论
{建议: 可以提交 / 修改后提交 / 建议将调用方文件的修复一起囊括}
```
#### 步骤 4: 响应用户并提供报告
输出或保存完整的 Markdown 报告,必要时提出主动帮用户把发现的联调问题(调用方 bug 等)以命令形式进行顺手修复的建议。
#### 本地审查命令快速补救参考:
| 场景 | 命令 |
|------|------|
| 修改后需重新审查暂存区 | `git diff --cached` |
| 检测改动后是否出现死类型 | `pnpm tsc --noEmit` |
| 放弃某个被污染的本地改动 | `git checkout -- <file>` |
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
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