refactor(services): 重构服务模块结构,按功能分类移动文件
将服务文件按功能分类移动到对应子目录,包括财务、营销、订单等模块 更新相关路由和导入路径,修复文件引用错误 归档旧版任务文档,更新README和任务统计信息
This commit is contained in:
711
server/src/services/tenant/HierarchyService.ts
Normal file
711
server/src/services/tenant/HierarchyService.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* [BE-MT002] 层级管理服务
|
||||
* 负责商户→部门→店铺三层层级结构的管理
|
||||
* AI注意: 所有层级操作必须通过此服务进行
|
||||
*/
|
||||
|
||||
import db from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { EventBusService } from './EventBusService';
|
||||
import RedisService from './RedisService';
|
||||
import { DataIsolationService, HierarchyLevel, HierarchyNode, DataIsolationContext } from './DataIsolationService';
|
||||
|
||||
// 部门接口
|
||||
export interface Department {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
path: string;
|
||||
depth: number;
|
||||
manager_id?: string;
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// 店铺接口
|
||||
export interface Shop {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
department_id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
shop_code: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// 层级统计
|
||||
export interface HierarchyStats {
|
||||
totalDepartments: number;
|
||||
totalShops: number;
|
||||
activeShops: number;
|
||||
inactiveShops: number;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
export class HierarchyService {
|
||||
private static readonly CACHE_PREFIX = 'hierarchy:';
|
||||
private static readonly CACHE_TTL = 3600;
|
||||
|
||||
/**
|
||||
* [BE-MT002-01] 初始化租户层级结构
|
||||
*/
|
||||
static async initializeTenantHierarchy(tenantId: string): Promise<void> {
|
||||
const exists = await db('cf_department')
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (exists) {
|
||||
logger.info(`[Hierarchy] Tenant ${tenantId} hierarchy already initialized`);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDepartment: Partial<Department> = {
|
||||
id: `DEPT-${tenantId}-DEFAULT`,
|
||||
tenant_id: tenantId,
|
||||
name: '默认部门',
|
||||
parent_id: null,
|
||||
path: `/DEPT-${tenantId}-DEFAULT`,
|
||||
depth: 1,
|
||||
status: 'ACTIVE',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
await db('cf_department').insert(defaultDepartment);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.department.created',
|
||||
data: {
|
||||
departmentId: defaultDepartment.id,
|
||||
tenantId,
|
||||
isDefault: true,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Initialized default department for tenant ${tenantId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-02] 创建部门
|
||||
*/
|
||||
static async createDepartment(
|
||||
tenantId: string,
|
||||
name: string,
|
||||
parentId: string | null = null,
|
||||
managerId?: string
|
||||
): Promise<Department> {
|
||||
let depth = 1;
|
||||
let parentPath = '';
|
||||
|
||||
if (parentId) {
|
||||
const parent = await db('cf_department')
|
||||
.where('id', parentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!parent) {
|
||||
throw new Error('父部门不存在');
|
||||
}
|
||||
|
||||
depth = parent.depth + 1;
|
||||
parentPath = parent.path;
|
||||
}
|
||||
|
||||
const id = `DEPT-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const path = `${parentPath}/${id}`;
|
||||
|
||||
const department: Partial<Department> = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
name,
|
||||
parent_id: parentId,
|
||||
path,
|
||||
depth,
|
||||
manager_id: managerId,
|
||||
status: 'ACTIVE',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
await db('cf_department').insert(department);
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.department.created',
|
||||
data: {
|
||||
departmentId: id,
|
||||
tenantId,
|
||||
parentId,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Created department ${id} for tenant ${tenantId}`);
|
||||
|
||||
return department as Department;
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-03] 更新部门
|
||||
*/
|
||||
static async updateDepartment(
|
||||
departmentId: string,
|
||||
tenantId: string,
|
||||
updates: Partial<Department>
|
||||
): Promise<Department> {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!department) {
|
||||
throw new Error('部门不存在');
|
||||
}
|
||||
|
||||
if (updates.parent_id !== undefined && updates.parent_id !== department.parent_id) {
|
||||
await this.validateParentChange(departmentId, updates.parent_id, tenantId);
|
||||
|
||||
if (updates.parent_id) {
|
||||
const newParent = await db('cf_department')
|
||||
.where('id', updates.parent_id)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
updates.depth = newParent.depth + 1;
|
||||
updates.path = `${newParent.path}/${departmentId}`;
|
||||
} else {
|
||||
updates.depth = 1;
|
||||
updates.path = `/${departmentId}`;
|
||||
}
|
||||
|
||||
await this.updateChildrenPaths(departmentId, updates.path as string, tenantId);
|
||||
}
|
||||
|
||||
await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.update({
|
||||
...updates,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.department.updated',
|
||||
data: {
|
||||
departmentId,
|
||||
tenantId,
|
||||
updates,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Updated department ${departmentId}`);
|
||||
|
||||
return await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-04] 删除部门
|
||||
*/
|
||||
static async deleteDepartment(departmentId: string, tenantId: string): Promise<void> {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!department) {
|
||||
throw new Error('部门不存在');
|
||||
}
|
||||
|
||||
const childCount = await db('cf_department')
|
||||
.where('parent_id', departmentId)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (childCount && Number(childCount.count) > 0) {
|
||||
throw new Error('该部门下存在子部门,无法删除');
|
||||
}
|
||||
|
||||
const shopCount = await db('cf_shop')
|
||||
.where('department_id', departmentId)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (shopCount && Number(shopCount.count) > 0) {
|
||||
throw new Error('该部门下存在店铺,无法删除');
|
||||
}
|
||||
|
||||
await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.delete();
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.department.deleted',
|
||||
data: {
|
||||
departmentId,
|
||||
tenantId,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Deleted department ${departmentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-05] 创建店铺
|
||||
*/
|
||||
static async createShop(
|
||||
tenantId: string,
|
||||
departmentId: string,
|
||||
name: string,
|
||||
platform: string,
|
||||
shopCode: string
|
||||
): Promise<Shop> {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!department) {
|
||||
throw new Error('部门不存在');
|
||||
}
|
||||
|
||||
const existingShop = await db('cf_shop')
|
||||
.where('tenant_id', tenantId)
|
||||
.where('shop_code', shopCode)
|
||||
.first();
|
||||
|
||||
if (existingShop) {
|
||||
throw new Error('店铺编码已存在');
|
||||
}
|
||||
|
||||
const id = `SHOP-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const path = `${department.path}/${id}`;
|
||||
|
||||
const shop: Partial<Shop> = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
department_id: departmentId,
|
||||
name,
|
||||
platform,
|
||||
shop_code: shopCode,
|
||||
path,
|
||||
depth: department.depth + 1,
|
||||
status: 'ACTIVE',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
await db('cf_shop').insert(shop);
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.shop.created',
|
||||
data: {
|
||||
shopId: id,
|
||||
tenantId,
|
||||
departmentId,
|
||||
platform,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Created shop ${id} for tenant ${tenantId}`);
|
||||
|
||||
return shop as Shop;
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-06] 更新店铺
|
||||
*/
|
||||
static async updateShop(
|
||||
shopId: string,
|
||||
tenantId: string,
|
||||
updates: Partial<Shop>
|
||||
): Promise<Shop> {
|
||||
const shop = await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!shop) {
|
||||
throw new Error('店铺不存在');
|
||||
}
|
||||
|
||||
if (updates.department_id && updates.department_id !== shop.department_id) {
|
||||
const newDepartment = await db('cf_department')
|
||||
.where('id', updates.department_id)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!newDepartment) {
|
||||
throw new Error('目标部门不存在');
|
||||
}
|
||||
|
||||
updates.path = `${newDepartment.path}/${shopId}`;
|
||||
updates.depth = newDepartment.depth + 1;
|
||||
}
|
||||
|
||||
if (updates.shop_code && updates.shop_code !== shop.shop_code) {
|
||||
const existingShop = await db('cf_shop')
|
||||
.where('tenant_id', tenantId)
|
||||
.where('shop_code', updates.shop_code)
|
||||
.whereNot('id', shopId)
|
||||
.first();
|
||||
|
||||
if (existingShop) {
|
||||
throw new Error('店铺编码已存在');
|
||||
}
|
||||
}
|
||||
|
||||
await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.update({
|
||||
...updates,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.shop.updated',
|
||||
data: {
|
||||
shopId,
|
||||
tenantId,
|
||||
updates,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Updated shop ${shopId}`);
|
||||
|
||||
return await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-07] 删除店铺
|
||||
*/
|
||||
static async deleteShop(shopId: string, tenantId: string): Promise<void> {
|
||||
const shop = await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!shop) {
|
||||
throw new Error('店铺不存在');
|
||||
}
|
||||
|
||||
await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.delete();
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.shop.deleted',
|
||||
data: {
|
||||
shopId,
|
||||
tenantId,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Deleted shop ${shopId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-08] 获取层级统计
|
||||
*/
|
||||
static async getHierarchyStats(tenantId: string): Promise<HierarchyStats> {
|
||||
const cacheKey = `${this.CACHE_PREFIX}stats:${tenantId}`;
|
||||
const cached = await RedisService.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const [deptCount, shopStats, maxDepth] = await Promise.all([
|
||||
db('cf_department')
|
||||
.where('tenant_id', tenantId)
|
||||
.count('id as count')
|
||||
.first(),
|
||||
|
||||
db('cf_shop')
|
||||
.where('tenant_id', tenantId)
|
||||
.select('status')
|
||||
.count('id as count')
|
||||
.groupBy('status'),
|
||||
|
||||
db('cf_department')
|
||||
.where('tenant_id', tenantId)
|
||||
.max('depth as maxDepth')
|
||||
.first(),
|
||||
]);
|
||||
|
||||
const stats: HierarchyStats = {
|
||||
totalDepartments: Number(deptCount?.count || 0),
|
||||
totalShops: shopStats.reduce((sum, s) => sum + Number(s.count), 0),
|
||||
activeShops: Number(shopStats.find(s => s.status === 'ACTIVE')?.count || 0),
|
||||
inactiveShops: Number(shopStats.find(s => s.status === 'INACTIVE')?.count || 0),
|
||||
maxDepth: Number(maxDepth?.maxDepth || 0),
|
||||
};
|
||||
|
||||
await RedisService.set(cacheKey, JSON.stringify(stats), this.CACHE_TTL);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-09] 获取部门树
|
||||
*/
|
||||
static async getDepartmentTree(tenantId: string): Promise<HierarchyNode[]> {
|
||||
const cacheKey = `${this.CACHE_PREFIX}dept_tree:${tenantId}`;
|
||||
const cached = await RedisService.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const departments = await db('cf_department')
|
||||
.where('tenant_id', tenantId)
|
||||
.where('status', 'ACTIVE')
|
||||
.orderBy('depth')
|
||||
.orderBy('name')
|
||||
.select('id', 'name', 'parent_id', 'path', 'depth');
|
||||
|
||||
const nodeMap = new Map<string, HierarchyNode>();
|
||||
const rootNodes: HierarchyNode[] = [];
|
||||
|
||||
departments.forEach(dept => {
|
||||
nodeMap.set(dept.id, {
|
||||
id: dept.id,
|
||||
type: 'DEPARTMENT',
|
||||
parentId: dept.parent_id,
|
||||
name: dept.name,
|
||||
path: dept.path,
|
||||
depth: dept.depth,
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parentId && nodeMap.has(node.parentId)) {
|
||||
const parent = nodeMap.get(node.parentId)!;
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
rootNodes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
await RedisService.set(cacheKey, JSON.stringify(rootNodes), this.CACHE_TTL);
|
||||
|
||||
return rootNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-10] 获取部门下的店铺
|
||||
*/
|
||||
static async getDepartmentShops(
|
||||
departmentId: string,
|
||||
tenantId: string,
|
||||
includeChildren: boolean = false
|
||||
): Promise<Shop[]> {
|
||||
let query = db('cf_shop')
|
||||
.where('tenant_id', tenantId);
|
||||
|
||||
if (includeChildren) {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.first('path');
|
||||
|
||||
if (!department) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childDeptIds = await db('cf_department')
|
||||
.where('tenant_id', tenantId)
|
||||
.where('path', 'like', `${department.path}%`)
|
||||
.pluck('id');
|
||||
|
||||
query = query.whereIn('department_id', childDeptIds);
|
||||
} else {
|
||||
query = query.where('department_id', departmentId);
|
||||
}
|
||||
|
||||
return query.orderBy('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-11] 获取用户层级上下文
|
||||
*/
|
||||
static async getUserHierarchyContext(userId: string): Promise<{
|
||||
tenantId: string;
|
||||
departmentId?: string;
|
||||
shopId?: string;
|
||||
hierarchyPath: string;
|
||||
}> {
|
||||
const user = await db('cf_user')
|
||||
.where('id', userId)
|
||||
.first('tenant_id', 'department_id', 'shop_id', 'hierarchy_path');
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId: user.tenant_id,
|
||||
departmentId: user.department_id,
|
||||
shopId: user.shop_id,
|
||||
hierarchyPath: user.hierarchy_path || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-12] 更新用户层级绑定
|
||||
*/
|
||||
static async updateUserHierarchy(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
departmentId?: string,
|
||||
shopId?: string
|
||||
): Promise<void> {
|
||||
let hierarchyPath = `/${tenantId}`;
|
||||
|
||||
if (departmentId) {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first('path');
|
||||
|
||||
if (department) {
|
||||
hierarchyPath = department.path;
|
||||
}
|
||||
}
|
||||
|
||||
if (shopId) {
|
||||
const shop = await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first('path');
|
||||
|
||||
if (shop) {
|
||||
hierarchyPath = shop.path;
|
||||
}
|
||||
}
|
||||
|
||||
await db('cf_user')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
tenant_id: tenantId,
|
||||
department_id: departmentId || null,
|
||||
shop_id: shopId || null,
|
||||
hierarchy_path: hierarchyPath,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.user.updated',
|
||||
data: {
|
||||
userId,
|
||||
tenantId,
|
||||
departmentId,
|
||||
shopId,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Updated user ${userId} hierarchy binding`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证父节点变更
|
||||
*/
|
||||
private static async validateParentChange(
|
||||
departmentId: string,
|
||||
newParentId: string | null,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
if (!newParentId) return;
|
||||
|
||||
if (departmentId === newParentId) {
|
||||
throw new Error('不能将部门设为自己的子部门');
|
||||
}
|
||||
|
||||
const newParent = await db('cf_department')
|
||||
.where('id', newParentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first('path');
|
||||
|
||||
if (!newParent) {
|
||||
throw new Error('父部门不存在');
|
||||
}
|
||||
|
||||
if (newParent.path.includes(departmentId)) {
|
||||
throw new Error('不能将部门移动到自己的子部门下');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新子节点路径
|
||||
*/
|
||||
private static async updateChildrenPaths(
|
||||
departmentId: string,
|
||||
newPath: string,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
const children = await db('cf_department')
|
||||
.where('tenant_id', tenantId)
|
||||
.where('path', 'like', `%/${departmentId}/%`)
|
||||
.select('id', 'path');
|
||||
|
||||
for (const child of children) {
|
||||
const updatedPath = child.path.replace(
|
||||
new RegExp(`.*${departmentId}`),
|
||||
newPath
|
||||
);
|
||||
|
||||
await db('cf_department')
|
||||
.where('id', child.id)
|
||||
.update({ path: updatedPath });
|
||||
}
|
||||
|
||||
const shops = await db('cf_shop')
|
||||
.where('tenant_id', tenantId)
|
||||
.where('path', 'like', `%/${departmentId}/%`)
|
||||
.select('id', 'path');
|
||||
|
||||
for (const shop of shops) {
|
||||
const updatedPath = shop.path.replace(
|
||||
new RegExp(`.*${departmentId}`),
|
||||
newPath
|
||||
);
|
||||
|
||||
await db('cf_shop')
|
||||
.where('id', shop.id)
|
||||
.update({ path: updatedPath });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除层级缓存
|
||||
*/
|
||||
private static async clearHierarchyCache(tenantId: string): Promise<void> {
|
||||
await RedisService.deletePattern(`${this.CACHE_PREFIX}*:${tenantId}*`);
|
||||
logger.debug(`[Hierarchy] Cleared cache for tenant ${tenantId}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user