
11.1 这一篇做什么前面几篇分别讲了每一层的写法这一篇把它们串起来从零写完一个项目管理模块的完整 CRUD。读完这篇你应该能照着写出其他任何模块。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.tsexportinterfaceProject{id:number;name:string;description:string;status:active|inactive;created_at:number;updated_at:number;}11.4 第二步写 Model 层src/models/project/project-model.tsimporttype{Project}from./project.js;exportclassProjectModel{#projects:Project[];constructor(initialData:readonlyProject[][]){this.#projectsinitialData.map((p)structuredClone(p));}list():Project[]{returnstructuredClone(this.#projects);}findById(id:number):Project|undefined{constprojectthis.#projects.find((p)p.idid);returnproject?structuredClone(project):undefined;}findByName(name:string):Project|undefined{constprojectthis.#projects.find((p)p.namename);returnproject?structuredClone(project):undefined;}create(input:PickProject,name|description|status):Project{constnowMath.floor(Date.now()/1000);constproject:Project{...input,id:this.#nextId(),created_at:now,updated_at:now,};this.#projects.push(structuredClone(project));returnstructuredClone(project);}update(id:number,input:PartialPickProject,name|description|status,):Project|undefined{constprojectthis.#projects.find((p)p.idid);if(!project)returnundefined;if(input.name!undefined)project.nameinput.name;if(input.description!undefined)project.descriptioninput.description;if(input.status!undefined)project.statusinput.status;project.updated_atMath.floor(Date.now()/1000);returnstructuredClone(project);}delete(id:number):boolean{constindexthis.#projects.findIndex((p)p.idid);if(index0)returnfalse;this.#projects.splice(index,1);returntrue;}#nextId():number{if(this.#projects.length0)return1;returnMath.max(...this.#projects.map((p)p.id))1;}}11.5 第三步准备初始数据src/data/project/index.tsimporttype{Project}from/models/project/project.js;constCREATED_AT1_700_000_000;exportconstprojectData:readonlyProject[][{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.tsimport{AppError}from/errors/app-error.js;importtype{ProjectModel}from/models/project/project-model.js;importtype{Project}from/models/project/project.js;exportclassProjectService{constructor(privatereadonlyprojects:ProjectModel){}// 查询列表支持按名称模糊搜索list(name):Project[]{constallthis.projects.list();if(!name)returnall;constlowername.toLowerCase();returnall.filter((p)p.name.toLowerCase().includes(lower));}// 查询详情getById(id:number):Project{constprojectthis.projects.findById(id);if(!project){thrownewAppError(项目不存在,{status:2});}returnproject;}// 新建create(body:Recordstring,unknown):Project{constnamethis.#requireString(body.name,项目名称不能为空);constdescriptiontypeofbody.descriptionstring?body.description:;conststatusbody.statusinactive?inactive:active;if(this.projects.findByName(name)){thrownewAppError(项目名称已存在,{status:2});}returnthis.projects.create({name,description,status});}// 更新update(id:number,body:Recordstring,unknown):Project{constcurrentthis.projects.findById(id);if(!current){thrownewAppError(项目不存在,{status:2});}constnamebody.name!undefined?this.#requireString(body.name,项目名称不能为空):undefined;if(namename!current.name){if(this.projects.findByName(name)){thrownewAppError(项目名称已存在,{status:2});}}constupdatedthis.projects.update(id,{name,description:typeofbody.descriptionstring?body.description:undefined,status:body.statusinactive||body.statusactive?body.status:undefined,});if(!updated){thrownewAppError(更新失败,{status:1});}returnupdated;}// 删除delete(id:number):void{constcurrentthis.projects.findById(id);if(!current){thrownewAppError(项目不存在,{status:2});}constsuccessthis.projects.delete(id);if(!success){thrownewAppError(删除失败,{status:1});}}#requireString(value:unknown,errorMsg:string):string{conststrtypeofvaluestring?value.trim():;if(!str){thrownewAppError(errorMsg,{status:2});}returnstr;}}11.7 第五步写 Controller 层src/controllers/project/project-controller.tsimporttype{Request,Response}fromexpress;importtype{ProjectService}from/services/project/project-service.js;import{sendOk,sendList}from/utils/response.js;import{parseOffset,parseSize,parseText}from/utils/pagination.js;exportclassProjectController{constructor(privatereadonlyservice:ProjectService){}list(req:Request,res:Response){constoffsetparseOffset(req.query.offset);constsizeparseSize(req.query.size);constnameparseText(req.query.name);constallthis.service.list(name);returnsendList(res,all.slice(offset,offsetsize),{count:all.length,offset,size,});};get(req:Request,res:Response){constprojectthis.service.getById(Number(req.params.id));returnsendOk(res,project);};create(req:Request,res:Response){constprojectthis.service.create(req.body);returnsendOk(res,{id:project.id});};update(req:Request,res:Response){this.service.update(Number(req.params.id),req.body);returnsendOk(res,null);};remove(req:Request,res:Response){this.service.delete(Number(req.params.id));returnsendOk(res,null);};}11.8 第六步写 Router 层src/routers/project/project-router.tsimport{Router}fromexpress;importtype{ProjectController}from/controllers/project/project-controller.js;exportfunctioncreateProjectRouter(controller:ProjectController){constrouterRouter();router.get(/,controller.list);// GET /projectsrouter.get(/:id,controller.get);// GET /projects/:idrouter.post(/,controller.create);// POST /projectsrouter.put(/:id,controller.update);// PUT /projects/:idrouter.delete(/:id,controller.remove);// DELETE /projects/:idreturnrouter;}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;exportfunctionbootstrap(){constprojectsnewProjectModel(projectData);constprojectnewProjectController(newProjectService(projects));return{projects,controllers:{project},};}src/routers/index.ts里加上import{createProjectRouter}from./project/project-router.js;exportfunctioncreateRouter(controllers:any){constrouterRouter();router.use(/projects,createProjectRouter(controllers.project));returnrouter;}11.10 测试一下启动服务npmrun dev用 curl 测试# 列表curlhttp://localhost:3000/api/v1/projects# 搜索curlhttp://localhost:3000/api/v1/projects?nameA# 详情curlhttp://localhost:3000/api/v1/projects/1# 新建curl-XPOST http://localhost:3000/api/v1/projects\-HContent-Type: application/json\-d{name:新项目,description:测试,status:active}# 更新curl-XPUT http://localhost:3000/api/v1/projects/3\-HContent-Type: application/json\-d{name:改名后的项目}# 删除curl-XDELETE http://localhost:3000/api/v1/projects/311.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路由映射每一层职责单一改其中一层不影响其他层。照着这个模板写其他模块就行。