外观
API 设计规范
本文档介绍 API 设计规范、请求验证和响应格式。
相关文档
快速参考
- 项目开发规范 - 核心原则、技术栈概览、快速开发指南
技术规范
开发指南
路由命名
- RESTful 风格
- 使用复数形式:
/users、/articles - 嵌套资源:
/users/:userId/articles
请求验证
使用 Zod 进行参数验证(定义 Schema 时的命名应为以小写字母 s 开头的小驼峰写法):
typescript
// 定义验证 Schema
export const sCreateUser = zod.object({
username: zod.string().min(3),
email: zod.string().email(),
});
// 在路由中使用
router.post(
"/users",
bodyValidateMiddleware({ body: sCreateUser }),
cCreateUser,
);1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
参数类型复用规范
重要原则:前端请求接口时的上行参数类型定义与后端 controller 里接收的参数类型有强对应关系。能复用的类型必须复用,不要重复定义。
类型复用规范
- 必须使用 typings 中的 ParamsApi* 类型:所有接口参数类型都应该在
typings目录下定义,并以ParamsApi*命名 - 前后端类型一致性:后端控制器使用的参数类型必须与前端使用的类型定义完全一致
- 避免使用
zod.infer:不要在控制器中使用zod.infer<typeof sXXX>,应该使用 typings 中定义的类型
正确做法:
typescript
// ✅ 正确:使用 typings 中定义的 ParamsApi* 类型
// 注意:typings 目录下的类型是全局类型,无需导入即可使用
// 在控制器中使用
export const cGetArticleInfo: ExpressRequestHandler = async (req, res) => {
const data: ParamsApiGetArticleInfo = sGetArticleInfo.parse(req.query);
// ...
};
export const cAddArticle: ExpressRequestHandler = async (req, res) => {
const body: ParamsApiAddArticle = sAddArticle.parse(req.body);
// ...
};1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
错误做法:
typescript
// ❌ 错误:不要使用 zod.infer
export const cGetArticleInfo: ExpressRequestHandler = async (req, res) => {
const data: zod.infer<typeof sGetArticleInfo> = sGetArticleInfo.parse(
req.query,
);
// ...
};
// ❌ 错误:不要使用内联类型定义
export const cAddArticle: ExpressRequestHandler = async (req, res) => {
const body: { title: string; content: string } = sAddArticle.parse(req.body);
// ...
};1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
类型定义位置
所有 API 参数类型应该定义在对应的 typings/*.d.ts 文件中:
typings/article.d.ts- 文章相关 API 类型typings/user.d.ts- 用户相关 API 类型typings/balance-api.d.ts- 余额相关 API 类型typings/product.d.ts- 产品相关 API 类型typings/file.d.ts- 文件相关 API 类型typings/chat.d.ts- 聊天相关 API 类型typings/mails.d.ts- 邮件相关 API 类型typings/email.d.ts- 邮箱相关 API 类型
类型命名规范
- 参数类型:使用
ParamsApi*前缀,如ParamsApiGetArticleInfo、ParamsApiAddArticle - 返回类型:使用
ReturnApi*前缀,如ReturnApiGetArticleInfo、ReturnApiAddArticle - 列表项类型:使用
*ListItem后缀,如ArticleListItem、ProductListItem
类型定义示例
在 typings/article.d.ts 中定义参数类型:
typescript
/**
* 查询文章详情 - 参数类型
*/
interface ParamsApiGetArticleInfo {
id: number;
}
/**
* 添加文章 - 参数类型
*/
interface ParamsApiAddArticle {
title: string;
name: string;
status: ArticleStatus;
top: number;
abstract: string;
thumbnail: string;
source_url?: string;
source_name?: string;
publish_date: string;
free_content?: string;
paid_content?: string;
price?: number;
outline?: string;
category_id?: number;
tag_ids?: number[];
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
在控制器中使用:
typescript
// ✅ 正确:使用 typings 中定义的类型
export const cGetArticleInfo: ExpressRequestHandler = async (req, res) => {
const data: ParamsApiGetArticleInfo = sGetArticleInfo.parse(req.query);
// ...
};
export const cAddArticle: ExpressRequestHandler = async (req, res) => {
const body: ParamsApiAddArticle = sAddArticle.parse(req.body);
// ...
};
// ❌ 错误:不要使用 zod.infer
const data: zod.infer<typeof sGetArticleInfo> = sGetArticleInfo.parse(
req.query,
);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
注意事项:
- 类型校验一致性:使用
const data: ParamsApiXXX = sXXX.parse(...)的写法可以同时校验通过 zod 定义的 schema 与 typescript 定义的类型是否一致 - 类型复用优先:优先使用 typings 中已定义的类型,避免重复定义
- 前后端类型一致:确保后端使用的类型与前端请求接口的类型定义完全一致
- 复杂类型定义:对于复杂类型,在 typings 中定义接口或类型别名,然后在控制器中使用
GET 请求中的数字类型参数处理
在 GET 请求中,查询参数(query parameters)都是字符串类型。对于需要数字类型的参数,应使用 zod.coerce.number() 自动转换并验证,而不是先定义为字符串再手动转换。
正确做法:
typescript
// ✅ 使用 zod.coerce.number() 自动转换
export const sGetProductInfo = zod.object({
id: zod.coerce.number().int().positive("产品ID必须为正整数"),
});
export const cGetProductInfo: ExpressRequestHandler = async (req, res) => {
const data = sGetProductInfo.parse(req.query);
const { id } = data; // id 已经是 number 类型,无需手动转换
// ...
};1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
错误做法:
typescript
// ❌ 不要先定义为字符串再手动转换
export const sGetProductInfo = zod.object({
id: zod.string(),
});
export const cGetProductInfo: ExpressRequestHandler = async (req, res) => {
const data = sGetProductInfo.parse(req.query);
const id = parseInt(data.id); // 不推荐:需要手动转换
// ...
};1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
分页参数处理:
项目中的分页参数统一使用 zodPageQueryCommon,已使用 zod.coerce.number() 处理:
typescript
// src/scripts/zodUtils.mts
export const zodPageQueryCommon = {
pageNo: zod.coerce.number().int().positive(),
pageSize: zod.coerce.number().int().positive(),
};
// 在控制器中使用
export const sGetArticlesListPage = zod.object({
...zodPageQueryCommon,
keyword: zod.string().optional(),
});
export const cGetArticlesListPage: ExpressRequestHandler = async (req, res) => {
const data = sGetArticlesListPage.parse(req.query);
const pageNo = data.pageNo; // 直接使用,无需 parseInt
const pageSize = data.pageSize; // 直接使用,无需 parseInt
// ...
// 返回分页数据时统一使用 res.successPage
res.successPage({
list: articles,
total: total[0].total,
pageNo,
pageSize,
});
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
分页规范:
- 统一字段名:分页参数统一使用
pageNo和pageSize,不要使用page和size - 复用 zodPageQueryCommon:定义 zod schema 时,不要直接重新定义
pageNo和pageSize,而是复用zodPageQueryCommon(必填) - 分页参数必填:分页参数
pageNo和pageSize始终为必传字段,不是可选字段 - 统一返回方法:返回分页数据时统一使用
res.successPage()方法,不要使用res.success()方法 - 复用类型定义:定义分页请求参数类型时,复用
ParamsQueryPageCommon(必填)
typescript
// ✅ 正确:复用 zodPageQueryCommon
export const sGetArticlesListPage = zod.object({
...zodPageQueryCommon,
keyword: zod.string().optional(),
});
// ❌ 错误:不要直接定义分页字段
export const sGetArticlesListPage = zod.object({
pageNo: zod.coerce.number().int().positive(),
pageSize: zod.coerce.number().int().positive(),
keyword: zod.string().optional(),
});
// ✅ 正确:类型定义复用 ParamsQueryPageCommon(分页参数必填)
interface ParamsApiGetArticleList extends ParamsQueryPageCommon {
keyword?: string;
}
// ✅ 正确:返回分页数据使用 res.successPage
res.successPage({
list: articles,
total: total[0].total,
pageNo,
pageSize,
});
// ❌ 错误:不要使用 res.success 返回分页数据
res.success({
list: articles,
total: total[0].total,
pageNo,
pageSize,
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
注意事项:
- 必须使用
zod.coerce.number():对于 GET 请求中需要数字类型的参数(如 ID、页码、每页数量等),必须使用zod.coerce.number()而不是zod.string()+parseInt() - 添加类型约束:通常配合
.int()(整数)和.positive()(正数)使用,确保数据有效性 - 错误消息:为数字类型参数提供清晰的错误消息,如
"产品ID必须为正整数" - 直接使用解析结果:使用
zod.coerce.number()后,解析出来的数据已经是number类型,无需再使用parseInt()或Number()转换 - 可选参数:如果参数是可选的,使用
.optional(),但要注意在代码中处理undefined的情况
支持多种类型的参数
某些接口可能需要支持多种类型的参数(如同时支持数字 ID 和字符串 token)。可以使用 zod.union() 来定义:
typescript
// ✅ 支持数字类型(projectId)或字符串类型(token)
export const sQuerySdkData = zod.object({
id: zod.union([
zod.coerce.number().int().positive("项目ID必须为正整数"),
zod.string().min(1, "token不能为空"),
]),
});
export const cQuerySdkData: ExpressRequestHandler = async (req, res, next) => {
try {
const query = sQuerySdkData.parse(req.query);
const id = query.id;
let projectId: number;
// 判断 id 是数字类型(projectId)还是字符串类型(token)
if (typeof id === "number") {
projectId = id;
} else {
// 字符串类型,认为是 token,需要解析并验证
projectId = await getProjectIdFromToken(id);
if (projectId === 0) {
res.fail("无效的 token");
return;
}
}
// 后续使用 projectId 进行查询
const project = await findProjectById(projectId);
// ...
} catch (err) {
next(err);
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
使用场景:
- 向后兼容:接口需要同时支持旧的方式(数字 ID)和新的方式(token)
- 灵活认证:允许客户端使用不同的认证方式访问接口
- 类型安全:通过
typeof判断参数类型,确保类型安全
响应格式
通过 ResponseMiddleware 中间件在 express 上集成了 res.success、res.successPage 和 res.fail 方法用来处理常规的 JSON 响应。
成功响应
typescript
// 标准响应
{
code: 200,
message: "success",
encrypted: false,
timestamp: 1762409800783,
data: { ... }
}
// 分页响应
{
code: 200,
message: "success",
encrypted: false,
timestamp: 1762409800783,
data: {
list: [...],
total: 100,
pageNo: 1,
pageSize: 20
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
错误响应
typescript
{
code: 500,
message: "错误信息",
encrypted: false,
timestamp: 1762409800783,
data: null
}1
2
3
4
5
6
7
2
3
4
5
6
7
泛型参数
res.success 和 res.successPage 都支持泛型参数,用于指定返回数据的类型,提升类型安全性。
重要原则:服务端接口通过 res.success 和 res.successPage 传入的泛型类型,与前端请求接口拿到的数据类型有强对应关系。能复用的类型必须复用,不要重复定义。
类型复用规范
- 必须使用 typings 中的 ReturnApi* 类型:所有接口返回类型都应该在
typings目录下定义,并以ReturnApi*命名 - 前后端类型一致性:后端控制器使用的泛型类型必须与前端使用的类型定义完全一致
- 避免内联类型定义:不要在控制器中直接使用内联对象类型,应该使用 typings 中定义的类型
正确做法:
typescript
// ✅ 正确:使用 typings 中定义的 ReturnApi* 类型
// 注意:typings 目录下的类型是全局类型,无需导入即可使用
// 使用已定义的类型
res.success<ReturnApiGetBalance>(data);
res.success<ReturnApiGetArticleInfo>(article);
res.success<ReturnApiGetArticleTags>({ list: tags });
res.success<ReturnApiAddArticleTag>({ id });
// 分页数据使用列表项类型
res.successPage<ArticleListItem>({
list: articles,
total: 100,
pageNo: 1,
pageSize: 20,
});
res.successPage<BalanceTransaction>({
list: transactions,
total: 50,
pageNo: 1,
pageSize: 10,
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
错误做法:
typescript
// ❌ 错误:不要使用内联类型定义
res.success<{ balance: number; frozen_balance: number }>(data);
res.success<{ list: ArticleTag[] }>({ list: tags });
res.success<{ user: UserInfo; token: string }>({ user, token });
// ❌ 错误:不要重复定义已存在的类型
// 如果 typings 中已有 ReturnApiGetBalance,不要重新定义1
2
3
4
5
6
7
2
3
4
5
6
7
类型定义位置
所有 API 返回类型应该定义在对应的 typings/*.d.ts 文件中:
typings/article.d.ts- 文章相关 API 类型typings/user.d.ts- 用户相关 API 类型typings/balance-api.d.ts- 余额相关 API 类型typings/product.d.ts- 产品相关 API 类型typings/file.d.ts- 文件相关 API 类型typings/chat.d.ts- 聊天相关 API 类型typings/root.d.ts- 根路径相关 API 类型typings/bugs-*.d.ts- Bug 监控相关 API 类型
类型命名规范
- 返回类型:使用
ReturnApi*前缀,如ReturnApiGetBalance、ReturnApiGetArticleList - 参数类型:使用
ParamsApi*前缀,如ParamsApiGetBalance、ParamsApiAddArticle - 列表项类型:使用
*ListItem后缀,如ArticleListItem、ProductListItem
注意事项:
- 必须使用泛型参数:所有
res.success和res.successPage调用都应该传入泛型参数,明确返回数据的类型 - 类型复用优先:优先使用 typings 中已定义的类型,避免重复定义
- 前后端类型一致:确保后端使用的类型与前端请求接口的类型定义完全一致
- 复杂类型定义:对于复杂类型,在 typings 中定义接口或类型别名,然后在泛型中使用
代码中使用
typescript
// ✅ 成功响应(必须使用泛型参数,优先使用 typings 中的类型)
res.success<number>(total.total);
res.success<string>("操作成功");
res.success<null>(null, "删除成功");
res.success<ReturnApiGetBalance>(data);
res.success<ReturnApiGetArticleInfo>(article);
res.success<ReturnApiGetArticleTags>({ list: tags });
res.success<ReturnApiAddArticleTag>({ id });
// ✅ 分页响应(必须使用泛型参数,使用列表项类型)
res.successPage<ArticleListItem>({
list: articles,
total: 100,
pageNo: 1,
pageSize: 20,
});
res.successPage<BalanceTransaction>({
list: transactions,
total: 50,
pageNo: 1,
pageSize: 10,
});
// 失败响应
res.fail(message, statusCode);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
类型定义示例
在 typings/article.d.ts 中定义返回类型:
typescript
/**
* 查询文章详情 - 返回类型
*/
type ReturnApiGetArticleInfo = Article & { tags: ArticleTag[] };
/**
* 获取所有标签 - 返回类型
*/
interface ReturnApiGetArticleTags {
list: ArticleTag[];
}
/**
* 添加标签 - 返回类型
*/
interface ReturnApiAddArticleTag {
id: number;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在控制器中使用:
typescript
// ✅ 正确:使用 typings 中定义的类型
res.success<ReturnApiGetArticleInfo>(result);
res.success<ReturnApiGetArticleTags>({ list: tags });
res.success<ReturnApiAddArticleTag>({ id });
// ❌ 错误:不要使用内联类型
res.success<Article & { tags: ArticleTag[] }>(result);
res.success<{ list: ArticleTag[] }>({ list: tags });
res.success<{ id: number }>({ id });1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
中间件使用
身份验证
typescript
router.use(userMiddleware()); // 获取用户信息
router.use(authMiddleware()); // 验证用户登录状态1
2
2
参数验证
typescript
router.post(
"/api/endpoint",
bodyValidateMiddleware({
body: schema, // 验证请求体
query: schema, // 验证查询参数
params: schema, // 验证路径参数
}),
controller,
);1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
错误处理
服务层错误处理
使用 TReturn<T> 类型表示可能返回错误的函数:
typescript
type TReturn<T> = [Error | null, T | undefined];
export async function getUserBalance(
userId: number,
): Promise<TReturn<{ balance: number }>> {
try {
// 业务逻辑
return [null, { balance: 100 }];
} catch (err) {
return [getError(err), undefined];
}
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
控制器错误处理
注意:控制器函数名应为以字符c开头的小驼峰写法。
typescript
export const cGetBalance: ExpressRequestHandler = async (req, res, next) => {
try {
const [err, data] = await getUserBalance(user.id);
if (err) {
res.fail(err.message);
return;
}
res.success(data);
} catch (err) {
logger.error("error in cGetBalance", err);
next(err); // 交给错误处理中间件
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13