07 — Service 层:业务逻辑

7.2 一个简单的例子

src/services/project/project-service.ts

typescript 复制代码
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;
    return all.filter((p) => p.name.toLowerCase().includes(name.toLowerCase()));
  }

  // ===== 查详情 =====
  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 {
    // 1. 提取并校验参数
    const name = this.#requireString(body.name, '项目名称不能为空');
    const description = typeof body.description === 'string' ? body.description : '';
    const status = body.status === 'inactive' ? 'inactive' : 'active';

    // 2. 业务规则:名称不能重复
    if (this.projects.findByName(name)) {
      throw new AppError('项目名称已存在', { status: 2 });
    }

    // 3. 调用 Model 创建
    return this.projects.create({ name, description, status });
  }

  // ===== 更新 =====
  update(id: number, body: Record<string, unknown>): Project {
    // 1. 检查是否存在
    const current = this.projects.findById(id);
    if (!current) {
      throw new AppError('项目不存在', { status: 2 });
    }

    // 2. 提取参数
    const name = body.name !== undefined
      ? this.#requireString(body.name, '项目名称不能为空')
      : undefined;

    // 3. 业务规则:改名时不能和其他项目重名
    if (name && name !== current.name) {
      if (this.projects.findByName(name)) {
        throw new AppError('项目名称已存在', { status: 2 });
      }
    }

    // 4. 调用 Model 更新
    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;
  }
}

7.3 Service 层 vs Controller 层,参数校验放哪

两者都做,但侧重点不同:

校验什么 例子
Controller 格式层面的校验 ID 是不是数字、必填参数传了没有
Service 业务层面的校验 名称是否重复、状态是否合法、有没有权限

Controller 的校验是"这个请求格式对不对",Service 的校验是"这个操作能不能做"。

实际项目中,简单的校验可以都放 Service 层,复杂的可以用 zodclass-validator 做 Schema 校验。


7.4 为什么入参用 Record<string, unknown>

你可能注意到了,createupdate 的入参不是具体的类型,而是 Record<string, unknown>

原因是:请求体是不可信的,你不能假设前端传进来的数据结构和你的类型定义一致。

typescript 复制代码
// ❌ 不要这样写
create(input: CreateProjectInput): Project {
  // input.name 一定是 string 吗?不一定!
  // 前端可能传 number、undefined、甚至 null
}

// ✅ 应该这样写
create(body: Record<string, unknown>): Project {
  const name = this.#requireString(body.name, '名称不能为空');
  // 经过校验后,name 一定是合法的 string
}

TypeScript 的类型在运行时是不存在的。如果你把 req.body 直接断言成某个类型,等于把安全门全打开了。

正确做法:从不可信输入(body/query/params)中提取字段,逐个校验,校验通过后再组装成可信的数据。


7.5 多 Model 协调的例子

Service 层的一个重要作用是协调多个 Model。

比如"删除项目时,要同时删除项目下的所有任务":

typescript 复制代码
export class ProjectService {
  constructor(
    private readonly projects: ProjectModel,
    private readonly tasks: TaskModel,  // 另一个 Model
  ) {}

  delete(id: number): void {
    const project = this.projects.findById(id);
    if (!project) {
      throw new AppError('项目不存在', { status: 2 });
    }

    // 删除项目下的所有任务
    this.tasks.deleteByProjectId(id);

    // 删除项目
    this.projects.delete(id);
  }
}

这种涉及多个数据模型的操作,就是 Service 层的事,不应该放在 Controller 里。

在数据库场景下,这还涉及事务(要么都成功,要么都失败),也是 Service 层管理的。


7.6 抛出错误而不是返回 null

Service 层遇到错误应该 throw,而不是返回 null{ error: ... }

typescript 复制代码
// ❌ 返回错误对象
getById(id: number): Project | { error: string } {
  const project = this.projects.findById(id);
  if (!project) return { error: '项目不存在' };
  return project;
}

// ✅ 直接抛
getById(id: number): Project {
  const project = this.projects.findById(id);
  if (!project) {
    throw new AppError('项目不存在', { status: 2 });
  }
  return project;
}

为什么 throw 更好:

  1. 不会忘处理:返回 null 或 error 对象,调用方忘了判断就会出 bug;throw 了不 catch 就会报错,很显眼
  2. 错误可以冒泡:一层层往上抛,最后由全局错误中间件统一处理
  3. 类型干净 :返回类型就是 Project,不用带 | null | Error 各种联合类型

Controller 层不需要 try/catch,Express 5 会自动把 async 里的错误传给错误处理中间件。


7.7 Service 方法的命名约定

操作 推荐命名 示例
查询列表 list / find list(), listByStatus()
查询单个 getById / findById getById(id)
创建 create create(data)
更新 update update(id, data)
删除 delete / remove delete(id)
业务操作 动词开头 assignUser(), toggleStatus()

保持命名一致,看方法名就知道做什么的。


7.8 小结

  • Service 层是业务核心,负责参数校验、业务规则、多 Model 协调
  • 入参用 Record<string, unknown>,因为请求体不可信,要逐个校验
  • 遇到错误 throw AppError,不要返回 null 或错误对象
  • 多 Model 的操作放在 Service 层,Controller 不直接操作多个 Model
  • Service 层不依赖 HTTP,换框架不用改 Service
相关推荐
苍何1 小时前
用 GPT6 + Hyper3D MCP 搓 3D 个人网站,太夯了!(附教程)
后端
cidy_981 小时前
05 — 分层架构设计思想
后端
虎虎(_ _)。゜zzZ1 小时前
SQLAlchemy入门教程
数据库·后端·python·sql·ai·sqlalchemy
右耳朵猫AI1 小时前
Go周刊2026W37 | Ebitengine 纯 Go 化、simd 重写 TurboPFor、json/v2 落地
后端·微服务·go
devpotato2 小时前
RPO与RTO:容灾的两个关键指标
java·后端
codigger2 小时前
程序员别再踩这 3 个坑——做了五年开发,我把能踩的坑全踩了一遍
后端·ai·程序员·架构·程序员职场
掘金挖土2 小时前
前端手摸手跑路之 AI 应用开发(六)
前端·后端
cidy_982 小时前
06 — Model 层:数据模型与操作
后端
TechLee2 小时前
一行代码防水平越权:用 PHP-Casbin 终结业务里的数据级 if-else
后端·php