Files
seclusion/docs/backend/crud-service.md
charilezhou 0156e17131 refactor(api): CrudService 分层架构重构
- 新增 BaseCrudService 抽象基类,提取通用辅助方法
- 新增 RelationCrudService 支持关联查询和一对一关系管理
- 新增 ManyToManyCrudService 支持多对多关系管理
- 重构 CrudService 继承 BaseCrudService
- 迁移 UserService 到 ManyToManyCrudService(用户-角色多对多)
- 迁移 RoleService 到 ManyToManyCrudService(角色-权限、角色-菜单双多对多)
- 更新 CrudService 使用文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:37:27 +08:00

21 KiB
Raw Permalink Blame History

CrudService 分层架构使用文档

本文档介绍 apps/api/src/common/crud/ 目录下的 CRUD 服务分层架构,帮助开发者快速理解和使用。

目录


架构概览

BaseCrudService (抽象基类 - 通用辅助方法)
       │
       ├── CrudService (单表 CRUD保持现有功能)
       │
       ├── RelationCrudService (带关联查询 + 一对一管理)
       │       │
       │       ├── findAllWithRelations()
       │       ├── findByIdWithRelations()
       │       ├── assignOneToOne()
       │       ├── removeOneToOne()
       │       └── getOneToOneTarget()
       │
       └── ManyToManyCrudService (多对多关系管理)
               │
               ├── assignManyToMany()
               ├── getManyToManyTargets()
               ├── addManyToManyRelations()
               └── removeManyToManyRelations()

选择指南

场景 推荐服务类
简单单表 CRUD CrudService
需要关联查询(一对一、一对多、多对一) RelationCrudService
需要管理多对多关系 ManyToManyCrudService

服务类继承关系

// 文件位置
apps/api/src/common/crud/
├── crud.types.ts              // 类型定义
├── crud.decorator.ts          // @CrudOptions 装饰器
├── base-crud.service.ts       // 抽象基类
├── crud.service.ts            // 单表 CRUD
├── relation-crud.service.ts   // 带关联查询
└── many-to-many-crud.service.ts // 多对多关系

配置选项

通过 @CrudOptions 装饰器配置服务行为:

interface CrudServiceOptions {
  // 分页配置
  defaultPageSize?: number;      // 默认分页大小(默认 20
  maxPageSize?: number;          // 最大分页大小(默认 100

  // 排序配置
  defaultSortBy?: string;        // 默认排序字段(默认 'createdAt'
  defaultSortOrder?: 'asc' | 'desc';  // 默认排序方向(默认 'desc'

  // 字段配置
  defaultSelect?: Record<string, boolean>;  // 默认返回字段
  filterableFields?: FilterableField[];     // 可过滤字段

  // 关联配置RelationCrudService
  relations?: Record<string, RelationConfig>;
  oneToOne?: Record<string, OneToOneConfig>;

  // 多对多配置ManyToManyCrudService
  manyToMany?: Record<string, ManyToManyConfig>;

  // 统计配置
  countRelations?: string[];     // 详情查询时统计的关系_count
}

过滤字段配置

// 简写形式:使用 equals 操作符
filterableFields: ['status', 'categoryId']

// 完整配置
filterableFields: [
  { field: 'name', operator: 'contains' },      // 模糊匹配
  { field: 'code', operator: 'equals' },        // 精确匹配
  { field: 'status', operator: 'in' },          // 多值匹配
  { field: 'email', operator: 'startsWith' },   // 前缀匹配
  { field: 'domain', operator: 'endsWith' },    // 后缀匹配
  { field: 'type', queryKey: 'typeFilter' },    // 自定义查询参数名
]

关联配置

// relations - 关联查询配置
relations: {
  class: {
    select: { id: true, code: true, name: true },
    includeInList: true,  // 是否在列表中包含(默认 true
  },
  department: {
    select: { id: true, name: true },
    includeInList: false, // 仅详情页显示
  }
}

// oneToOne - 一对一关系配置
oneToOne: {
  profile: {
    foreignKey: 'profileId',  // 外键字段名
    select: { id: true, bio: true, avatar: true },
  }
}

// manyToMany - 多对多关系配置
manyToMany: {
  teachers: {
    through: 'classTeacher',      // 中间表名
    foreignKey: 'classId',        // 当前实体外键
    targetKey: 'teacherId',       // 目标实体外键
    target: 'teacher',            // 目标模型名
    targetSelect: { id: true, name: true },  // 目标实体 select
  }
}

CrudService - 单表 CRUD

最基础的 CRUD 服务,适用于简单的单表操作。

定义

import { Injectable } from '@nestjs/common';
import type { Prisma, Teacher } from '@prisma/client';

import { CrudOptions } from '@/common/crud/crud.decorator';
import { CrudService } from '@/common/crud/crud.service';
import { PrismaService } from '@/prisma/prisma.service';

@Injectable()
@CrudOptions({
  filterableFields: [
    { field: 'name', operator: 'contains' },
    'teacherNo',
    'subject',
  ],
})
export class TeacherService extends CrudService<
  Teacher,                       // Entity 类型
  Prisma.TeacherCreateInput,     // CreateDto 类型
  Prisma.TeacherUpdateInput,     // UpdateDto 类型
  Prisma.TeacherWhereInput,      // WhereInput 类型(可选)
  Prisma.TeacherWhereUniqueInput // WhereUniqueInput 类型(可选)
> {
  constructor(prisma: PrismaService) {
    super(prisma, 'teacher');  // 模型名(小写)
  }

  // 可选:覆盖错误消息
  protected getNotFoundMessage(id: string): string {
    return `教师不存在: ${id}`;
  }

  protected getDeletedMessage(): string {
    return '教师已删除';
  }
}

内置方法

方法 描述 返回类型
findAll(params) 分页查询 PaginatedResponse<Entity>
findById(id) 根据 ID 查询 Entity
count(params) 统计数量 number
create(dto) 创建记录 Entity
update(id, dto) 更新记录 Entity
delete(id) 删除记录 { message: string }
findDeleted(params) 查询已删除记录(软删除) PaginatedResponse<Entity>
restore(id) 恢复已删除记录(软删除) Entity

使用示例

// Controller 中使用
@Controller('teachers')
export class TeacherController {
  constructor(private readonly teacherService: TeacherService) {}

  @Get()
  findAll(@Query() query: TeacherQueryDto) {
    // 自动根据 filterableFields 解析过滤条件
    return this.teacherService.findAll(query);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.teacherService.findById(id);
  }

  @Post()
  create(@Body() dto: CreateTeacherDto) {
    return this.teacherService.create(dto);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() dto: UpdateTeacherDto) {
    return this.teacherService.update(id, dto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.teacherService.delete(id);
  }
}

RelationCrudService - 带关联查询

继承自 CrudService,添加关联查询和一对一关系管理功能。

定义

import { Injectable } from '@nestjs/common';
import type { Prisma, Student } from '@prisma/client';

import { StudentResponseDto, UpdateStudentDto } from './dto/student.dto';

import { CrudOptions } from '@/common/crud/crud.decorator';
import { RelationCrudService } from '@/common/crud/relation-crud.service';
import { PrismaService } from '@/prisma/prisma.service';

// 带关联的实体类型
type StudentWithClass = Student & {
  class?: { id: string; code: string; name: true } | null;
};

@Injectable()
@CrudOptions({
  filterableFields: [
    { field: 'name', operator: 'contains' },
    'studentNo',
    'classId',
  ],
  relations: {
    class: { select: { id: true, code: true, name: true } },
  },
})
export class StudentService extends RelationCrudService<
  Student,
  Prisma.StudentCreateInput,
  UpdateStudentDto,
  Prisma.StudentWhereInput,
  Prisma.StudentWhereUniqueInput,
  StudentResponseDto  // ResponseDto 类型
> {
  constructor(prisma: PrismaService) {
    super(prisma, 'student');
  }

  protected getNotFoundMessage(id: string): string {
    return `学生不存在: ${id}`;
  }

  // 必须实现:实体转 DTO
  protected toResponseDto = (student: StudentWithClass): StudentResponseDto => ({
    id: student.id,
    studentNo: student.studentNo,
    name: student.name,
    gender: student.gender ?? undefined,
    phone: student.phone ?? undefined,
    email: student.email ?? undefined,
    classId: student.classId ?? undefined,
    class: student.class ?? undefined,
    createdAt: student.createdAt.toISOString(),
    updatedAt: student.updatedAt.toISOString(),
  });
}

内置方法

继承 CrudService 所有方法,新增:

方法 描述 返回类型
findAllWithRelations(params) 带关联的分页查询 PaginatedResponse<ResponseDto>
findByIdWithRelations(id) 带关联的详情查询 DetailResponseDto
assignOneToOne(entityId, relationName, targetId) 分配一对一关系 DetailResponseDto
removeOneToOne(entityId, relationName) 移除一对一关系 DetailResponseDto
getOneToOneTarget(entityId, relationName, transformer?) 获取一对一关联目标 T | null

一对一关系使用示例

@Injectable()
@CrudOptions({
  relations: {
    profile: { select: { id: true, bio: true, avatar: true } },
  },
  oneToOne: {
    profile: {
      foreignKey: 'profileId',
      select: { id: true, bio: true, avatar: true },
    },
  },
})
export class UserService extends RelationCrudService<...> {
  // ... toResponseDto 实现

  // 分配 Profile
  async assignProfile(userId: string, profileId: string) {
    return this.assignOneToOne(userId, 'profile', profileId);
  }

  // 移除 Profile设置 profileId 为 null
  async removeProfile(userId: string) {
    return this.removeOneToOne(userId, 'profile');
  }

  // 获取用户的 Profile
  async getProfile(userId: string) {
    return this.getOneToOneTarget(userId, 'profile');
  }
}

可覆盖的方法

// 覆盖详情 DTO 转换(默认使用 toResponseDto
protected toDetailDto(entity: StudentWithRelations): StudentDetailResponseDto {
  return {
    ...this.toResponseDto(entity),
    extraDetailField: entity.extraField,
  };
}

// 覆盖列表查询的 select 配置
protected getListSelect(): Record<string, boolean | object> | undefined {
  return {
    id: true,
    name: true,
    // 自定义字段选择
  };
}

ManyToManyCrudService - 多对多关系

继承自 RelationCrudService,添加多对多关系管理功能。

定义

import { Injectable } from '@nestjs/common';
import type { Class, Prisma } from '@prisma/client';

import {
  ClassResponseDto,
  ClassDetailResponseDto,
  TeacherBriefDto,
  AssignTeachersDto,
  UpdateClassDto,
} from './dto/class.dto';

import { CrudOptions } from '@/common/crud/crud.decorator';
import { ManyToManyCrudService } from '@/common/crud/many-to-many-crud.service';
import { PrismaService } from '@/prisma/prisma.service';

type ClassWithHeadTeacher = Class & {
  headTeacher?: { id: string; teacherNo: string; name: string; subject?: string | null } | null;
};

type ClassWithDetails = ClassWithHeadTeacher & {
  teachers?: Array<{
    teacher: { id: string; teacherNo: string; name: string; subject?: string | null };
  }>;
  _count?: { students: number };
};

@Injectable()
@CrudOptions({
  filterableFields: [
    { field: 'name', operator: 'contains' },
    'code',
    'grade',
  ],
  relations: {
    headTeacher: { select: { id: true, teacherNo: true, name: true, subject: true } },
  },
  manyToMany: {
    teachers: {
      through: 'classTeacher',        // 中间表
      foreignKey: 'classId',          // 当前实体外键
      targetKey: 'teacherId',         // 目标实体外键
      target: 'teacher',              // 目标模型
      targetSelect: { id: true, teacherNo: true, name: true, subject: true },
    },
  },
  countRelations: ['students'],       // 详情时统计学生数量
})
export class ClassService extends ManyToManyCrudService<
  Class,
  Prisma.ClassCreateInput,
  UpdateClassDto,
  Prisma.ClassWhereInput,
  Prisma.ClassWhereUniqueInput,
  ClassResponseDto,
  ClassDetailResponseDto  // 详情 DTO 类型(可选)
> {
  constructor(prisma: PrismaService) {
    super(prisma, 'class');
  }

  protected getNotFoundMessage(id: string): string {
    return `班级不存在: ${id}`;
  }

  // 列表 DTO 转换
  protected toResponseDto = (classEntity: ClassWithHeadTeacher): ClassResponseDto => ({
    id: classEntity.id,
    code: classEntity.code,
    name: classEntity.name,
    grade: classEntity.grade ?? undefined,
    headTeacherId: classEntity.headTeacherId ?? undefined,
    headTeacher: classEntity.headTeacher
      ? this.toTeacherBriefDto(classEntity.headTeacher)
      : undefined,
    createdAt: classEntity.createdAt.toISOString(),
    updatedAt: classEntity.updatedAt.toISOString(),
  });

  // 详情 DTO 转换(覆盖默认实现)
  protected override toDetailDto(classEntity: ClassWithDetails): ClassDetailResponseDto {
    return {
      id: classEntity.id,
      code: classEntity.code,
      name: classEntity.name,
      grade: classEntity.grade ?? undefined,
      headTeacherId: classEntity.headTeacherId ?? undefined,
      headTeacher: classEntity.headTeacher
        ? this.toTeacherBriefDto(classEntity.headTeacher)
        : undefined,
      teachers: classEntity.teachers?.map((ct) => this.toTeacherBriefDto(ct.teacher)) ?? [],
      studentCount: classEntity._count?.students ?? 0,
      createdAt: classEntity.createdAt.toISOString(),
      updatedAt: classEntity.updatedAt.toISOString(),
    };
  }

  // 辅助方法:转换教师
  private toTeacherBriefDto = (teacher: {
    id: string;
    teacherNo: string;
    name: string;
    subject?: string | null;
  }): TeacherBriefDto => ({
    id: teacher.id,
    teacherNo: teacher.teacherNo,
    name: teacher.name,
    subject: teacher.subject ?? undefined,
  });

  // 业务方法:分配任课教师
  async assignTeachers(classId: string, dto: AssignTeachersDto): Promise<ClassDetailResponseDto> {
    return this.assignManyToMany(classId, 'teachers', { targetIds: dto.teacherIds });
  }

  // 业务方法:获取任课教师列表
  async getTeachers(classId: string): Promise<TeacherBriefDto[]> {
    return this.getManyToManyTargets(
      classId,
      'teachers',
      this.toTeacherBriefDto as (item: Record<string, unknown>) => TeacherBriefDto
    );
  }
}

内置方法

继承 RelationCrudService 所有方法,新增:

方法 描述 返回类型
assignManyToMany(entityId, relationName, params) 分配多对多关系(替换或追加) DetailResponseDto
getManyToManyTargets(entityId, relationName, transformer?) 获取多对多关联的目标列表 T[]
addManyToManyRelations(entityId, relationName, targetIds) 追加多对多关系 void
removeManyToManyRelations(entityId, relationName, targetIds) 移除指定的多对多关系 void

assignManyToMany 参数

interface AssignManyToManyParams {
  targetIds: string[];  // 目标实体 ID 列表
  append?: boolean;     // true: 追加模式false: 替换模式(默认)
}

// 替换模式(默认):删除现有关系,建立新关系
await classService.assignManyToMany(classId, 'teachers', {
  targetIds: ['teacher1', 'teacher2']
});

// 追加模式:保留现有关系,追加新关系
await classService.assignManyToMany(classId, 'teachers', {
  targetIds: ['teacher3'],
  append: true,
});

装饰器

@CrudOptions

import { CrudOptions } from '@/common/crud/crud.decorator';

@Injectable()
@CrudOptions({
  defaultPageSize: 10,
  maxPageSize: 50,
  defaultSortBy: 'name',
  defaultSortOrder: 'asc',
  filterableFields: ['name', 'status'],
  relations: { ... },
  oneToOne: { ... },
  manyToMany: { ... },
  countRelations: ['items'],
})
export class MyService extends CrudService<...> {}

迁移指南

从手写分页/过滤迁移到 CrudService

Before:

@Injectable()
export class ProductService {
  constructor(private prisma: PrismaService) {}

  async findAll(params: ProductQueryDto) {
    const { page = 1, pageSize = 20, name, categoryId, sortBy, sortOrder } = params;

    const where: Prisma.ProductWhereInput = {};
    if (name) where.name = { contains: name };
    if (categoryId) where.categoryId = categoryId;

    const orderBy = sortBy ? { [sortBy]: sortOrder || 'desc' } : { createdAt: 'desc' };

    const [items, total] = await Promise.all([
      this.prisma.product.findMany({
        where,
        skip: (page - 1) * pageSize,
        take: pageSize,
        orderBy,
      }),
      this.prisma.product.count({ where }),
    ]);

    return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
  }
}

After:

@Injectable()
@CrudOptions({
  filterableFields: [
    { field: 'name', operator: 'contains' },
    'categoryId',
  ],
})
export class ProductService extends CrudService<
  Product,
  Prisma.ProductCreateInput,
  Prisma.ProductUpdateInput,
  Prisma.ProductWhereInput,
  Prisma.ProductWhereUniqueInput
> {
  constructor(prisma: PrismaService) {
    super(prisma, 'product');
  }
}

从手写关联查询迁移到 RelationCrudService

Before:

async findAllWithCategory(params: ProductQueryDto) {
  // 100+ 行重复的分页、过滤、排序逻辑...
  const items = await this.prisma.product.findMany({
    where,
    include: { category: { select: { id: true, name: true } } },
    // ...
  });
  return { items: items.map(this.toDto), ... };
}

After:

@CrudOptions({
  filterableFields: [{ field: 'name', operator: 'contains' }, 'categoryId'],
  relations: {
    category: { select: { id: true, name: true } },
  },
})
export class ProductService extends RelationCrudService<...> {
  protected toResponseDto = (product) => ({ ... });

  // findAllWithRelations() 自动可用
}

最佳实践

1. DTO 转换

始终定义 toResponseDto 方法,确保返回结构一致:

protected toResponseDto = (entity: EntityWithRelations): ResponseDto => ({
  id: entity.id,
  name: entity.name,
  // 处理可选字段
  description: entity.description ?? undefined,
  // 处理关联
  category: entity.category ?? undefined,
  // 处理日期
  createdAt: entity.createdAt.toISOString(),
});

2. 类型安全

定义完整的实体类型,包含关联:

// 基础实体 + 关联
type ProductWithCategory = Product & {
  category?: { id: string; name: string } | null;
};

// 详情实体(更多关联 + 统计)
type ProductWithDetails = ProductWithCategory & {
  tags?: Array<{ tag: { id: string; name: string } }>;
  _count?: { reviews: number };
};

3. 向后兼容方法名

保留原有方法名作为别名:

// 向后兼容
async findByIdWithCategory(id: string) {
  return this.findByIdWithRelations(id);
}

4. 配置与代码分离

使用 @CrudOptions 声明式配置,业务方法只关注特殊逻辑:

@CrudOptions({
  // 配置集中管理
  filterableFields: [...],
  relations: {...},
  manyToMany: {...},
})
export class ClassService extends ManyToManyCrudService<...> {
  // 业务方法简洁
  async assignTeachers(classId: string, dto: AssignTeachersDto) {
    return this.assignManyToMany(classId, 'teachers', { targetIds: dto.teacherIds });
  }
}

5. 错误消息本地化

覆盖消息方法提供友好的错误信息:

protected getNotFoundMessage(id: string): string {
  return `班级不存在: ${id}`;
}

protected getDeletedMessage(): string {
  return '班级已删除';
}

protected getDeletedNotFoundMessage(id: string): string {
  return `已删除的班级不存在: ${id}`;
}

文件清单

文件 描述
crud.types.ts 所有类型定义
crud.decorator.ts @CrudOptions 装饰器
base-crud.service.ts 抽象基类(通用辅助方法)
crud.service.ts 单表 CRUD 服务
relation-crud.service.ts 带关联查询 + 一对一管理
many-to-many-crud.service.ts 多对多关系管理

关系类型支持汇总

关系类型 服务类 配置项 读取方法 写入方法
单表 CrudService - findAll, findById create, update, delete
一对一 RelationCrudService oneToOne getOneToOneTarget assignOneToOne, removeOneToOne
一对多/多对一 RelationCrudService relations findAllWithRelations, findByIdWithRelations 通过外键直接更新
多对多 ManyToManyCrudService manyToMany getManyToManyTargets assignManyToMany, addManyToManyRelations, removeManyToManyRelations