11.2 要实现的接口
| 方法 | URL | 功能 |
|---|---|---|
| GET | /api/v1/projects | 分页查询项目列表,支持按名称搜索 |
| GET | /api/v1/projects/:id | 查询单个项目详情 |
| POST | /api/v1/projects | 新建项目 |
| PUT | /api/v1/projects/:id | 更新项目 |
| DELETE | /api/v1/projects/:id | 删除项目 |
统一响应格式:{ status: 0, data: ... }
11.3 第一步:定义数据模型
src/models/project/project.ts:
export interface Project {
id: number;
name: string;
description: string;
status: 'active' | 'inactive';
created_at: number;
updated_at: number;
}
11.4 第二步:写 Model 层
src/models/project/project-model.ts:
import type { Project } from './project.js';
export class ProjectModel {
#projects: Project[];
constructor(initialData: readonly Project[] = []) {
this.#projects = initialData.map((p) => structuredClone(p));
}
list(): Project[] {
return structuredClone(this.#projects);
}
findById(id: number): Project | undefined {
const project = this.#projects.find((p) => p.id === id);
return project ? structuredClone(project) : undefined;
}
findByName(name: string): Project | undefined {
const project = this.#projects.find((p) => p.name === name);
return project ? structuredClone(project) : undefined;
}
create(input: Pick<Project, 'name' | 'description' | 'status'>): Project {
const now = Math.floor(Date.now() / 1000);
const project: Project = {
...input,
id: this.#nextId(),
created_at: now,
updated_at: now,
};
this.#projects.push(structuredClone(project));
return structuredClone(project);
}
update(
id: number,
input: Partial<Pick<Project, 'name' | 'description' | 'status'>>,
): Project | undefined {
const project = this.#projects.find((p) => p.id === id);
if (!project) return undefined;
if (input.name !== undefined) project.name = input.name;
if (input.description !== undefined) project.description = input.description;
if (input.status !== undefined) project.status = input.status;
project.updated_at = Math.floor(Date.now() / 1000);
return structuredClone(project);
}
delete(id: number): boolean {
const index = this.#projects.findIndex((p) => p.id === id);
if (index < 0) return false;
this.#projects.splice(index, 1);
return true;
}
#nextId(): number {
if (this.#projects.length === 0) return 1;
return Math.max(...this.#projects.map((p) => p.id)) + 1;
}
}
11.5 第三步:准备初始数据
src/data/project/index.ts:
import type { Project } from '@/models/project/project.js';
const CREATED_AT = 1_700_000_000;
export const projectData: readonly Project[] = [
{
id: 1,
name: '示例项目A',
description: '这是第一个示例项目',
status: 'active',
created_at: CREATED_AT,
updated_at: CREATED_AT,
},
{
id: 2,
name: '示例项目B',
description: '这是第二个示例项目',
status: 'inactive',
created_at: CREATED_AT,
updated_at: CREATED_AT,
},
];
11.6 第四步:写 Service 层
src/services/project/project-service.ts:
import { AppError } from '@/errors/app-error.js';
import type { ProjectModel } from '@/models/project/project-model.js';
import type { Project } from '@/models/project/project.js';
export class ProjectService {
constructor(private readonly projects: ProjectModel) {}
// 查询列表(支持按名称模糊搜索)
list(name = ''): Project[] {
const all = this.projects.list();
if (!name) return all;
const lower = name.toLowerCase();
return all.filter((p) => p.name.toLowerCase().includes(lower));
}
// 查询详情
getById(id: number): Project {
const project = this.projects.findById(id);
if (!project) {
throw new AppError('项目不存在', { status: 2 });
}
return project;
}
// 新建
create(body: Record<string, unknown>): Project {
const name = this.#requireString(body.name, '项目名称不能为空');
const description = typeof body.description === 'string' ? body.description : '';
const status = body.status === 'inactive' ? 'inactive' : 'active';
if (this.projects.findByName(name)) {
throw new AppError('项目名称已存在', { status: 2 });
}
return this.projects.create({ name, description, status });
}
// 更新
update(id: number, body: Record<string, unknown>): Project {
const current = this.projects.findById(id);
if (!current) {
throw new AppError('项目不存在', { status: 2 });
}
const name = body.name !== undefined
? this.#requireString(body.name, '项目名称不能为空')
: undefined;
if (name && name !== current.name) {
if (this.projects.findByName(name)) {
throw new AppError('项目名称已存在', { status: 2 });
}
}
const updated = this.projects.update(id, {
name,
description: typeof body.description === 'string' ? body.description : undefined,
status: body.status === 'inactive' || body.status === 'active' ? body.status : undefined,
});
if (!updated) {
throw new AppError('更新失败', { status: 1 });
}
return updated;
}
// 删除
delete(id: number): void {
const current = this.projects.findById(id);
if (!current) {
throw new AppError('项目不存在', { status: 2 });
}
const success = this.projects.delete(id);
if (!success) {
throw new AppError('删除失败', { status: 1 });
}
}
#requireString(value: unknown, errorMsg: string): string {
const str = typeof value === 'string' ? value.trim() : '';
if (!str) {
throw new AppError(errorMsg, { status: 2 });
}
return str;
}
}
11.7 第五步:写 Controller 层
src/controllers/project/project-controller.ts:
import type { Request, Response } from 'express';
import type { ProjectService } from '@/services/project/project-service.js';
import { sendOk, sendList } from '@/utils/response.js';
import { parseOffset, parseSize, parseText } from '@/utils/pagination.js';
export class ProjectController {
constructor(private readonly service: ProjectService) {}
list = (req: Request, res: Response) => {
const offset = parseOffset(req.query.offset);
const size = parseSize(req.query.size);
const name = parseText(req.query.name);
const all = this.service.list(name);
return sendList(res, all.slice(offset, offset + size), {
count: all.length,
offset,
size,
});
};
get = (req: Request, res: Response) => {
const project = this.service.getById(Number(req.params.id));
return sendOk(res, project);
};
create = (req: Request, res: Response) => {
const project = this.service.create(req.body);
return sendOk(res, { id: project.id });
};
update = (req: Request, res: Response) => {
this.service.update(Number(req.params.id), req.body);
return sendOk(res, null);
};
remove = (req: Request, res: Response) => {
this.service.delete(Number(req.params.id));
return sendOk(res, null);
};
}
11.8 第六步:写 Router 层
src/routers/project/project-router.ts:
import { Router } from 'express';
import type { ProjectController } from '@/controllers/project/project-controller.js';
export function createProjectRouter(controller: ProjectController) {
const router = Router();
router.get('/', controller.list); // GET /projects
router.get('/:id', controller.get); // GET /projects/:id
router.post('/', controller.create); // POST /projects
router.put('/:id', controller.update); // PUT /projects/:id
router.delete('/:id', controller.remove); // DELETE /projects/:id
return router;
}
11.9 第七步:注册到总路由
src/bootstrap.ts 里加上:
import { projectData } from './data/project/index.js';
import { ProjectModel } from './models/project/project-model.js';
import { ProjectService } from './services/project/project-service.js';
import { ProjectController } from './controllers/project/project-controller.js';
export function bootstrap() {
const projects = new ProjectModel(projectData);
const project = new ProjectController(new ProjectService(projects));
return {
projects,
controllers: { project },
};
}
src/routers/index.ts 里加上:
import { createProjectRouter } from './project/project-router.js';
export function createRouter(controllers: any) {
const router = Router();
router.use('/projects', createProjectRouter(controllers.project));
return router;
}
11.10 测试一下
启动服务:
npm run dev
用 curl 测试:
# 列表
curl http://localhost:3000/api/v1/projects
# 搜索
curl "http://localhost:3000/api/v1/projects?name=A"
# 详情
curl http://localhost:3000/api/v1/projects/1
# 新建
curl -X POST http://localhost:3000/api/v1/projects \
-H "Content-Type: application/json" \
-d '{"name":"新项目","description":"测试","status":"active"}'
# 更新
curl -X PUT http://localhost:3000/api/v1/projects/3 \
-H "Content-Type: application/json" \
-d '{"name":"改名后的项目"}'
# 删除
curl -X DELETE http://localhost:3000/api/v1/projects/3
11.11 小结
一个完整的 CRUD 模块需要 6 个文件:
| 文件 | 作用 |
|---|---|
models/project/project.ts | 类型定义 |
models/project/project-model.ts | 数据操作 |
data/project/index.ts | 初始数据 |
services/project/project-service.ts | 业务逻辑 |
controllers/project/project-controller.ts | 请求处理 |
routers/project/project-router.ts | 路由映射 |
每一层职责单一,改其中一层不影响其他层。照着这个模板写其他模块就行。