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 故意没有 passwordHashtokenVersion 和失败次数。Model 是数据库对象,DTO 才是 API 明确允许公开的数据。

5. 扩展 Express Request

创建 types/express.d.ts

import type { AuthenticatedAdmin } from "./auth";

declare global {
  namespace Express {
    interface Request {
      admin?: AuthenticatedAdmin;
    }
  }
}

export {};

为什么是可选属性:

受保护 Controller 可以在鉴权后做守卫,或封装一个明确的读取函数:

export function requireAdmin(req: Express.Request): AuthenticatedAdmin {
  if (!req.admin) {
    // TODO:抛出项目已有的 AppError
    throw new Error("TODO");
  }

  return req.admin;
}

确认 tsconfig.jsoninclude 覆盖该 .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 与验收

13. 本章练习

  1. 为什么 LoginBody 不应直接复用 AdminUser Model 类型?
  2. 为什么 req.admin 在全局类型中是可选的?
  3. 写出 PATCH /actors/:actorId 的完整 Request 泛型。
  4. 如果数据库新增 internal_note,直接返回 toJSON() 有什么风险?
  5. 说明 Service 不依赖 Express 的测试优势。
  6. 调整中间件顺序,验证未登录请求是否会进入 Validator。