- 新增 BaseCrudService 抽象基类,提取通用辅助方法 - 新增 RelationCrudService 支持关联查询和一对一关系管理 - 新增 ManyToManyCrudService 支持多对多关系管理 - 重构 CrudService 继承 BaseCrudService - 迁移 UserService 到 ManyToManyCrudService(用户-角色多对多) - 迁移 RoleService 到 ManyToManyCrudService(角色-权限、角色-菜单双多对多) - 更新 CrudService 使用文档 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
21 KiB
21 KiB
CrudService 分层架构使用文档
本文档介绍 apps/api/src/common/crud/ 目录下的 CRUD 服务分层架构,帮助开发者快速理解和使用。
目录
- 架构概览
- 服务类继承关系
- 配置选项
- CrudService - 单表 CRUD
- RelationCrudService - 带关联查询
- ManyToManyCrudService - 多对多关系
- 装饰器
- 迁移指南
- 最佳实践
架构概览
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 |