- 实现了 DELETE /api/dict/:id 接口,用于软删除字典项。 - 支持 `cascade=true` 查询参数,用于级联软删除所有子孙节点。 - 添加了 DeleteDictSchema 用于请求验证。 - 添加了 DeleteDictResponsesSchema 用于 API 文档。 - 服务层实现包含对系统字典的保护、以及对有子节点的非级联删除的防护。 - 所有数据库查询和更新均使用原生 SQL 以规避 ORM 类型问题。 - 在控制器中添加了新路由。 - 在 dict.test.md 中为删除接口添加了全面的测试用例。
598 lines
22 KiB
TypeScript
598 lines
22 KiB
TypeScript
/**
|
||
* @file 字典模块Service层实现
|
||
* @author AI Assistant
|
||
* @date 2024-12-19
|
||
* @lastEditor AI Assistant
|
||
* @lastEditTime 2025-01-07
|
||
* @description 字典模块的业务逻辑实现,包括创建、查询和树结构生成
|
||
*/
|
||
|
||
import { Logger } from '@/plugins/logger/logger.service';
|
||
import { db } from '@/plugins/drizzle/drizzle.service';
|
||
import { sysDict } from '@/eneities';
|
||
import { eq, and, or, ne, desc, asc, sql, inArray, isNull, not, max } from 'drizzle-orm';
|
||
import { successResponse, BusinessError } from '@/utils/responseFormate';
|
||
import { nextId } from '@/utils/snowflake';
|
||
import type {
|
||
CreateDictRequest,
|
||
GetDictTreeByCodeRequest,
|
||
GetDictTreeQueryRequest,
|
||
UpdateDictBodyRequest,
|
||
SortDictRequest,
|
||
DeleteDictRequest,
|
||
} from './dict.schema';
|
||
import type {
|
||
CreateDictSuccessType,
|
||
GetDictByIdSuccessType,
|
||
GetDictTreeByCodeSuccessType,
|
||
GetDictTreeSuccessType,
|
||
UpdateDictSuccessType,
|
||
SortDictSuccessType,
|
||
DeleteDictSuccessType,
|
||
} from './dict.response';
|
||
|
||
/**
|
||
* 字典服务类
|
||
* @description 处理字典相关的业务逻辑
|
||
*/
|
||
export class DictService {
|
||
/**
|
||
* 创建字典项
|
||
* @param body 创建字典项请求参数
|
||
* @returns Promise<CreateDictSuccessType>
|
||
* @throws BusinessError 业务逻辑错误
|
||
*/
|
||
public async createDict(body: CreateDictRequest): Promise<CreateDictSuccessType> {
|
||
// 1. code唯一性校验
|
||
const existCode = await db()
|
||
.select({ id: sysDict.id })
|
||
.from(sysDict)
|
||
.where(eq(sysDict.code, body.code))
|
||
.limit(1);
|
||
if (existCode.length > 0) {
|
||
throw new BusinessError(`字典代码已存在: ${body.code}`, 409);
|
||
}
|
||
|
||
// 2. name同级唯一性校验
|
||
const pid = body.pid || '0';
|
||
const existName = await db()
|
||
.select({ id: sysDict.id })
|
||
.from(sysDict)
|
||
.where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid)))
|
||
.limit(1);
|
||
if (existName.length > 0) {
|
||
throw new BusinessError(`字典名称已存在: ${body.name}`, 409);
|
||
}
|
||
|
||
// 3. 父级校验与层级处理
|
||
let level = 1;
|
||
if (pid !== '0') {
|
||
const parent = await db().select().from(sysDict).where(eq(sysDict.id, pid)).limit(1);
|
||
if (parent.length === 0) {
|
||
throw new BusinessError(`父级字典不存在: ${pid}`, 404);
|
||
}
|
||
if (parent[0]!.status !== 'active') {
|
||
throw new BusinessError(`父级字典状态非active: ${pid}`, 400);
|
||
}
|
||
level = parent[0]!.level + 1;
|
||
if (level > 10) {
|
||
throw new BusinessError(`字典层级超过限制: ${level}`, 400);
|
||
}
|
||
}
|
||
|
||
// 4. sortOrder处理(同级最大+1)
|
||
let sortOrder = 0;
|
||
if (body.sortOrder !== undefined) {
|
||
sortOrder = body.sortOrder;
|
||
} else {
|
||
const maxSort = await db()
|
||
.select({ maxSort: max(sysDict.sortOrder) })
|
||
.from(sysDict)
|
||
.where(eq(sysDict.pid, pid));
|
||
sortOrder = (maxSort[0]?.maxSort ?? 0) + 1;
|
||
}
|
||
|
||
// 5. 数据写入
|
||
const dictId = nextId();
|
||
|
||
await db()
|
||
.insert(sysDict)
|
||
.values([
|
||
{
|
||
id: dictId.toString(),
|
||
code: body.code,
|
||
name: body.name,
|
||
value: body.value ?? null,
|
||
description: body.description ?? null,
|
||
icon: body.icon ?? null,
|
||
pid: BigInt(pid),
|
||
level,
|
||
sortOrder,
|
||
status: body.status,
|
||
isSystem: body.isSystem ? 1 : 0,
|
||
color: body.color ?? null,
|
||
extra: body.extra ?? {},
|
||
},
|
||
] as any);
|
||
|
||
// 6. 查询刚插入的数据
|
||
const insertedArr = await db().select().from(sysDict).where(eq(sysDict.id, dictId.toString())).limit(1);
|
||
if (!insertedArr || insertedArr.length === 0) {
|
||
throw new BusinessError('创建字典项失败', 500);
|
||
}
|
||
const inserted = insertedArr[0]!;
|
||
|
||
// 7. 返回统一响应
|
||
return successResponse(
|
||
{
|
||
id: String(inserted.id),
|
||
code: inserted.code,
|
||
name: inserted.name,
|
||
value: inserted.value,
|
||
description: inserted.description,
|
||
icon: inserted.icon,
|
||
pid: String(inserted.pid),
|
||
level: inserted.level,
|
||
sortOrder: inserted.sortOrder,
|
||
status: inserted.status,
|
||
isSystem: Boolean(inserted.isSystem),
|
||
color: inserted.color,
|
||
extra: inserted.extra,
|
||
createdAt: inserted.createdAt,
|
||
updatedAt: inserted.updatedAt,
|
||
},
|
||
'创建字典项成功',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 获取字典项内容
|
||
* @param id 字典项ID
|
||
* @returns Promise<GetDictByIdSuccessType>
|
||
* @throws BusinessError 业务逻辑错误
|
||
*/
|
||
public async getDictById(id: string): Promise<GetDictByIdSuccessType> {
|
||
Logger.info(`获取字典项内容:${id}`);
|
||
|
||
const dictArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1);
|
||
|
||
if (!dictArr || dictArr.length === 0) {
|
||
Logger.warn(`字典项不存在:${id}`);
|
||
throw new BusinessError(`字典项不存在:${id}`, 404);
|
||
}
|
||
|
||
const dict = dictArr[0]!;
|
||
|
||
Logger.info(`获取字典项内容成功:${id}`);
|
||
|
||
return successResponse({
|
||
id: String(dict.id),
|
||
code: dict.code,
|
||
name: dict.name,
|
||
value: dict.value,
|
||
description: dict.description,
|
||
icon: dict.icon,
|
||
pid: String(dict.pid),
|
||
level: dict.level,
|
||
sortOrder: dict.sortOrder,
|
||
status: dict.status,
|
||
isSystem: Boolean(dict.isSystem),
|
||
color: dict.color,
|
||
extra: dict.extra,
|
||
createdAt: dict.createdAt,
|
||
updatedAt: dict.updatedAt,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取完整字典树
|
||
* @param query 查询参数
|
||
* @returns Promise<GetDictTreeSuccessType>
|
||
*/
|
||
public async getDictTree(query: GetDictTreeQueryRequest): Promise<GetDictTreeSuccessType> {
|
||
Logger.info(`获取完整字典树, 查询参数: ${JSON.stringify(query)}`);
|
||
|
||
const conditions = [];
|
||
if (query.status && query.status !== 'all') {
|
||
conditions.push(eq(sysDict.status, query.status));
|
||
}
|
||
if (query.isSystem && query.isSystem !== 'all') {
|
||
conditions.push(eq(sysDict.isSystem, query.isSystem === 'true' ? 1 : 0));
|
||
}
|
||
|
||
const dictList = await db()
|
||
.select()
|
||
.from(sysDict)
|
||
.where(and(...conditions))
|
||
.orderBy(asc(sysDict.sortOrder));
|
||
|
||
if (!dictList || dictList.length === 0) {
|
||
return successResponse([], '获取完整字典树成功');
|
||
}
|
||
|
||
type DictNode = Omit<(typeof dictList)[0], 'id' | 'pid' | 'isSystem'> & {
|
||
id: string;
|
||
pid: string;
|
||
isSystem: boolean;
|
||
children?: DictNode[];
|
||
};
|
||
|
||
const nodeMap = new Map<string, DictNode>();
|
||
const tree: DictNode[] = [];
|
||
|
||
for (const item of dictList) {
|
||
const node: DictNode = {
|
||
...item,
|
||
id: String(item.id),
|
||
pid: String(item.pid),
|
||
isSystem: Boolean(item.isSystem),
|
||
children: [],
|
||
};
|
||
nodeMap.set(node.id, node);
|
||
}
|
||
|
||
for (const node of nodeMap.values()) {
|
||
if (node.pid && node.pid !== '0' && nodeMap.has(node.pid)) {
|
||
const parent = nodeMap.get(node.pid);
|
||
if (parent && parent.children) {
|
||
parent.children.push(node);
|
||
}
|
||
} else {
|
||
tree.push(node);
|
||
}
|
||
}
|
||
|
||
return successResponse(tree, '获取完整字典树成功');
|
||
}
|
||
|
||
/**
|
||
* 获取指定字典树
|
||
* @param params 路径参数
|
||
* @param query 查询参数
|
||
* @returns Promise<GetDictTreeByCodeSuccessType>
|
||
*/
|
||
public async getDictTreeByCode(
|
||
{ code }: { code: string },
|
||
query: GetDictTreeByCodeRequest,
|
||
): Promise<GetDictTreeByCodeSuccessType> {
|
||
Logger.info(`获取指定字典树: ${code}, 查询参数: ${JSON.stringify(query)}`);
|
||
|
||
// 1. 查找根节点
|
||
const rootNodeArr = await db().select({ id: sysDict.id }).from(sysDict).where(eq(sysDict.code, code)).limit(1);
|
||
if (rootNodeArr.length === 0) {
|
||
throw new BusinessError(`字典代码不存在: ${code}`, 404);
|
||
}
|
||
const rootId = String(rootNodeArr[0]!.id);
|
||
|
||
// 2. 获取所有字典数据
|
||
const allDicts = await db().select().from(sysDict).orderBy(asc(sysDict.sortOrder));
|
||
|
||
// 3. 在内存中构建完整的树
|
||
type DictNode = Omit<(typeof allDicts)[0], 'id' | 'pid' | 'isSystem'> & {
|
||
id: string;
|
||
pid: string;
|
||
isSystem: boolean;
|
||
children?: DictNode[];
|
||
};
|
||
|
||
const nodeMap = new Map<string, DictNode>();
|
||
allDicts.forEach((item) => {
|
||
nodeMap.set(String(item.id), {
|
||
...item,
|
||
id: String(item.id),
|
||
pid: String(item.pid),
|
||
isSystem: Boolean(item.isSystem),
|
||
children: [],
|
||
});
|
||
});
|
||
|
||
// 4. 定义递归过滤和构建子树的函数
|
||
const buildSubTree = (node: DictNode): DictNode | null => {
|
||
// 应用过滤条件
|
||
if (query.status && query.status !== 'all' && node.status !== query.status) {
|
||
return null;
|
||
}
|
||
if (query.isSystem && query.isSystem !== 'all' && node.isSystem !== (query.isSystem === 'true')) {
|
||
return null;
|
||
}
|
||
|
||
const children = allDicts
|
||
.filter((child) => String(child.pid) === node.id)
|
||
.map((child) => buildSubTree(nodeMap.get(String(child.id))!))
|
||
.filter((child): child is DictNode => child !== null);
|
||
|
||
if (children.length > 0) {
|
||
node.children = children;
|
||
} else {
|
||
delete node.children;
|
||
}
|
||
|
||
return node;
|
||
};
|
||
|
||
// 5. 找到根节点并构建其子树
|
||
const root = nodeMap.get(rootId);
|
||
if (!root) {
|
||
return successResponse([], '获取指定字典树成功');
|
||
}
|
||
|
||
const finalTree = buildSubTree(root);
|
||
|
||
return successResponse(finalTree ? [finalTree] : [], '获取指定字典树成功');
|
||
}
|
||
|
||
/**
|
||
* 更新字典项
|
||
* @param id 字典项ID
|
||
* @param body 更新内容
|
||
* @returns Promise<UpdateDictSuccessType>
|
||
*/
|
||
public async updateDict(id: string, body: UpdateDictBodyRequest): Promise<UpdateDictSuccessType> {
|
||
Logger.info(`更新字典项: ${id}, body: ${JSON.stringify(body)}`);
|
||
|
||
// 1. 检查字典项是否存在
|
||
const existingArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1);
|
||
if (existingArr.length === 0) {
|
||
throw new BusinessError(`字典项不存在: ${id}`, 404);
|
||
}
|
||
const existing = existingArr[0]!;
|
||
|
||
// 2. 唯一性校验
|
||
if (body.code) {
|
||
const existCode = await db()
|
||
.select({ id: sysDict.id })
|
||
.from(sysDict)
|
||
.where(and(eq(sysDict.code, body.code), ne(sysDict.id, id)))
|
||
.limit(1);
|
||
if (existCode.length > 0) {
|
||
throw new BusinessError(`字典代码已存在: ${body.code}`, 409);
|
||
}
|
||
}
|
||
if (body.name) {
|
||
const pid = body.pid ? String(body.pid) : existing.pid ? String(existing.pid) : '0';
|
||
const existName = await db()
|
||
.select({ id: sysDict.id })
|
||
.from(sysDict)
|
||
.where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid), ne(sysDict.id, id)))
|
||
.limit(1);
|
||
if (existName.length > 0) {
|
||
throw new BusinessError(`同级下字典名称已存在: ${body.name}`, 409);
|
||
}
|
||
}
|
||
|
||
// 3. 构建更新数据
|
||
const updateData: Partial<typeof existing> = {};
|
||
for (const key in body) {
|
||
if (Object.prototype.hasOwnProperty.call(body, key) && body[key as keyof typeof body] !== undefined) {
|
||
if (key === 'isSystem') {
|
||
(updateData as any)[key] = body[key] ? 1 : 0;
|
||
} else {
|
||
(updateData as any)[key] = body[key as keyof typeof body];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Object.keys(updateData).length === 0) {
|
||
return successResponse(
|
||
{
|
||
...existing,
|
||
id: String(existing.id),
|
||
pid: String(existing.pid),
|
||
isSystem: Boolean(existing.isSystem),
|
||
},
|
||
'未提供任何更新内容',
|
||
);
|
||
}
|
||
|
||
// 4. 执行更新
|
||
await db().update(sysDict).set(updateData).where(eq(sysDict.id, id));
|
||
|
||
// 5. 查询并返回更新后的数据
|
||
const updatedArr = await db().select().from(sysDict).where(eq(sysDict.id, id)).limit(1);
|
||
const updated = updatedArr[0]!;
|
||
|
||
return successResponse(
|
||
{
|
||
...updated,
|
||
id: String(updated.id),
|
||
pid: String(updated.pid),
|
||
isSystem: Boolean(updated.isSystem),
|
||
},
|
||
'更新字典项成功',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 字典项排序
|
||
* @param body SortDictRequest 排序请求参数
|
||
* @returns Promise<SortDictSuccessType>
|
||
* @throws BusinessError 业务逻辑错误
|
||
* @description 处理字典项的排序和移动(改变父级),使用原生SQL保证性能和类型安全
|
||
*/
|
||
public async sortDict(body: SortDictRequest): Promise<SortDictSuccessType> {
|
||
const { items } = body;
|
||
if (!items || items.length === 0) {
|
||
throw new BusinessError('排序项列表不能为空', 400);
|
||
}
|
||
|
||
const itemIds = items.map((item) => BigInt(item.id));
|
||
const parentIds = [
|
||
...new Set(
|
||
items
|
||
.map((item) => item.pid)
|
||
.filter((pid) => pid !== 0 && pid !== '0')
|
||
.map((pid) => BigInt(pid as string)),
|
||
),
|
||
];
|
||
|
||
try {
|
||
await db().transaction(async (tx) => {
|
||
// 1. 使用原生SQL验证所有待排序的字典项是否存在
|
||
if (itemIds.length > 0) {
|
||
const result: any[] = await tx.execute(
|
||
sql`SELECT id FROM sys_dict WHERE id IN ${itemIds}`,
|
||
);
|
||
const existingIds = result[0].map((row: { id: bigint }) => row.id);
|
||
if (existingIds.length !== itemIds.length) {
|
||
const foundIds = new Set(existingIds);
|
||
const notFoundIds = itemIds.filter((id) => !foundIds.has(id));
|
||
Logger.warn(`部分字典项不存在:${notFoundIds.join(', ')}`);
|
||
throw new BusinessError(`部分字典项不存在: ${notFoundIds.join(', ')}`, 404);
|
||
}
|
||
}
|
||
|
||
// 2. 使用原生SQL验证所有父级字典是否存在
|
||
if (parentIds.length > 0) {
|
||
const result: any[] = await tx.execute(
|
||
sql`SELECT id FROM sys_dict WHERE id IN ${parentIds}`,
|
||
);
|
||
const existingParentIds = result[0].map((row: { id: bigint }) => row.id);
|
||
if (existingParentIds.length !== parentIds.length) {
|
||
const foundParentIds = new Set(existingParentIds);
|
||
const notFoundParentIds = parentIds.filter((id) => !foundParentIds.has(id));
|
||
Logger.warn(`部分父级字典不存在:${notFoundParentIds.join(', ')}`);
|
||
throw new BusinessError(
|
||
`部分父级字典不存在: ${notFoundParentIds.join(', ')}`,
|
||
404,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 3. 使用原生SQL构建并执行批量更新
|
||
if (items.length > 0) {
|
||
const pidSqlChunks = items.map(
|
||
(item) =>
|
||
sql`WHEN ${BigInt(item.id)} THEN ${
|
||
item.pid === 0 || item.pid === '0' ? BigInt(0) : BigInt(item.pid)
|
||
}`,
|
||
);
|
||
const sortOrderSqlChunks = items.map(
|
||
(item) => sql`WHEN ${BigInt(item.id)} THEN ${item.sortOrder}`,
|
||
);
|
||
|
||
const finalUpdateQuery = sql`
|
||
UPDATE sys_dict
|
||
SET
|
||
pid = CASE id ${sql.join(pidSqlChunks, sql` `)} END,
|
||
sort_order = CASE id ${sql.join(sortOrderSqlChunks, sql` `)} END,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id IN ${itemIds}
|
||
`;
|
||
await tx.execute(finalUpdateQuery);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof BusinessError) {
|
||
throw error;
|
||
}
|
||
Logger.error('字典项排序失败', { error });
|
||
throw new BusinessError('字典项排序失败,请稍后重试', 500);
|
||
}
|
||
|
||
Logger.info('字典项排序成功');
|
||
return successResponse(null, '字典项排序成功');
|
||
}
|
||
|
||
/**
|
||
* 删除字典项(软删除)
|
||
* @param params DeleteDictRequest 删除请求参数 { id, cascade }
|
||
* @returns Promise<DeleteDictSuccessType>
|
||
* @throws BusinessError 业务逻辑错误
|
||
* @description 软删除字典项,可选择级联删除子项
|
||
*/
|
||
public async deleteDict({
|
||
id,
|
||
cascade,
|
||
}: DeleteDictRequest): Promise<DeleteDictSuccessType> {
|
||
const dictId = BigInt(id);
|
||
Logger.info(`请求删除字典项: ${dictId}, 是否级联: ${!!cascade}`);
|
||
|
||
try {
|
||
await db().transaction(async (tx) => {
|
||
// 1. 查找字典项
|
||
const dictResult: any[] = await tx.execute(
|
||
sql`SELECT * FROM sys_dict WHERE id = ${dictId} LIMIT 1`,
|
||
);
|
||
const dictToDelete = dictResult[0] ? dictResult[0][0] : null;
|
||
|
||
if (!dictToDelete) {
|
||
Logger.warn(`删除失败,字典项不存在: ${dictId}`);
|
||
throw new BusinessError('字典项不存在', 404);
|
||
}
|
||
|
||
// 2. 系统字典不允许删除
|
||
if (dictToDelete.is_system) {
|
||
Logger.warn(`删除失败,系统字典不允许删除: ${dictId}`);
|
||
throw new BusinessError('系统字典不允许删除', 409);
|
||
}
|
||
|
||
// 3. 查找所有子孙节点
|
||
const childrenIds: bigint[] = [];
|
||
if (cascade) {
|
||
const allDicts = await tx.query.sysDict.findMany({
|
||
columns: { id: true, pid: true },
|
||
});
|
||
const allDictsBigInt = allDicts.map((d) => ({
|
||
id: BigInt(d.id),
|
||
pid: d.pid ? BigInt(d.pid) : null,
|
||
}));
|
||
this.findAllChildrenIds(dictId, allDictsBigInt, childrenIds);
|
||
} else {
|
||
// 如果不级联,检查是否有直接子节点
|
||
const childResult: any[] = await tx.execute(
|
||
sql`SELECT id FROM sys_dict WHERE pid = ${dictId} LIMIT 1`,
|
||
);
|
||
if (childResult[0] && childResult[0].length > 0) {
|
||
Logger.warn(`删除失败,存在子字典项,且未启用级联删除: ${dictId}`);
|
||
throw new BusinessError(
|
||
'存在子字典项,无法删除,请先删除子项或使用级联删除',
|
||
400,
|
||
);
|
||
}
|
||
}
|
||
|
||
const idsToDelete = [dictId, ...childrenIds];
|
||
|
||
Logger.info(`准备删除以下字典项ID: ${idsToDelete.join(', ')}`);
|
||
|
||
// 4. 使用原生SQL执行软删除
|
||
if (idsToDelete.length > 0) {
|
||
await tx.execute(
|
||
sql`UPDATE sys_dict SET status = 'inactive' WHERE id IN ${idsToDelete}`,
|
||
);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof BusinessError) {
|
||
throw error;
|
||
}
|
||
Logger.error('字典项删除失败', { error });
|
||
throw new BusinessError('字典项删除失败,请稍后重试', 500);
|
||
}
|
||
|
||
Logger.info(`字典项 ${dictId} 及关联子项(如有)已成功软删除`);
|
||
return successResponse(null, '字典项删除成功');
|
||
}
|
||
|
||
/**
|
||
* 递归查找所有子孙节点的ID
|
||
* @param parentId 父级ID
|
||
* @param allItems 所有字典项列表
|
||
* @param childrenIds 存储子孙节点ID的数组
|
||
*/
|
||
private findAllChildrenIds(
|
||
parentId: bigint,
|
||
allItems: { id: bigint; pid: bigint | null }[],
|
||
childrenIds: bigint[],
|
||
) {
|
||
const children = allItems.filter((item) => item.pid === parentId);
|
||
for (const child of children) {
|
||
childrenIds.push(child.id);
|
||
this.findAllChildrenIds(child.id, allItems, childrenIds);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出单例实例
|
||
export const dictService = new DictService();
|