11 — 完整 CRUD 示例

5 阅读4分钟

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路由映射

每一层职责单一,改其中一层不影响其他层。照着这个模板写其他模块就行。