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. 练习
- 把
InMemoryRecordRepo替换为 SQLite 实现。 - 增加
GET /api/records?page=1&size=20分页,返回{ items, total }。 - 增加一个简单的 API Key 鉴权中间件,并为其编写测试。
文章评论 (0)