refactor(api): CrudService 分层架构重构
- 新增 BaseCrudService 抽象基类,提取通用辅助方法 - 新增 RelationCrudService 支持关联查询和一对一关系管理 - 新增 ManyToManyCrudService 支持多对多关系管理 - 重构 CrudService 继承 BaseCrudService - 迁移 UserService 到 ManyToManyCrudService(用户-角色多对多) - 迁移 RoleService 到 ManyToManyCrudService(角色-权限、角色-菜单双多对多) - 更新 CrudService 使用文档 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
225
apps/api/src/common/crud/base-crud.service.ts
Normal file
225
apps/api/src/common/crud/base-crud.service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { PaginatedResponse } from '@seclusion/shared';
|
||||
|
||||
import { getCrudOptions } from './crud.decorator';
|
||||
import {
|
||||
CrudServiceOptions,
|
||||
FilterableField,
|
||||
FilterFieldConfig,
|
||||
FindAllParams,
|
||||
OrderByItem,
|
||||
} from './crud.types';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* 分页参数构建结果
|
||||
*/
|
||||
export interface PaginationArgs {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
orderBy: OrderByItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD 抽象基类 - 提供通用辅助方法
|
||||
*
|
||||
* 子类包括:
|
||||
* - CrudService: 单表 CRUD
|
||||
* - RelationCrudService: 带关联查询
|
||||
* - ManyToManyCrudService: 多对多关系管理
|
||||
*/
|
||||
export abstract class BaseCrudService {
|
||||
protected readonly options: Required<CrudServiceOptions>;
|
||||
protected readonly prisma: PrismaService;
|
||||
|
||||
protected constructor(
|
||||
prisma: PrismaService,
|
||||
protected readonly modelName: string
|
||||
) {
|
||||
this.prisma = prisma;
|
||||
// 从装饰器获取配置,如果没有则使用默认配置
|
||||
this.options = getCrudOptions(this);
|
||||
}
|
||||
|
||||
// ============ 通用辅助方法 ============
|
||||
|
||||
/**
|
||||
* 获取默认 select 配置
|
||||
* 子类可覆盖此方法自定义返回字段
|
||||
*/
|
||||
protected getDefaultSelect(): Record<string, boolean> | undefined {
|
||||
return Object.keys(this.options.defaultSelect).length > 0
|
||||
? this.options.defaultSelect
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析多字段排序
|
||||
* @param sortBy 排序字段(逗号分隔)
|
||||
* @param sortOrder 排序方向(逗号分隔)
|
||||
* @returns Prisma orderBy 数组
|
||||
*/
|
||||
protected parseOrderBy(sortBy: string, sortOrder: string): OrderByItem[] {
|
||||
const fields = sortBy.split(',').map((s) => s.trim());
|
||||
const orders = sortOrder.split(',').map((s) => s.trim() as 'asc' | 'desc');
|
||||
|
||||
return fields.map((field, i) => ({
|
||||
[field]: orders[i] || orders[0] || 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为非分页查询
|
||||
*/
|
||||
protected isNoPagination(pageSize: number): boolean {
|
||||
return pageSize <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化过滤字段配置
|
||||
*/
|
||||
private normalizeFilterField(field: FilterableField): FilterFieldConfig {
|
||||
if (typeof field === 'string') {
|
||||
return { field, queryKey: field, operator: 'equals' };
|
||||
}
|
||||
return {
|
||||
field: field.field,
|
||||
queryKey: field.queryKey || field.field,
|
||||
operator: field.operator || 'equals',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置从参数中构建 where 条件
|
||||
*/
|
||||
protected buildFilterWhere(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {};
|
||||
const { filterableFields } = this.options;
|
||||
|
||||
if (!filterableFields.length) {
|
||||
return where;
|
||||
}
|
||||
|
||||
for (const fieldConfig of filterableFields) {
|
||||
const config = this.normalizeFilterField(fieldConfig);
|
||||
const value = params[config.queryKey!];
|
||||
|
||||
// 跳过未提供的参数
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据操作符构建条件
|
||||
switch (config.operator) {
|
||||
case 'equals':
|
||||
where[config.field] = value;
|
||||
break;
|
||||
case 'contains':
|
||||
where[config.field] = { contains: value };
|
||||
break;
|
||||
case 'startsWith':
|
||||
where[config.field] = { startsWith: value };
|
||||
break;
|
||||
case 'endsWith':
|
||||
where[config.field] = { endsWith: value };
|
||||
break;
|
||||
case 'in':
|
||||
// 支持逗号分隔的字符串或数组
|
||||
where[config.field] = {
|
||||
in: Array.isArray(value) ? value : String(value).split(','),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分页参数
|
||||
* @param params 查询参数
|
||||
* @returns 分页参数对象(skip, take, orderBy)
|
||||
*/
|
||||
protected buildPaginationArgs<WhereInput>(
|
||||
params: FindAllParams<WhereInput>
|
||||
): PaginationArgs & { isNoPagination: boolean; page: number; pageSize: number } {
|
||||
const paramsObj = params as FindAllParams<WhereInput> & Record<string, unknown>;
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = this.options.defaultPageSize,
|
||||
sortBy = this.options.defaultSortBy,
|
||||
sortOrder = this.options.defaultSortOrder,
|
||||
} = paramsObj;
|
||||
|
||||
const orderBy = this.parseOrderBy(sortBy, sortOrder);
|
||||
const isNoPagination = this.isNoPagination(pageSize);
|
||||
|
||||
if (isNoPagination) {
|
||||
return { orderBy, isNoPagination, page: 1, pageSize: 0 };
|
||||
}
|
||||
|
||||
const limitedPageSize = Math.min(pageSize, this.options.maxPageSize);
|
||||
const skip = (page - 1) * limitedPageSize;
|
||||
|
||||
return {
|
||||
skip,
|
||||
take: limitedPageSize,
|
||||
orderBy,
|
||||
isNoPagination,
|
||||
page,
|
||||
pageSize: limitedPageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分页响应
|
||||
*/
|
||||
protected buildPaginatedResponse<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
isNoPagination: boolean
|
||||
): PaginatedResponse<T> {
|
||||
if (isNoPagination) {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 可覆盖的消息方法 ============
|
||||
|
||||
/**
|
||||
* 获取记录不存在的错误消息
|
||||
*/
|
||||
protected getNotFoundMessage(id: string): string {
|
||||
return `记录不存在: ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取删除成功的消息
|
||||
*/
|
||||
protected getDeletedMessage(_id: string): string {
|
||||
return '记录已删除';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已删除记录不存在的错误消息
|
||||
*/
|
||||
protected getDeletedNotFoundMessage(id: string): string {
|
||||
return `已删除的记录不存在: ${id}`;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,8 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PaginatedResponse } from '@seclusion/shared';
|
||||
|
||||
import { getCrudOptions } from './crud.decorator';
|
||||
import {
|
||||
CrudServiceOptions,
|
||||
FilterableField,
|
||||
FilterFieldConfig,
|
||||
FindAllParams,
|
||||
PrismaDelegate,
|
||||
SoftDeletable,
|
||||
} from './crud.types';
|
||||
import { BaseCrudService } from './base-crud.service';
|
||||
import { FindAllParams, PrismaDelegate, SoftDeletable } from './crud.types';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
@@ -52,17 +45,9 @@ export abstract class CrudService<
|
||||
UpdateDto,
|
||||
WhereInput = Record<string, unknown>,
|
||||
WhereUniqueInput = { id: string },
|
||||
> {
|
||||
protected readonly options: Required<CrudServiceOptions>;
|
||||
protected readonly prisma: PrismaService;
|
||||
|
||||
protected constructor(
|
||||
prisma: PrismaService,
|
||||
protected readonly modelName: string
|
||||
) {
|
||||
this.prisma = prisma;
|
||||
// 从装饰器获取配置,如果没有则使用默认配置
|
||||
this.options = getCrudOptions(this);
|
||||
> extends BaseCrudService {
|
||||
protected constructor(prisma: PrismaService, modelName: string) {
|
||||
super(prisma, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,98 +69,6 @@ export abstract class CrudService<
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认 select 配置
|
||||
* 子类可覆盖此方法自定义返回字段
|
||||
*/
|
||||
protected getDefaultSelect(): Record<string, boolean> | undefined {
|
||||
return Object.keys(this.options.defaultSelect).length > 0
|
||||
? this.options.defaultSelect
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析多字段排序
|
||||
* @param sortBy 排序字段(逗号分隔)
|
||||
* @param sortOrder 排序方向(逗号分隔)
|
||||
* @returns Prisma orderBy 数组
|
||||
*/
|
||||
protected parseOrderBy(sortBy: string, sortOrder: string): Record<string, 'asc' | 'desc'>[] {
|
||||
const fields = sortBy.split(',').map((s) => s.trim());
|
||||
const orders = sortOrder.split(',').map((s) => s.trim() as 'asc' | 'desc');
|
||||
|
||||
return fields.map((field, i) => ({
|
||||
[field]: orders[i] || orders[0] || 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为非分页查询
|
||||
*/
|
||||
protected isNoPagination(pageSize: number): boolean {
|
||||
return pageSize <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化过滤字段配置
|
||||
*/
|
||||
private normalizeFilterField(field: FilterableField): FilterFieldConfig {
|
||||
if (typeof field === 'string') {
|
||||
return { field, queryKey: field, operator: 'equals' };
|
||||
}
|
||||
return {
|
||||
field: field.field,
|
||||
queryKey: field.queryKey || field.field,
|
||||
operator: field.operator || 'equals',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置从参数中构建 where 条件
|
||||
*/
|
||||
protected buildFilterWhere(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {};
|
||||
const { filterableFields } = this.options;
|
||||
|
||||
if (!filterableFields.length) {
|
||||
return where;
|
||||
}
|
||||
|
||||
for (const fieldConfig of filterableFields) {
|
||||
const config = this.normalizeFilterField(fieldConfig);
|
||||
const value = params[config.queryKey!];
|
||||
|
||||
// 跳过未提供的参数
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据操作符构建条件
|
||||
switch (config.operator) {
|
||||
case 'equals':
|
||||
where[config.field] = value;
|
||||
break;
|
||||
case 'contains':
|
||||
where[config.field] = { contains: value };
|
||||
break;
|
||||
case 'startsWith':
|
||||
where[config.field] = { startsWith: value };
|
||||
break;
|
||||
case 'endsWith':
|
||||
where[config.field] = { endsWith: value };
|
||||
break;
|
||||
case 'in':
|
||||
// 支持逗号分隔的字符串或数组
|
||||
where[config.field] = {
|
||||
in: Array.isArray(value) ? value : String(value).split(','),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询所有记录
|
||||
* pageSize <= 0 时返回所有数据(不分页)
|
||||
@@ -188,14 +81,7 @@ export abstract class CrudService<
|
||||
params: P = {} as P
|
||||
): Promise<PaginatedResponse<Entity>> {
|
||||
const paramsObj = params as P & Record<string, unknown>;
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = this.options.defaultPageSize,
|
||||
sortBy = this.options.defaultSortBy,
|
||||
sortOrder = this.options.defaultSortOrder,
|
||||
where: explicitWhere,
|
||||
...restParams
|
||||
} = paramsObj;
|
||||
const { where: explicitWhere, page: _page, pageSize: _pageSize, sortBy: _sortBy, sortOrder: _sortOrder, ...restParams } = paramsObj;
|
||||
|
||||
// 构建 where 条件:合并显式传入的 where 和自动解析的过滤条件
|
||||
const filterWhere = this.buildFilterWhere(restParams);
|
||||
@@ -204,53 +90,39 @@ export abstract class CrudService<
|
||||
...(explicitWhere as Record<string, unknown>),
|
||||
} as WhereInput;
|
||||
|
||||
// 解析多字段排序
|
||||
const orderBy = this.parseOrderBy(sortBy, sortOrder);
|
||||
// 构建分页参数
|
||||
const paginationArgs = this.buildPaginationArgs(params);
|
||||
const { orderBy, isNoPagination, page, pageSize } = paginationArgs;
|
||||
|
||||
// 非分页查询
|
||||
if (this.isNoPagination(pageSize)) {
|
||||
if (isNoPagination) {
|
||||
const items = await this.model.findMany({
|
||||
where: where as WhereInput,
|
||||
where,
|
||||
select: this.getDefaultSelect(),
|
||||
orderBy,
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
return this.buildPaginatedResponse(items, items.length, 1, items.length, true);
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
const limitedPageSize = Math.min(pageSize, this.options.maxPageSize);
|
||||
const skip = (page - 1) * limitedPageSize;
|
||||
|
||||
// 并行执行查询和计数
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where: where as WhereInput,
|
||||
where,
|
||||
select: this.getDefaultSelect(),
|
||||
skip,
|
||||
take: limitedPageSize,
|
||||
skip: paginationArgs.skip,
|
||||
take: paginationArgs.take,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where: where as WhereInput }),
|
||||
this.model.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize: limitedPageSize,
|
||||
totalPages: Math.ceil(total / limitedPageSize),
|
||||
};
|
||||
return this.buildPaginatedResponse(items, total, page, pageSize, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单条记录
|
||||
* @param id 记录 ID
|
||||
* @throws NotFoundException 当记录不存在时
|
||||
*/
|
||||
async findById(id: string): Promise<Entity> {
|
||||
@@ -272,9 +144,7 @@ export abstract class CrudService<
|
||||
* 1. 直接传 where 对象
|
||||
* 2. 传递查询参数,根据 filterableFields 配置自动构建 where
|
||||
*/
|
||||
async count<P extends { where?: WhereInput }>(
|
||||
params: P = {} as P
|
||||
): Promise<number> {
|
||||
async count<P extends { where?: WhereInput }>(params: P = {} as P): Promise<number> {
|
||||
const paramsObj = params as P & Record<string, unknown>;
|
||||
const { where: explicitWhere, ...restParams } = paramsObj;
|
||||
|
||||
@@ -350,13 +220,14 @@ export abstract class CrudService<
|
||||
throw new Error('此模型不支持软删除功能');
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = this.options.defaultPageSize,
|
||||
sortBy = 'deletedAt',
|
||||
sortOrder = 'desc',
|
||||
where = {} as WhereInput,
|
||||
} = params;
|
||||
const { where = {} as WhereInput } = params;
|
||||
|
||||
// 覆盖默认排序为 deletedAt
|
||||
const modifiedParams = {
|
||||
...params,
|
||||
sortBy: params.sortBy || 'deletedAt',
|
||||
sortOrder: params.sortOrder || 'desc',
|
||||
};
|
||||
|
||||
// 显式指定 deletedAt 条件,绕过自动过滤
|
||||
const deletedWhere = {
|
||||
@@ -364,46 +235,44 @@ export abstract class CrudService<
|
||||
deletedAt: { not: null },
|
||||
} as WhereInput;
|
||||
|
||||
// 解析多字段排序
|
||||
const orderBy = this.parseOrderBy(sortBy, sortOrder);
|
||||
// 构建分页参数
|
||||
const paginationArgs = this.buildPaginationArgs(modifiedParams);
|
||||
const { orderBy, isNoPagination, page, pageSize } = paginationArgs;
|
||||
|
||||
// 非分页查询
|
||||
if (this.isNoPagination(pageSize)) {
|
||||
if (isNoPagination) {
|
||||
const items = await this.model.findMany({
|
||||
where: deletedWhere,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items as (Entity & SoftDeletable)[],
|
||||
total: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
return this.buildPaginatedResponse(
|
||||
items as (Entity & SoftDeletable)[],
|
||||
items.length,
|
||||
1,
|
||||
items.length,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
const limitedPageSize = Math.min(pageSize, this.options.maxPageSize);
|
||||
const skip = (page - 1) * limitedPageSize;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where: deletedWhere,
|
||||
skip,
|
||||
take: limitedPageSize,
|
||||
skip: paginationArgs.skip,
|
||||
take: paginationArgs.take,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where: deletedWhere }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items as (Entity & SoftDeletable)[],
|
||||
return this.buildPaginatedResponse(
|
||||
items as (Entity & SoftDeletable)[],
|
||||
total,
|
||||
page,
|
||||
pageSize: limitedPageSize,
|
||||
totalPages: Math.ceil(total / limitedPageSize),
|
||||
};
|
||||
pageSize,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,27 +301,4 @@ export abstract class CrudService<
|
||||
select: this.getDefaultSelect(),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 可覆盖的消息方法 ============
|
||||
|
||||
/**
|
||||
* 获取记录不存在的错误消息
|
||||
*/
|
||||
protected getNotFoundMessage(id: string): string {
|
||||
return `记录不存在: ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取删除成功的消息
|
||||
*/
|
||||
protected getDeletedMessage(_id: string): string {
|
||||
return '记录已删除';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已删除记录不存在的错误消息
|
||||
*/
|
||||
protected getDeletedNotFoundMessage(id: string): string {
|
||||
return `已删除的记录不存在: ${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,54 @@ import { PaginationParams } from '@seclusion/shared';
|
||||
*/
|
||||
export type FilterOperator = 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'in';
|
||||
|
||||
// ============ 关联关系配置类型 ============
|
||||
|
||||
/**
|
||||
* 关联字段配置
|
||||
*/
|
||||
export interface RelationConfig {
|
||||
/** 关联字段的 select 配置 */
|
||||
select: Record<string, boolean | object>;
|
||||
/** 是否在列表查询中包含(默认 true) */
|
||||
includeInList?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多对多关系配置
|
||||
*/
|
||||
export interface ManyToManyConfig {
|
||||
/** 中间表名(如 'classTeacher') */
|
||||
through: string;
|
||||
/** 当前实体在中间表的外键(如 'classId') */
|
||||
foreignKey: string;
|
||||
/** 目标实体在中间表的外键(如 'teacherId') */
|
||||
targetKey: string;
|
||||
/** 目标实体模型名(如 'teacher') */
|
||||
target: string;
|
||||
/** 目标实体的 select 配置 */
|
||||
targetSelect?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多对多分配操作参数
|
||||
*/
|
||||
export interface AssignManyToManyParams {
|
||||
/** 要分配的目标实体 ID 列表 */
|
||||
targetIds: string[];
|
||||
/** 是否追加模式(默认 false,即替换现有关系) */
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一对一关系配置
|
||||
*/
|
||||
export interface OneToOneConfig {
|
||||
/** 外键字段名(如 'profileId',存储在当前实体上) */
|
||||
foreignKey: string;
|
||||
/** 关联的 select 配置(用于查询时包含关联数据) */
|
||||
select?: Record<string, boolean | object>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤字段配置
|
||||
*/
|
||||
@@ -40,6 +88,14 @@ export interface CrudServiceOptions {
|
||||
defaultSelect?: Record<string, boolean>;
|
||||
/** 可过滤字段配置 */
|
||||
filterableFields?: FilterableField[];
|
||||
/** 关联关系配置(用于 RelationCrudService) */
|
||||
relations?: Record<string, RelationConfig>;
|
||||
/** 一对一关系配置(用于 RelationCrudService 的 assignOneToOne) */
|
||||
oneToOne?: Record<string, OneToOneConfig>;
|
||||
/** 多对多关系配置(用于 ManyToManyCrudService) */
|
||||
manyToMany?: Record<string, ManyToManyConfig>;
|
||||
/** 详情查询时额外包含的关系(用于 _count 等) */
|
||||
countRelations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +108,10 @@ export const DEFAULT_CRUD_OPTIONS: Required<CrudServiceOptions> = {
|
||||
defaultSortOrder: 'desc',
|
||||
defaultSelect: {},
|
||||
filterableFields: [],
|
||||
relations: {},
|
||||
oneToOne: {},
|
||||
manyToMany: {},
|
||||
countRelations: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,10 +144,7 @@ export interface PrismaDelegate<T, CreateInput, UpdateInput, WhereInput, WhereUn
|
||||
take?: number;
|
||||
orderBy?: OrderByItem | OrderByItem[];
|
||||
}): Promise<T[]>;
|
||||
findUnique(args: {
|
||||
where: WhereUniqueInput;
|
||||
select?: Record<string, boolean>;
|
||||
}): Promise<T | null>;
|
||||
findUnique(args: { where: WhereUniqueInput; select?: Record<string, boolean> }): Promise<T | null>;
|
||||
findFirst(args?: { where?: WhereInput; select?: Record<string, boolean> }): Promise<T | null>;
|
||||
create(args: { data: CreateInput; select?: Record<string, boolean> }): Promise<T>;
|
||||
update(args: {
|
||||
|
||||
322
apps/api/src/common/crud/many-to-many-crud.service.ts
Normal file
322
apps/api/src/common/crud/many-to-many-crud.service.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { AssignManyToManyParams, ManyToManyConfig } from './crud.types';
|
||||
import { RelationCrudService } from './relation-crud.service';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Prisma 事务客户端类型
|
||||
*/
|
||||
type TransactionClient = Parameters<Parameters<PrismaService['client']['$transaction']>[0]>[0];
|
||||
|
||||
/**
|
||||
* 多对多关系管理的 CRUD 服务
|
||||
*
|
||||
* 继承自 RelationCrudService,添加多对多关系管理功能。
|
||||
* 通过 @CrudOptions 装饰器配置 manyToMany 即可自动处理多对多关系。
|
||||
*
|
||||
* @typeParam Entity - 实体类型
|
||||
* @typeParam CreateDto - 创建 DTO 类型
|
||||
* @typeParam UpdateDto - 更新 DTO 类型
|
||||
* @typeParam WhereInput - Prisma Where 输入类型
|
||||
* @typeParam WhereUniqueInput - Prisma WhereUnique 输入类型
|
||||
* @typeParam ResponseDto - 响应 DTO 类型
|
||||
* @typeParam DetailResponseDto - 详情响应 DTO 类型
|
||||
*
|
||||
* @example
|
||||
* @Injectable()
|
||||
* @CrudOptions({
|
||||
* filterableFields: ['name', 'code'],
|
||||
* relations: { headTeacher: { select: { id: true, name: 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<...> {
|
||||
* async assignTeachers(classId: string, dto: AssignTeachersDto) {
|
||||
* return this.assignManyToMany(classId, 'teachers', { targetIds: dto.teacherIds });
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export abstract class ManyToManyCrudService<
|
||||
Entity extends { id: string },
|
||||
CreateDto,
|
||||
UpdateDto,
|
||||
WhereInput = Record<string, unknown>,
|
||||
WhereUniqueInput = { id: string },
|
||||
ResponseDto = Entity,
|
||||
DetailResponseDto = ResponseDto,
|
||||
> extends RelationCrudService<
|
||||
Entity,
|
||||
CreateDto,
|
||||
UpdateDto,
|
||||
WhereInput,
|
||||
WhereUniqueInput,
|
||||
ResponseDto,
|
||||
DetailResponseDto
|
||||
> {
|
||||
protected constructor(prisma: PrismaService, modelName: string) {
|
||||
super(prisma, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多对多关系配置
|
||||
* @param relationName 关系名称(在 manyToMany 配置中的 key)
|
||||
* @throws Error 当关系未配置时
|
||||
*/
|
||||
protected getManyToManyConfig(relationName: string): ManyToManyConfig {
|
||||
const config = this.options.manyToMany[relationName];
|
||||
if (!config) {
|
||||
throw new Error(`多对多关系 "${relationName}" 未配置`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查实体是否存在
|
||||
* @param id 实体 ID
|
||||
* @throws NotFoundException 当实体不存在时
|
||||
*/
|
||||
protected async ensureEntityExists(id: string): Promise<void> {
|
||||
const entity = await this.model.findUnique({
|
||||
where: { id } as unknown as WhereUniqueInput,
|
||||
});
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getNotFoundMessage(id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配多对多关系
|
||||
* 默认为替换模式(先删除现有关系,再添加新关系)
|
||||
*
|
||||
* @param entityId 实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @param params 分配参数
|
||||
* @returns 更新后的详情
|
||||
*/
|
||||
async assignManyToMany(
|
||||
entityId: string,
|
||||
relationName: string,
|
||||
params: AssignManyToManyParams
|
||||
): Promise<DetailResponseDto> {
|
||||
const config = this.getManyToManyConfig(relationName);
|
||||
const { targetIds, append = false } = params;
|
||||
|
||||
// 检查实体是否存在
|
||||
await this.ensureEntityExists(entityId);
|
||||
|
||||
// 使用事务处理
|
||||
await this.prisma.client.$transaction(async (tx: TransactionClient) => {
|
||||
const txThroughModel = (tx as unknown as Record<string, unknown>)[
|
||||
config.through
|
||||
] as {
|
||||
deleteMany: (args: unknown) => Promise<unknown>;
|
||||
createMany: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
// 非追加模式:先删除现有关系
|
||||
if (!append) {
|
||||
await txThroughModel.deleteMany({
|
||||
where: { [config.foreignKey]: entityId },
|
||||
});
|
||||
}
|
||||
|
||||
// 添加新关系
|
||||
if (targetIds.length > 0) {
|
||||
await txThroughModel.createMany({
|
||||
data: targetIds.map((targetId) => ({
|
||||
[config.foreignKey]: entityId,
|
||||
[config.targetKey]: targetId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 返回更新后的详情
|
||||
return this.findByIdWithRelations(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多对多关系的目标实体列表
|
||||
*
|
||||
* @param entityId 实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @param transformer 可选的转换函数
|
||||
* @returns 目标实体列表
|
||||
*/
|
||||
async getManyToManyTargets<T = Record<string, unknown>>(
|
||||
entityId: string,
|
||||
relationName: string,
|
||||
transformer?: (item: Record<string, unknown>) => T
|
||||
): Promise<T[]> {
|
||||
const config = this.getManyToManyConfig(relationName);
|
||||
|
||||
// 检查实体是否存在
|
||||
await this.ensureEntityExists(entityId);
|
||||
|
||||
// 获取关系字段名(实体上的字段名,可能与配置的 relationName 不同)
|
||||
const relationFieldName = this.getRelationFieldName(relationName);
|
||||
|
||||
// 获取 Prisma 模型
|
||||
const prismaModel = (this.prisma as unknown as Record<string, unknown>)[
|
||||
this.modelName
|
||||
] as {
|
||||
findUnique: (args: unknown) => Promise<Record<string, unknown> | null>;
|
||||
};
|
||||
|
||||
// 查询实体及其关联
|
||||
const entity = await prismaModel.findUnique({
|
||||
where: { id: entityId },
|
||||
include: {
|
||||
[relationFieldName]: {
|
||||
include: {
|
||||
[config.target]: config.targetSelect
|
||||
? { select: config.targetSelect }
|
||||
: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getNotFoundMessage(entityId));
|
||||
}
|
||||
|
||||
// 提取目标实体
|
||||
const relations = entity[relationFieldName] as Array<Record<string, unknown>> | undefined;
|
||||
if (!relations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets = relations.map((relation) => relation[config.target] as Record<string, unknown>);
|
||||
|
||||
// 应用转换函数
|
||||
if (transformer) {
|
||||
return targets.map(transformer);
|
||||
}
|
||||
|
||||
return targets as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取关系字段名
|
||||
* 默认返回 relationName,子类可覆盖以映射不同的字段名
|
||||
*
|
||||
* @example
|
||||
* // ClassService 中配置的 relationName 是 'teachers'
|
||||
* // 但实体上的字段名是 'teachers'(通过中间表 classTeacher)
|
||||
* protected getRelationFieldName(relationName: string): string {
|
||||
* const mapping: Record<string, string> = {
|
||||
* teachers: 'teachers', // Class.teachers -> ClassTeacher[]
|
||||
* };
|
||||
* return mapping[relationName] || relationName;
|
||||
* }
|
||||
*/
|
||||
protected getRelationFieldName(relationName: string): string {
|
||||
return relationName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加多对多关系(追加模式)
|
||||
*
|
||||
* @param entityId 实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @param targetIds 要添加的目标实体 ID 列表
|
||||
*/
|
||||
async addManyToManyRelations(
|
||||
entityId: string,
|
||||
relationName: string,
|
||||
targetIds: string[]
|
||||
): Promise<void> {
|
||||
const config = this.getManyToManyConfig(relationName);
|
||||
|
||||
// 检查实体是否存在
|
||||
await this.ensureEntityExists(entityId);
|
||||
|
||||
// 获取中间表模型
|
||||
const throughModel = (this.prisma as unknown as Record<string, unknown>)[
|
||||
config.through
|
||||
] as {
|
||||
createMany: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
// 添加关系
|
||||
if (targetIds.length > 0) {
|
||||
await throughModel.createMany({
|
||||
data: targetIds.map((targetId) => ({
|
||||
[config.foreignKey]: entityId,
|
||||
[config.targetKey]: targetId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除多对多关系
|
||||
*
|
||||
* @param entityId 实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @param targetIds 要移除的目标实体 ID 列表
|
||||
*/
|
||||
async removeManyToManyRelations(
|
||||
entityId: string,
|
||||
relationName: string,
|
||||
targetIds: string[]
|
||||
): Promise<void> {
|
||||
const config = this.getManyToManyConfig(relationName);
|
||||
|
||||
// 检查实体是否存在
|
||||
await this.ensureEntityExists(entityId);
|
||||
|
||||
// 获取中间表模型
|
||||
const throughModel = (this.prisma as unknown as Record<string, unknown>)[
|
||||
config.through
|
||||
] as {
|
||||
deleteMany: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
// 移除关系
|
||||
if (targetIds.length > 0) {
|
||||
await throughModel.deleteMany({
|
||||
where: {
|
||||
[config.foreignKey]: entityId,
|
||||
[config.targetKey]: { in: targetIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 buildDetailInclude 以支持多对多关系
|
||||
* 包含普通关联 + 多对多关联 + _count 统计
|
||||
*/
|
||||
protected override buildDetailInclude(): Record<string, boolean | object> {
|
||||
const baseInclude = super.buildDetailInclude();
|
||||
const { manyToMany } = this.options;
|
||||
|
||||
// 添加多对多关联
|
||||
for (const [relationName, config] of Object.entries(manyToMany)) {
|
||||
const relationFieldName = this.getRelationFieldName(relationName);
|
||||
baseInclude[relationFieldName] = {
|
||||
include: {
|
||||
[config.target]: config.targetSelect
|
||||
? { select: config.targetSelect }
|
||||
: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return baseInclude;
|
||||
}
|
||||
}
|
||||
385
apps/api/src/common/crud/relation-crud.service.ts
Normal file
385
apps/api/src/common/crud/relation-crud.service.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@seclusion/shared';
|
||||
|
||||
import { CrudService } from './crud.service';
|
||||
import { FindAllParams, OneToOneConfig } from './crud.types';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* 带关联查询的 CRUD 服务
|
||||
*
|
||||
* 继承自 CrudService,添加关联查询功能。
|
||||
* 通过 @CrudOptions 装饰器配置 relations 即可自动处理关联查询。
|
||||
*
|
||||
* @typeParam Entity - 实体类型
|
||||
* @typeParam CreateDto - 创建 DTO 类型
|
||||
* @typeParam UpdateDto - 更新 DTO 类型
|
||||
* @typeParam WhereInput - Prisma Where 输入类型
|
||||
* @typeParam WhereUniqueInput - Prisma WhereUnique 输入类型
|
||||
* @typeParam ResponseDto - 响应 DTO 类型(用于 toResponseDto 转换)
|
||||
* @typeParam DetailResponseDto - 详情响应 DTO 类型(可选,默认同 ResponseDto)
|
||||
*
|
||||
* @example
|
||||
* @Injectable()
|
||||
* @CrudOptions({
|
||||
* filterableFields: ['name', '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
|
||||
* > {
|
||||
* constructor(prisma: PrismaService) {
|
||||
* super(prisma, 'student');
|
||||
* }
|
||||
*
|
||||
* protected toResponseDto = (entity: StudentWithRelations): StudentResponseDto => ({ ... });
|
||||
* }
|
||||
*/
|
||||
export abstract class RelationCrudService<
|
||||
Entity extends { id: string },
|
||||
CreateDto,
|
||||
UpdateDto,
|
||||
WhereInput = Record<string, unknown>,
|
||||
WhereUniqueInput = { id: string },
|
||||
ResponseDto = Entity,
|
||||
DetailResponseDto = ResponseDto,
|
||||
> extends CrudService<Entity, CreateDto, UpdateDto, WhereInput, WhereUniqueInput> {
|
||||
protected constructor(prisma: PrismaService, modelName: string) {
|
||||
super(prisma, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换实体为响应 DTO(子类必须实现)
|
||||
*/
|
||||
protected abstract toResponseDto(entity: Entity & Record<string, unknown>): ResponseDto;
|
||||
|
||||
/**
|
||||
* 转换实体为详情响应 DTO
|
||||
* 默认使用 toResponseDto,子类可覆盖
|
||||
*/
|
||||
protected toDetailDto(entity: Entity & Record<string, unknown>): DetailResponseDto {
|
||||
return this.toResponseDto(entity) as unknown as DetailResponseDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建关联 select 配置
|
||||
* 根据 relations 配置生成 Prisma select 对象
|
||||
*/
|
||||
protected buildRelationSelect(includeAll = false): Record<string, boolean | { select: Record<string, boolean | object> }> {
|
||||
const { relations } = this.options;
|
||||
const select: Record<string, boolean | { select: Record<string, boolean | object> }> = {};
|
||||
|
||||
for (const [relationName, config] of Object.entries(relations)) {
|
||||
// 仅在列表查询中包含 includeInList !== false 的关联
|
||||
if (!includeAll && config.includeInList === false) {
|
||||
continue;
|
||||
}
|
||||
select[relationName] = { select: config.select };
|
||||
}
|
||||
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建详情查询的 include 配置
|
||||
* 包含所有关联 + _count 统计
|
||||
*/
|
||||
protected buildDetailInclude(): Record<string, boolean | object> {
|
||||
const { relations, countRelations } = this.options;
|
||||
const include: Record<string, boolean | object> = {};
|
||||
|
||||
// 添加所有关联
|
||||
for (const [relationName, config] of Object.entries(relations)) {
|
||||
include[relationName] = { select: config.select };
|
||||
}
|
||||
|
||||
// 添加 _count 统计
|
||||
if (countRelations.length > 0) {
|
||||
include._count = {
|
||||
select: countRelations.reduce(
|
||||
(acc, relation) => ({ ...acc, [relation]: true }),
|
||||
{} as Record<string, boolean>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return include;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表查询的 select 配置
|
||||
* 可被子类覆盖以自定义字段选择
|
||||
*/
|
||||
protected getListSelect(): Record<string, boolean | object> | undefined {
|
||||
const defaultSelect = this.getDefaultSelect();
|
||||
const relationSelect = this.buildRelationSelect(false);
|
||||
|
||||
// 如果没有配置任何关联,使用默认 select
|
||||
if (Object.keys(relationSelect).length === 0) {
|
||||
return defaultSelect;
|
||||
}
|
||||
|
||||
// 合并默认 select 和关联 select
|
||||
if (defaultSelect) {
|
||||
return { ...defaultSelect, ...relationSelect };
|
||||
}
|
||||
|
||||
// 如果没有默认 select,需要手动指定基础字段
|
||||
// 这里返回 undefined 让 Prisma 返回所有字段,加上关联
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询列表(包含关联)
|
||||
* 自动根据配置的 relations 包含关联数据
|
||||
*/
|
||||
async findAllWithRelations<P extends FindAllParams<WhereInput>>(
|
||||
params: P = {} as P
|
||||
): Promise<PaginatedResponse<ResponseDto>> {
|
||||
const paramsObj = params as P & Record<string, unknown>;
|
||||
const {
|
||||
where: explicitWhere,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
sortBy: _sortBy,
|
||||
sortOrder: _sortOrder,
|
||||
...restParams
|
||||
} = paramsObj;
|
||||
|
||||
// 构建 where 条件
|
||||
const filterWhere = this.buildFilterWhere(restParams);
|
||||
const where = {
|
||||
...filterWhere,
|
||||
...(explicitWhere as Record<string, unknown>),
|
||||
} as WhereInput;
|
||||
|
||||
// 构建分页参数
|
||||
const paginationArgs = this.buildPaginationArgs(params);
|
||||
const { orderBy, isNoPagination, page, pageSize } = paginationArgs;
|
||||
|
||||
// 构建 select/include
|
||||
const relationSelect = this.buildRelationSelect(false);
|
||||
const hasRelations = Object.keys(relationSelect).length > 0;
|
||||
|
||||
// 获取 Prisma 模型
|
||||
const prismaModel = (this.prisma as unknown as Record<string, unknown>)[
|
||||
this.modelName
|
||||
] as {
|
||||
findMany: (args: unknown) => Promise<(Entity & Record<string, unknown>)[]>;
|
||||
count: (args: unknown) => Promise<number>;
|
||||
};
|
||||
|
||||
// 构建查询参数
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
where,
|
||||
orderBy,
|
||||
};
|
||||
|
||||
// 使用 select 还是 include
|
||||
if (hasRelations) {
|
||||
const defaultSelect = this.getDefaultSelect();
|
||||
if (defaultSelect) {
|
||||
queryArgs.select = { ...defaultSelect, ...relationSelect };
|
||||
} else {
|
||||
queryArgs.include = relationSelect;
|
||||
}
|
||||
} else {
|
||||
const defaultSelect = this.getDefaultSelect();
|
||||
if (defaultSelect) {
|
||||
queryArgs.select = defaultSelect;
|
||||
}
|
||||
}
|
||||
|
||||
// 非分页查询
|
||||
if (isNoPagination) {
|
||||
const items = await prismaModel.findMany(queryArgs);
|
||||
const dtoItems = items.map((item) => this.toResponseDto(item));
|
||||
|
||||
return this.buildPaginatedResponse(dtoItems, dtoItems.length, 1, dtoItems.length, true);
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
queryArgs.skip = paginationArgs.skip;
|
||||
queryArgs.take = paginationArgs.take;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prismaModel.findMany(queryArgs),
|
||||
prismaModel.count({ where }),
|
||||
]);
|
||||
|
||||
const dtoItems = items.map((item) => this.toResponseDto(item));
|
||||
|
||||
return this.buildPaginatedResponse(dtoItems, total, page, pageSize, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询详情(包含所有关联和统计)
|
||||
*/
|
||||
async findByIdWithRelations(id: string): Promise<DetailResponseDto> {
|
||||
const include = this.buildDetailInclude();
|
||||
const hasInclude = Object.keys(include).length > 0;
|
||||
|
||||
// 获取 Prisma 模型
|
||||
const prismaModel = (this.prisma as unknown as Record<string, unknown>)[
|
||||
this.modelName
|
||||
] as {
|
||||
findUnique: (args: unknown) => Promise<(Entity & Record<string, unknown>) | null>;
|
||||
};
|
||||
|
||||
// 构建查询参数
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
where: { id },
|
||||
};
|
||||
|
||||
if (hasInclude) {
|
||||
queryArgs.include = include;
|
||||
} else {
|
||||
const defaultSelect = this.getDefaultSelect();
|
||||
if (defaultSelect) {
|
||||
queryArgs.select = defaultSelect;
|
||||
}
|
||||
}
|
||||
|
||||
const entity = await prismaModel.findUnique(queryArgs);
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getNotFoundMessage(id));
|
||||
}
|
||||
|
||||
return this.toDetailDto(entity);
|
||||
}
|
||||
|
||||
// ============ 一对一关系辅助方法 ============
|
||||
|
||||
/**
|
||||
* 获取一对一关系配置
|
||||
* @param relationName 关系名称(在 oneToOne 配置中的 key)
|
||||
* @throws Error 当关系未配置时
|
||||
*/
|
||||
protected getOneToOneConfig(relationName: string): OneToOneConfig {
|
||||
const config = this.options.oneToOne[relationName];
|
||||
if (!config) {
|
||||
throw new Error(`一对一关系 "${relationName}" 未配置`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配一对一关系
|
||||
* 将目标实体关联到当前实体
|
||||
*
|
||||
* @param entityId 当前实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @param targetId 目标实体 ID(传 null 移除关系)
|
||||
* @returns 更新后的详情
|
||||
*
|
||||
* @example
|
||||
* // 配置
|
||||
* @CrudOptions({
|
||||
* oneToOne: {
|
||||
* profile: { foreignKey: 'profileId', select: { id: true, bio: true } }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // 使用
|
||||
* await userService.assignOneToOne(userId, 'profile', profileId);
|
||||
*/
|
||||
async assignOneToOne(
|
||||
entityId: string,
|
||||
relationName: string,
|
||||
targetId: string | null
|
||||
): Promise<DetailResponseDto> {
|
||||
const config = this.getOneToOneConfig(relationName);
|
||||
|
||||
// 检查实体是否存在
|
||||
const entity = await this.model.findUnique({
|
||||
where: { id: entityId } as unknown as WhereUniqueInput,
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getNotFoundMessage(entityId));
|
||||
}
|
||||
|
||||
// 更新外键
|
||||
await this.model.update({
|
||||
where: { id: entityId } as unknown as WhereUniqueInput,
|
||||
data: { [config.foreignKey]: targetId } as unknown as UpdateDto,
|
||||
});
|
||||
|
||||
// 返回更新后的详情
|
||||
return this.findByIdWithRelations(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一对一关系
|
||||
* 等同于 assignOneToOne(entityId, relationName, null)
|
||||
*
|
||||
* @param entityId 当前实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @returns 更新后的详情
|
||||
*/
|
||||
async removeOneToOne(
|
||||
entityId: string,
|
||||
relationName: string
|
||||
): Promise<DetailResponseDto> {
|
||||
return this.assignOneToOne(entityId, relationName, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一对一关联的目标实体
|
||||
*
|
||||
* @param entityId 当前实体 ID
|
||||
* @param relationName 关系名称
|
||||
* @param transformer 可选的转换函数
|
||||
* @returns 目标实体或 null
|
||||
*/
|
||||
async getOneToOneTarget<T = Record<string, unknown>>(
|
||||
entityId: string,
|
||||
relationName: string,
|
||||
transformer?: (item: Record<string, unknown>) => T
|
||||
): Promise<T | null> {
|
||||
const config = this.getOneToOneConfig(relationName);
|
||||
|
||||
// 获取 Prisma 模型
|
||||
const prismaModel = (this.prisma as unknown as Record<string, unknown>)[
|
||||
this.modelName
|
||||
] as {
|
||||
findUnique: (args: unknown) => Promise<Record<string, unknown> | null>;
|
||||
};
|
||||
|
||||
// 构建查询,包含关联
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
where: { id: entityId },
|
||||
include: {
|
||||
[relationName]: config.select ? { select: config.select } : true,
|
||||
},
|
||||
};
|
||||
|
||||
const entity = await prismaModel.findUnique(queryArgs);
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException(this.getNotFoundMessage(entityId));
|
||||
}
|
||||
|
||||
const target = entity[relationName] as Record<string, unknown> | null;
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 应用转换函数
|
||||
if (transformer) {
|
||||
return transformer(target);
|
||||
}
|
||||
|
||||
return target as T;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Role, Prisma } from '@prisma/client';
|
||||
import type { RoleDetailResponse } from '@seclusion/shared';
|
||||
import { Role, Prisma, Permission, Menu } from '@prisma/client';
|
||||
import type { MenuMeta, MenuType } from '@seclusion/shared';
|
||||
|
||||
import { CreateRoleDto, UpdateRoleDto, RoleDetailResponseDto } from '../dto/role.dto';
|
||||
import { CreateRoleDto, UpdateRoleDto, RoleResponseDto, RoleDetailResponseDto } from '../dto/role.dto';
|
||||
|
||||
import { CrudOptions } from '@/common/crud/crud.decorator';
|
||||
import { CrudService } from '@/common/crud/crud.service';
|
||||
import { ManyToManyCrudService } from '@/common/crud/many-to-many-crud.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
// 带关联的角色类型
|
||||
type RoleWithRelations = Role & {
|
||||
permissions?: Array<{ permission: Permission }>;
|
||||
menus?: Array<{ menu: Menu }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@CrudOptions({
|
||||
defaultPageSize: 20,
|
||||
@@ -25,13 +31,29 @@ import { PrismaService } from '@/prisma/prisma.service';
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
manyToMany: {
|
||||
permissions: {
|
||||
through: 'rolePermission',
|
||||
foreignKey: 'roleId',
|
||||
targetKey: 'permissionId',
|
||||
target: 'permission',
|
||||
},
|
||||
menus: {
|
||||
through: 'roleMenu',
|
||||
foreignKey: 'roleId',
|
||||
targetKey: 'menuId',
|
||||
target: 'menu',
|
||||
},
|
||||
},
|
||||
})
|
||||
export class RoleService extends CrudService<
|
||||
export class RoleService extends ManyToManyCrudService<
|
||||
Role,
|
||||
Prisma.RoleCreateInput,
|
||||
Prisma.RoleUpdateInput,
|
||||
Prisma.RoleWhereInput,
|
||||
Prisma.RoleWhereUniqueInput
|
||||
Prisma.RoleWhereUniqueInput,
|
||||
RoleResponseDto,
|
||||
RoleDetailResponseDto
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'role');
|
||||
@@ -49,6 +71,66 @@ export class RoleService extends CrudService<
|
||||
return '已删除的角色不存在';
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表 DTO 转换
|
||||
*/
|
||||
protected toResponseDto = (role: Role): RoleResponseDto => ({
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isSystem: role.isSystem,
|
||||
isEnabled: role.isEnabled,
|
||||
sort: role.sort,
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 详情 DTO 转换(包含权限和菜单)
|
||||
*/
|
||||
protected override toDetailDto(role: RoleWithRelations): RoleDetailResponseDto {
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isSystem: role.isSystem,
|
||||
isEnabled: role.isEnabled,
|
||||
sort: role.sort,
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
permissions: role.permissions?.map((rp) => ({
|
||||
id: rp.permission.id,
|
||||
code: rp.permission.code,
|
||||
name: rp.permission.name,
|
||||
description: rp.permission.description,
|
||||
resource: rp.permission.resource,
|
||||
action: rp.permission.action,
|
||||
isEnabled: rp.permission.isEnabled,
|
||||
createdAt: rp.permission.createdAt.toISOString(),
|
||||
updatedAt: rp.permission.updatedAt.toISOString(),
|
||||
})) ?? [],
|
||||
menus: role.menus?.map((rm) => ({
|
||||
id: rm.menu.id,
|
||||
parentId: rm.menu.parentId,
|
||||
code: rm.menu.code,
|
||||
name: rm.menu.name,
|
||||
type: rm.menu.type as MenuType,
|
||||
path: rm.menu.path,
|
||||
icon: rm.menu.icon,
|
||||
isExternal: rm.menu.isExternal,
|
||||
isHidden: rm.menu.isHidden,
|
||||
isEnabled: rm.menu.isEnabled,
|
||||
isStatic: rm.menu.isStatic,
|
||||
sort: rm.menu.sort,
|
||||
meta: rm.menu.meta as MenuMeta | null,
|
||||
createdAt: rm.menu.createdAt.toISOString(),
|
||||
updatedAt: rm.menu.updatedAt.toISOString(),
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建角色(包含权限和菜单分配)
|
||||
*/
|
||||
@@ -62,24 +144,12 @@ export class RoleService extends CrudService<
|
||||
|
||||
// 分配权限
|
||||
if (permissionIds?.length) {
|
||||
await this.prisma.rolePermission.createMany({
|
||||
data: permissionIds.map((permissionId) => ({
|
||||
roleId: role.id,
|
||||
permissionId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await this.addManyToManyRelations(role.id, 'permissions', permissionIds);
|
||||
}
|
||||
|
||||
// 分配菜单
|
||||
if (menuIds?.length) {
|
||||
await this.prisma.roleMenu.createMany({
|
||||
data: menuIds.map((menuId) => ({
|
||||
roleId: role.id,
|
||||
menuId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await this.addManyToManyRelations(role.id, 'menus', menuIds);
|
||||
}
|
||||
|
||||
return role;
|
||||
@@ -91,11 +161,8 @@ export class RoleService extends CrudService<
|
||||
async updateRole(id: string, dto: UpdateRoleDto): Promise<Role> {
|
||||
const { permissionIds, menuIds, ...roleData } = dto;
|
||||
|
||||
// 检查是否为系统角色
|
||||
const existingRole = await this.findById(id);
|
||||
if (existingRole.isSystem && dto.name === undefined && dto.description === undefined) {
|
||||
// 系统角色只允许修改名称和描述
|
||||
}
|
||||
// 检查是否存在
|
||||
await this.findById(id);
|
||||
|
||||
// 更新角色基本信息
|
||||
const role = await this.prisma.role.update({
|
||||
@@ -103,42 +170,14 @@ export class RoleService extends CrudService<
|
||||
data: roleData,
|
||||
});
|
||||
|
||||
// 更新权限
|
||||
// 更新权限(替换模式)
|
||||
if (permissionIds !== undefined) {
|
||||
// 删除现有权限
|
||||
await this.prisma.rolePermission.deleteMany({
|
||||
where: { roleId: id },
|
||||
});
|
||||
|
||||
// 添加新权限
|
||||
if (permissionIds.length) {
|
||||
await this.prisma.rolePermission.createMany({
|
||||
data: permissionIds.map((permissionId) => ({
|
||||
roleId: id,
|
||||
permissionId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await this.assignManyToMany(id, 'permissions', { targetIds: permissionIds });
|
||||
}
|
||||
|
||||
// 更新菜单
|
||||
// 更新菜单(替换模式)
|
||||
if (menuIds !== undefined) {
|
||||
// 删除现有菜单
|
||||
await this.prisma.roleMenu.deleteMany({
|
||||
where: { roleId: id },
|
||||
});
|
||||
|
||||
// 添加新菜单
|
||||
if (menuIds.length) {
|
||||
await this.prisma.roleMenu.createMany({
|
||||
data: menuIds.map((menuId) => ({
|
||||
roleId: id,
|
||||
menuId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await this.assignManyToMany(id, 'menus', { targetIds: menuIds });
|
||||
}
|
||||
|
||||
return role;
|
||||
@@ -160,66 +199,8 @@ export class RoleService extends CrudService<
|
||||
/**
|
||||
* 获取角色详情(包含权限和菜单)
|
||||
*/
|
||||
async findByIdWithDetail(id: string): Promise<RoleDetailResponse> {
|
||||
const role = await this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
menus: {
|
||||
include: {
|
||||
menu: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new BadRequestException(this.getNotFoundMessage());
|
||||
}
|
||||
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isSystem: role.isSystem,
|
||||
isEnabled: role.isEnabled,
|
||||
sort: role.sort,
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
updatedAt: role.updatedAt.toISOString(),
|
||||
permissions: role.permissions.map((rp) => ({
|
||||
id: rp.permission.id,
|
||||
code: rp.permission.code,
|
||||
name: rp.permission.name,
|
||||
description: rp.permission.description,
|
||||
resource: rp.permission.resource,
|
||||
action: rp.permission.action,
|
||||
isEnabled: rp.permission.isEnabled,
|
||||
createdAt: rp.permission.createdAt.toISOString(),
|
||||
updatedAt: rp.permission.updatedAt.toISOString(),
|
||||
})),
|
||||
menus: role.menus.map((rm) => ({
|
||||
id: rm.menu.id,
|
||||
parentId: rm.menu.parentId,
|
||||
code: rm.menu.code,
|
||||
name: rm.menu.name,
|
||||
type: rm.menu.type as RoleDetailResponseDto['menus'][0]['type'],
|
||||
path: rm.menu.path,
|
||||
icon: rm.menu.icon,
|
||||
isExternal: rm.menu.isExternal,
|
||||
isHidden: rm.menu.isHidden,
|
||||
isEnabled: rm.menu.isEnabled,
|
||||
isStatic: rm.menu.isStatic,
|
||||
sort: rm.menu.sort,
|
||||
meta: rm.menu.meta as RoleDetailResponseDto['menus'][0]['meta'],
|
||||
createdAt: rm.menu.createdAt.toISOString(),
|
||||
updatedAt: rm.menu.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
async findByIdWithDetail(id: string): Promise<RoleDetailResponseDto> {
|
||||
return this.findByIdWithRelations(id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, User } from '@prisma/client';
|
||||
import type { AssignRolesDto, UserWithRolesResponse } from '@seclusion/shared';
|
||||
import type { AssignRolesDto } from '@seclusion/shared';
|
||||
|
||||
import { UpdateUserDto } from './dto/user.dto';
|
||||
import { UpdateUserDto, UserResponseDto, UserWithRolesResponseDto } from './dto/user.dto';
|
||||
|
||||
import { CrudOptions } from '@/common/crud/crud.decorator';
|
||||
import { CrudService } from '@/common/crud/crud.service';
|
||||
import { ManyToManyCrudService } from '@/common/crud/many-to-many-crud.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
// 带角色关联的用户类型
|
||||
type UserWithRoles = User & {
|
||||
roles?: Array<{
|
||||
role: { id: string; code: string; name: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@CrudOptions({
|
||||
defaultPageSize: 20,
|
||||
@@ -22,13 +29,24 @@ import { PrismaService } from '@/prisma/prisma.service';
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
manyToMany: {
|
||||
roles: {
|
||||
through: 'userRole',
|
||||
foreignKey: 'userId',
|
||||
targetKey: 'roleId',
|
||||
target: 'role',
|
||||
targetSelect: { id: true, code: true, name: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
export class UserService extends CrudService<
|
||||
export class UserService extends ManyToManyCrudService<
|
||||
User,
|
||||
Prisma.UserCreateInput,
|
||||
UpdateUserDto,
|
||||
Prisma.UserWhereInput,
|
||||
Prisma.UserWhereUniqueInput
|
||||
Prisma.UserWhereUniqueInput,
|
||||
UserResponseDto,
|
||||
UserWithRolesResponseDto
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'user');
|
||||
@@ -47,76 +65,45 @@ export class UserService extends CrudService<
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情(包含角色信息)
|
||||
* 列表 DTO 转换
|
||||
*/
|
||||
async findByIdWithRoles(id: string): Promise<UserWithRolesResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
protected toResponseDto = (user: User): UserResponseDto => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarId: user.avatarId,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 详情 DTO 转换(包含角色)
|
||||
*/
|
||||
protected override toDetailDto(user: UserWithRoles): UserWithRolesResponseDto {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarId: user.avatarId,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
roles: user.roles.map((ur) => ur.role),
|
||||
roles: user.roles?.map((ur) => ur.role) ?? [],
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情(包含角色信息)
|
||||
*/
|
||||
async findByIdWithRoles(id: string): Promise<UserWithRolesResponseDto> {
|
||||
return this.findByIdWithRelations(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配角色给用户
|
||||
* 会先删除用户现有的所有角色,再分配新角色
|
||||
*/
|
||||
async assignRoles(userId: string, dto: AssignRolesDto): Promise<UserWithRolesResponse> {
|
||||
// 检查用户是否存在
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 使用事务:先删除所有现有角色,再添加新角色
|
||||
await this.prisma.client.$transaction(async (tx) => {
|
||||
// 删除现有角色关联
|
||||
await tx.userRole.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
// 添加新角色关联
|
||||
if (dto.roleIds.length > 0) {
|
||||
await tx.userRole.createMany({
|
||||
data: dto.roleIds.map((roleId) => ({
|
||||
userId,
|
||||
roleId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 返回更新后的用户信息
|
||||
return this.findByIdWithRoles(userId);
|
||||
async assignRoles(userId: string, dto: AssignRolesDto): Promise<UserWithRolesResponseDto> {
|
||||
return this.assignManyToMany(userId, 'roles', { targetIds: dto.roleIds });
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
| ---------------------------------------------------- | -------------------- | -------------------------------- |
|
||||
| [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 |
|
||||
| [backend/soft-delete.md](./backend/soft-delete.md) | 软删除设计文档 | 了解软删除机制、扩展新模型 |
|
||||
| [backend/crud-service.md](./backend/crud-service.md) | CRUD Service 模板 | 快速创建标准 CRUD 服务 |
|
||||
| [api/crud-service.md](./api/crud-service.md) | CrudService 分层架构 | CRUD 服务开发、关联查询、多对多关系 |
|
||||
| [../plop/README.md](../plop/README.md) | CRUD 代码生成器 | 一键生成全栈 CRUD 模块 |
|
||||
|
||||
## 任务文档
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
# 菜单及路由管理体系实施文档
|
||||
|
||||
## 需求确认
|
||||
|
||||
| 项目 | 选择 |
|
||||
|------|------|
|
||||
| 菜单来源 | 混合模式(核心菜单静态 + 扩展菜单数据库)|
|
||||
| 权限粒度 | 按钮级别 |
|
||||
| 权限模型 | RBAC(角色-权限模型)|
|
||||
| 超级管理员 | 需要内置 |
|
||||
| 权限编码 | `资源:操作` 格式(如 `user:create`)|
|
||||
| 前端权限 | Hook + 组件 + 指令 |
|
||||
| 路由守卫 | 自动拦截(跳转 403)|
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库模型设计
|
||||
|
||||
### 新增模型
|
||||
|
||||
```prisma
|
||||
// 角色表
|
||||
model Role {
|
||||
id String @id @default(cuid(2))
|
||||
code String @unique // 角色编码: admin, user
|
||||
name String // 角色名称
|
||||
description String?
|
||||
isSystem Boolean @default(false) // 系统内置角色不可删
|
||||
isEnabled Boolean @default(true)
|
||||
sort Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
users UserRole[]
|
||||
permissions RolePermission[]
|
||||
menus RoleMenu[]
|
||||
}
|
||||
|
||||
// 权限表
|
||||
model Permission {
|
||||
id String @id @default(cuid(2))
|
||||
code String @unique // 权限编码: user:create
|
||||
name String
|
||||
description String?
|
||||
resource String // 资源: user
|
||||
action String // 操作: create
|
||||
isEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
roles RolePermission[]
|
||||
}
|
||||
|
||||
// 菜单表
|
||||
model Menu {
|
||||
id String @id @default(cuid(2))
|
||||
parentId String?
|
||||
code String @unique
|
||||
name String
|
||||
type String @default("menu") // dir / menu / button
|
||||
path String?
|
||||
icon String? // Lucide 图标名
|
||||
component String?
|
||||
permission String? // 关联权限编码
|
||||
isExternal Boolean @default(false)
|
||||
isHidden Boolean @default(false)
|
||||
isEnabled Boolean @default(true)
|
||||
isStatic Boolean @default(true) // 静态/动态菜单
|
||||
sort Int @default(0)
|
||||
meta Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
parent Menu? @relation("MenuTree", fields: [parentId], references: [id])
|
||||
children Menu[] @relation("MenuTree")
|
||||
roles RoleMenu[]
|
||||
}
|
||||
|
||||
// 关联表
|
||||
model UserRole { ... }
|
||||
model RolePermission { ... }
|
||||
model RoleMenu { ... }
|
||||
|
||||
// User 模型扩展
|
||||
model User {
|
||||
// ... 现有字段
|
||||
isSuperAdmin Boolean @default(false) // 超级管理员标记
|
||||
roles UserRole[]
|
||||
}
|
||||
```
|
||||
|
||||
### 超级管理员设计
|
||||
|
||||
- `User.isSuperAdmin` 字段标记
|
||||
- 超管拥有所有权限,跳过权限检查
|
||||
- 超管标记仅允许数据库直接修改
|
||||
|
||||
---
|
||||
|
||||
## 二、共享类型定义
|
||||
|
||||
**文件**: `packages/shared/src/types/permission.ts`
|
||||
|
||||
### 枚举常量
|
||||
|
||||
```typescript
|
||||
export const MenuType = { DIR: 'dir', MENU: 'menu', BUTTON: 'button' } as const;
|
||||
export const SystemRoleCode = { SUPER_ADMIN: 'super_admin', ADMIN: 'admin', USER: 'user' } as const;
|
||||
export const PermissionAction = { CREATE: 'create', READ: 'read', UPDATE: 'update', DELETE: 'delete' } as const;
|
||||
```
|
||||
|
||||
### 接口类型
|
||||
|
||||
- `RoleResponse` / `RoleDetailResponse` - 角色响应
|
||||
- `PermissionResponse` - 权限响应
|
||||
- `MenuResponse` / `MenuTreeNode` - 菜单响应
|
||||
- `AuthUserWithPermissions` - 带权限的认证用户
|
||||
- `UserMenusAndPermissionsResponse` - 用户菜单和权限响应
|
||||
|
||||
---
|
||||
|
||||
## 三、后端模块设计
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
apps/api/src/permission/
|
||||
├── decorators/
|
||||
│ ├── index.ts
|
||||
│ └── require-permission.decorator.ts
|
||||
├── guards/
|
||||
│ ├── index.ts
|
||||
│ └── permission.guard.ts
|
||||
├── dto/
|
||||
│ ├── index.ts
|
||||
│ ├── permission.dto.ts
|
||||
│ ├── role.dto.ts
|
||||
│ └── menu.dto.ts
|
||||
├── services/
|
||||
│ ├── index.ts
|
||||
│ ├── permission.service.ts
|
||||
│ ├── role.service.ts
|
||||
│ └── menu.service.ts
|
||||
├── controllers/
|
||||
│ ├── index.ts
|
||||
│ ├── permission.controller.ts
|
||||
│ ├── role.controller.ts
|
||||
│ └── menu.controller.ts
|
||||
├── index.ts
|
||||
└── permission.module.ts
|
||||
```
|
||||
|
||||
### 核心实现
|
||||
|
||||
**权限装饰器** `@RequirePermission(...permissions)`
|
||||
|
||||
```typescript
|
||||
export const RequirePermission = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSION_KEY, permissions);
|
||||
```
|
||||
|
||||
**权限守卫** `PermissionGuard`
|
||||
|
||||
```typescript
|
||||
canActivate(context) {
|
||||
const permissions = reflector.get(PERMISSION_KEY, handler);
|
||||
if (!permissions) return true;
|
||||
|
||||
const user = request.user;
|
||||
if (user.isSuperAdmin) return true; // 超管直接通过
|
||||
|
||||
return permissions.some(p => user.permissions.includes(p));
|
||||
}
|
||||
```
|
||||
|
||||
**JWT 策略更新** - 返回用户权限信息
|
||||
|
||||
```typescript
|
||||
validate(payload) {
|
||||
const user = await prisma.user.findUnique({
|
||||
include: { roles: { include: { role: { include: { permissions } } } } }
|
||||
});
|
||||
return { ...user, roles: [...], roleIds: [...], permissions: [...] };
|
||||
}
|
||||
```
|
||||
|
||||
### API 端点
|
||||
|
||||
| 模块 | 端点 | 权限 |
|
||||
|------|------|------|
|
||||
| 角色 | `GET/POST /roles` | `role:read/create` |
|
||||
| 角色 | `GET/PATCH/DELETE /roles/:id` | `role:read/update/delete` |
|
||||
| 权限 | `GET /permissions` | `permission:read` |
|
||||
| 菜单 | `GET/POST /menus` | `menu:read/create` |
|
||||
| 菜单 | `PATCH/DELETE /menus/:id` | `menu:update/delete` |
|
||||
| 当前用户 | `GET /auth/menus` | 登录即可 |
|
||||
|
||||
---
|
||||
|
||||
## 四、前端权限系统设计
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── stores/permissionStore.ts # 权限状态
|
||||
├── hooks/usePermission.ts # 权限检查 Hook
|
||||
├── components/permission/
|
||||
│ ├── index.ts
|
||||
│ ├── PermissionGuard.tsx # 权限守卫组件
|
||||
│ └── WithPermission.tsx # 权限 HOC
|
||||
├── services/permission.service.ts # 权限服务
|
||||
├── config/route-permissions.ts # 路由权限映射
|
||||
├── lib/icons.ts # 图标映射
|
||||
└── app/403/page.tsx # 无权限页面
|
||||
```
|
||||
|
||||
### 权限 Store
|
||||
|
||||
```typescript
|
||||
interface PermissionState {
|
||||
menus: MenuTreeNode[];
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
interface PermissionActions {
|
||||
setPermissionData: (data) => void;
|
||||
clearPermissionData: () => void;
|
||||
hasPermission: (p: string | string[]) => boolean;
|
||||
hasAllPermissions: (permissions: string[]) => boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### usePermission Hook
|
||||
|
||||
```typescript
|
||||
function usePermission() {
|
||||
const { permissions, isSuperAdmin } = usePermissionStore();
|
||||
|
||||
const hasPermission = (permission: string | string[]) => {
|
||||
if (isSuperAdmin) return true;
|
||||
if (Array.isArray(permission)) return permission.some(p => permissions.includes(p));
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
return { hasPermission, hasAnyPermission, hasAllPermissions };
|
||||
}
|
||||
```
|
||||
|
||||
### PermissionGuard 组件
|
||||
|
||||
```tsx
|
||||
<PermissionGuard permission="user:create">
|
||||
<CreateUserButton />
|
||||
</PermissionGuard>
|
||||
|
||||
<PermissionGuard permission={['user:create', 'user:update']} fallback={<span>无权限</span>}>
|
||||
<UserForm />
|
||||
</PermissionGuard>
|
||||
|
||||
<PermissionGuard permission={['user:create', 'user:update']} mode="all">
|
||||
<UserForm />
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
### 路由守卫
|
||||
|
||||
在 Dashboard Layout 中实现:
|
||||
|
||||
1. 登录后获取用户菜单和权限
|
||||
2. 存入 permissionStore
|
||||
3. 路由变化时检查权限
|
||||
4. 无权限跳转 403 页面
|
||||
|
||||
---
|
||||
|
||||
## 五、文件清单
|
||||
|
||||
### 后端文件
|
||||
|
||||
| 优先级 | 文件路径 | 说明 |
|
||||
|--------|----------|------|
|
||||
| P0 | `apps/api/prisma/schema.prisma` | 数据库模型 |
|
||||
| P0 | `apps/api/prisma/seed.ts` | 种子数据 |
|
||||
| P0 | `apps/api/src/permission/guards/permission.guard.ts` | 权限守卫 |
|
||||
| P0 | `apps/api/src/permission/decorators/require-permission.decorator.ts` | 权限装饰器 |
|
||||
| P0 | `apps/api/src/auth/strategies/jwt.strategy.ts` | JWT 策略(已更新) |
|
||||
| P0 | `apps/api/src/auth/auth.controller.ts` | 认证控制器(已更新) |
|
||||
| P0 | `apps/api/src/auth/auth.module.ts` | 认证模块(已更新) |
|
||||
| P1 | `apps/api/src/permission/services/*.ts` | 服务层 |
|
||||
| P1 | `apps/api/src/permission/controllers/*.ts` | 控制器层 |
|
||||
| P1 | `apps/api/src/permission/dto/*.ts` | DTO 定义 |
|
||||
| P1 | `apps/api/src/permission/permission.module.ts` | 权限模块 |
|
||||
| P1 | `apps/api/src/app.module.ts` | 应用模块(已更新) |
|
||||
| P1 | `apps/api/src/prisma/prisma.service.ts` | Prisma 服务(已更新) |
|
||||
|
||||
### 共享包文件
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|----------|------|
|
||||
| `packages/shared/src/types/permission.ts` | 权限相关类型定义 |
|
||||
| `packages/shared/src/types/index.ts` | 类型导出(已更新) |
|
||||
|
||||
### 前端文件
|
||||
|
||||
| 优先级 | 文件路径 | 说明 |
|
||||
|--------|----------|------|
|
||||
| P0 | `apps/web/src/stores/permissionStore.ts` | 权限状态管理 |
|
||||
| P0 | `apps/web/src/app/(dashboard)/layout.tsx` | Dashboard 布局(已更新) |
|
||||
| P1 | `apps/web/src/hooks/usePermission.ts` | 权限检查 Hook |
|
||||
| P1 | `apps/web/src/components/permission/PermissionGuard.tsx` | 权限守卫组件 |
|
||||
| P1 | `apps/web/src/components/permission/WithPermission.tsx` | 权限 HOC |
|
||||
| P1 | `apps/web/src/components/layout/Sidebar.tsx` | 侧边栏(已更新) |
|
||||
| P1 | `apps/web/src/lib/icons.ts` | 图标映射工具 |
|
||||
| P1 | `apps/web/src/config/route-permissions.ts` | 路由权限配置 |
|
||||
| P1 | `apps/web/src/config/constants.ts` | 常量配置(已更新) |
|
||||
| P1 | `apps/web/src/services/permission.service.ts` | 权限服务 |
|
||||
| P1 | `apps/web/src/app/403/page.tsx` | 403 页面 |
|
||||
| P2 | `apps/web/src/components/ui/collapsible.tsx` | 折叠组件 |
|
||||
|
||||
---
|
||||
|
||||
## 六、使用指南
|
||||
|
||||
### 后端使用
|
||||
|
||||
#### 1. 保护接口
|
||||
|
||||
```typescript
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@ApiBearerAuth()
|
||||
export class UserController {
|
||||
@Get()
|
||||
@RequirePermission('user:read')
|
||||
findAll() { ... }
|
||||
|
||||
@Post()
|
||||
@RequirePermission('user:create')
|
||||
create() { ... }
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('user:delete')
|
||||
delete() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 获取当前用户权限
|
||||
|
||||
```typescript
|
||||
@Get('profile')
|
||||
getProfile(@CurrentUser() user: AuthUserWithPermissions) {
|
||||
// user.isSuperAdmin - 是否超管
|
||||
// user.roles - 角色编码列表
|
||||
// user.permissions - 权限编码列表
|
||||
}
|
||||
```
|
||||
|
||||
### 前端使用
|
||||
|
||||
#### 1. 权限守卫组件
|
||||
|
||||
```tsx
|
||||
import { PermissionGuard } from '@/components/permission';
|
||||
|
||||
// 单个权限
|
||||
<PermissionGuard permission="user:create">
|
||||
<Button>创建用户</Button>
|
||||
</PermissionGuard>
|
||||
|
||||
// 多个权限(OR 关系)
|
||||
<PermissionGuard permission={['user:create', 'user:update']}>
|
||||
<UserForm />
|
||||
</PermissionGuard>
|
||||
|
||||
// 多个权限(AND 关系)
|
||||
<PermissionGuard permission={['user:create', 'user:update']} mode="all">
|
||||
<UserForm />
|
||||
</PermissionGuard>
|
||||
|
||||
// 带 fallback
|
||||
<PermissionGuard permission="user:delete" fallback={<span>无权限</span>}>
|
||||
<DeleteButton />
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
#### 2. usePermission Hook
|
||||
|
||||
```tsx
|
||||
import { usePermission } from '@/hooks';
|
||||
|
||||
function MyComponent() {
|
||||
const { hasPermission, isSuperAdmin } = usePermission();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasPermission('user:create') && <CreateButton />}
|
||||
{hasPermission(['user:update', 'user:delete']) && <ActionMenu />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 路由权限配置
|
||||
|
||||
```typescript
|
||||
// config/route-permissions.ts
|
||||
export const routePermissions: Record<string, string | string[]> = {
|
||||
'/users': 'user:read',
|
||||
'/users/create': 'user:create',
|
||||
'/roles': 'role:read',
|
||||
};
|
||||
|
||||
export const publicRoutes = [
|
||||
'/dashboard',
|
||||
'/profile',
|
||||
'/settings',
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、部署步骤
|
||||
|
||||
### 1. 数据库迁移
|
||||
|
||||
```bash
|
||||
# 启动数据库
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
# 执行数据库迁移
|
||||
cd apps/api && pnpm db:migrate
|
||||
|
||||
# 初始化种子数据
|
||||
cd apps/api && pnpm db:seed
|
||||
```
|
||||
|
||||
### 2. 验证
|
||||
|
||||
1. 使用超级管理员账号登录
|
||||
- 邮箱: `admin@seclusion.dev`
|
||||
- 密码: `admin123`
|
||||
|
||||
2. 访问 Swagger 文档查看新增的权限管理 API
|
||||
- `http://localhost:4000/api/docs`
|
||||
|
||||
3. 验证菜单根据权限动态显示
|
||||
|
||||
4. 验证无权限路由跳转到 403 页面
|
||||
|
||||
---
|
||||
|
||||
## 八、预置数据
|
||||
|
||||
### 角色
|
||||
|
||||
| 编码 | 名称 | 说明 |
|
||||
|------|------|------|
|
||||
| `super_admin` | 超级管理员 | 系统内置,拥有所有权限 |
|
||||
| `admin` | 管理员 | 系统内置,拥有大部分权限 |
|
||||
| `user` | 普通用户 | 系统内置,基础权限 |
|
||||
|
||||
### 权限
|
||||
|
||||
| 编码 | 名称 | 资源 | 操作 |
|
||||
|------|------|------|------|
|
||||
| `user:create` | 创建用户 | user | create |
|
||||
| `user:read` | 查看用户 | user | read |
|
||||
| `user:update` | 更新用户 | user | update |
|
||||
| `user:delete` | 删除用户 | user | delete |
|
||||
| `role:create` | 创建角色 | role | create |
|
||||
| `role:read` | 查看角色 | role | read |
|
||||
| `role:update` | 更新角色 | role | update |
|
||||
| `role:delete` | 删除角色 | role | delete |
|
||||
| `permission:read` | 查看权限 | permission | read |
|
||||
| `menu:create` | 创建菜单 | menu | create |
|
||||
| `menu:read` | 查看菜单 | menu | read |
|
||||
| `menu:update` | 更新菜单 | menu | update |
|
||||
| `menu:delete` | 删除菜单 | menu | delete |
|
||||
|
||||
### 菜单
|
||||
|
||||
| 编码 | 名称 | 类型 | 路径 | 图标 | 权限 |
|
||||
|------|------|------|------|------|------|
|
||||
| `dashboard` | 仪表盘 | menu | /dashboard | LayoutDashboard | - |
|
||||
| `user-management` | 用户管理 | menu | /users | Users | user:read |
|
||||
| `system` | 系统管理 | dir | - | Settings | - |
|
||||
| `role-management` | 角色管理 | menu | /roles | Shield | role:read |
|
||||
| `menu-management` | 菜单管理 | menu | /menus | Menu | menu:read |
|
||||
| `profile` | 个人中心 | menu | /profile | User | - |
|
||||
| `settings` | 系统设置 | menu | /settings | Settings | - |
|
||||
|
||||
---
|
||||
|
||||
## 九、阶段四:管理界面(已完成)
|
||||
|
||||
### 角色管理
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|----------|------|
|
||||
| `apps/web/src/app/(dashboard)/roles/page.tsx` | 角色管理页面 |
|
||||
| `apps/web/src/components/roles/RolesTable.tsx` | 角色列表表格组件 |
|
||||
| `apps/web/src/components/roles/RoleEditDialog.tsx` | 角色编辑弹窗组件 |
|
||||
| `apps/web/src/hooks/useRoles.ts` | 角色相关 Hooks |
|
||||
| `apps/web/src/services/role.service.ts` | 角色服务层 |
|
||||
|
||||
### 菜单管理
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|----------|------|
|
||||
| `apps/web/src/app/(dashboard)/menus/page.tsx` | 菜单管理页面 |
|
||||
| `apps/web/src/components/menus/MenusTable.tsx` | 菜单列表表格组件 |
|
||||
| `apps/web/src/components/menus/MenuEditDialog.tsx` | 菜单编辑弹窗组件 |
|
||||
| `apps/web/src/hooks/useMenus.ts` | 菜单相关 Hooks |
|
||||
| `apps/web/src/services/menu.service.ts` | 菜单服务层 |
|
||||
|
||||
### 功能说明
|
||||
|
||||
1. **角色管理**:
|
||||
- 角色列表展示(支持分页、搜索、排序)
|
||||
- 新建/编辑角色
|
||||
- 为角色分配权限
|
||||
- 删除角色(系统内置角色不可删除)
|
||||
|
||||
2. **菜单管理**:
|
||||
- 菜单列表展示(支持分页、搜索、排序)
|
||||
- 新建/编辑菜单
|
||||
- 支持目录、菜单、按钮三种类型
|
||||
- 删除菜单(静态菜单不可删除)
|
||||
|
||||
---
|
||||
|
||||
## 十、后续扩展建议
|
||||
|
||||
1. **数据权限** - 支持数据行级权限控制
|
||||
2. **权限缓存** - Redis 缓存用户权限减少数据库查询
|
||||
3. **权限继承** - 支持角色继承
|
||||
4. **操作日志** - 记录权限变更日志
|
||||
5. **批量导入导出** - 支持权限配置的批量操作
|
||||
Reference in New Issue
Block a user