02 工程结构与共享 TypeScript 类型
本章先定义代码放在哪里、函数接收什么、返回什么。你可以直接使用骨架,但需要完成 TODO。核心目标是避免 Router、Controller、Service 和 Model 混成一个大文件。
1. 渐进式目录
第一阶段只创建认证所需文件:
routes/
auth.ts
controllers/
auth.controller.ts
services/
auth.service.ts
models/
admin-user.ts
middlewares/
authenticate-admin.ts
types/
api.ts
auth.ts
express.d.ts
等开始 Sakila CRUD 再增加:
routes/sakila/
controllers/sakila/
services/sakila/
models/sakila/
validations/sakila/
不要为了练习一开始引入 Repository、依赖注入容器或大型框架。
2. 通用响应类型
export interface ApiSuccess<T> {
code: "OK";
message: string;
data: T;
}
export interface ApiFailure {
code: string;
message: string;
data: null;
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
辅助函数是可选项。初学阶段可以显式写 res.json(),先看清响应结构;重复明显后再抽取。
3. 分页类型
外部 HTTP 类型:
export interface RawPageQuery {
page?: string;
pageSize?: string;
}
内部 Service 类型:
export interface PageOptions {
page: number;
pageSize: number;
}
export interface PageResult<T> extends PageOptions {
list: T[];
total: number;
totalPages: number;
}
转换骨架:
export function toPageOptions(query: RawPageQuery): PageOptions {
// 前提:express-validator 已确认输入合法
// TODO:设置默认值,并将字符串安全转换为 number
throw new Error("TODO");
}
4. 管理员与认证类型
export enum AdminAccountStatus {
Disabled = 0,
Active = 1,
Locked = 2,
}
export interface AdminUserDto {
id: string;
username: string;
displayName: string;
email: string | null;
accountStatus: AdminAccountStatus;
lastLoginAt: string | null;
createdAt: string;
}
export interface LoginBody {
username: string;
password: string;
}
export interface LoginResultDto {
accessToken: string;
tokenType: "Bearer";
expiresIn: number;
admin: AdminUserDto;
}
export interface AdminAccessTokenPayload {
sub: string;
role: "manager";
tokenVersion: number;
}
export interface AuthenticatedAdmin {
id: string;
username: string;
role: "manager";
}
AdminUserDto 故意没有 passwordHash、tokenVersion 和失败次数。Model 是数据库对象,DTO 才是 API 明确允许公开的数据。
5. 扩展 Express Request
创建 types/express.d.ts:
import type { AuthenticatedAdmin } from "./auth";
declare global {
namespace Express {
interface Request {
admin?: AuthenticatedAdmin;
}
}
}
export {};
为什么是可选属性:
- 登录接口执行时还没有管理员;
- 鉴权中间件执行前不能假设它存在;
- TypeScript 不知道某条 Router 一定先执行了中间件。
受保护 Controller 可以在鉴权后做守卫,或封装一个明确的读取函数:
export function requireAdmin(req: Express.Request): AuthenticatedAdmin {
if (!req.admin) {
// TODO:抛出项目已有的 AppError
throw new Error("TODO");
}
return req.admin;
}
确认 tsconfig.json 的 include 覆盖该 .d.ts。不要为解决报错在各处写:
(req as any).admin
6. Express Request 泛型顺序
Request<Params, ResBody, ReqBody, ReqQuery>
登录 Controller 签名:
import type { Request, Response } from "express";
type EmptyParams = Record<string, never>;
type EmptyQuery = Record<string, never>;
export async function loginController(
req: Request<
EmptyParams,
ApiResponse<LoginResultDto>,
LoginBody,
EmptyQuery
>,
res: Response<ApiResponse<LoginResultDto>>,
): Promise<void> {
// TODO:调用 loginAdmin,并返回统一结构
}
演员详情则会使用:
interface ActorIdParams {
actorId: string;
}
不要把 URL 参数一开始就写成 number;Express 从路径中取得的是字符串。
7. Controller 与 Service 契约
Service 不依赖 Express:
export interface LoginInput {
username: string;
password: string;
}
export async function loginAdmin(
input: LoginInput,
): Promise<LoginResultDto> {
// TODO:查询账号、校验密码、签发 JWT
throw new Error("TODO");
}
Controller 只做 HTTP 适配:
export async function loginController(
req: Request<EmptyParams, ApiResponse<LoginResultDto>, LoginBody>,
res: Response<ApiResponse<LoginResultDto>>,
): Promise<void> {
const result = await loginAdmin({
username: req.body.username,
password: req.body.password,
});
res.json({
code: "OK",
message: "登录成功",
data: result,
});
}
Express 5 会把 async Controller 中未捕获的 Promise rejection 交给错误处理中间件。不要在每个 Controller 中机械地 try/catch 后又丢失错误类型。
8. Router 骨架
import { Router } from "express";
const router = Router();
router.post(
"/login",
loginValidation,
validateRequest,
loginController,
);
router.get(
"/me",
authenticateAdmin,
getCurrentAdminController,
);
router.patch(
"/password",
authenticateAdmin,
changePasswordValidation,
validateRequest,
changePasswordController,
);
export default router;
这里故意让鉴权早于需要查询当前管理员的业务。验证请求格式和鉴权谁先执行,可按是否希望未登录者获知详细参数规则来决定,但同一项目要保持一致。
9. 受保护 Sakila Router
推荐在父 Router 上统一保护:
const sakilaRouter = Router();
sakilaRouter.use(authenticateAdmin);
sakilaRouter.use("/actors", actorRouter);
sakilaRouter.use("/customers", customerRouter);
sakilaRouter.use("/films", filmRouter);
错误示例:
sakilaRouter.use("/actors", actorRouter);
sakilaRouter.use(authenticateAdmin);
Express 从上到下执行,上面的演员路由不会被后注册的鉴权中间件保护。
10. DTO 转换边界
定义转换函数:
export function toAdminUserDto(admin: AdminUser): AdminUserDto {
return {
id: admin.id,
username: admin.username,
displayName: admin.displayName,
email: admin.email,
accountStatus: admin.accountStatus,
lastLoginAt: admin.lastLoginAt?.toISOString() ?? null,
createdAt: admin.createdAt.toISOString(),
};
}
即便 Model 默认隐藏密码,也不要直接把 admin.toJSON() 当作稳定 API 契约。数据库增加字段时,DTO 白名单不会意外扩大响应。
11. 错误码初始集合
建议先约定:
VALIDATION_ERROR
AUTH_REQUIRED
INVALID_ACCESS_TOKEN
INVALID_CREDENTIALS
ACCOUNT_DISABLED
ACCOUNT_LOCKED
ADMIN_NOT_FOUND
ADMIN_USERNAME_EXISTS
EMAIL_ALREADY_EXISTS
INTERNAL_SERVER_ERROR
错误码供程序判断,message 供人阅读。前端不要依赖中文 message 进行分支判断。
12. 本章 TODO 与验收
- [ ] 创建共享类型文件,不使用
any。 - [ ] 让
types/express.d.ts被 TypeScript 正确加载。 - [ ] 实现
toPageOptions()的默认值和转换。 - [ ] 定义
toAdminUserDto()并确保不返回敏感字段。 - [ ] 写出登录和
/me的 Router/Controller/Service 空骨架。 - [ ] 运行
npx tsc --noEmit。
13. 本章练习
- 为什么
LoginBody不应直接复用AdminUserModel 类型? - 为什么
req.admin在全局类型中是可选的? - 写出
PATCH /actors/:actorId的完整 Request 泛型。 - 如果数据库新增
internal_note,直接返回toJSON()有什么风险? - 说明 Service 不依赖 Express 的测试优势。
- 调整中间件顺序,验证未登录请求是否会进入 Validator。