Commit 73d13a91 by Archer Committed by GitHub

feat: add API key tag management (#7235)

* doc

* feat: add api key tag management

* fix: keep legacy api key name default

* fix: align api key sort order

* fix: restore api key app priority sorting

* style: align api key create button

* style: adjust api key table toolbar layout

* style: align api key toolbar actions

* style: use white api key create button

* fix: ui

* feat: delete toast

* doc
parent 1c380ea3
# API Key 标签改造方案
## 背景
API Key 已从应用级 Key 调整为全局 Key,Key 数量增长后需要新的管理维度。用户仍需要通过标签管理 Key 的使用场景,同时历史应用级 Key 需要保留“原来属于哪个应用”的可见线索。
本方案覆盖后端 API、数据模型、兼容逻辑和前端展示约定。`openapi.appId` 保留,不改变现有鉴权和 completions 兼容语义。
## 目标
### P0
1. API Key 支持绑定多个标签。
2. 支持标签 CRUD:创建、列表、更新、删除。
3. API Key create/update 接受 `tags` 字段创建或更新标签绑定。
4. API Key list 返回标签信息。
5. API Key list 支持按标签筛选。
6. API Key list 支持按 Key 名称搜索。
7. 暂不内置系统标签,新成员标签列表初始为空。
8. 历史带 `appId` 的 API Key 记录应用名到 `appName` 字段,并在 list 返回。
9. API Key list 接受 `appId`,把相同 `appId` 的 Key 排在前面,但不改变可见范围,也不作为过滤条件。
10. 前端在名称下方展示标签;如果返回 `appName`,自动补一个特殊标签并排在第一个。
### P1
1. 标签列表返回每个标签下的 Key 数量。
### P2
1. 支持批量给 Key 增删标签。
2. 支持批量删除长期未使用 Key。
3. 支持标签合并,方便用户整理重复标签。
## 最终方案摘要
1. 后端新增 `openapi_tags` 集合管理标签,API Key 文档新增 `tagIds` 保存绑定关系。
2. API Key create/update 接受 `tags` 字段,服务端校验标签归属后写入 `tagIds`。
3. API Key list 支持按 `keyword` 搜索 Key 名称,支持按 `tags` 进行多标签筛选。
4. 不提供无标签筛选。
5. 标签 CRUD 独立为 `/api/support/openapi/tag/*`,只允许登录态调用。
6. 不再自动创建默认系统标签;历史 `type='system'` 标签保留兼容,但按普通标签允许编辑和删除。
7. 历史带 `appId` 的 Key 增加 `appName` 展示字段,由迁移脚本回填;list 只读取快照字段,不实时查询应用。
8. API Key list 接受 `appId`,只用于把相同 `appId` 的历史 Key 排前,不过滤其他 Key。
9. 前端在表格上方新增搜索、标签筛选、标签管理入口。
10. 表格在名称下方展示标签,`appName` 作为第一个特殊标签;最多显示 3 个标签,超出用 `+N` 收起。
11. UI 参考知识库标签管理交互,但 API Key 单独实现一套组件,不复用知识库标签业务组件。
## 设计原则
1. 标签是 API Key 管理元数据,不参与开放接口鉴权。
2. 标签不影响 `authProxy`、限额、用量统计、复制、健康检查和现有 `appId` 兼容行为。
3. `appName` 只做历史应用名展示,不参与鉴权,不替代 `appId`。
4. API Key 当前按登录成员本人 `tmbId` 管理,标签也按 `{ teamId, tmbId }` 隔离。
5. Key 文档只存 `tagIds`,标签名称、排序、类型放在独立标签表中,便于重命名和统计。
6. API 入参使用 `tags` 表达标签绑定;服务端内部转换为 `tagIds` 存储。
7. `appName` 展示标签是前端展示层在标签列自动补充的特殊标签,不写入标签表,也不写入 `tagIds`。
## 数据模型
### 标签集合
新增目录:
```text
packages/service/support/openapi/tag/
├── schema.ts
├── entity.ts
└── service.ts
```
新增集合:`openapi_tags`。
建议字段:
```ts
type OpenApiTagSchema = {
_id: string;
teamId: string;
tmbId: string;
name: string;
normalizedName: string;
type: 'system' | 'custom';
order: number;
createTime: Date;
updateTime: Date;
};
```
字段说明:
- `teamId`:团队 ID。
- `tmbId`:团队成员 ID,与现有 API Key 管理范围一致。
- `name`:标签展示名。
- `normalizedName`:标签名规范化值,用于同一成员下去重。建议规则为 `trim().toLowerCase()`。
- `type`:历史兼容字段。新标签均写入 `custom`;旧数据可能存在 `system`,当前按普通标签处理。
- `order`:排序值。新建标签默认放在当前列表最前面,用户可在标签管理中拖拽排序。
- `createTime` / `updateTime`:创建和更新时间。
索引:
```ts
OpenApiTagSchema.index({ teamId: 1, tmbId: 1, normalizedName: 1 }, { unique: true });
OpenApiTagSchema.index({ teamId: 1, tmbId: 1, type: 1, order: 1 });
```
### API Key 集合扩展
在 `packages/service/support/openapi/schema.ts` 的 `OpenApiSchema` 增加:
```ts
tagIds: {
type: [Schema.Types.ObjectId],
default: []
},
appName: {
type: String
}
```
字段说明:
- `tagIds`:Key 绑定的标签 ID。
- `appName`:历史带 `appId` 的应用级 Key 对应应用名。该字段是展示快照,不参与鉴权。
对应共享类型 `packages/global/support/openapi/type.ts` 增加:
```ts
tagIds?: string[];
appName?: string;
```
建议索引:
```ts
OpenApiSchema.index({ teamId: 1, tmbId: 1, tagIds: 1, _id: -1 });
OpenApiSchema.index({ teamId: 1, tmbId: 1, appId: 1, _id: -1 });
OpenApiSchema.index({ teamId: 1, tmbId: 1, name: 1 });
```
`name` 模糊搜索如果使用正则,普通索引只能有限辅助。P0 可先接受;如果后续 Key 数量继续增长,再考虑增加 `normalizedName` 或搜索索引。
## 兼容设计
### appName 补全
历史 API Key 如果有 `appId`,需要记录对应应用名到 `openapi.appName`。
采用迁移脚本一次性补全,不在 `GET /support/openapi/list` 中实时查询应用:
1. `GET /support/openapi/list` 只读取 `openapi.appName` 快照字段。
2. 如果历史 Key 缺失 `appName`,list 直接返回空,不临时查询 `MongoApp`。
3. 缺失数据通过管理员迁移脚本补齐。
4. 如果应用不存在,迁移脚本保持 `appName` 为空,不阻塞列表。
设计原因:
- 不改变 `openapi.appId`。
- list 是高频接口,不能因为历史兼容字段引入跨集合实时查询和写回。
- `appName` 是历史展示快照,允许通过迁移脚本异步补齐,不影响 API Key 鉴权和使用。
发布时新增管理员脚本 `projects/app/src/pages/api/admin/initv4151.ts` 做一次性全量回填:
1. 需要 root 权限调用。
2. 分页扫描 `appId` 存在且 `appName` 缺失的 `openapi` 记录。
3. 批量查询 `MongoApp` 获取应用名。
4. 使用 `bulkWrite` 写入 `openapi.appName`。
5. 脚本可重复执行,不覆盖已有 `appName`。
6. 如果 `appId` 无效或应用不存在,跳过并计数,不修改 `appId`。
7. 脚本只做 `appName` 回填,不处理标签、不修改标签绑定。
### list appId 排序
`GET /support/openapi/list` 接受 `appId`:
- 只用于排序,不用于过滤。
- 不扩大现有可见范围,仍然只返回当前登录成员本人 Key。
- 匹配 `openapi.appId === query.appId` 的 Key 排在前面。
- 其他排序保持 `_id desc` 或当前创建时间倒序。
- 如果同时传 `keyword`、`tags`,先按这些条件筛选,再在结果集内按 `appId` 置前。
当前 `OPENAPI_KEY_MAX_COUNT` 是成员级数量限制,列表结果规模有限,P0 可以在应用层排序;后续如果数量限制变大,再改为 aggregation 排序。
## OpenAPI Schema 调整
API Key 相关接口也必须同步 OpenAPI 文档。`list/create/update` 的新增字段,以及标签
`list/create/update/delete` 接口,需要在 `packages/global/openapi/support/openapi/` 下补齐
zod schema、route 声明和 OpenAPI 路由注册;不能只实现
`projects/app/src/pages/api/...` 路由。
### 标签结构
建议新增文件:
```text
packages/global/openapi/support/openapi/tag.ts
```
定义:
```ts
export const OpenApiTagTypeSchema = z.enum(['system', 'custom']);
export const OpenApiTagSchema = z.object({
_id: ObjectIdSchema.meta({ example: '68ad85a7463006c963799a05', description: '标签 ID' }),
name: z.string().meta({ example: '客户 A', description: '标签名称' }),
type: OpenApiTagTypeSchema.meta({
example: 'custom',
description: '标签类型;system 仅用于兼容历史数据,新标签均为 custom'
}),
order: z.number().meta({ example: 10, description: '排序值' }),
createTime: z.coerce.date().meta({ description: '创建时间' }),
updateTime: z.coerce.date().meta({ description: '更新时间' }),
keyCount: z.number().optional().meta({ example: 12, description: '绑定该标签的 API Key 数量' })
});
export type OpenApiTagType = z.infer<typeof OpenApiTagSchema>;
```
标签 ID 入参复用:
```ts
export const OpenApiTagsInputSchema = z
.array(ObjectIdSchema)
.max(20)
.meta({ description: '标签 ID 列表' });
```
### API Key 返回结构
文件:`packages/global/openapi/support/openapi/api.ts`
扩展 `OpenApiKeySchema`:
```ts
appName: z.string().optional().meta({
example: '客服助手',
description: '历史应用级 API Key 对应应用名,仅用于展示'
}),
tagIds: z
.array(ObjectIdSchema)
.default([])
.meta({ description: 'API Key 绑定的标签 ID 列表' }),
tags: z
.array(OpenApiTagSchema)
.default([])
.meta({ description: 'API Key 绑定的标签列表' })
```
### 创建 API Key
`CreateApiKeyBodySchema` 增加:
```ts
name: z.string().min(1).max(50).meta({
example: '客户 A Key',
description: 'API Key 名称'
}),
tags: OpenApiTagsInputSchema.optional().meta({
example: ['68ad85a7463006c963799a05'],
description: '绑定的标签 ID 列表'
})
```
语义:
- 不传 `tags`:创建无标签 Key。
- 传空数组:创建无标签 Key。
- 传标签 ID:必须全部属于当前 `{ teamId, tmbId }`。
- `tags` 只处理标签绑定,不创建标签;新标签必须先调用 tag create API。
### 更新 API Key
`UpdateApiKeyBodySchema` 增加 `tags`,并把 refine 调整为:
```ts
name !== undefined ||
limit !== undefined ||
authProxy !== undefined ||
tags !== undefined
```
语义:
- `tags` 出现时整体替换标签绑定。
- `tags: []` 表示清空标签。
- 不传 `tags` 表示不修改标签。
### 获取 API Key 列表
`GetApiKeyListQuerySchema` 增加:
```ts
keyword: z.string().trim().max(100).optional().meta({
example: 'production',
description: '按 API Key 名称搜索'
}),
tags: z
.union([ObjectIdSchema, z.array(ObjectIdSchema)])
.optional()
.meta({
example: ['68ad85a7463006c963799a05'],
description: '按标签筛选;多个标签默认要求同时包含'
}),
appId: ObjectIdSchema.optional().meta({
example: '68ad85a7463006c963799a05',
description: '应用 ID,仅用于把相同 appId 的历史 Key 排在前面'
}),
sortBy: z.enum(['createTime', 'lastUsedTime', 'remainingPoints']).default('createTime').meta({
example: 'createTime',
description: '排序字段;appId 置顶优先级最高,同一组内再按该字段排序'
})
```
筛选与排序语义:
- `keyword` 只匹配 `name`,不匹配明文 `apiKey`。
- `tags` 使用 `$all`,多个标签表示同时包含。
- `appId` 只排序,不过滤,且永远是最高优先级。
- `sortBy` 默认 `createTime`,可选 `lastUsedTime`、`remainingPoints`;同一 appId 置顶组内,时间越近越靠前,剩余积分越少越靠前。
- `remainingPoints = limit.maxUsagePoints - usagePoints`;不限额 Key 按无限剩余处理。
## 标签 CRUD API
### 路由
新增:
```text
GET /api/support/openapi/tag/list
POST /api/support/openapi/tag/create
PUT /api/support/openapi/tag/update
DELETE /api/support/openapi/tag/delete
```
全部使用登录态鉴权,不支持 API Key 鉴权。
每个标签接口都需要在 OpenAPI 中声明 method、path、summary、tags、query/body/response
schema,并和 API Key 接口归到同一组开放平台管理文档下。
### 获取标签列表
```ts
export const GetOpenApiTagListQuerySchema = z.object({
withKeyCount: BoolSchema.optional().meta({
example: false,
description: '是否返回每个标签绑定的 API Key 数量'
})
});
export const GetOpenApiTagListResponseSchema = z.array(OpenApiTagSchema);
```
行为:
1. 鉴权获取当前 `teamId`、`tmbId`。
2. 返回当前成员标签,不自动创建默认标签。
3. P1 如果 `withKeyCount=true`,聚合统计每个标签绑定的 Key 数量。
### 创建标签
```ts
export const CreateOpenApiTagBodySchema = z.object({
name: z.string().trim().min(1).max(50).meta({
example: '客户 A',
description: '标签名称'
})
});
export const CreateOpenApiTagResponseSchema = OpenApiTagSchema;
```
行为:
- 创建 `type='custom'` 标签。
- 同一 `{ teamId, tmbId }` 下 `normalizedName` 唯一。
### 更新标签
```ts
export const UpdateOpenApiTagBodySchema = z.object({
tagId: ObjectIdSchema.meta({ description: '标签 ID' }),
name: z.string().trim().min(1).max(50).optional().meta({ description: '标签名称' }),
order: z.number().int().nonnegative().optional().meta({ description: '排序值' })
});
export const UpdateOpenApiTagResponseSchema = z.undefined().meta({ description: '更新成功' });
```
行为:
- 只能更新当前成员自己的标签。
- 历史 `type='system'` 标签按普通标签处理,允许重命名和排序。
- 更新名称时校验重名。
### 删除标签
```ts
export const DeleteOpenApiTagQuerySchema = z.object({
tagId: ObjectIdSchema.meta({ description: '标签 ID' })
});
export const DeleteOpenApiTagResponseSchema = z.undefined().meta({ description: '删除成功' });
```
行为:
1. 校验标签属于当前成员。
2. 删除标签后,从当前成员所有 Key 中 `$pull: { tagIds: tagId }`。
3. 历史 `type='system'` 标签按普通标签处理,允许删除。
4. 不删除任何 API Key。
## 服务层设计
新增 `packages/service/support/openapi/tag/service.ts`。
核心函数:
```ts
/**
* 校验标签归属并返回去重后的标签 ID。
*
* API Key 当前按 tmbId 隔离管理,因此标签也必须属于同一个 teamId + tmbId。
*/
export async function validateOpenApiTags(props: {
teamId: string;
tmbId: string;
tags: string[];
}) {}
/**
* 根据查询条件获取 API Key 标签,并按 tagId 组织成 Map,供列表接口组装 tags 字段。
*/
export async function getOpenApiTagMap(props: {
teamId: string;
tmbId: string;
tagIds: string[];
}) {}
```
当前不提供默认标签初始化函数,新成员标签列表初始为空。
## API Key CRUD 改造
### create
文件:`projects/app/src/pages/api/support/openapi/create.ts`
流程:
1. `parseApiInput` 解析 `tags`。
2. `authUserPer` 保持现有创建权限。
3. 如果传入 `tags`,调用 `validateOpenApiTags`。
4. 创建 `MongoOpenApi` 时写入去重后的 `tagIds`。
### update
文件:`projects/app/src/pages/api/support/openapi/update.ts`
流程:
1. `parseApiInput` 解析 `tags`。
2. `authOpenApiKeyCrud` 保持现有本人 Key 权限。
3. 如果传入 `tags`,调用 `validateOpenApiTags`。
4. `findByIdAndUpdate` 增加 `tagIds` 整体替换。
5. 审计日志继续使用 `UPDATE_API_KEY`,可在 params 中记录标签数量变化。
### list
文件:`projects/app/src/pages/api/support/openapi/list.ts`
流程:
1. `parseApiInput` 解析 `keyword`、`tags`、`appId`、`sortBy`。
2. `authUserPer` 获取 `teamId`、`tmbId`。
3. 基于 `{ teamId, tmbId }` 构造查询条件。
4. `keyword` 转义后用于 `name` 正则搜索。
5. `tags` 使用 `$all` 过滤。
6. 直接使用 `openapi.appName` 快照字段,不在 list 中查询 `MongoApp` 或回填 `appName`。
7. 批量查询标签,组装返回字段 `tags`。
8. 如果传入 `appId`,把 `openapi.appId === appId` 的记录排在前面。
9. 在 appId 置顶分组内,根据 `sortBy` 排序:创建时间、最后使用时间按倒序,剩余积分按升序。
### copy/delete/health/auth
不改运行时行为:
- `copy` 继续返回真实明文 Key,不返回标签相关特殊信息。
- `delete` 删除 Key,不删除标签。
- `health` 不返回标签,但返回 `usagePoints` 和 `maxUsagePoints`,便于调用方判断已用额度和积分上限;`maxUsagePoints=-1` 表示无限制。
- `authOpenApiKey` 不读取、不返回标签和 `appName`。
## 默认标签策略
暂不内置系统标签,也不做懒初始化。
- 新成员标签列表初始为空。
- 用户按需创建业务标签。
- 历史数据里如果已经存在 `type='system'` 标签,继续返回并允许编辑、删除和绑定,按普通标签处理。
## 前端展示与交互
### 组件策略
UI 参考知识库标签管理的交互模式,但 API Key 单独做一套组件,不直接复用知识库标签组件。
原因:
1. 知识库标签组件绑定了 dataset、collection、context 和集合批量操作语义。
2. API Key 标签只需要管理标签本身和 Key 绑定关系,业务边界更窄。
3. 单独实现可以复用视觉和交互模式,避免引入知识库上下文依赖。
建议新增组件:
```text
projects/app/src/components/support/apikey/TagManageModal.tsx
projects/app/src/components/support/apikey/TagMultiSelect.tsx
projects/app/src/components/support/apikey/TagDisplayList.tsx
```
参考来源:
- 标签管理弹窗参考 `projects/app/src/pageComponents/dataset/detail/CollectionCard/TagManageModal.tsx` 的结构,包括搜索、新增、编辑、删除和使用数量展示。
- 表格内标签展示参考 `projects/app/src/pageComponents/dataset/detail/CollectionCard/TagsPopOver.tsx` 的 `+N` 和 Popover 展示全部标签方式。
API Key 专用标签管理弹窗设计:
1. 标题:`标签管理`。
2. 顶部展示标签总数、搜索框、新增标签按钮。
3. 列表展示标签名、绑定 Key 数量、编辑按钮、删除按钮。
4. 所有标签都允许编辑和删除;历史 `type='system'` 标签按普通标签处理。
5. 删除标签前使用 `PopoverConfirm` 二次确认。
6. 删除标签只解绑 Key,不删除 Key。
7. 搜索只在标签管理弹窗内过滤标签名,不影响 API Key list。
### 顶部工具栏
当前顶部已有标题、API Base URL 和新建按钮。改造后在这一区域下方、表格上方新增一行工具栏:
```text
搜索 Key 名称 | 标签筛选 | 排序 | 管理标签
```
布局规则:
1. `搜索 Key 名称` 放左侧,对应 list 的 `keyword`。
2. `标签筛选` 放搜索框右侧,对应 list 的 `tags`。
3. `排序` 放标签筛选右侧,对应 list 的 `sortBy`。
4. `管理标签` 放排序右侧,打开标签管理弹窗。
5. 不增加无标签筛选。
6. 右上角原有 `新建` 按钮保留。
7. 账号 API Key 页完整展示四项工具;应用发布页空间更紧,可以把 `管理标签` 收进标签筛选下拉底部,但搜索、标签筛选和排序仍保留。
排序控件:
1. 默认值:`按创建时间`。
2. 可选:`按最后使用时间`、`按剩余积分`。
3. 从应用发布页进入时,仍然传 `appId` 给 list;`appId` 匹配的历史 Key 永远排在最前,排序控件只影响置顶组内和非置顶组内顺序。
标签筛选控件使用多选下拉:
1. 下拉列表展示所有真实标签,不展示 `appName` 特殊标签。
2. 选中后控件内显示 `已选 N 个标签`,不要把所有选中标签横向铺开。
3. 支持清空筛选。
4. 底部可以放 `管理标签` 入口,方便用户补标签。
### 列表展示
表格在名称下方展示标签,不再单独开标签列:
```text
名称 | API Key | 积分消耗 | 过期时间 | 最后使用时间 | 创建时间 | 操作
```
展示规则:
1. 名称列第一行展示 Key 名称,第二行展示标签。
2. 如果 `item.appName` 存在,前端构造一个展示用特殊标签,排在标签列表第一位。
4. `appName` 特殊标签不参与编辑、不参与删除、不写入 tag CRUD。
5. 其余标签来自 `item.tags`。
6. 最多展示 3 个标签。
7. `appName` 存在时固定占第一个展示位,真实标签最多再展示 2 个。
8. 超出的标签用 `+N` 收起。
9. hover 或点击 `+N` 用 Popover 展示全部标签。
10. 如果没有 `appName` 且没有用户标签,标签列显示 `-` 或留空,按现有表格空值风格决定。
11. `appName` 标签使用特殊样式,表示旧应用来源。参考图里的蓝色应用名标签:不可编辑、不可删除、不进入普通标签管理,也不能手动添加给其他 Key。
12. 真实标签使用普通浅色背景。
13. 标签高度控制在约 20px,小字号,标签列最多一行,避免表格行过高。
14. 最后使用时间和创建时间分两列展示;空间不足时允许在日期和时间之间自然换行。
15. 名称和标签展示超长时单行截断,使用 `MyTooltip` 在 hover 时展示完整内容。
名称列辅助说明:
- 普通新 Key 可展示轻量命名提示,例如 `推荐命名:场景 + 环境`。
- 历史带 `appId` 的 Key 可展示 `历史 Key 自动归类`。
- 辅助说明只作为解释文本,不承担标签筛选或标签管理语义。
表格上方不增加特殊标签说明提示,避免干扰主流程。
建议前端计算:
```ts
const displayTags = [
...(item.appName
? [{ _id: `appName-${item._id}`, name: item.appName, type: 'appName', readonly: true }]
: []),
...item.tags
];
const visibleTags = displayTags.slice(0, 3);
const hiddenTags = displayTags.slice(3);
```
`TagDisplayList` 建议 props:
```ts
type ApiKeyDisplayTag = {
_id: string;
name: string;
type: 'appName' | 'system' | 'custom';
readonly?: boolean;
};
type TagDisplayListProps = {
tags: ApiKeyDisplayTag[];
maxVisible?: number; // 默认 3
};
```
示例:
```text
名称:客户 A Key
标签:[客服助手] [客户 A] [正式调用] [+4]
```
```text
名称:测试 Key
标签:[测试环境] [临时调试] [客户 B] [+2]
```
### 创建和编辑
`EditKeyModal` 增加标签选择:
- 打开弹窗前或页面初始化时调用 `getOpenApiTags`。
- 创建 Key 时提交 `tags: selectedTagIds`。
- 编辑 Key 时用 `defaultData.tags.map((tag) => tag._id)` 初始化选中项。
- 保存时提交 `tags: selectedTagIds`。
- API Key 名称输入框最大长度 50。
`appName` 特殊标签不展示在编辑控件中。
### 筛选与搜索
API Key 管理页增加:
- 名称搜索输入框,对应 `keyword`。
- 标签筛选控件,对应 `tags`。
- 标签名称输入框最大长度 50。
应用发布页或应用详情页进入 API Key 表格时,调用 list 传当前 `appId`,让历史同应用 Key 排在前面:
```ts
getOpenApiKeys({ appId, sortBy })
```
## 前端 API 封装
文件:`projects/app/src/web/support/openapi/api.ts`
新增:
```ts
export const getOpenApiTags = (params?: GetOpenApiTagListQueryType) =>
GET<GetOpenApiTagListResponseType>('/support/openapi/tag/list', params);
export const createOpenApiTag = (data: CreateOpenApiTagBodyType) =>
POST<CreateOpenApiTagResponseType>('/support/openapi/tag/create', data);
export const updateOpenApiTag = (data: UpdateOpenApiTagBodyType) =>
PUT<UpdateOpenApiTagResponseType>('/support/openapi/tag/update', data);
export const deleteOpenApiTag = (tagId: DeleteOpenApiTagQueryType['tagId']) =>
DELETE<DeleteOpenApiTagResponseType>('/support/openapi/tag/delete', { tagId });
```
现有 `createAOpenApiKey` 和 `putOpenApiKey` 类型随 OpenAPI schema 自动接受 `tags`。
## 权限与安全
1. 标签管理只允许登录态,不允许 API Key 鉴权。
2. 标签必须属于当前 `{ teamId, tmbId }`。
3. Key 只能绑定当前 `{ teamId, tmbId }` 下的标签。
4. 标签不参与开放接口鉴权,不改变现有 API Key 使用能力。
5. `appName` 不参与开放接口鉴权,只在 API Key 管理列表返回。
6. 标签不会在外部开放接口、健康检查或用量记录中暴露。
7. 删除标签只解绑 Key,不删除 Key。
## 错误处理
建议新增 OpenAPI 错误码:
- `tagUnExist`:标签不存在或不属于当前成员。
- `tagNameDuplicate`:标签名重复。
- `systemTagReadonly`:系统标签不允许删除或重命名。
如果不新增错误码,可复用:
- 不存在或无权限:`OpenApiErrEnum.unAuth`
- 重名:业务字符串错误或新增更明确错误码
- 入参冲突:通过 zod refine + `parseApiInput` 返回请求参数错误
## 测试计划
新增测试:
```text
projects/app/test/api/support/openapi/tag/list.test.ts
projects/app/test/api/support/openapi/tag/create.test.ts
projects/app/test/api/support/openapi/tag/update.test.ts
projects/app/test/api/support/openapi/tag/delete.test.ts
```
扩展测试:
```text
projects/app/test/api/support/openapi/list.test.ts
projects/app/test/api/support/openapi/create.test.ts
projects/app/test/api/support/openapi/update.test.ts
projects/app/test/api/support/openapi/copy.test.ts
projects/app/test/api/support/openapi/health.test.ts
projects/app/test/pages/components/support/apikey/Table.test.tsx
```
覆盖点:
1. 首次标签 list 不创建默认标签,新成员标签列表为空。
2. API Key list 不创建默认标签。
3. 创建自定义标签成功。
4. 同一成员下标签重名失败。
5. 不同成员可创建同名标签。
6. 创建 Key 可通过 `tags` 绑定本人标签。
7. 更新 Key 可通过 `tags` 整体替换标签。
8. 创建或更新 Key 绑定其他成员标签失败。
9. 更新 Key 的 `tags: []` 会清空标签。
10. API Key list 返回 `appName`、`tagIds` 和 `tags`。
11. API Key list 只返回已落库的 `appName` 快照,不实时查询应用名。
12. API Key list 不回填历史 Key 的 `appName`,缺失数据由 `initv4151` 脚本处理。
13. API Key list 传 `appId` 时,相同 `appId` 的 Key 排在前面,但不筛掉其他 Key。
14. API Key list 支持 `keyword` 搜索名称。
15. API Key list 支持单标签筛选。
16. API Key list 支持多标签 `$all` 筛选。
17. 删除自定义标签会从本人 Key 中解绑,不影响其他成员。
18. 删除 API Key 不删除标签。
19. copy/health/authOpenApiKey 行为不受标签和 `appName` 影响;health 会返回 `usagePoints` 与 `maxUsagePoints`。
20. 前端名称下方会把 `appName` 构造成第一个特殊标签。
21. 前端名称下方最多展示 3 个标签,超出显示 `+N`,并可查看全部标签。
22. 标签筛选控件选中多个标签后显示 `已选 N 个标签`。
23. 表格在名称列下方展示标签,不单独开标签列。
24. 标签管理弹窗参考知识库标签管理交互,但不依赖知识库 context。
25. 不内置系统默认标签;历史 `type='system'` 标签在标签管理弹窗中可编辑、可删除。
26. `appName` 特殊标签不出现在标签筛选和标签管理列表中。
27. OpenAPI 文档包含 API Key `list/create/update` 新增字段和标签 CRUD 接口。
28. API Key list 支持 `sortBy=createTime|lastUsedTime|remainingPoints`。
29. API Key list 同时传 `appId` 和 `sortBy` 时,`appId` 置顶优先级高于排序字段。
30. 前端工具栏包含排序选框,默认按创建时间排序。
局部测试命令:
```bash
pnpm test projects/app/test/api/support/openapi/tag/list.test.ts
pnpm test projects/app/test/api/support/openapi/tag/create.test.ts
pnpm test projects/app/test/api/support/openapi/tag/update.test.ts
pnpm test projects/app/test/api/support/openapi/tag/delete.test.ts
pnpm test projects/app/test/api/support/openapi/list.test.ts
pnpm test projects/app/test/api/support/openapi/create.test.ts
pnpm test projects/app/test/api/support/openapi/update.test.ts
```
最后运行:
```bash
pnpm test
```
## 分阶段 TODO
### P0
- [x] 新增 `openapi_tags` schema、entity、service。
- [x] `openapi` schema 增加 `tagIds`、`appName`。
- [x] 新增 `initv4151.ts` 管理员脚本,全量回填历史 Key 的 `appName`。
- [x] 新增标签 CRUD OpenAPI schema、API 声明和路由注册。
- [x] 更新 API Key `list/create/update` OpenAPI schema,补齐 `tags`、`tagIds`、`appName`、`keyword`、`appId`、`sortBy` 字段。
- [x] 实现标签 list/create/update/delete API。
- [x] create/update Key 支持 `tags` 校验并写入 `tagIds`。
- [x] list Key 支持名称搜索、标签筛选,并返回 `tags`。
- [x] list Key 支持 `appId` 排序置前。
- [x] list Key 支持创建时间、最后使用时间、剩余积分排序,且 `appId` 置顶最高优先级。
- [x] list Key 对历史 `appId` Key 补齐并返回 `appName`。
- [x] 新增 API Key 专用 `TagManageModal`、`TagMultiSelect`、`TagDisplayList`,参考知识库标签管理交互但不复用其业务组件。
- [x] 前端在名称下方展示标签,`appName` 作为第一个特殊标签。
- [x] 前端去掉蓝色应用名特殊标签说明条。
- [x] 补充接口测试。
- [ ] 补充前端展示测试。
### P1
- [x] 标签 list 支持 `keyCount`。
- [x] 补充 keyCount 测试。
### P2
- [ ] 批量给 Key 增删标签。
- [ ] 批量删除长期未使用 Key。
- [ ] 标签合并。
## 待确认问题
1. 标签是否保持当前方案的成员级隔离 `{ teamId, tmbId }`,还是要做团队共享标签?
2. 是否继续保留历史 `type='system'` 字段?当前保留兼容,历史 system 标签按普通标签处理。
3. 多标签筛选是否确定为 `$all` 语义?如果需要 OR,可以新增 `tagFilterMode`。
4. `appName` 字段是否需要在应用重命名后同步更新?本方案按历史展示快照处理,只在缺失时补齐。
......@@ -8,8 +8,12 @@ import { Alert } from '@/components/docs/Alert';
FastGPT is an AI Agent application development platform built on large language models. It combines Knowledge Base Q&A, visual Workflows, Agent orchestration, tool calling, and skill extensions so developers and business users can quickly build custom AI applications.
<Alert icon="🤖" context="success">
Try FastGPT now - International: [https://fastgpt.io](https://fastgpt.io) - China Mainland:
[https://fastgpt.cn](https://fastgpt.cn)
Try FastGPT now
- International: [https://fastgpt.io](https://fastgpt.io)
- China Mainland: [https://fastgpt.cn](https://fastgpt.cn)
</Alert>
| | |
......
......@@ -8,8 +8,12 @@ import { Alert } from '@/components/docs/Alert';
FastGPT 是一个基于大语言模型的 AI Agent 应用开发平台,集知识库问答、可视化工作流、Agent 编排、工具调用和技能扩展于一体,让开发者和业务人员都能快速构建专属 AI 应用。
<Alert icon="🤖" context="success">
快速开始体验 - 国际版:[https://fastgpt.io](https://fastgpt.io) -
中国大陆版:[https://fastgpt.cn](https://fastgpt.cn)
快速开始体验
- 国际版:[https://fastgpt.io](https://fastgpt.io)
- 中国大陆版:[https://fastgpt.cn](https://fastgpt.cn)
</Alert>
| | |
......
---
title: OpenAPI Introduction
description: FastGPT OpenAPI Introduction
title: API Documentation Introduction
description: Introduction to FastGPT API Documentation
---
## Automated API Documentation
Starting with `4.15.0`, FastGPT API documentation is generated automatically with `zod-openapi` (some legacy endpoints have not been migrated, so they are not shown). You can view the latest endpoint status by opening the API documentation URL. The manually edited endpoint descriptions in the left sidebar of this documentation are no longer updated.
Starting with `4.15.0`, FastGPT API docs are generated with `zod-openapi`. Some legacy endpoints have not been migrated yet, so they may not appear in the generated docs. Visit the generated API documentation URL to view the latest endpoint details. The manually maintained endpoint descriptions in the left-hand documentation are no longer updated.
FastGPT provides two generated API documents:
FastGPT API documentation is split into two sets:
- Dev API: all development APIs. Not every endpoint can be called with an API Key.
- System OpenAPI: public endpoints that support API Key authentication.
- System OpenAPI: all system public endpoints, callable with a system API Key.
## API Documentation URLs
## API Documentation URL
Use your FastGPT endpoint as the base URL and append the path below:
`endpoint` is your FastGPT access URL. Append the corresponding path to open the documentation.
- Dev API: `{{endpoint}}/apidoc/devapi`
- System OpenAPI: `{{endpoint}}/apidoc/systemopenapi`
## Cloud API Documentation URLs
## Cloud API Documentation URL
**Dev API:**
- [China Mainland Documentation](https://cloud.fastgpt.cn/apidoc/devapi)
- [International Documentation](https://cloud.fastgpt.io/apidoc/devapi)
- [China Mainland documentation](https://cloud.fastgpt.cn/apidoc/devapi)
- [International documentation](https://cloud.fastgpt.io/apidoc/devapi)
**System OpenAPI:**
**System OpenAPI**
- [China Mainland Documentation](https://cloud.fastgpt.cn/apidoc/systemopenapi)
- [International Documentation](https://cloud.fastgpt.io/apidoc/systemopenapi)
- [China Mainland documentation](https://cloud.fastgpt.cn/apidoc/systemopenapi)
- [International documentation](https://cloud.fastgpt.io/apidoc/systemopenapi)
## Usage Guide
## Usage Notes
FastGPT OpenAPI lets you authenticate with an API Key to access FastGPT services and resources -- such as calling app chat endpoints, uploading knowledge base data, search testing, and more. For compatibility and security reasons, not all endpoints support API Key access.
FastGPT OpenAPI endpoints let you authenticate with an API Key to operate related FastGPT services and resources, such as calling app chat endpoints, uploading Knowledge Base data, and running search tests. For compatibility and security reasons, not all endpoints can be accessed with an API Key.
## How to Find Your BaseURL
### How to Get an API Key
**Note: BaseURL is not an endpoint address -- it's the root URL for all endpoints. Requesting the BaseURL directly won't work.**
You can find API Keys in two places:
![](../../public/imgs/fastgpt-api-baseurl.png)
1. `Account` - `API Keys`
2. `App` - `Publish Channels` - `API Access`
### API Key Scope
## API Key
An API Key acts as the current account's access credential within the current team. In other words, any resource the account can access in that team can also be operated through the API Key.
FastGPT uses a unified API Key model. API Keys are member credentials and are not bound to a single app.
### How to Find the BaseURL
- For app-related endpoints other than `chat/completions`, pass `appId` explicitly in the request body or query.
- For `chat/completions`, passing `body.appId` is recommended.
- For OpenAI SDK compatibility, `Authorization: Bearer <apiKey>-<appId>` is also supported for `chat/completions`. The `-<appId>` suffix is only a transport compatibility format and is not stored.
- Team owners can enable `authProxy` on an API Key to let `chat/completions` run as another team member. This does not skip app or chat permission checks.
**Note: BaseURL is not an endpoint URL. It is the root URL for all endpoints, and requesting the BaseURL directly does nothing.**
![](../../public/imgs/fastgpt-api-baseurl.png)
## Basic Configuration
### Basic Configuration
In OpenAPI, all endpoints authenticate via Header.Authorization.
In OpenAPI, all endpoints authenticate through `Header.Authorization`.
```
baseUrl: "http://localhost:3000/api"
......@@ -60,48 +60,3 @@ headers: {
Authorization: "Bearer {{apikey}}"
}
```
**Example: Start an App Chat**
```sh
curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \
--header 'Authorization: Bearer fastgpt-xxxxxx' \
--header 'Content-Type: application/json' \
--data-raw '{
"appId": "your_app_id",
"chatId": "111",
"stream": false,
"detail": false,
"messages": [
{
"content": "Who is the director",
"role": "user"
}
]
}'
```
## Custom User ID
Since `v4.8.13`, you can pass a custom user ID that will be saved in the chat history.
```sh
curl --location --request POST 'http://localhost:3000/api/v1/chat/completions' \
--header 'Authorization: Bearer fastgpt-xxxxxx' \
--header 'Content-Type: application/json' \
--data-raw '{
"appId": "your_app_id",
"chatId": "111",
"stream": false,
"detail": false,
"messages": [
{
"content": "Who is the director",
"role": "user"
}
],
"customUid": "xxxxxx"
}'
```
In the chat history, this record's user will be displayed as `xxxxxx`.
......@@ -33,6 +33,17 @@ endpoint 是你的 FastGPT 访问地址,拼上对应 path 即可打开文档
FastGPT OpenAPI 接口允许你使用 API Key 进行鉴权,从而操作 FastGPT 上的相关服务和资源,例如:调用应用对话接口、上传知识库数据、搜索测试等等。出于兼容性和安全考虑,并不是所有的接口都允许通过 API Key 访问。
### 如何获取 API Key
系统里有两个地方可看到 API 密钥
1. 在 `账号` - `Api 密钥` 中获取
2. 在 `应用` - `发布渠道` - `API 访问` 里查看。
### API 密钥可用范围
API 密钥相当于当前账号,在当前团队下的访问凭证。也就是,在该团队下有权限的资源,都可以通过 API 密钥进行操作。
### 如何查看 BaseURL
**注意:BaseURL 不是接口地址,而是所有接口的根地址,直接请求 BaseURL 是没有用的。**
......
---
title: 'V4.15.0'
title: 'V4.15.0 (Includes Upgrade Script)'
description: 'FastGPT V4.15.0 Release Notes'
---
......
---
title: 'V4.15.0'
title: 'V4.15.0(包含升级脚本)'
description: 'FastGPT V4.15.0 更新说明'
---
......
......@@ -3,11 +3,28 @@ title: 'V4.15.1 (In Progress)'
description: 'FastGPT V4.15.1 Release Notes'
---
## 📦 Upgrade Guide
### API Key App Name Initialization
To keep older API keys compatible and make it easier to find keys previously associated with apps, v4.15.1 adds global API Key tag management and an `appName` display snapshot for historical app-level API Keys. After upgrading, run the initialization script once to backfill app names for existing API Keys whose `appId` field is still present.
From any terminal, send an HTTP request. Replace `{{rootkey}}` with the `rootkey` from your environment variables, and `{{host}}` with your FastGPT domain.
```bash
curl -X POST "{{host}}/api/admin/initv4151" \
-H "rootkey: {{rootkey}}"
```
The script only fills missing `appName` values. It does not overwrite existing values, does not change the `appId` field, and does not create or bind tags. It is safe to run multiple times.
## 🚀 New Features
1. Added global API Key tag management and an `appName` display snapshot for historical app-level API Keys, making older API keys compatible and easier to find when they were previously associated with apps.
## ⚙️ Improvements
## 🐛 Bug Fixes
## 🐛 Fixes
1. Workflow tool debugging did not show run details.
......
......@@ -3,8 +3,25 @@ title: 'V4.15.1(进行中)'
description: 'FastGPT V4.15.1 更新说明'
---
## 📦 升级指南
### API Key 应用名初始化
为了兼容旧版 API 密钥,便于找到以前应用关联的密钥,v4.15.1 增加了全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照。升级后建议执行一次初始化脚本,为已有 `appId` 的历史 API Key 自动回填应用名。
从任意终端,发起 1 个 HTTP 请求。其中 `{{rootkey}}` 替换成环境变量里的 `rootkey`;`{{host}}` 替换成 FastGPT 域名。
```bash
curl -X POST "{{host}}/api/admin/initv4151" \
-H "rootkey: {{rootkey}}"
```
脚本只会回填缺失的 `appName`,不会覆盖已有值,不会修改 `appId`,也不会创建或绑定标签。脚本可重复执行。
## 🚀 新增内容
1. 增加全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照,便于兼容旧版 API 密钥并查找以前应用关联的密钥。
## ⚙️ 优化
## 🐛 修复
......@@ -12,3 +29,5 @@ description: 'FastGPT V4.15.1 更新说明'
1. 工作流工具调试时,运行详情看不到。
## 🛠️ 代码优化
1. 修复冒号的文件路径,避免 window 系统不兼容。
......@@ -123,8 +123,8 @@
"content/guide/dataset/third-party/yuque_dataset.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/websync.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/websync.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/getting-started/index.en.mdx": "2026-06-24T18:16:41+08:00",
"content/guide/getting-started/index.mdx": "2026-06-24T18:16:41+08:00",
"content/guide/getting-started/index.en.mdx": "2026-07-01T18:15:09+08:00",
"content/guide/getting-started/index.mdx": "2026-07-01T18:15:09+08:00",
"content/guide/getting-started/quick-start.en.mdx": "2026-07-01T17:20:32+08:00",
"content/guide/getting-started/quick-start.mdx": "2026-07-01T17:20:32+08:00",
"content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00",
......@@ -157,8 +157,8 @@
"content/openapi/dataset.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/index.en.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/index.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/intro.en.mdx": "2026-06-23T13:54:06+08:00",
"content/openapi/intro.mdx": "2026-06-23T13:54:06+08:00",
"content/openapi/intro.en.mdx": "2026-07-02T11:38:08+08:00",
"content/openapi/intro.mdx": "2026-07-02T11:38:08+08:00",
"content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/index.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/intro.en.mdx": "2026-06-09T16:03:58+08:00",
......@@ -299,8 +299,8 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/41500.en.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/upgrading/4-15/41500.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/upgrading/4-15/41500.en.mdx": "2026-07-02T09:56:55+08:00",
"content/self-host/upgrading/4-15/41500.mdx": "2026-07-02T09:56:55+08:00",
"content/self-host/upgrading/4-15/41501.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-06-23T13:54:06+08:00",
......@@ -314,8 +314,8 @@
"content/self-host/upgrading/4-15/41506.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/4151.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-02T09:56:55+08:00",
"content/self-host/upgrading/4-15/4151.mdx": "2026-07-02T09:56:55+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
import z from 'zod';
import { ObjectIdSchema } from '../../../common/type/mongo';
import { getErrorResponse } from '../../type';
import { OpenApiTagSchema, OpenApiTagsInputSchema } from './tag';
const OptionalDateSchema = z.preprocess((value) => {
if (value === undefined || value === null || value === '') return undefined;
......@@ -19,6 +20,9 @@ export const ApiKeyLimitSchema = z
.optional()
.meta({ description: 'API Key 使用限制' });
export const ApiKeyListSortBySchema = z.enum(['createTime', 'lastUsedTime', 'remainingPoints']);
export type ApiKeyListSortByType = z.infer<typeof ApiKeyListSortBySchema>;
export const OpenApiKeySchema = z.object({
_id: ObjectIdSchema.meta({ description: 'API Key 记录 ID' }),
teamId: ObjectIdSchema.meta({ description: '团队 ID' }),
......@@ -34,6 +38,20 @@ export const OpenApiKeySchema = z.object({
authProxy: z.boolean().default(false).meta({
description: '是否允许 API Key 在 chat/completions 请求中通过 authProxy 代理团队成员身份'
}),
appName: z.string().optional().meta({
example: '客服助手',
description: '历史应用级 API Key 对应应用名,仅用于展示'
}),
tagIds: z
.array(ObjectIdSchema)
.default([])
.meta({
example: ['68ad85a7463006c963799a05'],
description: 'API Key 绑定的标签 ID 列表'
}),
tags: z.array(OpenApiTagSchema).default([]).meta({
description: 'API Key 绑定的标签列表'
}),
name: z.string().default('Api Key').meta({ description: 'API Key 名称' }),
usagePoints: z.number().default(0).meta({ description: '累计使用积分' }),
limit: ApiKeyLimitSchema.meta({
......@@ -51,12 +69,21 @@ export type OpenApiKeySchemaType = z.infer<typeof OpenApiKeySchema>;
* ============================================================================ */
export const CreateApiKeyBodySchema = z.object({
name: z.string().min(1).meta({ example: '生产环境 Key', description: 'API Key 名称' }),
name: z
.string()
.trim()
.min(1)
.max(50)
.meta({ example: '客户 A Key', description: 'API Key 名称' }),
authProxy: z.boolean().optional().meta({
example: false,
description:
'是否允许系统 API Key 在 chat/completions 请求中代理团队成员身份;仅团队 owner 可开启'
}),
tags: OpenApiTagsInputSchema.optional().meta({
example: ['68ad85a7463006c963799a05'],
description: '绑定的标签 ID 列表'
}),
limit: ApiKeyLimitSchema.meta({
description: 'API Key 使用限制,未配置时表示不限制过期时间和积分用量'
})
......@@ -76,7 +103,34 @@ export type CreateApiKeyResponseType = z.infer<typeof CreateApiKeyResponseSchema
* Tags: ['API Key 管理']
* ============================================================================ */
export const GetApiKeyListQuerySchema = z.object({});
const ApiKeyListTagsQuerySchema = z.preprocess((value) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value;
if (typeof value === 'string' && value.includes(',')) {
return value.split(',').filter(Boolean);
}
return [value];
}, OpenApiTagsInputSchema.optional());
export const GetApiKeyListQuerySchema = z.object({
keyword: z.string().trim().max(100).optional().meta({
example: 'production',
description: '按 API Key 名称或 Key 值片段搜索'
}),
tags: ApiKeyListTagsQuerySchema.meta({
example: ['68ad85a7463006c963799a05'],
description: '按标签筛选;多个标签默认要求同时包含'
}),
appId: ObjectIdSchema.optional().meta({
example: '68ad85a7463006c963799a05',
description: '应用 ID,仅用于把相同 appId 的历史 Key 排在前面;不影响可见范围'
}),
sortBy: ApiKeyListSortBySchema.default('createTime').meta({
example: 'createTime',
description:
'排序字段。appId 置顶优先级最高;同一组内 createTime、lastUsedTime 按倒序排序,时间越近越靠前;remainingPoints 表示剩余积分,按升序排序,剩余少的排在前面,不限额 Key 排在最后'
})
});
export type GetApiKeyListQueryType = z.infer<typeof GetApiKeyListQuerySchema>;
export const GetApiKeyListResponseSchema = z.array(OpenApiKeySchema).meta({
......@@ -97,10 +151,10 @@ export const UpdateApiKeyBodySchema = CreateApiKeyBodySchema.partial()
_id: ObjectIdSchema.meta({ description: 'API Key 记录 ID' })
})
.refine(
({ name, limit, authProxy }) =>
name !== undefined || limit !== undefined || authProxy !== undefined,
({ name, limit, authProxy, tags }) =>
name !== undefined || limit !== undefined || authProxy !== undefined || tags !== undefined,
{
message: 'name, limit or authProxy is required'
message: 'name, limit, authProxy or tags is required'
}
);
export type UpdateApiKeyBodyType = z.infer<typeof UpdateApiKeyBodySchema>;
......@@ -163,6 +217,14 @@ export const ApiKeyHealthResponseSchema = z.object({
valid: z.literal(true).meta({
description: 'API Key 是否有效'
}),
usagePoints: z.number().default(0).meta({
example: 100,
description: 'API Key 已使用积分'
}),
maxUsagePoints: z.number().default(-1).meta({
example: -1,
description: 'API Key 最大积分用量限制,-1 表示无限制'
}),
appId: ObjectIdSchema.optional().meta({
example: '68ad85a7463006c963799a05',
description: '旧应用 API Key 绑定的应用 ID;系统 API Key 不返回'
......
......@@ -14,6 +14,16 @@ import {
UpdateApiKeyBodySchema,
UpdateApiKeyResponseSchema
} from './api';
import {
CreateOpenApiTagBodySchema,
CreateOpenApiTagResponseSchema,
DeleteOpenApiTagQuerySchema,
DeleteOpenApiTagResponseSchema,
GetOpenApiTagListQuerySchema,
GetOpenApiTagListResponseSchema,
UpdateOpenApiTagBodySchema,
UpdateOpenApiTagResponseSchema
} from './tag';
import { DevApiTagsMap } from '../../tag';
export const ApiKeyPath: OpenAPIPath = {
......@@ -155,5 +165,93 @@ export const ApiKeyPath: OpenAPIPath = {
}
}
}
},
'/support/openapi/tag/list': {
get: {
summary: '获取 API Key 标签列表',
description: '获取当前登录成员的 API Key 标签列表',
tags: [DevApiTagsMap.apiKey],
requestParams: {
query: GetOpenApiTagListQuerySchema
},
responses: {
200: {
description: '成功获取 API Key 标签列表',
content: {
'application/json': {
schema: GetOpenApiTagListResponseSchema
}
}
}
}
}
},
'/support/openapi/tag/create': {
post: {
summary: '创建 API Key 标签',
description: '创建当前登录成员的 API Key 自定义标签',
tags: [DevApiTagsMap.apiKey],
requestBody: {
content: {
'application/json': {
schema: CreateOpenApiTagBodySchema
}
}
},
responses: {
200: {
description: '成功创建 API Key 标签',
content: {
'application/json': {
schema: CreateOpenApiTagResponseSchema
}
}
}
}
}
},
'/support/openapi/tag/update': {
put: {
summary: '更新 API Key 标签',
description: '更新当前登录成员的 API Key 标签',
tags: [DevApiTagsMap.apiKey],
requestBody: {
content: {
'application/json': {
schema: UpdateOpenApiTagBodySchema
}
}
},
responses: {
200: {
description: '成功更新 API Key 标签',
content: {
'application/json': {
schema: UpdateOpenApiTagResponseSchema
}
}
}
}
}
},
'/support/openapi/tag/delete': {
delete: {
summary: '删除 API Key 标签',
description: '删除当前登录成员的 API Key 自定义标签,并从 API Key 绑定中解绑',
tags: [DevApiTagsMap.apiKey],
requestParams: {
query: DeleteOpenApiTagQuerySchema
},
responses: {
200: {
description: '成功删除 API Key 标签',
content: {
'application/json': {
schema: DeleteOpenApiTagResponseSchema
}
}
}
}
}
}
};
import z from 'zod';
import { ObjectIdSchema } from '../../../common/type/mongo';
import { BoolSchema, IntSchema } from '../../../common/zod';
export const OpenApiTagTypeSchema = z.enum(['system', 'custom']);
export const OpenApiTagSchema = z.object({
_id: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '标签 ID'
}),
name: z.string().meta({
example: '客户 A',
description: '标签名称'
}),
type: OpenApiTagTypeSchema.meta({
example: 'custom',
description: '标签类型;system 仅用于兼容历史数据,新标签均为 custom'
}),
order: z.number().meta({
example: 10,
description: '排序值'
}),
createTime: z.coerce.date().meta({
description: '创建时间'
}),
updateTime: z.coerce.date().meta({
description: '更新时间'
}),
keyCount: IntSchema.optional().meta({
example: 12,
description: '绑定该标签的 API Key 数量'
})
});
export type OpenApiTagType = z.infer<typeof OpenApiTagSchema>;
export const OpenApiTagsInputSchema = z
.array(ObjectIdSchema)
.max(20)
.meta({
example: ['68ad85a7463006c963799a05'],
description: '标签 ID 列表'
});
export type OpenApiTagsInputType = z.infer<typeof OpenApiTagsInputSchema>;
/* ============================================================================
* API: 获取 API Key 标签列表
* Route: GET /api/support/openapi/tag/list
* Method: GET
* Description: 获取当前登录成员的 API Key 标签列表。
* Tags: ['API Key 管理']
* ============================================================================ */
export const GetOpenApiTagListQuerySchema = z.object({
withKeyCount: BoolSchema.optional().meta({
example: false,
description: '是否返回每个标签绑定的 API Key 数量'
})
});
export type GetOpenApiTagListQueryType = z.infer<typeof GetOpenApiTagListQuerySchema>;
export const GetOpenApiTagListResponseSchema = z.array(OpenApiTagSchema).meta({
description: 'API Key 标签列表'
});
export type GetOpenApiTagListResponseType = z.infer<typeof GetOpenApiTagListResponseSchema>;
/* ============================================================================
* API: 创建 API Key 标签
* Route: POST /api/support/openapi/tag/create
* Method: POST
* Description: 创建当前登录成员的 API Key 自定义标签。
* Tags: ['API Key 管理']
* ============================================================================ */
export const CreateOpenApiTagBodySchema = z.object({
name: z.string().trim().min(1).max(50).meta({
example: '客户 A',
description: '标签名称'
})
});
export type CreateOpenApiTagBodyType = z.infer<typeof CreateOpenApiTagBodySchema>;
export const CreateOpenApiTagResponseSchema = OpenApiTagSchema.meta({
description: '新创建的 API Key 标签'
});
export type CreateOpenApiTagResponseType = z.infer<typeof CreateOpenApiTagResponseSchema>;
/* ============================================================================
* API: 更新 API Key 标签
* Route: PUT /api/support/openapi/tag/update
* Method: PUT
* Description: 更新当前登录成员的 API Key 标签。
* Tags: ['API Key 管理']
* ============================================================================ */
export const UpdateOpenApiTagBodySchema = z
.object({
tagId: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '标签 ID'
}),
name: z.string().trim().min(1).max(50).optional().meta({
example: '客户 A',
description: '标签名称'
}),
order: IntSchema.optional().meta({
example: 10,
description: '排序值'
})
})
.refine(({ name, order }) => name !== undefined || order !== undefined, {
message: 'name or order is required'
});
export type UpdateOpenApiTagBodyType = z.infer<typeof UpdateOpenApiTagBodySchema>;
export const UpdateOpenApiTagResponseSchema = z.undefined().meta({
description: '更新成功'
});
export type UpdateOpenApiTagResponseType = z.infer<typeof UpdateOpenApiTagResponseSchema>;
/* ============================================================================
* API: 删除 API Key 标签
* Route: DELETE /api/support/openapi/tag/delete
* Method: DELETE
* Description: 删除当前登录成员的 API Key 自定义标签,并从 API Key 绑定中解绑。
* Tags: ['API Key 管理']
* ============================================================================ */
export const DeleteOpenApiTagQuerySchema = z.object({
tagId: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '标签 ID'
})
});
export type DeleteOpenApiTagQueryType = z.infer<typeof DeleteOpenApiTagQuerySchema>;
export const DeleteOpenApiTagResponseSchema = z.undefined().meta({
description: '删除成功'
});
export type DeleteOpenApiTagResponseType = z.infer<typeof DeleteOpenApiTagResponseSchema>;
......@@ -6,6 +6,8 @@ export type OpenApiSchema = {
lastUsedTime?: Date;
apiKey: string;
appId?: string;
appName?: string;
tagIds?: string[];
authProxy?: boolean;
name: string;
usagePoints: number;
......
......@@ -66,6 +66,16 @@ describe('GetPreviewNodeQuerySchema', () => {
expect(tags.some((tag) => tag.startsWith('systemOpenAPI:'))).toBe(false);
});
it('groups API key management APIs under common basic features in dev API document', () => {
expect(openAPIDocument.paths['/support/openapi/create']?.post).toBeDefined();
expect(openAPIDocument.paths['/support/openapi/list']?.get).toBeDefined();
const tagGroups = openAPIDocument['x-tagGroups'] ?? [];
const commonBasicGroup = tagGroups.find((group) => group.name === '通用-基础功能');
expect(commonBasicGroup?.tags).toContain('API Key 管理');
});
it('includes chat quote APIs in System OpenAPI document', () => {
expect(apiDocOpenAPIDocument.paths['/core/chat/record/getQuote']?.post).toBeDefined();
expect(apiDocOpenAPIDocument.paths['/core/chat/record/getCollectionQuote']?.post).toBeDefined();
......
import { connectionMongo, getMongoModel, type Model } from '../../common/mongo';
const { Schema, model, models } = connectionMongo;
import { connectionMongo, getMongoModel } from '../../common/mongo';
const { Schema } = connectionMongo;
import { type OpenApiSchema } from '@fastgpt/global/support/openapi/type';
import {
TeamCollectionName,
......@@ -35,13 +35,21 @@ const OpenApiSchema = new Schema(
type: String,
required: false
},
appName: {
type: String
},
tagIds: {
type: [Schema.Types.ObjectId],
default: []
},
authProxy: {
type: Boolean,
default: false
},
name: {
type: String,
default: 'Api Key'
default: 'Api Key',
maxlength: 50
},
usagePoints: {
type: Number,
......@@ -65,6 +73,9 @@ const OpenApiSchema = new Schema(
try {
OpenApiSchema.index({ teamId: 1 });
OpenApiSchema.index({ apiKey: 1 });
OpenApiSchema.index({ teamId: 1, tmbId: 1, tagIds: 1, _id: -1 });
OpenApiSchema.index({ teamId: 1, tmbId: 1, appId: 1, _id: -1 });
OpenApiSchema.index({ teamId: 1, tmbId: 1, name: 1 });
} catch (error) {
const logger = getLogger(LogCategories.INFRA.MONGO);
logger.error('Failed to build OpenAPI indexes', { error });
......
import type { ClientSession } from '../../../common/mongo';
import { MongoOpenApiTag, type OpenApiTagSchemaType } from './schema';
export type CreateOpenApiTagData = Pick<
OpenApiTagSchemaType,
'teamId' | 'tmbId' | 'name' | 'normalizedName' | 'type' | 'order'
>;
export const createOpenApiTag = (data: CreateOpenApiTagData, session?: ClientSession) =>
MongoOpenApiTag.create([data], { session });
export const findOpenApiTagsByMember = ({ teamId, tmbId }: { teamId: string; tmbId: string }) =>
MongoOpenApiTag.find({ teamId, tmbId }).sort({ order: 1, createTime: 1, _id: 1 }).lean();
export const findOpenApiTagsByIds = ({
teamId,
tmbId,
tagIds
}: {
teamId: string;
tmbId: string;
tagIds: string[];
}) =>
MongoOpenApiTag.find({
teamId,
tmbId,
_id: { $in: tagIds }
})
.sort({ order: 1, createTime: 1, _id: 1 })
.lean();
import {
TeamCollectionName,
TeamMemberCollectionName
} from '@fastgpt/global/support/user/team/constant';
import { connectionMongo, getMongoModel } from '../../../common/mongo';
import { getLogger, LogCategories } from '../../../common/logger';
import type { OpenApiTagType } from '@fastgpt/global/openapi/support/openapi/tag';
const { Schema } = connectionMongo;
export const OpenApiTagCollectionName = 'openapi_tags';
export type OpenApiTagSchemaType = OpenApiTagType & {
teamId: string;
tmbId: string;
normalizedName: string;
};
const OpenApiTagSchema = new Schema({
teamId: {
type: Schema.Types.ObjectId,
ref: TeamCollectionName,
required: true
},
tmbId: {
type: Schema.Types.ObjectId,
ref: TeamMemberCollectionName,
required: true
},
name: {
type: String,
required: true,
maxlength: 50
},
normalizedName: {
type: String,
required: true
},
type: {
type: String,
enum: ['system', 'custom'],
required: true
},
order: {
type: Number,
default: 100
},
createTime: {
type: Date,
default: () => new Date()
},
updateTime: {
type: Date,
default: () => new Date()
}
});
try {
OpenApiTagSchema.index({ teamId: 1, tmbId: 1, normalizedName: 1 }, { unique: true });
OpenApiTagSchema.index({ teamId: 1, tmbId: 1, type: 1, order: 1 });
} catch (error) {
const logger = getLogger(LogCategories.INFRA.MONGO);
logger.error('Failed to build OpenAPI tag indexes', { error });
}
export const MongoOpenApiTag = getMongoModel<OpenApiTagSchemaType>(
OpenApiTagCollectionName,
OpenApiTagSchema
);
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import type { OpenApiTagType } from '@fastgpt/global/openapi/support/openapi/tag';
import { Types } from '../../../common/mongo';
import { MongoOpenApi } from '../schema';
import { MongoOpenApiTag, type OpenApiTagSchemaType } from './schema';
import { findOpenApiTagsByIds, findOpenApiTagsByMember } from './entity';
const toStringId = (value: unknown) => String(value || '');
export const normalizeOpenApiTagName = (name: string) => name.trim().toLowerCase();
const serializeOpenApiTag = (tag: OpenApiTagSchemaType): OpenApiTagType => ({
_id: toStringId(tag._id),
name: tag.name,
type: tag.type,
order: tag.order,
createTime: tag.createTime,
updateTime: tag.updateTime,
...(tag.keyCount !== undefined && { keyCount: tag.keyCount })
});
/**
* 兼容旧模块引用的空实现。
*
* API Key 标签已取消默认系统标签初始化。保留这个导出是为了避免开发环境热更新或旧分支代码
* 仍引用该函数时出现模块导出错误;调用它不会创建任何标签。
*/
export async function ensureDefaultOpenApiTags() {
return;
}
/**
* 返回当前成员的 API Key 标签列表,可选统计每个标签绑定的 Key 数量。
*/
export async function listOpenApiTags({
teamId,
tmbId,
withKeyCount = false
}: {
teamId: string;
tmbId: string;
withKeyCount?: boolean;
}) {
const tags = await findOpenApiTagsByMember({ teamId, tmbId });
if (!withKeyCount) {
return tags.map(serializeOpenApiTag);
}
const tagObjectIds = tags.map((tag) => new Types.ObjectId(toStringId(tag._id)));
if (tagObjectIds.length === 0) {
return [];
}
const keyCountAgg = await MongoOpenApi.aggregate<{ _id: Types.ObjectId; keyCount: number }>([
{
$match: {
teamId: new Types.ObjectId(teamId),
tmbId: new Types.ObjectId(tmbId),
tagIds: { $in: tagObjectIds }
}
},
{ $unwind: '$tagIds' },
{
$match: {
tagIds: { $in: tagObjectIds }
}
},
{
$group: {
_id: '$tagIds',
keyCount: { $sum: 1 }
}
}
]);
const keyCountMap = new Map(keyCountAgg.map((item) => [toStringId(item._id), item.keyCount]));
return tags.map((tag) =>
serializeOpenApiTag({
...tag,
keyCount: keyCountMap.get(toStringId(tag._id)) ?? 0
})
);
}
/**
* 创建当前成员的自定义 API Key 标签。
*/
export async function createOpenApiTag({
teamId,
tmbId,
name
}: {
teamId: string;
tmbId: string;
name: string;
}) {
const normalizedName = normalizeOpenApiTagName(name);
const exists = await MongoOpenApiTag.exists({
teamId,
tmbId,
normalizedName
});
if (exists) {
return Promise.reject(CommonErrEnum.invalidParams);
}
const firstTag = await MongoOpenApiTag.findOne({
teamId,
tmbId
})
.sort({ order: 1, createTime: 1, _id: 1 })
.select('order')
.lean();
const [tag] = await MongoOpenApiTag.create([
{
teamId,
tmbId,
name,
normalizedName,
type: 'custom',
// 新建标签默认放到最前面,避免用户创建后还要滚到底部查找。
order: (firstTag?.order ?? 10) - 10
}
]);
return serializeOpenApiTag(tag.toObject());
}
/**
* 更新当前成员的 API Key 标签。
*
* 历史数据里可能存在 type=system 的标签,当前按普通标签处理,允许重命名和排序。
*/
export async function updateOpenApiTag({
teamId,
tmbId,
tagId,
name,
order
}: {
teamId: string;
tmbId: string;
tagId: string;
name?: string;
order?: number;
}) {
const tag = await MongoOpenApiTag.findOne({
_id: tagId,
teamId,
tmbId
}).lean();
if (!tag) {
return Promise.reject(CommonErrEnum.invalidResource);
}
const normalizedName = name === undefined ? undefined : normalizeOpenApiTagName(name);
if (normalizedName) {
const exists = await MongoOpenApiTag.exists({
teamId,
tmbId,
normalizedName,
_id: { $ne: tagId }
});
if (exists) {
return Promise.reject(CommonErrEnum.invalidParams);
}
}
await MongoOpenApiTag.updateOne(
{
_id: tagId,
teamId,
tmbId
},
{
$set: {
...(name !== undefined && {
name,
normalizedName
}),
...(order !== undefined && { order }),
updateTime: new Date()
}
}
);
}
/**
* 删除当前成员的标签,并从当前成员的 API Key 绑定中解绑。
*/
export async function deleteOpenApiTag({
teamId,
tmbId,
tagId
}: {
teamId: string;
tmbId: string;
tagId: string;
}) {
const tag = await MongoOpenApiTag.findOne({
_id: tagId,
teamId,
tmbId
}).lean();
if (!tag) {
return Promise.reject(CommonErrEnum.invalidResource);
}
await MongoOpenApiTag.deleteOne({ _id: tagId, teamId, tmbId });
await MongoOpenApi.updateMany(
{
teamId,
tmbId
},
{
$pull: {
tagIds: new Types.ObjectId(tagId)
}
}
);
}
/**
* 校验标签归属并返回去重后的标签 ID。
*
* API Key 当前按 tmbId 隔离管理,因此标签也必须属于同一个 teamId + tmbId。
*/
export async function validateOpenApiTags({
teamId,
tmbId,
tags
}: {
teamId: string;
tmbId: string;
tags: string[];
}) {
const uniqueTagIds = Array.from(new Set(tags.map(toStringId))).filter(Boolean);
if (uniqueTagIds.length === 0) {
return [];
}
const matchedTags = await MongoOpenApiTag.find({
teamId,
tmbId,
_id: { $in: uniqueTagIds }
})
.select({ _id: 1 })
.lean();
if (matchedTags.length !== uniqueTagIds.length) {
return Promise.reject(CommonErrEnum.invalidResource);
}
return uniqueTagIds;
}
/**
* 根据标签 ID 批量读取标签,并按 tagId 组织成 Map,供 API Key list 组装返回。
*/
export async function getOpenApiTagMap({
teamId,
tmbId,
tagIds
}: {
teamId: string;
tmbId: string;
tagIds: string[];
}) {
const uniqueTagIds = Array.from(new Set(tagIds.map(toStringId))).filter(Boolean);
if (uniqueTagIds.length === 0) {
return new Map<string, OpenApiTagType>();
}
const tags = await findOpenApiTagsByIds({ teamId, tmbId, tagIds: uniqueTagIds });
return new Map(tags.map((tag) => [toStringId(tag._id), serializeOpenApiTag(tag)]));
}
{
"key_tips": "You can use API keys to access some specific interfaces (you cannot access the application, you need to use the API key in the application to access the application)"
"cancel_select": "Clear selection",
"created_at": "Created",
"create_tag_with_name": "Create \"{{name}}\"",
"create_time": "Creation time",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Delete this tag? It will be removed from API keys that use it.",
"edit_tag": "Edit tag",
"key_tips": "You can use API keys to access specific endpoints. To access an app, use the API key inside that app.",
"last_used_at": "Last used",
"last_used_time": "Last used time",
"no_tags": "No tags",
"remaining_points": "Remaining credits",
"search_key_name_or_value": "Search by key name or key fragment",
"search_or_add_tag": "Search or add tag",
"search_tag": "Search tags",
"select_tag": "Select tags",
"sort_label": "Sort",
"sort_by_create_time": "Creation time",
"sort_by_last_used_time": "Last used time",
"sort_by_remaining_points": "Remaining credits",
"tag_filter": "Filter by tag",
"tag_manage": "Tag management",
"tag_name": "Tag name",
"tag_total": "Total {{total}} tags",
"tags": "Tags",
"time": "Time",
"tutorial": "Tutorial"
}
......@@ -973,7 +973,7 @@
"support": "Support",
"support.inform.Read": "Read",
"support.openapi.Api baseurl": "API Base URL",
"support.openapi.Api manager": "API Key list",
"support.openapi.Api manager": "My API keys",
"support.openapi.Auth proxy": "Auth proxy",
"support.openapi.Auth proxy tip": "Allow a team-level API key to proxy a team member identity through authProxy in chat/completions requests. Only team owners can enable it.",
"support.openapi.Copy api key": "Copy API Key",
......
{
"key_tips": "你可以使用 API 密钥访问一些特定的接口(无法访问应用,访问应用需使用应用内的 API key)"
"cancel_select": "取消选择",
"created_at": "创建",
"create_tag_with_name": "创建 \"{{name}}\"",
"create_time": "创建时间",
"delete_tag": "删除标签",
"delete_tag_confirm": "确认删除该标签?删除后会从已绑定的 API Key 中移除。",
"edit_tag": "编辑标签",
"key_tips": "你可以使用 API 密钥访问一些特定的接口(无法访问应用,访问应用需使用应用内的 API key)",
"last_used_at": "最后使用",
"last_used_time": "最后使用时间",
"no_tags": "暂无标签",
"remaining_points": "剩余积分",
"search_key_name_or_value": "按 Key 名称或 Key 片段搜索",
"search_or_add_tag": "搜索或添加标签",
"search_tag": "搜索标签",
"select_tag": "选择标签",
"sort_label": "排序",
"sort_by_create_time": "按创建时间",
"sort_by_last_used_time": "按最后使用时间",
"sort_by_remaining_points": "按剩余积分",
"tag_filter": "按标签筛选",
"tag_manage": "标签管理",
"tag_name": "标签名称",
"tag_total": "共{{total}}个标签",
"tags": "标签",
"time": "时间",
"tutorial": "教程"
}
......@@ -973,7 +973,7 @@
"support": "支持",
"support.inform.Read": "已读",
"support.openapi.Api baseurl": "API 根地址",
"support.openapi.Api manager": "密钥列表",
"support.openapi.Api manager": "我的密钥",
"support.openapi.Auth proxy": "身份代理",
"support.openapi.Auth proxy tip": "允许团队级 API 密钥在 chat/completions 请求中通过 authProxy 代理团队成员身份。仅团队所有者可开启。",
"support.openapi.Copy api key": "复制 API 密钥",
......
{
"key_tips": "你可以使用 API 金鑰存取一些特定的介面(無法存取應用,存取應用程式需使用應用程式內的 API key)"
"cancel_select": "取消選擇",
"created_at": "建立",
"create_tag_with_name": "建立 \"{{name}}\"",
"create_time": "建立時間",
"delete_tag": "刪除標籤",
"delete_tag_confirm": "確認刪除該標籤?刪除後會從已綁定的 API Key 中移除。",
"edit_tag": "編輯標籤",
"key_tips": "你可以使用 API 金鑰存取一些特定的介面(無法存取應用,存取應用程式需使用應用程式內的 API key)",
"last_used_at": "最後使用",
"last_used_time": "最後使用時間",
"no_tags": "暫無標籤",
"remaining_points": "剩餘積分",
"search_key_name_or_value": "按 Key 名稱或 Key 片段搜尋",
"search_or_add_tag": "搜尋或新增標籤",
"search_tag": "搜尋標籤",
"select_tag": "選擇標籤",
"sort_label": "排序",
"sort_by_create_time": "按建立時間",
"sort_by_last_used_time": "按最後使用時間",
"sort_by_remaining_points": "按剩餘積分",
"tag_filter": "按標籤篩選",
"tag_manage": "標籤管理",
"tag_name": "標籤名稱",
"tag_total": "共 {{total}} 個標籤",
"tags": "標籤",
"time": "時間",
"tutorial": "教程"
}
......@@ -962,7 +962,7 @@
"support": "支援",
"support.inform.Read": "已讀",
"support.openapi.Api baseurl": "API 根網址",
"support.openapi.Api manager": "密鑰列表",
"support.openapi.Api manager": "我的密鑰",
"support.openapi.Auth proxy": "身份代理",
"support.openapi.Auth proxy tip": "允許團隊級 API 金鑰在 chat/completions 請求中透過 authProxy 代理團隊成員身份。僅團隊擁有者可開啟。",
"support.openapi.Copy api key": "複製 API 金鑰",
......
{
"name": "@fastgpt/app",
"version": "4.15.0",
"version": "4.15.1",
"private": false,
"browserslist": [
"Chrome >= 80",
......
......@@ -10,7 +10,6 @@ import {
Th,
Td,
TableContainer,
useTheme,
Link,
Input,
IconButton,
......@@ -21,11 +20,14 @@ import {
createAOpenApiKey,
delOpenApiById,
putOpenApiKey,
copyOpenApiKey
copyOpenApiKey,
getOpenApiTags,
createOpenApiTag
} from '@/web/support/openapi/api';
import type { EditApiKeyProps } from '@/global/support/openapi/api';
import type { ApiKeyListSortByType } from '@fastgpt/global/openapi/support/openapi/api';
import type { OpenApiTagType } from '@fastgpt/global/openapi/support/openapi/tag';
import dayjs from 'dayjs';
import { AddIcon } from '@chakra-ui/icons';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next';
......@@ -34,11 +36,17 @@ import MyModalV2 from '@fastgpt/web/components/v2/common/MyModal';
import { Controller, useForm } from 'react-hook-form';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { getDocPath } from '@/web/common/system/doc';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import MyBox from '@fastgpt/web/components/common/MyBox';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import MySelect from '@fastgpt/web/components/common/MySelect';
import TagDisplayList, { type ApiKeyDisplayTag } from './TagDisplayList';
import TagMultiSelect from './TagMultiSelect';
import TagManageModal from './TagManageModal';
import { useDebounce } from 'ahooks';
type EditProps = EditApiKeyProps & { _id?: string };
const defaultEditData: EditProps = {
......@@ -52,6 +60,7 @@ const defaultEditData: EditProps = {
const getDefaultEditData = (): EditProps => ({
name: defaultEditData.name,
authProxy: defaultEditData.authProxy,
tags: [],
limit: {
maxUsagePoints: defaultEditData.limit?.maxUsagePoints ?? -1,
expiredTime: defaultEditData.limit?.expiredTime
......@@ -66,19 +75,140 @@ const maskApiKey = (apiKey: string) => {
type ApiKeyTableProps = {
tips?: string;
mode?: 'account' | 'publish';
appId?: string;
};
const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
const isSameTagIds = (left: string[], right: string[]) => {
if (left.length !== right.length) return false;
const rightSet = new Set(right);
return left.every((item) => rightSet.has(item));
};
const ApiKeyTagEditor = ({
apiKeyId,
appName,
tagIds,
allTags,
onSave,
onManage,
onCreateTag,
isLoading
}: {
apiKeyId: string;
appName?: string;
tagIds: string[];
allTags: OpenApiTagType[];
onSave: (apiKeyId: string, tagIds: string[]) => Promise<void>;
onManage: () => void;
onCreateTag: (name: string) => Promise<OpenApiTagType | void>;
isLoading: boolean;
}) => {
const [localTagIds, setLocalTagIds] = useState(tagIds);
const selectedTags = useMemo(
() => localTagIds.flatMap((id) => allTags.find((tag) => tag._id === id) || []),
[allTags, localTagIds]
);
const displayTags = useMemo<ApiKeyDisplayTag[]>(
() => [
...(appName
? [
{
_id: `appName-${apiKeyId}`,
name: appName,
isAppName: true
}
]
: []),
...selectedTags
],
[apiKeyId, appName, selectedTags]
);
if (displayTags.length === 0) {
return null;
}
return (
<TagMultiSelect
tags={allTags}
value={localTagIds}
onChange={setLocalTagIds}
onManage={onManage}
onCreateTag={onCreateTag}
isLoading={isLoading}
placement="bottom-start"
popoverW="180px"
renderTrigger={({ openSelector }) => (
<Box
mt={1}
py={0.5}
px={0.25}
w={'100%'}
maxW={'100%'}
cursor={'pointer'}
_hover={{
bg: 'myGray.50',
borderRadius: '3px'
}}
onClick={(e) => {
e.stopPropagation();
if ((e.target as HTMLElement).closest('[data-api-key-overflow-tags]')) {
return;
}
openSelector();
}}
>
<TagDisplayList tags={displayTags} />
</Box>
)}
onClose={(nextTagIds) => {
if (!isSameTagIds(nextTagIds, tagIds)) {
return onSave(apiKeyId, nextTagIds);
}
}}
/>
);
};
const ApiKeyTable = ({ mode = 'account', appId }: ApiKeyTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const { copyData } = useCopyData();
const { feConfigs } = useSystemStore();
const isPublishMode = mode === 'publish';
const hasUsagePlan = !!feConfigs?.isPlus;
const baseUrl =
feConfigs?.customApiDomain || (typeof location !== 'undefined' ? `${location.origin}/api` : '');
const [editData, setEditData] = useState<EditProps>();
const [apiKey, setApiKey] = useState('');
const [copyingApiKeyId, setCopyingApiKeyId] = useState<string>();
const [keyword, setKeyword] = useState('');
const requestKeyword = useDebounce(keyword.trim(), { wait: 300 });
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<ApiKeyListSortByType>('createTime');
const effectiveSortBy = hasUsagePlan || sortBy !== 'remainingPoints' ? sortBy : 'createTime';
const [showTagManage, setShowTagManage] = useState(false);
const sortOptions = useMemo<
{
label: string;
value: ApiKeyListSortByType;
}[]
>(
() => [
{ label: t('account_apikey:sort_by_create_time'), value: 'createTime' },
{ label: t('account_apikey:sort_by_last_used_time'), value: 'lastUsedTime' },
...(hasUsagePlan
? [
{
label: t('account_apikey:sort_by_remaining_points'),
value: 'remainingPoints' as const
}
]
: [])
],
[hasUsagePlan, t]
);
const { ConfirmModal, openConfirm } = useConfirm({
type: 'delete',
......@@ -86,6 +216,7 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
});
const { runAsync: onclickRemove } = useRequest(delOpenApiById, {
successToast: t('common:delete_success'),
onSuccess() {
refetch();
}
......@@ -93,6 +224,20 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
const { runAsync: copyApiKey } = useRequest(copyOpenApiKey, {
errorToast: 'Error'
});
const { runAsync: onUpdateApiKeyTags, loading: isUpdatingApiKeyTags } = useRequest(
({ apiKeyId, tagIds }: { apiKeyId: string; tagIds: string[] }) =>
putOpenApiKey({
_id: apiKeyId,
tags: tagIds
}),
{
errorToast: t('common:update_failed'),
onSuccess() {
refetch();
refetchTags();
}
}
);
const onCopyApiKey = async (id: string) => {
setCopyingApiKeyId(id);
......@@ -108,9 +253,36 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
data: apiKeys = [],
loading: isGetting,
run: refetch
} = useRequest(() => getOpenApiKeys(), {
} = useRequest(
() =>
getOpenApiKeys({
keyword: requestKeyword || undefined,
tags: selectedTagIds.length > 0 ? selectedTagIds : undefined,
sortBy: effectiveSortBy,
appId
}),
{
manual: false,
refreshDeps: [requestKeyword, selectedTagIds, effectiveSortBy, appId]
}
);
const {
data: openApiTags = [],
loading: isGettingTags,
run: refetchTags
} = useRequest(() => getOpenApiTags({ withKeyCount: true }), {
manual: false
});
const { runAsync: onCreateTagFromSelect } = useRequest(
async (name: string) => createOpenApiTag({ name }),
{
successToast: t('common:create_success'),
errorToast: t('common:create_failed'),
onSuccess() {
refetchTags();
}
}
);
return (
<MyBox
......@@ -123,76 +295,158 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
px={0}
minH={isPublishMode ? '50vh' : undefined}
>
<Box display={['block', 'flex']} alignItems={'center'}>
<Box flex={1}>
<Flex alignItems={'center'}>
<Box
color={'myGray.900'}
fontSize={isPublishMode ? ['md', 'lg'] : 'lg'}
fontWeight={isPublishMode ? 'bold' : 'normal'}
<Flex flexDirection={'column'} alignItems={'stretch'} gap={3}>
<Flex minW={0} alignItems={'center'}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
{t('common:support.openapi.Api manager')}({apiKeys.length})
</Box>
{feConfigs?.docUrl && (
<Link
href={feConfigs.openAPIDocUrl || getDocPath('/openapi/intro')}
target={'_blank'}
ml={isPublishMode ? 2 : 1}
color={'primary.500'}
fontSize={'sm'}
>
{t('common:support.openapi.Api manager')}({apiKeys.length})
</Box>
{feConfigs?.docUrl && (
<Link
href={feConfigs.openAPIDocUrl || getDocPath('/openapi/intro')}
target={'_blank'}
ml={isPublishMode ? 2 : 1}
color={'primary.500'}
<Flex alignItems={'center'}>
<MyIcon name="book" w={'17px'} h={'17px'} mr="1" />
{t('account_apikey:tutorial')}
</Flex>
</Link>
)}
</Flex>
<Flex
alignItems={['stretch', 'center']}
justifyContent={'space-between'}
gap={3}
minW={0}
flexDirection={['column', 'row']}
flexWrap={'wrap'}
>
<Flex
alignItems={['stretch', 'center']}
gap={2}
flex={['unset', '1 1 0']}
minW={0}
w={['100%', 'auto']}
flexDirection={['column', 'row']}
flexWrap={'wrap'}
>
<SearchInput
value={keyword}
placeholder={t('account_apikey:search_key_name_or_value')}
bg={'white'}
maxW={['100%', '240px']}
onChange={(e) => setKeyword(e.target.value)}
/>
<TagMultiSelect
tags={openApiTags}
value={selectedTagIds}
onChange={setSelectedTagIds}
label={t('account_apikey:tags')}
placeholder={t('common:All')}
onManage={() => setShowTagManage(true)}
onCreateTag={onCreateTagFromSelect}
isLoading={isGettingTags}
w={['100%', '220px']}
/>
<MySelect<ApiKeyListSortByType>
width={['100%', '200px']}
h={'36px'}
value={effectiveSortBy}
list={sortOptions}
menuPlacement={'bottom-end'}
onChange={setSortBy}
valueLabel={
<Flex alignItems={'center'} w={'100%'} minW={0}>
<Box flexShrink={0} color={'myGray.600'}>
{t('account_apikey:sort_label')}
</Box>
<Box mx={3} w={'1px'} h={'16px'} bg={'myGray.200'} />
<Box
flex={1}
color={'myGray.900'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{sortOptions.find((item) => item.value === effectiveSortBy)?.label}
</Box>
</Flex>
}
/>
</Flex>
<Flex
alignItems={['stretch', 'center']}
justifyContent={['flex-start', 'flex-end']}
gap={2}
flexShrink={0}
minW={0}
w={['100%', 'auto']}
flexDirection={['column', 'row']}
>
<MyTooltip label={t('common:click_to_copy')}>
<Flex
alignItems={'center'}
w={['100%', '320px']}
h={'36px'}
px={3}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'md'}
cursor={'pointer'}
userSelect={'none'}
bg={'white'}
fontSize={'sm'}
_hover={{
borderColor: 'primary.300',
boxShadow: '0 0 0 2px rgba(51, 112, 255, 0.12)'
}}
onClick={() => copyData(baseUrl, t('common:support.openapi.Copy success'))}
>
<Flex alignItems={'center'}>
<MyIcon name="book" w={'17px'} h={'17px'} mr="1" />
{t('common:read_doc')}
</Flex>
</Link>
)}
<Box flexShrink={0} color={'myGray.600'}>
{t('common:support.openapi.Api baseurl')}
</Box>
<Box mx={2} w={'1px'} h={'16px'} bg={'myGray.200'} />
<Box
flex={1}
minW={0}
color={'myGray.900'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{baseUrl}
</Box>
</Flex>
</MyTooltip>
<Button
size={['sm', 'md']}
leftIcon={<MyIcon name={'common/addLight'} w={'1.25rem'} color={'white'} />}
variant={'primary'}
onClick={() => setEditData(getDefaultEditData())}
>
{t('common:new_create')}
</Button>
</Flex>
</Box>
<Flex
mt={[2, 0]}
bg={'myGray.100'}
py={2}
px={4}
borderRadius={'md'}
cursor={'pointer'}
userSelect={'none'}
onClick={() => copyData(baseUrl, t('common:support.openapi.Copy success'))}
>
<Box border={theme.borders.md} px={2} borderRadius={'md'} fontSize={'xs'}>
{t('common:support.openapi.Api baseurl')}
</Box>
<Box ml={2} fontSize={'sm'}>
{baseUrl}
</Box>
</Flex>
<Box mt={[2, 0]} textAlign={'right'}>
<Button
ml={3}
leftIcon={<AddIcon fontSize={'md'} />}
variant={isPublishMode ? 'primary' : 'whitePrimary'}
onClick={() => setEditData(getDefaultEditData())}
>
{t('common:new_create')}
</Button>
</Box>
</Box>
</Flex>
<TableContainer mt={3} position={'relative'} minH={'300px'}>
<Table>
<Table sx={{ tableLayout: 'fixed' }}>
<Thead>
<Tr>
<Th>{t('common:Name')}</Th>
<Th>API KEY</Th>
<Th>{t('common:support.outlink.Usage points')}</Th>
{feConfigs?.isPlus && (
<Th w={'240px'}>{t('common:Name')}</Th>
<Th w={'130px'}>API KEY</Th>
{hasUsagePlan && <Th w={'150px'}>{t('common:support.outlink.Usage points')}</Th>}
{hasUsagePlan && (
<>
<Th>{t('common:expired_time')}</Th>
<Th w={'120px'}>{t('common:expired_time')}</Th>
</>
)}
<Th>{t('common:create_time')}</Th>
<Th>{t('common:last_use_time')}</Th>
<Th />
<Th w={'160px'}>{t('account_apikey:last_used_time')}</Th>
<Th w={'160px'}>{t('account_apikey:create_time')}</Th>
<Th w={'92px'} />
</Tr>
</Thead>
<Tbody fontSize={'sm'}>
......@@ -200,23 +454,62 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
({
_id,
name,
usagePoints,
limit,
usagePoints,
apiKey,
canCopy,
createTime,
lastUsedTime,
authProxy
authProxy,
appName,
tagIds
}) => (
<Tr key={_id}>
<Td>{name}</Td>
<Td>
<Flex alignItems={'center'} gap={1} role={'group'}>
<Box>{maskApiKey(apiKey)}</Box>
<Td maxW={'240px'}>
<Flex flexDirection={'column'} minW={0}>
<MyTooltip label={name} showOnlyWhenOverflow>
<Box
maxW={'220px'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{name}
</Box>
</MyTooltip>
<ApiKeyTagEditor
key={`${_id}-${(tagIds || []).join(',')}`}
apiKeyId={_id}
appName={appName}
tagIds={tagIds || []}
allTags={openApiTags}
onSave={async (apiKeyId, tagIds) => {
await onUpdateApiKeyTags({
apiKeyId,
tagIds
});
}}
onManage={() => setShowTagManage(true)}
onCreateTag={onCreateTagFromSelect}
isLoading={isGettingTags || isUpdatingApiKeyTags}
/>
</Flex>
</Td>
<Td maxW={'130px'}>
<Flex alignItems={'center'} gap={1} role={'group'} minW={0}>
<Box
minW={0}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{maskApiKey(apiKey)}
</Box>
{canCopy && (
<MyIcon
name={copyingApiKeyId === _id ? 'common/loading' : 'copy'}
w={'15px'}
flexShrink={0}
aria-label={t('common:Copy')}
role={'button'}
tabIndex={0}
......@@ -238,13 +531,15 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
)}
</Flex>
</Td>
<Td>
{Math.round(usagePoints)}/
{feConfigs?.isPlus && limit?.maxUsagePoints && limit?.maxUsagePoints > -1
? `${limit?.maxUsagePoints}`
: t('common:Unlimited')}
</Td>
{feConfigs?.isPlus && (
{hasUsagePlan && (
<Td whiteSpace={'nowrap'}>
{Math.round(usagePoints)}/
{limit?.maxUsagePoints && limit?.maxUsagePoints > -1
? `${limit?.maxUsagePoints}`
: t('common:Unlimited')}
</Td>
)}
{hasUsagePlan && (
<>
<Td whiteSpace={'pre-wrap'}>
{limit?.expiredTime
......@@ -253,50 +548,41 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
</Td>
</>
)}
<Td whiteSpace={'pre-wrap'}>
{dayjs(createTime).format('YYYY/MM/DD\nHH:mm:ss')}
</Td>
<Td whiteSpace={'pre-wrap'}>
<Td whiteSpace={'normal'}>
{lastUsedTime
? dayjs(lastUsedTime).format('YYYY/MM/DD\nHH:mm:ss')
? dayjs(lastUsedTime).format('YYYY/MM/DD HH:mm:ss')
: t('common:un_used')}
</Td>
<Td>
<MyMenu
offset={[-50, 5]}
Button={
<Td whiteSpace={'normal'}>{dayjs(createTime).format('YYYY/MM/DD HH:mm:ss')}</Td>
<Td w={'92px'}>
<Flex alignItems={'center'} gap={2}>
<MyTooltip label={t('common:Edit')}>
<IconButton
icon={<MyIcon name={'more'} w={'14px'} />}
name={'more'}
icon={<MyIcon name={'edit'} w={4} />}
variant={'whitePrimary'}
size={'sm'}
aria-label={''}
aria-label={t('common:Edit')}
onClick={() =>
setEditData({
_id,
name,
limit,
authProxy,
tags: tagIds || []
})
}
/>
}
menuList={[
{
children: [
{
label: t('common:Edit'),
icon: 'edit',
onClick: () =>
setEditData({
_id,
name,
limit,
authProxy
})
},
{
label: t('common:Delete'),
icon: 'delete',
type: 'danger',
onClick: () => openConfirm({ onConfirm: () => onclickRemove(_id) })()
}
]
}
]}
/>
</MyTooltip>
<MyTooltip label={t('common:Delete')}>
<IconButton
icon={<MyIcon name={'delete'} w={4} />}
variant={'whiteDanger'}
size={'sm'}
aria-label={t('common:Delete')}
onClick={() => openConfirm({ onConfirm: () => onclickRemove(_id) })()}
/>
</MyTooltip>
</Flex>
</Td>
</Tr>
)
......@@ -308,14 +594,17 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
{!!editData && (
<EditKeyModal
defaultData={editData}
tags={openApiTags}
onClose={() => setEditData(undefined)}
onCreate={(id) => {
setApiKey(id);
refetch();
refetchTags();
setEditData(undefined);
}}
onEdit={() => {
refetch();
refetchTags();
setEditData(undefined);
}}
/>
......@@ -354,6 +643,14 @@ const ApiKeyTable = ({ mode = 'account' }: ApiKeyTableProps) => {
<MyIcon ml={1} name={'copy'} w={'16px'}></MyIcon>
</Flex>
</MyModalV2>
{showTagManage && (
<TagManageModal
tags={openApiTags}
onClose={() => setShowTagManage(false)}
onRefreshTags={refetchTags}
onRefreshKeys={refetch}
/>
)}
</MyBox>
);
};
......@@ -363,11 +660,13 @@ export default React.memo(ApiKeyTable);
// edit link modal
function EditKeyModal({
defaultData,
tags,
onClose,
onCreate,
onEdit
}: {
defaultData: EditProps;
tags: OpenApiTagType[];
onClose: () => void;
onCreate: (id: string) => void;
onEdit: () => void;
......@@ -408,7 +707,7 @@ function EditKeyModal({
<MyModalV2
isOpen={true}
title={isEdit ? t('publish:edit_api_key') : t('publish:create_api_key')}
size="md"
size="sm"
onClose={onClose}
footer={
<>
......@@ -418,9 +717,14 @@ function EditKeyModal({
<Button
isLoading={creating || updating}
onClick={submitShareChat((data) =>
isEdit ? onclickUpdate(data) : onclickCreate(data)
)}
onClick={submitShareChat((data) => {
const trimData = {
...data,
name: data.name.trim()
};
return isEdit ? onclickUpdate(trimData) : onclickCreate(trimData);
})}
>
{t('common:Confirm')}
</Button>
......@@ -432,12 +736,30 @@ function EditKeyModal({
<FormLabel flex={'0 0 90px'}>{t('common:Name')}</FormLabel>
<Input
placeholder={t('publish:key_alias') || 'key_alias'}
maxLength={100}
maxLength={50}
{...register('name', {
required: t('common:name_is_empty') || 'name_is_empty'
required: t('common:name_is_empty') || 'name_is_empty',
validate: (value) => !!value.trim() || t('common:name_is_empty') || 'name_is_empty'
})}
/>
</Flex>
<Flex alignItems={'center'} gap={4}>
<FormLabel flex={'0 0 90px'}>{t('account_apikey:tags')}</FormLabel>
<Controller
control={control}
name="tags"
render={({ field }) => (
<TagMultiSelect
tags={tags}
value={field.value || []}
onChange={field.onChange}
placeholder={t('account_apikey:select_tag')}
showFooter={false}
w={'100%'}
/>
)}
/>
</Flex>
{feConfigs?.isPlus && (
<>
<Flex alignItems={'center'} gap={4}>
......@@ -473,7 +795,7 @@ function EditKeyModal({
</Flex>
</>
)}
<Flex alignItems={'center'} mt={4}>
<Flex alignItems={'center'} gap={4} mt={4}>
<FormLabel display={'flex'} flex={'0 0 90px'} alignItems={'center'}>
{t('common:support.openapi.Auth proxy')}
<QuestionTip ml={1} label={t('common:support.openapi.Auth proxy tip')}></QuestionTip>
......
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
import type { OpenApiTagType } from '@fastgpt/global/openapi/support/openapi/tag';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
export type ApiKeyDisplayTag = Pick<OpenApiTagType, '_id' | 'name'> & {
isAppName?: boolean;
};
const TAG_GAP_PX = 8;
const TagPill = React.forwardRef<
HTMLDivElement,
{
tag: ApiKeyDisplayTag;
showFullName?: boolean;
showTooltip?: boolean;
}
>(({ tag, showFullName = false, showTooltip = false }, ref) => {
const nameNode = (
<Box
minW={0}
overflow={showFullName ? 'visible' : 'hidden'}
textOverflow={showFullName ? 'clip' : 'ellipsis'}
whiteSpace={'nowrap'}
>
{tag.name}
</Box>
);
return (
<Flex
ref={ref}
alignItems={'center'}
h={5}
px={2}
fontSize={'11px'}
fontWeight={'500'}
lineHeight={'20px'}
bg={tag.isAppName ? 'orange.50' : '#F0FBFF'}
color={tag.isAppName ? 'orange.600' : '#0884DD'}
borderRadius={'xs'}
maxW={showFullName ? '260px' : '120px'}
flexShrink={0}
overflow={showFullName ? 'visible' : 'hidden'}
userSelect={'none'}
>
{showTooltip && !showFullName ? (
<MyTooltip label={tag.name} showOnlyWhenOverflow>
{nameNode}
</MyTooltip>
) : (
nameNode
)}
</Flex>
);
});
TagPill.displayName = 'TagPill';
const OverflowBadge = React.forwardRef<HTMLDivElement, { count: number }>(({ count }, ref) => (
<Flex
ref={ref}
alignItems={'center'}
h={5}
px={2}
bg={'#1118240D'}
borderRadius={'33px'}
fontSize={'11px'}
flexShrink={0}
userSelect={'none'}
>
{`+${count}`}
</Flex>
));
OverflowBadge.displayName = 'OverflowBadge';
const ApiKeyTag = ({
tag,
showFullName = false
}: {
tag: ApiKeyDisplayTag;
showFullName?: boolean;
}) => <TagPill tag={tag} showFullName={showFullName} showTooltip />;
const TagDisplayList = ({ tags }: { tags: ApiKeyDisplayTag[] }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const overflowMeasureRef = React.useRef<HTMLDivElement>(null);
const tagMeasureRefs = React.useRef<Array<HTMLDivElement | null>>([]);
const [visibleCount, setVisibleCount] = React.useState(tags.length);
const calculateVisibleCount = React.useCallback(() => {
const containerWidth = containerRef.current?.clientWidth || 0;
const overflowBadgeWidth = overflowMeasureRef.current?.offsetWidth || 0;
const tagWidths = tags.map((_, index) => tagMeasureRefs.current[index]?.offsetWidth || 0);
if (containerWidth <= 0 || tagWidths.some((width) => width <= 0)) {
setVisibleCount(tags.length);
return;
}
for (let count = tags.length; count >= 0; count--) {
const overflowCount = tags.length - count;
const visibleWidth =
tagWidths.slice(0, count).reduce((sum, width) => sum + width, 0) +
Math.max(count - 1, 0) * TAG_GAP_PX;
const totalWidth =
visibleWidth +
(overflowCount > 0 ? overflowBadgeWidth : 0) +
(overflowCount > 0 && count > 0 ? TAG_GAP_PX : 0);
if (totalWidth <= containerWidth) {
setVisibleCount((oldCount) => (oldCount === count ? oldCount : count));
return;
}
}
setVisibleCount(0);
}, [tags]);
React.useEffect(() => {
const frameId = requestAnimationFrame(calculateVisibleCount);
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') {
return () => cancelAnimationFrame(frameId);
}
const resizeObserver = new ResizeObserver(calculateVisibleCount);
resizeObserver.observe(container);
return () => {
cancelAnimationFrame(frameId);
resizeObserver.disconnect();
};
}, [calculateVisibleCount]);
const safeVisibleCount = Math.min(visibleCount, tags.length);
const visibleTags = tags.slice(0, safeVisibleCount);
const overflowTags = tags.slice(safeVisibleCount);
if (tags.length === 0) {
return null;
}
return (
<Box ref={containerRef} position={'relative'} w={'100%'} minW={0} userSelect={'none'}>
<Flex alignItems={'center'} gap={2} maxW={'100%'} minW={0} overflow={'visible'}>
{visibleTags.map((tag) => (
<ApiKeyTag key={tag._id} tag={tag} />
))}
{overflowTags.length > 0 && (
<MyPopover
placement="bottom-end"
hasArrow={false}
offset={[2, 2]}
w={'360px'}
maxW={'calc(100vw - 32px)'}
trigger={'hover'}
Trigger={
<Box
data-api-key-overflow-tags
onClick={(e) => {
e.stopPropagation();
}}
>
<OverflowBadge count={overflowTags.length} />
</Box>
}
>
{() => (
<Flex gap={2} p={3} flexWrap={'wrap'}>
{overflowTags.map((tag) => (
<ApiKeyTag key={tag._id} tag={tag} showFullName />
))}
</Flex>
)}
</MyPopover>
)}
</Flex>
<Flex
position={'absolute'}
visibility={'hidden'}
pointerEvents={'none'}
h={0}
overflow={'hidden'}
gap={2}
>
{tags.map((tag, index) => (
<TagPill
key={tag._id}
tag={tag}
ref={(element) => {
tagMeasureRefs.current[index] = element;
}}
/>
))}
<OverflowBadge ref={overflowMeasureRef} count={tags.length} />
</Flex>
</Box>
);
};
export default React.memo(TagDisplayList);
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Flex, Input } from '@chakra-ui/react';
import type { OpenApiTagType } from '@fastgpt/global/openapi/support/openapi/tag';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyInput from '@/components/MyInput';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { createOpenApiTag, deleteOpenApiTag, updateOpenApiTag } from '@/web/support/openapi/api';
import { useTranslation } from 'next-i18next';
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
const ApiKeyTagBox = ({ name }: { name: string }) => (
<MyTooltip label={name} showOnlyWhenOverflow>
<Box
px={3}
py={1.5}
bg={'#DBF3FF'}
color={'#0884DD'}
fontSize={'xs'}
borderRadius={'sm'}
maxW={'260px'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{name}
</Box>
</MyTooltip>
);
const TagManageModal = ({
tags,
onClose,
onRefreshTags,
onRefreshKeys
}: {
tags: OpenApiTagType[];
onClose: () => void;
onRefreshTags: () => void;
onRefreshKeys?: () => void;
}) => {
const { t } = useTranslation();
const tagInputRef = useRef<HTMLInputElement>(null);
const editInputRef = useRef<HTMLInputElement>(null);
const [orderedTags, setOrderedTags] = useState<OpenApiTagType[] | undefined>(undefined);
const [newTag, setNewTag] = useState<string | undefined>(undefined);
const [searchText, setSearchText] = useState('');
const [currentEditTag, setCurrentEditTag] = useState<OpenApiTagType | undefined>(undefined);
const [currentEditTagContent, setCurrentEditTagContent] = useState<string | undefined>(undefined);
const localTags = orderedTags || tags;
useEffect(() => {
if (newTag !== undefined) {
tagInputRef.current?.focus();
}
}, [newTag]);
useEffect(() => {
if (currentEditTag !== undefined) {
editInputRef.current?.focus();
}
}, [currentEditTag]);
const filteredTags = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
if (!keyword) return localTags;
return localTags.filter((tag) => tag.name.toLowerCase().includes(keyword));
}, [searchText, localTags]);
const { runAsync: onCreateTag } = useRequest(async (name: string) => createOpenApiTag({ name }), {
successToast: t('common:create_success'),
errorToast: t('common:create_failed'),
onSuccess() {
setOrderedTags(undefined);
onRefreshTags();
}
});
const { runAsync: onUpdateTag } = useRequest(
async ({ tagId, name }: { tagId: string; name: string }) => updateOpenApiTag({ tagId, name }),
{
successToast: t('common:update_success'),
errorToast: t('common:update_failed'),
onSuccess() {
setOrderedTags(undefined);
onRefreshTags();
}
}
);
const { runAsync: onUpdateTagOrders } = useRequest(
async (updates: { tagId: string; order: number }[]) => {
await Promise.all(updates.map((item) => updateOpenApiTag(item)));
},
{
errorToast: t('common:update_failed'),
onSuccess() {
onRefreshTags();
}
}
);
const { runAsync: onDeleteTag } = useRequest(deleteOpenApiTag, {
successToast: t('common:delete_success'),
errorToast: t('common:delete_failed'),
onSuccess() {
setOrderedTags(undefined);
onRefreshTags();
onRefreshKeys?.();
}
});
const submitCreate = async () => {
const name = newTag?.trim();
if (name && !tags.some((tag) => tag.name === name)) {
await onCreateTag(name);
}
setNewTag(undefined);
};
const submitUpdate = async (tag: OpenApiTagType) => {
const name = currentEditTagContent?.trim();
if (name && name !== tag.name && !tags.some((item) => item.name === name)) {
await onUpdateTag({
tagId: tag._id,
name
});
}
setCurrentEditTag(undefined);
setCurrentEditTagContent(undefined);
};
const updateTagOrder = async (nextTags: OpenApiTagType[]) => {
setOrderedTags(nextTags);
const updates = nextTags
.map((tag, index) => ({
tagId: tag._id,
order: (index + 1) * 10,
originOrder: tag.order
}))
.filter((item) => item.order !== item.originOrder)
.map(({ tagId, order }) => ({ tagId, order }));
if (updates.length > 0) {
await onUpdateTagOrders(updates);
}
};
const reorderVisibleTags = async (sortedVisibleTags: OpenApiTagType[]) => {
const keyword = searchText.trim().toLowerCase();
if (!keyword) {
await updateTagOrder(sortedVisibleTags);
return;
}
const sortedVisibleTagIds = new Set(sortedVisibleTags.map((tag) => tag._id));
let sortedVisibleIndex = 0;
const nextTags = localTags.map((tag) =>
sortedVisibleTagIds.has(tag._id) ? sortedVisibleTags[sortedVisibleIndex++] : tag
);
await updateTagOrder(nextTags);
};
return (
<MyModal
isOpen
onClose={onClose}
title={t('account_apikey:tag_manage')}
w={'580px'}
h={'600px'}
closeOnOverlayClick={false}
bodyStyles={{
px: 0,
pt: 0,
pb: 0,
overflow: 'hidden'
}}
>
<Flex
alignItems={'center'}
color={'myGray.900'}
pb={2}
borderBottom={'1px solid #E8EBF0'}
mx={8}
pt={6}
>
<MyIcon name="menu" w={5} />
<Box ml={2} fontWeight={'semibold'} flex={'1 0 0'}>
{t('account_apikey:tag_total', {
total: localTags.length
})}
</Box>
<MyInput
placeholder={t('common:Search')}
w={'160px'}
h={8}
mr={2}
onChange={(e) => {
setSearchText(e.target.value);
}}
/>
<Button
size={'sm'}
h={8}
minH={8}
leftIcon={<MyIcon name="common/addLight" w={4} />}
variant={'whitePrimary'}
fontSize={'xs'}
onClick={() => {
setNewTag('');
}}
>
{t('common:new_create')}
</Button>
</Flex>
<Flex px={8} w={'full'}>
{newTag !== undefined && (
<Flex py={3} px={2} w={'full'} borderBottom={'1px solid #E8EBF0'}>
<Input
placeholder={t('account_apikey:tag_name')}
value={newTag}
maxLength={50}
isRequired
onChange={(e) => setNewTag(e.target.value)}
ref={tagInputRef}
w={'200px'}
onBlur={submitCreate}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submitCreate();
}
}}
/>
</Flex>
)}
</Flex>
<Flex
px={8}
flex={'1 0 0'}
fontSize={'sm'}
pb={2}
overflowY={'auto'}
flexDirection={'column'}
>
{filteredTags.length === 0 ? (
<Box py={8} textAlign={'center'} color={'myGray.500'}>
{t('account_apikey:no_tags')}
</Box>
) : (
<DndDrag<OpenApiTagType>
dataList={filteredTags}
onDragEndCb={reorderVisibleTags}
renderInnerPlaceholder={false}
>
{({ provided }) => (
<Flex ref={provided.innerRef} {...provided.droppableProps} flexDirection={'column'}>
{filteredTags.map((tag, index) => (
<Draggable
key={tag._id}
draggableId={tag._id}
index={index}
isDragDisabled={currentEditTag?._id === tag._id}
>
{(provided, snapshot) => (
<Flex
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1
}}
py={2}
borderBottom={'1px solid #E8EBF0'}
sx={{
'&:hover .icon-box': {
display: 'flex'
}
}}
>
<Flex
px={2}
py={1}
flex={'1'}
_hover={{ bg: 'myGray.100' }}
alignItems={'center'}
borderRadius={'xs'}
>
<Flex flex={'1 0 0'} alignItems={'center'} minW={0}>
<Box
{...provided.dragHandleProps}
mr={2}
cursor={'grab'}
lineHeight={1}
flexShrink={0}
>
<MyIcon name="drag" w={4} color={'myGray.400'} />
</Box>
{currentEditTag?._id !== tag._id ? (
<ApiKeyTagBox name={tag.name} />
) : (
<Input
placeholder={t('account_apikey:edit_tag')}
value={
currentEditTagContent !== undefined
? currentEditTagContent
: tag.name
}
onChange={(e) => setCurrentEditTagContent(e.target.value)}
ref={editInputRef}
maxLength={50}
isRequired
w={'200px'}
onBlur={() => submitUpdate(tag)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submitUpdate(tag);
}
}}
/>
)}
{tag.keyCount !== undefined && (
<Box as={'span'} color={'myGray.500'} ml={2}>{`(${
tag.keyCount
})`}</Box>
)}
</Flex>
<>
<Box
className="icon-box"
display="none"
_hover={{ bg: '#1118240D' }}
mr={2}
p={1}
borderRadius={'sm'}
cursor={'pointer'}
onClick={() => {
setCurrentEditTag(tag);
setCurrentEditTagContent(tag.name);
}}
>
<MyIcon name="edit" w={4} />
</Box>
<PopoverConfirm
showCancel
content={t('account_apikey:delete_tag_confirm')}
type="delete"
Trigger={
<Box
className="icon-box"
display="none"
_hover={{ bg: '#1118240D' }}
p={1}
borderRadius={'sm'}
cursor={'pointer'}
>
<MyIcon name="delete" w={4} />
</Box>
}
onConfirm={() => onDeleteTag(tag._id)}
/>
</>
</Flex>
</Flex>
)}
</Draggable>
))}
{provided.placeholder}
</Flex>
)}
</DndDrag>
)}
</Flex>
</MyModal>
);
};
export default React.memo(TagManageModal);
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Checkbox, Flex, Input, type PlacementWithLogical } from '@chakra-ui/react';
import type { OpenApiTagType } from '@fastgpt/global/openapi/support/openapi/tag';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
const TagMultiSelect = ({
tags,
value,
onChange,
label,
placeholder,
onManage,
onCreateTag,
isLoading = false,
showFooter = true,
w = '180px',
Trigger,
renderTrigger,
placement = 'bottom',
popoverW = '180px',
onClose
}: {
tags: OpenApiTagType[];
value: string[];
onChange: (value: string[]) => void;
label?: string;
placeholder?: string;
onManage?: () => void;
onCreateTag?: (name: string) => Promise<OpenApiTagType | void>;
isLoading?: boolean;
showFooter?: boolean;
w?: string | string[];
Trigger?: React.ReactNode;
renderTrigger?: (props: { openSelector: () => void }) => React.ReactNode;
placement?: PlacementWithLogical;
popoverW?: string;
onClose?: (value: string[]) => void | Promise<void>;
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const latestValueRef = useRef(value);
const tagsContainerRef = useRef<HTMLDivElement>(null);
const triggerButtonRef = useRef<HTMLButtonElement>(null);
const [openSelectorSignal, setOpenSelectorSignal] = useState(0);
const [visibleSelectedTags, setVisibleSelectedTags] = useState<OpenApiTagType[]>([]);
const [overflowSelectedTags, setOverflowSelectedTags] = useState<OpenApiTagType[]>([]);
useEffect(() => {
latestValueRef.current = value;
}, [value]);
const emitChange = (nextValue: string[]) => {
latestValueRef.current = nextValue;
onChange(nextValue);
};
const filteredTags = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return tags;
return tags.filter((tag) => tag.name.toLowerCase().includes(keyword));
}, [search, tags]);
const selectedTags = useMemo(
() => value.flatMap((id) => tags.find((tag) => tag._id === id) || []),
[tags, value]
);
const calculateSelectedTagLayout = useCallback(() => {
if (!tagsContainerRef.current || selectedTags.length === 0) {
setVisibleSelectedTags(selectedTags);
setOverflowSelectedTags([]);
return;
}
const containerWidth = tagsContainerRef.current.offsetWidth;
const tagGap = 4;
const overflowIndicatorWidth = 34;
const measureTagWidth = (tag: OpenApiTagType) => {
const estimatedWidth = tag.name.length * 8 + 18;
return Math.min(Math.max(estimatedWidth, 34), 96);
};
if (selectedTags.length === 1) {
setVisibleSelectedTags(selectedTags);
setOverflowSelectedTags([]);
return;
}
let usedWidth = 0;
let visibleCount = 0;
for (let i = 0; i < selectedTags.length; i++) {
const currentTagWidth = measureTagWidth(selectedTags[i]);
const currentGap = i > 0 ? tagGap : 0;
const remainingCount = selectedTags.length - i - 1;
const overflowSpace = remainingCount > 0 ? overflowIndicatorWidth + tagGap : 0;
if (usedWidth + currentTagWidth + currentGap + overflowSpace <= containerWidth) {
usedWidth += currentTagWidth + currentGap;
visibleCount = i + 1;
} else {
break;
}
}
setVisibleSelectedTags(selectedTags.slice(0, Math.max(visibleCount, 1)));
setOverflowSelectedTags(selectedTags.slice(Math.max(visibleCount, 1)));
}, [selectedTags]);
useEffect(() => {
if (!tagsContainerRef.current || typeof ResizeObserver === 'undefined') {
calculateSelectedTagLayout();
return;
}
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(calculateSelectedTagLayout);
});
resizeObserver.observe(tagsContainerRef.current);
requestAnimationFrame(calculateSelectedTagLayout);
return () => {
resizeObserver.disconnect();
};
}, [calculateSelectedTagLayout]);
const onToggle = (tagId: string) => {
const currentValue = latestValueRef.current;
if (currentValue.includes(tagId)) {
emitChange(currentValue.filter((id) => id !== tagId));
} else {
emitChange([...currentValue, tagId]);
}
};
const onClickCreate = async () => {
const name = search.trim();
if (!name || !onCreateTag) return;
const tag = await onCreateTag(name);
const currentValue = latestValueRef.current;
if (tag?._id && !currentValue.includes(tag._id)) {
emitChange([...currentValue, tag._id]);
}
setSearch('');
};
const openSelector = useCallback(() => {
setOpenSelectorSignal((signal) => signal + 1);
}, []);
useEffect(() => {
if (openSelectorSignal === 0) return;
triggerButtonRef.current?.click();
}, [openSelectorSignal]);
const defaultTrigger = (
<Flex
alignItems={'center'}
px={3}
py={2}
w={w}
borderRadius={'md'}
border={'1px solid'}
borderColor={'myGray.250'}
bg={'white'}
cursor={'pointer'}
overflow={'hidden'}
h={['28px', '36px']}
fontSize={'sm'}
_hover={{
boxShadow: '0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)',
borderColor: 'primary.300'
}}
>
{label && (
<>
<Box flexShrink={0} color={'myGray.600'}>
{label}
</Box>
<Box mx={2} w={'1px'} h={'16px'} bg={'myGray.200'} flexShrink={0} />
</>
)}
<Flex
ref={tagsContainerRef}
flex={'1 1 0'}
minW={0}
alignItems={'center'}
gap={1}
overflow={'hidden'}
>
{selectedTags.length === 0 ? (
<Box overflow={'hidden'} textOverflow={'ellipsis'} whiteSpace={'nowrap'}>
{placeholder || t('account_apikey:tags')}
</Box>
) : (
<>
{visibleSelectedTags.map((tag) => (
<Flex
key={tag._id}
alignItems={'center'}
h={5}
px={2}
bg={'white'}
border={'base'}
color={'myGray.900'}
borderRadius={'sm'}
fontSize={'xs'}
flexShrink={0}
maxW={'96px'}
overflow={'hidden'}
>
<Box overflow={'hidden'} textOverflow={'ellipsis'} whiteSpace={'nowrap'} minW={0}>
{tag.name}
</Box>
</Flex>
))}
{overflowSelectedTags.length > 0 && (
<Flex
alignItems={'center'}
h={5}
px={2}
bg={'#1118240D'}
borderRadius={'33px'}
fontSize={'xs'}
color={'myGray.600'}
flexShrink={0}
>
{`+${overflowSelectedTags.length}`}
</Flex>
)}
</>
)}
</Flex>
<MyIcon name={'core/chat/chevronDown'} w={'14px'} flexShrink={0} />
</Flex>
);
const triggerNode = renderTrigger ? (
<Box
as="button"
ref={triggerButtonRef}
type="button"
w={'100%'}
h={'100%'}
p={0}
border={0}
opacity={0}
pointerEvents={'none'}
/>
) : (
Trigger || defaultTrigger
);
const selectorPopover = (
<MyPopover
placement={placement}
hasArrow={false}
offset={[2, 2]}
w={popoverW}
closeOnBlur
trigger={'click'}
onCloseFunc={() => {
setSearch('');
onClose?.(latestValueRef.current);
}}
Trigger={triggerNode}
>
{({ onClose }) => (
<MyBox isLoading={isLoading} onClick={(e) => e.stopPropagation()}>
<Box px={1.5} pt={1.5}>
<Input
pl={2}
h={8}
borderRadius={'xs'}
value={search}
placeholder={t('account_apikey:search_or_add_tag')}
maxLength={50}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickCreate();
}
}}
/>
</Box>
<Box my={1} px={1.5} maxH={'240px'} overflow={'auto'}>
{search.trim() && onCreateTag && !tags.some((tag) => tag.name === search.trim()) && (
<Flex
alignItems={'center'}
fontSize={'sm'}
px={1}
cursor={'pointer'}
_hover={{ bg: '#1118240D', color: 'primary.700' }}
borderRadius={'xs'}
onClick={onClickCreate}
>
<MyIcon name={'common/addLight'} w={'16px'} />
<Box ml={2} py={2}>
{t('account_apikey:create_tag_with_name', {
name: search.trim()
})}
</Box>
</Flex>
)}
{filteredTags.length === 0 ? (
<Box px={1} py={2} color={'myGray.500'} fontSize={'sm'}>
{t('account_apikey:no_tags')}
</Box>
) : (
filteredTags.map((tag) => {
const checked = value.includes(tag._id);
return (
<Flex
alignItems={'center'}
fontSize={'sm'}
px={1}
py={1}
my={1}
cursor={'pointer'}
color={checked ? 'primary.700' : 'myGray.600'}
_hover={{
bg: '#1118240D',
color: 'primary.700',
...(checked ? {} : { svg: { color: '#F3F3F4' } })
}}
borderRadius={'xs'}
key={tag._id}
onClick={(e) => {
e.preventDefault();
onToggle(tag._id);
}}
>
<Checkbox
isChecked={checked}
onClick={(e) => e.stopPropagation()}
onChange={() => onToggle(tag._id)}
size={'md'}
icon={<MyIcon name={'common/check'} w={'12px'} />}
/>
<MyTooltip label={tag.name} showOnlyWhenOverflow>
<Box
ml={2}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{tag.name}
</Box>
</MyTooltip>
</Flex>
);
})
)}
</Box>
{showFooter && (
<Flex borderTop={'1px solid #E8EBF0'} color={'myGray.600'}>
<Button
w={'full'}
fontSize={'sm'}
_hover={{ bg: '#1118240D', color: 'primary.700' }}
borderRadius={'none'}
borderBottomLeftRadius={'md'}
variant={'unstyled'}
onClick={() => {
setSearch('');
emitChange([]);
onClose();
}}
>
{t('account_apikey:cancel_select')}
</Button>
<Box w={'1px'} bg={'myGray.200'} />
<Button
w={'full'}
fontSize={'sm'}
_hover={{ bg: '#1118240D', color: 'primary.700' }}
borderRadius={'none'}
borderBottomRightRadius={'md'}
variant={'unstyled'}
onClick={() => {
onManage?.();
onClose();
}}
>
{t('account_apikey:tag_manage')}
</Button>
</Flex>
)}
</MyBox>
)}
</MyPopover>
);
if (renderTrigger) {
return (
<Box position={'relative'} w={'100%'}>
{renderTrigger({ openSelector })}
<Box position={'absolute'} inset={0} pointerEvents={'none'}>
{selectorPopover}
</Box>
</Box>
);
}
return selectorPopover;
};
export default React.memo(TagMultiSelect);
......@@ -5,5 +5,6 @@ export type GetApiKeyProps = Record<string, never>;
export type EditApiKeyProps = {
name: string;
authProxy?: boolean;
tags?: string[];
limit: OpenApiSchema['limit'];
};
import ApiKeyTable from '@/components/support/apikey/Table';
import { useTranslation } from 'next-i18next';
const API = () => {
const { t } = useTranslation();
return <ApiKeyTable mode="publish" />;
const API = ({ appId }: { appId: string }) => {
return <ApiKeyTable mode="publish" appId={appId} />;
};
export default API;
......@@ -173,7 +173,7 @@ const OutLink = () => {
{linkType === PublishChannelEnum.share && (
<Link appId={appId} type={PublishChannelEnum.share} />
)}
{linkType === PublishChannelEnum.apikey && <API />}
{linkType === PublishChannelEnum.apikey && <API appId={appId} />}
{linkType === PublishChannelEnum.feishu && <FeiShu appId={appId} />}
{linkType === PublishChannelEnum.dingtalk && <DingTalk appId={appId} />}
{linkType === PublishChannelEnum.wecom && <Wecom appId={appId} />}
......
import { NextAPI } from '@/service/middleware/entry';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { getLogger } from '@fastgpt/service/common/logger';
import { Types, type AnyBulkWriteOperation } from '@fastgpt/service/common/mongo';
import type { OpenApiSchema } from '@fastgpt/global/support/openapi/type';
const logger = getLogger(['initv4151']);
const BATCH_SIZE = 500;
export type ResponseType = {
message: string;
scannedRecords: number;
updatedRecords: number;
skippedInvalidAppId: number;
skippedMissingApp: number;
};
type OpenApiAppNameMigrationItem = Pick<OpenApiSchema, '_id' | 'appId' | 'appName'>;
/**
* 为历史应用级 API Key 回填应用名快照。
*
* 只处理有 appId 且 appName 为空的记录,避免覆盖已经生成过的历史展示值。
*/
export async function migrateOpenApiAppNames(): Promise<Omit<ResponseType, 'message'>> {
let lastId: string | undefined;
let scannedRecords = 0;
let updatedRecords = 0;
let skippedInvalidAppId = 0;
let skippedMissingApp = 0;
while (true) {
const openApis = (await MongoOpenApi.find(
{
...(lastId
? {
_id: {
$gt: lastId
}
}
: {}),
appId: {
$exists: true,
$nin: ['', null]
},
$or: [{ appName: { $exists: false } }, { appName: '' }, { appName: null }]
},
{
_id: 1,
appId: 1,
appName: 1
}
)
.sort({ _id: 1 })
.limit(BATCH_SIZE)
.lean()) as OpenApiAppNameMigrationItem[];
if (openApis.length === 0) {
break;
}
scannedRecords += openApis.length;
lastId = String(openApis[openApis.length - 1]._id);
const validAppIds = Array.from(
new Set(
openApis.flatMap((item) => {
const appId = String(item.appId || '');
if (!Types.ObjectId.isValid(appId)) {
skippedInvalidAppId += 1;
return [];
}
return [appId];
})
)
);
if (validAppIds.length === 0) {
continue;
}
const apps = await MongoApp.find(
{
_id: {
$in: validAppIds
}
},
{
_id: 1,
name: 1
}
).lean();
const appNameMap = new Map(apps.map((app) => [String(app._id), app.name]));
const ops: AnyBulkWriteOperation<OpenApiSchema>[] = [];
for (const item of openApis) {
const appId = String(item.appId || '');
if (!Types.ObjectId.isValid(appId)) {
continue;
}
const appName = appNameMap.get(appId);
if (!appName) {
skippedMissingApp += 1;
continue;
}
ops.push({
updateOne: {
filter: {
_id: item._id,
$or: [{ appName: { $exists: false } }, { appName: '' }, { appName: null }]
},
update: {
$set: {
appName
}
}
}
});
}
if (ops.length > 0) {
const result = await MongoOpenApi.bulkWrite(ops, {
ordered: false
});
updatedRecords += result.modifiedCount;
logger.info(`[initv4151] Updated ${result.modifiedCount} OpenAPI app names`);
}
}
return {
scannedRecords,
updatedRecords,
skippedInvalidAppId,
skippedMissingApp
};
}
/**
* 4.15.1 版本数据初始化脚本
* 1. 为历史带 appId 的 API Key 自动生成 appName 展示快照
*/
async function handler(req: ApiRequestProps): Promise<ResponseType> {
await authCert({ req, authRoot: true });
const result = await migrateOpenApiAppNames();
return {
message: `Completed v4.15.1 initialization: Updated ${result.updatedRecords} OpenAPI app names`,
...result
};
}
export default NextAPI(handler);
......@@ -10,6 +10,7 @@ import { addAuditLog } from '@fastgpt/service/support/user/audit/util';
import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { appEnv } from '@/env';
import { validateOpenApiTags } from '@fastgpt/service/support/openapi/tag/service';
import {
CreateApiKeyBodySchema,
CreateApiKeyResponseSchema,
......@@ -23,7 +24,8 @@ async function handler(
const {
name,
limit,
authProxy = false
authProxy = false,
tags
} = parseApiInput({
req,
bodySchema: CreateApiKeyBodySchema
......@@ -46,6 +48,13 @@ async function handler(
const nanoid = getNanoid(Math.floor(Math.random() * 14) + 52);
const apiKey = `${global.systemEnv?.openapiPrefix || 'fastgpt'}-${nanoid}`;
const tagIds = tags
? await validateOpenApiTags({
teamId,
tmbId,
tags
})
: [];
await MongoOpenApi.create({
teamId,
......@@ -53,6 +62,7 @@ async function handler(
apiKey,
authProxy,
name,
tagIds,
limit
});
......
......@@ -24,6 +24,8 @@ export async function handler(req: ApiRequestProps): Promise<ApiKeyHealthRespons
return ApiKeyHealthResponseSchema.parse({
valid: true,
usagePoints: apiKeyDoc.usagePoints ?? 0,
maxUsagePoints: apiKeyDoc.limit?.maxUsagePoints ?? -1,
appId: apiKeyDoc.appId
});
}
......
......@@ -3,20 +3,109 @@ import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { getOpenApiTagMap } from '@fastgpt/service/support/openapi/tag/service';
import {
GetApiKeyListQuerySchema,
GetApiKeyListResponseSchema,
type ApiKeyListSortByType,
type GetApiKeyListQueryType,
type GetApiKeyListResponseType
} from '@fastgpt/global/openapi/support/openapi/api';
import type { OpenApiSchema } from '@fastgpt/global/support/openapi/type';
type OpenApiListItem = OpenApiSchema & {
canCopy: boolean;
};
const maskApiKey = (apiKey: string) => `******${apiKey.substring(apiKey.length - 4)}`;
const getRemainingPoints = (item: Pick<OpenApiSchema, 'limit' | 'usagePoints'>) => {
const maxUsagePoints = item.limit?.maxUsagePoints ?? -1;
if (maxUsagePoints < 0) {
return Number.POSITIVE_INFINITY;
}
return maxUsagePoints - (item.usagePoints ?? 0);
};
const getSortValue = (item: OpenApiSchema, sortBy: ApiKeyListSortByType) => {
if (sortBy === 'lastUsedTime') {
return item.lastUsedTime ? new Date(item.lastUsedTime).getTime() : 0;
}
if (sortBy === 'remainingPoints') {
return getRemainingPoints(item);
}
return new Date(item.createTime).getTime();
};
const compareSortValue = (a: OpenApiSchema, b: OpenApiSchema, sortBy: ApiKeyListSortByType) => {
const aSortValue = getSortValue(a, sortBy);
const bSortValue = getSortValue(b, sortBy);
if (aSortValue === bSortValue) {
return 0;
}
// 剩余积分排序用于优先发现即将耗尽的 Key,因此按小到大排列,不限量排最后。
if (sortBy === 'remainingPoints') {
return aSortValue > bSortValue ? 1 : -1;
}
return aSortValue > bSortValue ? -1 : 1;
};
const sortOpenApiList = ({
list,
appId,
sortBy
}: {
list: OpenApiListItem[];
appId?: string;
sortBy: ApiKeyListSortByType;
}) =>
list.sort((a, b) => {
const aAppMatched = appId && String(a.appId || '') === appId ? 1 : 0;
const bAppMatched = appId && String(b.appId || '') === appId ? 1 : 0;
if (aAppMatched !== bAppMatched) {
return bAppMatched - aAppMatched;
}
const sortValueDiff = compareSortValue(a, b, sortBy);
if (sortValueDiff !== 0) {
return sortValueDiff;
}
return String(b._id).localeCompare(String(a._id));
});
const filterOpenApiListByKeyword = (list: OpenApiSchema[], keyword?: string) => {
if (!keyword) {
return list;
}
const normalizedKeyword = keyword.toLowerCase();
return list.filter((item) => {
if (item.name.toLowerCase().includes(normalizedKeyword)) {
return true;
}
// API Key 片段过滤在内存中完成,避免把用户输入的 Key 片段写入 Mongo 查询和慢查询日志。
return item.apiKey.toLowerCase().includes(normalizedKeyword);
});
};
async function handler(
req: ApiRequestProps<any, GetApiKeyListQueryType>
): Promise<GetApiKeyListResponseType> {
parseApiInput({
const { keyword, tags, appId, sortBy } = parseApiInput({
req,
querySchema: GetApiKeyListQuerySchema
});
}).query;
const { teamId, tmbId } = await authUserPer({
req,
authToken: true
......@@ -24,15 +113,40 @@ async function handler(
const findResponse = await MongoOpenApi.find({
teamId,
tmbId
}).sort({ _id: -1 });
tmbId,
...(tags && tags.length > 0
? {
tagIds: {
$all: tags
}
}
: {})
})
.sort({ _id: -1 })
.lean();
return GetApiKeyListResponseSchema.parse(
findResponse.map((item) => ({
...item.toObject({ getters: true }),
const openApis = filterOpenApiListByKeyword(findResponse as OpenApiSchema[], keyword);
const tagMap = await getOpenApiTagMap({
teamId,
tmbId,
tagIds: openApis.flatMap((item) => item.tagIds || [])
});
const responseList = sortOpenApiList({
list: openApis.map((item) => ({
...item,
apiKey: maskApiKey(item.apiKey),
tagIds: item.tagIds || [],
tags: (item.tagIds || []).flatMap((tagId) => {
const tag = tagMap.get(String(tagId));
return tag ? [tag] : [];
}),
canCopy: true
}))
);
})),
appId,
sortBy
});
return GetApiKeyListResponseSchema.parse(responseList);
}
export default NextAPI(handler);
import { NextAPI } from '@/service/middleware/entry';
import {
CreateOpenApiTagBodySchema,
CreateOpenApiTagResponseSchema,
type CreateOpenApiTagBodyType,
type CreateOpenApiTagResponseType
} from '@fastgpt/global/openapi/support/openapi/tag';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { createOpenApiTag } from '@fastgpt/service/support/openapi/tag/service';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
async function handler(
req: ApiRequestProps<CreateOpenApiTagBodyType>
): Promise<CreateOpenApiTagResponseType> {
const { name } = parseApiInput({
req,
bodySchema: CreateOpenApiTagBodySchema
}).body;
const { teamId, tmbId } = await authUserPer({
req,
authToken: true
});
const tag = await createOpenApiTag({
teamId,
tmbId,
name
});
return CreateOpenApiTagResponseSchema.parse(tag);
}
export default NextAPI(handler);
import { NextAPI } from '@/service/middleware/entry';
import {
DeleteOpenApiTagQuerySchema,
DeleteOpenApiTagResponseSchema,
type DeleteOpenApiTagQueryType,
type DeleteOpenApiTagResponseType
} from '@fastgpt/global/openapi/support/openapi/tag';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { deleteOpenApiTag } from '@fastgpt/service/support/openapi/tag/service';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
async function handler(
req: ApiRequestProps<Record<string, never>, DeleteOpenApiTagQueryType>
): Promise<DeleteOpenApiTagResponseType> {
const { tagId } = parseApiInput({
req,
querySchema: DeleteOpenApiTagQuerySchema
}).query;
const { teamId, tmbId } = await authUserPer({
req,
authToken: true
});
await deleteOpenApiTag({
teamId,
tmbId,
tagId
});
return DeleteOpenApiTagResponseSchema.parse(undefined);
}
export default NextAPI(handler);
import { NextAPI } from '@/service/middleware/entry';
import {
GetOpenApiTagListQuerySchema,
GetOpenApiTagListResponseSchema,
type GetOpenApiTagListQueryType,
type GetOpenApiTagListResponseType
} from '@fastgpt/global/openapi/support/openapi/tag';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { listOpenApiTags } from '@fastgpt/service/support/openapi/tag/service';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
async function handler(
req: ApiRequestProps<Record<string, never>, GetOpenApiTagListQueryType>
): Promise<GetOpenApiTagListResponseType> {
const { withKeyCount } = parseApiInput({
req,
querySchema: GetOpenApiTagListQuerySchema
}).query;
const { teamId, tmbId } = await authUserPer({
req,
authToken: true
});
const tags = await listOpenApiTags({
teamId,
tmbId,
withKeyCount
});
return GetOpenApiTagListResponseSchema.parse(tags);
}
export default NextAPI(handler);
import { NextAPI } from '@/service/middleware/entry';
import {
UpdateOpenApiTagBodySchema,
UpdateOpenApiTagResponseSchema,
type UpdateOpenApiTagBodyType,
type UpdateOpenApiTagResponseType
} from '@fastgpt/global/openapi/support/openapi/tag';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { updateOpenApiTag } from '@fastgpt/service/support/openapi/tag/service';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
async function handler(
req: ApiRequestProps<UpdateOpenApiTagBodyType>
): Promise<UpdateOpenApiTagResponseType> {
const { tagId, name, order } = parseApiInput({
req,
bodySchema: UpdateOpenApiTagBodySchema
}).body;
const { teamId, tmbId } = await authUserPer({
req,
authToken: true
});
await updateOpenApiTag({
teamId,
tmbId,
tagId,
name,
order
});
return UpdateOpenApiTagResponseSchema.parse(undefined);
}
export default NextAPI(handler);
......@@ -7,6 +7,7 @@ import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { OpenApiErrEnum } from '@fastgpt/global/common/error/code/openapi';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { validateOpenApiTags } from '@fastgpt/service/support/openapi/tag/service';
import {
UpdateApiKeyBodySchema,
UpdateApiKeyResponseSchema,
......@@ -17,7 +18,7 @@ import {
async function handler(
req: ApiRequestProps<UpdateApiKeyBodyType>
): Promise<UpdateApiKeyResponseType> {
const { _id, name, limit, authProxy } = parseApiInput({
const { _id, name, limit, authProxy, tags } = parseApiInput({
req,
bodySchema: UpdateApiKeyBodySchema
}).body;
......@@ -39,6 +40,15 @@ async function handler(
}
}
const tagIds =
tags !== undefined
? await validateOpenApiTags({
teamId,
tmbId,
tags
})
: undefined;
(async () => {
addAuditLog({
tmbId,
......@@ -53,7 +63,8 @@ async function handler(
await MongoOpenApi.findByIdAndUpdate(_id, {
...(name && { name }),
...(limit && { limit }),
...(authProxy !== undefined && { authProxy })
...(authProxy !== undefined && { authProxy }),
...(tagIds !== undefined && { tagIds })
});
return UpdateApiKeyResponseSchema.parse(undefined);
......
......@@ -84,6 +84,7 @@ export async function getServerSideProps(context: any) {
'user',
'file',
'publish',
'account_apikey',
'workflow',
'skill'
]))
......
......@@ -11,6 +11,16 @@ import type {
UpdateApiKeyBodyType,
UpdateApiKeyResponseType
} from '@fastgpt/global/openapi/support/openapi/api';
import type {
CreateOpenApiTagBodyType,
CreateOpenApiTagResponseType,
DeleteOpenApiTagQueryType,
DeleteOpenApiTagResponseType,
GetOpenApiTagListQueryType,
GetOpenApiTagListResponseType,
UpdateOpenApiTagBodyType,
UpdateOpenApiTagResponseType
} from '@fastgpt/global/openapi/support/openapi/tag';
/**
* crete a api key
......@@ -27,8 +37,14 @@ export const putOpenApiKey = (data: UpdateApiKeyBodyType) =>
/**
* get api keys
*/
export const getOpenApiKeys = (params?: GetApiKeyListQueryType) =>
GET<GetApiKeyListResponseType>('/support/openapi/list', params);
export const getOpenApiKeys = (params?: GetApiKeyListQueryType) => {
const { tags, ...rest } = params || {};
return GET<GetApiKeyListResponseType>('/support/openapi/list', {
...rest,
tags: tags && tags.length > 0 ? tags.join(',') : undefined
});
};
/**
* copy api key and record audit
......@@ -41,3 +57,15 @@ export const copyOpenApiKey = (data: CopyApiKeyBodyType) =>
*/
export const delOpenApiById = (id: DeleteApiKeyQueryType['id']) =>
DELETE<DeleteApiKeyResponseType>(`/support/openapi/delete`, { id });
export const getOpenApiTags = (params?: GetOpenApiTagListQueryType) =>
GET<GetOpenApiTagListResponseType>('/support/openapi/tag/list', params);
export const createOpenApiTag = (data: CreateOpenApiTagBodyType) =>
POST<CreateOpenApiTagResponseType>('/support/openapi/tag/create', data);
export const updateOpenApiTag = (data: UpdateOpenApiTagBodyType) =>
PUT<UpdateOpenApiTagResponseType>('/support/openapi/tag/update', data);
export const deleteOpenApiTag = (tagId: DeleteOpenApiTagQueryType['tagId']) =>
DELETE<DeleteOpenApiTagResponseType>('/support/openapi/tag/delete', { tagId });
import handler from '@/pages/api/admin/initv4151';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { Types } from '@fastgpt/service/common/mongo';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { getRootUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it } from 'vitest';
describe('admin/initv4151', () => {
it('为有 appId 且缺少 appName 的历史 APIKey 回填应用名', async () => {
const user = await getRootUser();
const app = await MongoApp.create({
teamId: user.teamId,
tmbId: user.tmbId,
name: '历史应用',
type: AppTypeEnum.simple
});
const missingAppId = String(new Types.ObjectId());
await MongoOpenApi.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
apiKey: 'fastgpt-legacy-app-key',
name: 'legacy app key'
},
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
appName: '已有快照',
apiKey: 'fastgpt-existing-appname-key',
name: 'existing appName key'
},
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: missingAppId,
apiKey: 'fastgpt-missing-app-key',
name: 'missing app key'
},
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: 'invalid-app-id',
apiKey: 'fastgpt-invalid-appid-key',
name: 'invalid appId key'
}
]);
const result = await Call(handler, {
auth: user
});
expect(result.code).toBe(200);
expect(result.data.updatedRecords).toBe(1);
expect(result.data.skippedMissingApp).toBe(1);
expect(result.data.skippedInvalidAppId).toBe(1);
const migrated = await MongoOpenApi.findOne({ name: 'legacy app key' }).lean();
const existing = await MongoOpenApi.findOne({ name: 'existing appName key' }).lean();
const missing = await MongoOpenApi.findOne({ name: 'missing app key' }).lean();
const invalid = await MongoOpenApi.findOne({ name: 'invalid appId key' }).lean();
expect(migrated?.appName).toBe('历史应用');
expect(existing?.appName).toBe('已有快照');
expect(missing?.appName).toBeUndefined();
expect(invalid?.appName).toBeUndefined();
});
});
......@@ -3,6 +3,7 @@ import * as createapi from '@/pages/api/support/openapi/create';
import { TeamApikeyCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { MongoOpenApiTag } from '@fastgpt/service/support/openapi/tag/schema';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { getFakeUsers } from '@test/datas/users';
import { Call } from '@test/utils/request';
......@@ -196,4 +197,121 @@ describe('support/openapi/create', () => {
expect(secondRes.code).toBe(500);
expect(await MongoOpenApi.findOne({ name: 'second env limited key' })).toBeNull();
});
it('limits APIKey name to 50 chars', async () => {
const users = await getFakeUsers(1);
const allowedName = 'a'.repeat(50);
const rejectedName = 'b'.repeat(51);
const allowed = await Call<EditApiKeyProps>(createapi.default, {
auth: users.owner,
body: {
name: allowedName,
limit: {
maxUsagePoints: 1000
}
}
});
const rejected = await Call<EditApiKeyProps>(createapi.default, {
auth: users.owner,
body: {
name: rejectedName,
limit: {
maxUsagePoints: 1000
}
}
});
expect(allowed.code).toBe(200);
expect(rejected.code).toBe(500);
expect(await MongoOpenApi.findOne({ name: allowedName })).not.toBeNull();
expect(await MongoOpenApi.findOne({ name: rejectedName })).toBeNull();
});
it('rejects empty APIKey name after trimming', async () => {
const users = await getFakeUsers(1);
const res = await Call<EditApiKeyProps>(createapi.default, {
auth: users.owner,
body: {
name: ' ',
limit: {
maxUsagePoints: 1000
}
}
});
expect(res.code).toBe(500);
expect(await MongoOpenApi.findOne({ name: ' ' })).toBeNull();
});
it('creates APIKey with tags that belong to current member', async () => {
const users = await getFakeUsers(1);
const [member] = users.members;
await MongoResourcePermission.create({
resourceType: 'team',
teamId: member.teamId,
resourceId: null,
tmbId: member.tmbId,
permission: TeamApikeyCreatePermissionVal
});
const tag = await MongoOpenApiTag.create({
teamId: member.teamId,
tmbId: member.tmbId,
name: '生产',
normalizedName: '生产',
type: 'custom',
order: 100
});
const res = await Call<EditApiKeyProps>(createapi.default, {
auth: member,
body: {
name: 'tagged key',
tags: [String(tag._id)],
limit: {
maxUsagePoints: 1000
}
} as EditApiKeyProps
});
expect(res.code).toBe(200);
const openapi = await MongoOpenApi.findOne({ name: 'tagged key' }).lean();
expect((openapi?.tagIds || []).map(String)).toEqual([String(tag._id)]);
});
it('rejects creating APIKey with tags from another member', async () => {
const users = await getFakeUsers(1);
const [member] = users.members;
await MongoResourcePermission.create({
resourceType: 'team',
teamId: member.teamId,
resourceId: null,
tmbId: member.tmbId,
permission: TeamApikeyCreatePermissionVal
});
const ownerTag = await MongoOpenApiTag.create({
teamId: users.owner.teamId,
tmbId: users.owner.tmbId,
name: 'owner tag',
normalizedName: 'owner tag',
type: 'custom',
order: 100
});
const res = await Call<EditApiKeyProps>(createapi.default, {
auth: member,
body: {
name: 'invalid tagged key',
tags: [String(ownerTag._id)],
limit: {
maxUsagePoints: 1000
}
} as EditApiKeyProps
});
expect(res.code).toBe(500);
expect(await MongoOpenApi.findOne({ name: 'invalid tagged key' })).toBeNull();
});
});
......@@ -19,7 +19,11 @@ describe('support/openapi/health', () => {
tmbId: user.tmbId,
appId: String(app._id),
apiKey: 'fastgpt-legacy-app-key',
name: 'legacy app key'
name: 'legacy app key',
usagePoints: 25,
limit: {
maxUsagePoints: 1000
}
});
const result = await handler({
......@@ -30,6 +34,8 @@ describe('support/openapi/health', () => {
expect(result).toEqual({
valid: true,
usagePoints: 25,
maxUsagePoints: 1000,
appId: String(app._id)
});
});
......@@ -50,7 +56,9 @@ describe('support/openapi/health', () => {
} as any);
expect(result).toEqual({
valid: true
valid: true,
usagePoints: 0,
maxUsagePoints: -1
});
});
});
......@@ -5,6 +5,7 @@ import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { getFakeUsers, getRootUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { MongoOpenApiTag } from '@fastgpt/service/support/openapi/tag/schema';
describe('support/openapi/list', () => {
it('团队级 APIKey 列表对有复制权限的记录返回脱敏值和复制权限', async () => {
......@@ -57,7 +58,7 @@ describe('support/openapi/list', () => {
expect(result.data[0].canCopy).toBe(true);
});
it('旧 appId 查询参数被忽略,只返回本人 APIKey', async () => {
it('旧 appId 查询参数只用于置顶排序,不扩大可见范围', async () => {
const { owner, members } = await getFakeUsers(1);
const [member] = members;
const app = await MongoApp.create({
......@@ -71,11 +72,19 @@ describe('support/openapi/list', () => {
{
teamId: owner.teamId,
tmbId: owner.tmbId,
createTime: new Date('2024-01-01T00:00:00.000Z'),
appId: String(app._id),
apiKey: 'fastgpt-app-secret',
name: 'legacy app key'
},
{
teamId: owner.teamId,
tmbId: owner.tmbId,
createTime: new Date('2025-01-01T00:00:00.000Z'),
apiKey: 'fastgpt-new-global-secret',
name: 'new global key'
},
{
teamId: member.teamId,
tmbId: member.tmbId,
appId: String(app._id),
......@@ -92,11 +101,12 @@ describe('support/openapi/list', () => {
});
expect(result.code).toBe(200);
expect(result.data).toHaveLength(1);
expect(result.data).toHaveLength(2);
expect(result.data[0].name).toBe('legacy app key');
expect(result.data[0].apiKey).toBe('******cret');
expect(result.data[0].canCopy).toBe(true);
expect(result.data[0].authProxy).toBe(false);
expect(result.data[1].name).toBe('new global key');
});
it('团队级 APIKey 列表返回 authProxy 状态', async () => {
......@@ -117,4 +127,286 @@ describe('support/openapi/list', () => {
expect(result.data).toHaveLength(1);
expect(result.data[0].authProxy).toBe(true);
});
it('returns tags and filters APIKeys by keyword and tags', async () => {
const user = await getRootUser();
const [prodTag, customerTag] = await MongoOpenApiTag.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
name: '生产环境',
normalizedName: '生产环境',
type: 'custom',
order: 100
},
{
teamId: user.teamId,
tmbId: user.tmbId,
name: '客户 A',
normalizedName: '客户 a',
type: 'custom',
order: 101
}
]);
await MongoOpenApi.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-production-secret',
name: 'production customer key',
tagIds: [prodTag._id, customerTag._id]
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-debug-secret',
name: 'debug key',
tagIds: [prodTag._id]
}
]);
const result = await Call(handler, {
auth: user,
query: {
keyword: 'customer',
tags: [String(prodTag._id), String(customerTag._id)]
}
});
expect(result.code).toBe(200);
expect(result.data).toHaveLength(1);
expect(result.data[0].name).toBe('production customer key');
expect(result.data[0].tagIds).toEqual([String(prodTag._id), String(customerTag._id)]);
expect(result.data[0].tags.map((tag) => tag.name)).toEqual(['生产环境', '客户 A']);
const commaQueryResult = await Call(handler, {
auth: user,
query: {
tags: `${prodTag._id},${customerTag._id}`
}
});
expect(commaQueryResult.code).toBe(200);
expect(commaQueryResult.data.map((item) => item.name)).toEqual(['production customer key']);
});
it('filters APIKeys by key value keyword contains match', async () => {
const user = await getRootUser();
await MongoOpenApi.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-search-value-secret',
name: 'normal name'
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-other-secret',
name: 'other name'
}
]);
const result = await Call(handler, {
auth: user,
query: {
keyword: 'search-value'
}
});
expect(result.code).toBe(200);
expect(result.data).toHaveLength(1);
expect(result.data[0].name).toBe('normal name');
expect(result.data[0].apiKey).toBe('******cret');
const discontinuousResult = await Call(handler, {
auth: user,
query: {
keyword: 'searchsecret'
}
});
expect(discontinuousResult.code).toBe(200);
expect(discontinuousResult.data).toHaveLength(0);
const shortKeyFragmentResult = await Call(handler, {
auth: user,
query: {
keyword: 'val'
}
});
expect(shortKeyFragmentResult.code).toBe(200);
expect(shortKeyFragmentResult.data).toHaveLength(1);
expect(shortKeyFragmentResult.data[0].name).toBe('normal name');
});
it('returns persisted appName snapshot without filling missing appName on list', async () => {
const user = await getRootUser();
const app = await MongoApp.create({
teamId: user.teamId,
tmbId: user.tmbId,
name: '历史应用',
type: AppTypeEnum.simple
});
const openapi = await MongoOpenApi.create({
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
apiKey: 'fastgpt-legacy-appname-secret',
name: 'legacy appName key'
});
await MongoOpenApi.create({
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
appName: '已有快照',
apiKey: 'fastgpt-existing-appname-secret',
name: 'existing appName key'
});
const result = await Call(handler, {
auth: user
});
expect(result.code).toBe(200);
expect(result.data.find((item) => item.name === 'legacy appName key')?.appName).toBeUndefined();
expect(result.data.find((item) => item.name === 'existing appName key')?.appName).toBe(
'已有快照'
);
const updated = await MongoOpenApi.findById(openapi._id).lean();
expect(updated?.appName).toBeUndefined();
});
it('sorts by last used time with appId priority first', async () => {
const user = await getRootUser();
const app = await MongoApp.create({
teamId: user.teamId,
tmbId: user.tmbId,
name: '排序应用',
type: AppTypeEnum.simple
});
await MongoOpenApi.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
apiKey: 'fastgpt-app-old-used',
name: 'app old used',
lastUsedTime: new Date('2024-01-01T00:00:00.000Z')
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-global-new-used',
name: 'global new used',
lastUsedTime: new Date('2026-01-01T00:00:00.000Z')
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-global-middle-used',
name: 'global middle used',
lastUsedTime: new Date('2025-01-01T00:00:00.000Z')
}
]);
const result = await Call(handler, {
auth: user,
query: {
appId: String(app._id),
sortBy: 'lastUsedTime'
}
});
expect(result.code).toBe(200);
expect(result.data.map((item) => item.name)).toEqual([
'app old used',
'global new used',
'global middle used'
]);
});
it('sorts by remaining points ascending with unlimited keys last', async () => {
const user = await getRootUser();
const app = await MongoApp.create({
teamId: user.teamId,
tmbId: user.tmbId,
name: '积分排序应用',
type: AppTypeEnum.simple
});
await MongoOpenApi.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
apiKey: 'fastgpt-app-limited',
name: 'app limited remaining',
usagePoints: 95,
limit: {
maxUsagePoints: 100
}
},
{
teamId: user.teamId,
tmbId: user.tmbId,
appId: String(app._id),
apiKey: 'fastgpt-app-unlimited',
name: 'app unlimited remaining',
usagePoints: 999,
limit: {
maxUsagePoints: -1
}
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-low-remaining',
name: 'low remaining',
usagePoints: 90,
limit: {
maxUsagePoints: 100
}
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-high-remaining',
name: 'high remaining',
usagePoints: 10,
limit: {
maxUsagePoints: 100
}
},
{
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-unlimited',
name: 'unlimited remaining',
usagePoints: 999,
limit: {
maxUsagePoints: -1
}
}
]);
const result = await Call(handler, {
auth: user,
query: {
appId: String(app._id),
sortBy: 'remainingPoints'
}
});
expect(result.code).toBe(200);
expect(result.data.map((item) => item.name)).toEqual([
'app limited remaining',
'app unlimited remaining',
'low remaining',
'high remaining',
'unlimited remaining'
]);
});
});
import createHandler from '@/pages/api/support/openapi/tag/create';
import deleteHandler from '@/pages/api/support/openapi/tag/delete';
import listHandler from '@/pages/api/support/openapi/tag/list';
import updateHandler from '@/pages/api/support/openapi/tag/update';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { MongoOpenApiTag } from '@fastgpt/service/support/openapi/tag/schema';
import { getFakeUsers, getRootUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it } from 'vitest';
describe('support/openapi/tag', () => {
it('does not create default system tags when listing tags', async () => {
const user = await getRootUser();
const first = await Call(listHandler, {
auth: user
});
const second = await Call(listHandler, {
auth: user
});
expect(first.code).toBe(200);
expect(second.code).toBe(200);
expect(first.data).toEqual([]);
expect(second.data).toEqual([]);
expect(await MongoOpenApiTag.countDocuments({ teamId: user.teamId, tmbId: user.tmbId })).toBe(
0
);
});
it('creates custom tags and rejects duplicate names for the same member', async () => {
const { owner, members } = await getFakeUsers(1);
const [member] = members;
const created = await Call(createHandler, {
auth: owner,
body: {
name: '客户 A'
}
});
const duplicated = await Call(createHandler, {
auth: owner,
body: {
name: ' 客户 A '
}
});
const sameNameForOtherMember = await Call(createHandler, {
auth: member,
body: {
name: '客户 A'
}
});
expect(created.code).toBe(200);
expect(created.data.name).toBe('客户 A');
expect(created.data.type).toBe('custom');
expect(duplicated.code).toBe(500);
expect(sameNameForOtherMember.code).toBe(200);
});
it('puts newly created tags at the beginning of the tag list', async () => {
const user = await getRootUser();
const created = await Call(createHandler, {
auth: user,
body: {
name: '新建在最前'
}
});
const list = await Call(listHandler, {
auth: user
});
expect(created.code).toBe(200);
expect(list.code).toBe(200);
expect(list.data[0]._id).toBe(created.data._id);
expect(list.data[0].name).toBe('新建在最前');
});
it('limits tag name to 50 chars', async () => {
const user = await getRootUser();
const allowedName = 'a'.repeat(50);
const rejectedName = 'b'.repeat(51);
const created = await Call(createHandler, {
auth: user,
body: {
name: allowedName
}
});
const rejectedCreate = await Call(createHandler, {
auth: user,
body: {
name: rejectedName
}
});
const rejectedUpdate = await Call(updateHandler, {
auth: user,
body: {
tagId: created.data._id,
name: rejectedName
}
});
expect(created.code).toBe(200);
expect(rejectedCreate.code).toBe(500);
expect(rejectedUpdate.code).toBe(500);
expect(await MongoOpenApiTag.findOne({ name: allowedName })).not.toBeNull();
expect(await MongoOpenApiTag.findOne({ name: rejectedName })).toBeNull();
});
it('rejects empty tag name after trimming', async () => {
const user = await getRootUser();
const rejectedCreate = await Call(createHandler, {
auth: user,
body: {
name: ' '
}
});
expect(rejectedCreate.code).toBe(500);
expect(await MongoOpenApiTag.findOne({ name: ' ' })).toBeNull();
});
it('updates custom tags and treats historical system tags as normal tags', async () => {
const user = await getRootUser();
const [systemTag] = await MongoOpenApiTag.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
name: '历史系统标签',
normalizedName: '历史系统标签',
type: 'system',
order: 1
}
]);
const customTag = await Call(createHandler, {
auth: user,
body: {
name: '临时客户'
}
});
const updateCustom = await Call(updateHandler, {
auth: user,
body: {
tagId: customTag.data._id,
name: '客户 B',
order: 5
}
});
const updateSystem = await Call(updateHandler, {
auth: user,
body: {
tagId: String(systemTag._id),
name: '历史标签改名'
}
});
expect(updateCustom.code).toBe(200);
expect(updateSystem.code).toBe(200);
const updated = await MongoOpenApiTag.findById(customTag.data._id).lean();
const updatedSystem = await MongoOpenApiTag.findById(systemTag._id).lean();
expect(updated?.name).toBe('客户 B');
expect(updated?.order).toBe(5);
expect(updatedSystem?.name).toBe('历史标签改名');
});
it('deletes historical system tags and unbinds them from APIKeys', async () => {
const user = await getRootUser();
const [systemTag] = await MongoOpenApiTag.create([
{
teamId: user.teamId,
tmbId: user.tmbId,
name: '历史系统标签',
normalizedName: '历史系统标签',
type: 'system',
order: 1
}
]);
await MongoOpenApi.create({
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-system-tagged',
name: 'system tagged',
tagIds: [systemTag._id]
});
const deleted = await Call(deleteHandler, {
auth: user,
query: {
tagId: String(systemTag._id)
}
});
expect(deleted.code).toBe(200);
expect(await MongoOpenApiTag.findById(systemTag._id)).toBeNull();
const key = await MongoOpenApi.findOne({ name: 'system tagged' }).lean();
expect(key?.tagIds || []).toHaveLength(0);
});
it('deletes custom tags and unbinds them from current member APIKeys', async () => {
const { owner, members } = await getFakeUsers(1);
const [member] = members;
const ownerTag = await Call(createHandler, {
auth: owner,
body: {
name: '待删除'
}
});
const memberTag = await Call(createHandler, {
auth: member,
body: {
name: '待删除'
}
});
await MongoOpenApi.create([
{
teamId: owner.teamId,
tmbId: owner.tmbId,
apiKey: 'fastgpt-owner-tagged',
name: 'owner tagged',
tagIds: [ownerTag.data._id]
},
{
teamId: member.teamId,
tmbId: member.tmbId,
apiKey: 'fastgpt-member-tagged',
name: 'member tagged',
tagIds: [memberTag.data._id]
}
]);
const deleted = await Call(deleteHandler, {
auth: owner,
query: {
tagId: ownerTag.data._id
}
});
expect(deleted.code).toBe(200);
expect(await MongoOpenApiTag.findById(ownerTag.data._id)).toBeNull();
const ownerKey = await MongoOpenApi.findOne({ name: 'owner tagged' }).lean();
const memberKey = await MongoOpenApi.findOne({ name: 'member tagged' }).lean();
expect(ownerKey?.tagIds || []).toHaveLength(0);
expect((memberKey?.tagIds || []).map(String)).toEqual([memberTag.data._id]);
});
it('returns key count when requested', async () => {
const user = await getRootUser();
const tag = await Call(createHandler, {
auth: user,
body: {
name: '统计'
}
});
await MongoOpenApi.create({
teamId: user.teamId,
tmbId: user.tmbId,
apiKey: 'fastgpt-count-tagged',
name: 'count tagged',
tagIds: [tag.data._id]
});
const list = await Call(listHandler, {
auth: user,
query: {
withKeyCount: true
}
});
const countedTag = list.data.find((item) => item._id === tag.data._id);
expect(countedTag?.keyCount).toBe(1);
});
});
......@@ -2,6 +2,7 @@ import type { UpdateApiKeyBodyType } from '@fastgpt/global/openapi/support/opena
import handler from '@/pages/api/support/openapi/update';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { MongoOpenApiTag } from '@fastgpt/service/support/openapi/tag/schema';
import { getFakeUsers } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it } from 'vitest';
......@@ -143,4 +144,88 @@ describe('support/openapi/update', () => {
const updated = await MongoOpenApi.findById(openapi._id).lean();
expect(updated?.authProxy).toBe(false);
});
it('replaces and clears APIKey tags', async () => {
const { owner } = await getFakeUsers(1);
const [tagA, tagB] = await MongoOpenApiTag.create([
{
teamId: owner.teamId,
tmbId: owner.tmbId,
name: 'A',
normalizedName: 'a',
type: 'custom',
order: 100
},
{
teamId: owner.teamId,
tmbId: owner.tmbId,
name: 'B',
normalizedName: 'b',
type: 'custom',
order: 101
}
]);
const openapi = await MongoOpenApi.create({
teamId: owner.teamId,
tmbId: owner.tmbId,
apiKey: 'fastgpt-tag-update',
name: 'tag update',
tagIds: [tagA._id]
});
const replaced = await Call<UpdateApiKeyBodyType>(handler, {
auth: owner,
body: {
_id: String(openapi._id),
tags: [String(tagB._id)]
}
});
expect(replaced.code).toBe(200);
const replacedKey = await MongoOpenApi.findById(openapi._id).lean();
expect((replacedKey?.tagIds || []).map(String)).toEqual([String(tagB._id)]);
const cleared = await Call<UpdateApiKeyBodyType>(handler, {
auth: owner,
body: {
_id: String(openapi._id),
tags: []
}
});
expect(cleared.code).toBe(200);
const clearedKey = await MongoOpenApi.findById(openapi._id).lean();
expect(clearedKey?.tagIds || []).toHaveLength(0);
});
it('rejects updating APIKey with tags from another member', async () => {
const { owner, members } = await getFakeUsers(1);
const [member] = members;
const memberTag = await MongoOpenApiTag.create({
teamId: member.teamId,
tmbId: member.tmbId,
name: 'member tag',
normalizedName: 'member tag',
type: 'custom',
order: 100
});
const openapi = await MongoOpenApi.create({
teamId: owner.teamId,
tmbId: owner.tmbId,
apiKey: 'fastgpt-invalid-tag-update',
name: 'invalid tag update'
});
const res = await Call<UpdateApiKeyBodyType>(handler, {
auth: owner,
body: {
_id: String(openapi._id),
tags: [String(memberTag._id)]
}
});
expect(res.code).toBe(500);
const updated = await MongoOpenApi.findById(openapi._id).lean();
expect(updated?.tagIds || []).toHaveLength(0);
});
});
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