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
......@@ -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",
......
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);
......@@ -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