编程语言

TypeScript教程(四)实战 —— Node.js 后端 API(记账服务)

TypeScript 04:实战 —— Node.js 后端 API(记账服务)

目标:用 TypeScript 实现一个类似django记账项目 accounting app 的 REST API,掌握后端常用模式。

Django 概念 本项目对应
urls.py routes/*.ts(Express Router)
views.py controllers/路由处理函数
forms.py / DRF Serializer zod schema
models.py + ORM repository(先用内存实现,再换数据库)
middleware Express 中间件
settings.py config.ts + 环境变量

1. 初始化项目

mkdir ts-accounting && cd ts-accounting
npm init -y
npm install express zod
npm install -D typescript tsx @types/node @types/express vitest supertest @types/supertest
npx tsc --init

package.json 关键部分:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "vitest run"
  }
}

目录结构:

src/
├── config.ts
├── app.ts              # 创建 app(便于测试)
├── server.ts           # 启动入口
├── models/record.ts    # 类型 + 校验 schema
├── repositories/recordRepo.ts
├── services/recordService.ts
├── routes/records.ts
└── middlewares/error.ts
tests/
└── records.test.ts

2. 模型与校验(zod ≈ pydantic)

src/models/record.ts:

import { z } from "zod";

export const RecordCreateSchema = z.object({
  type: z.enum(["income", "expense"]),
  category: z.string().min(1).max(30),
  amount: z.number().positive(),
  note: z.string().max(200).optional(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
});

export const RecordUpdateSchema = RecordCreateSchema.partial();

// 从 schema 推导类型:校验规则与类型只写一份
export type RecordCreate = z.infer<typeof RecordCreateSchema>;
export type RecordUpdate = z.infer<typeof RecordUpdateSchema>;
export type AccountRecord = RecordCreate & { id: number; createdAt: string };

3. 仓储层(面向接口,方便替换数据库)

src/repositories/recordRepo.ts:

import type { AccountRecord, RecordCreate, RecordUpdate } from "../models/record.js";

export interface RecordRepo {
  list(filter?: { month?: string; type?: string }): Promise<AccountRecord[]>;
  get(id: number): Promise<AccountRecord | undefined>;
  create(data: RecordCreate): Promise<AccountRecord>;
  update(id: number, data: RecordUpdate): Promise<AccountRecord | undefined>;
  remove(id: number): Promise<boolean>;
}

export class InMemoryRecordRepo implements RecordRepo {
  private items = new Map<number, AccountRecord>();
  private seq = 0;

  async list(filter: { month?: string; type?: string } = {}) {
    return [...this.items.values()].filter(r =>
      (!filter.month || r.date.startsWith(filter.month)) &&
      (!filter.type || r.type === filter.type));
  }

  async get(id: number) {
    return this.items.get(id);
  }

  async create(data: RecordCreate) {
    const record = { ...data, id: ++this.seq, createdAt: new Date().toISOString() };
    this.items.set(record.id, record);
    return record;
  }

  async update(id: number, data: RecordUpdate) {
    const old = this.items.get(id);
    if (!old) return undefined;
    const updated = { ...old, ...data };
    this.items.set(id, updated);
    return updated;
  }

  async remove(id: number) {
    return this.items.delete(id);
  }
}

换成真实数据库时,只需新写一个 SqliteRecordRepo implements RecordRepo(可用 better-sqlite3、drizzle-orm 或 prisma),上层代码不变。

4. 服务层(业务逻辑)

src/services/recordService.ts:

import type { RecordRepo } from "../repositories/recordRepo.js";

export class RecordService {
  constructor(private repo: RecordRepo) {}

  async monthlySummary(month: string) {
    const records = await this.repo.list({ month });
    const summary = { income: 0, expense: 0, byCategory: {} as Record<string, number> };
    for (const r of records) {
      summary[r.type] += r.amount;
      if (r.type === "expense") {
        summary.byCategory[r.category] = (summary.byCategory[r.category] ?? 0) + r.amount;
      }
    }
    return { ...summary, balance: summary.income - summary.expense };
  }
}

5. 错误处理中间件

src/middlewares/error.ts:

import type { ErrorRequestHandler } from "express";
import { ZodError } from "zod";

export class HttpError extends Error {
  constructor(public status: number, message: string) { super(message); }
}

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof ZodError) {
    return res.status(400).json({ error: "ValidationError", details: err.issues });
  }
  if (err instanceof HttpError) {
    return res.status(err.status).json({ error: err.message });
  }
  console.error(err);
  res.status(500).json({ error: "Internal Server Error" });
};

6. 路由

src/routes/records.ts:

import { Router } from "express";
import { RecordCreateSchema, RecordUpdateSchema } from "../models/record.js";
import type { RecordRepo } from "../repositories/recordRepo.js";
import { RecordService } from "../services/recordService.js";
import { HttpError } from "../middlewares/error.js";

export function recordRouter(repo: RecordRepo): Router {
  const router = Router();
  const service = new RecordService(repo);

  router.get("/", async (req, res) => {
    const { month, type } = req.query as { month?: string; type?: string };
    res.json(await repo.list({ month, type }));
  });

  router.get("/summary/:month", async (req, res) => {
    res.json(await service.monthlySummary(req.params.month));
  });

  router.get("/:id", async (req, res) => {
    const record = await repo.get(Number(req.params.id));
    if (!record) throw new HttpError(404, "record not found");
    res.json(record);
  });

  router.post("/", async (req, res) => {
    const data = RecordCreateSchema.parse(req.body); // 校验失败抛 ZodError
    res.status(201).json(await repo.create(data));
  });

  router.patch("/:id", async (req, res) => {
    const data = RecordUpdateSchema.parse(req.body);
    const record = await repo.update(Number(req.params.id), data);
    if (!record) throw new HttpError(404, "record not found");
    res.json(record);
  });

  router.delete("/:id", async (req, res) => {
    const ok = await repo.remove(Number(req.params.id));
    if (!ok) throw new HttpError(404, "record not found");
    res.status(204).end();
  });

  return router;
}

Express 5 会自动捕获 async 处理函数中抛出的错误并交给错误中间件;若用 Express 4,需要包装一层或使用 express-async-errors。

7. 组装 app 与启动

src/config.ts:

export const config = {
  port: Number(process.env.PORT ?? 3000),
};

src/app.ts:

import express from "express";
import { recordRouter } from "./routes/records.js";
import { errorHandler } from "./middlewares/error.js";
import { InMemoryRecordRepo, type RecordRepo } from "./repositories/recordRepo.js";

export function createApp(repo: RecordRepo = new InMemoryRecordRepo()) {
  const app = express();
  app.use(express.json());

  // 简单的请求日志中间件
  app.use((req, res, next) => {
    const start = Date.now();
    res.on("finish", () =>
      console.log(`${req.method} ${req.url} ${res.statusCode} ${Date.now() - start}ms`));
    next();
  });

  app.get("/health", (_req, res) => res.json({ status: "ok" }));
  app.use("/api/records", recordRouter(repo));
  app.use(errorHandler);
  return app;
}

src/server.ts:

import { createApp } from "./app.js";
import { config } from "./config.js";

createApp().listen(config.port, () =>
  console.log(`listening on http://localhost:${config.port}`));

运行与调用:

npm run dev
curl -X POST localhost:3000/api/records -H 'Content-Type: application/json' \
  -d '{"type":"expense","category":"food","amount":35.5,"date":"2026-11-01"}'
curl localhost:3000/api/records/summary/2026-11

8. API 测试(vitest + supertest ≈ pytest + requests/TestClient)

tests/records.test.ts:

import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { createApp } from "../src/app.js";

describe("records api", () => {
  let app: ReturnType<typeof createApp>;
  beforeEach(() => { app = createApp(); });   // ≈ pytest fixture

  it("creates a record", async () => {
    const res = await request(app).post("/api/records").send({
      type: "expense", category: "food", amount: 20, date: "2026-11-01",
    });
    expect(res.status).toBe(201);
    expect(res.body).toMatchObject({ id: 1, category: "food" });
  });

  it.each([
    [{ amount: -1 }, "amount"],
    [{ date: "2026/11/01" }, "date"],
  ])("rejects invalid payload %o", async (patch, field) => {   // ≈ pytest.mark.parametrize
    const body = { type: "expense", category: "food", amount: 1, date: "2026-11-01", ...patch };
    const res = await request(app).post("/api/records").send(body);
    expect(res.status).toBe(400);
    expect(JSON.stringify(res.body.details)).toContain(field);
  });

  it("returns monthly summary", async () => {
    await request(app).post("/api/records").send({ type: "income", category: "salary", amount: 1000, date: "2026-11-01" });
    await request(app).post("/api/records").send({ type: "expense", category: "food", amount: 200, date: "2026-11-02" });
    const res = await request(app).get("/api/records/summary/2026-11");
    expect(res.body.balance).toBe(800);
  });
});

9. 进一步方向

  • 框架:NestJS(结构与 ASP.NET Core 很像:依赖注入、装饰器、模块),Fastify(性能更高)。
  • ORM:Prisma(≈ Django ORM 的体验)、Drizzle(类型安全、轻量)。
  • 文档:zod-to-openapi 生成 Swagger。

10. 练习

  1. 把 InMemoryRecordRepo 替换为 SQLite 实现。
  2. 增加 GET /api/records?page=1&size=20 分页,返回 { items, total }。
  3. 增加一个简单的 API Key 鉴权中间件,并为其编写测试。

文章评论 (0)