feat: 添加部门管理功能、主题切换和多语言支持
refactor(dashboard): 重构用户管理页面和路由结构 feat(server): 实现部门管理API和RBAC增强功能 docs: 更新用户手册和管理员指南文档 style: 统一图标使用和组件命名规范 test: 添加部门服务和数据隔离测试用例 chore: 更新依赖和配置文件
This commit is contained in:
11
server/jest.config.js
Normal file
11
server/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest'
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
moduleDirectories: ['node_modules', 'src']
|
||||
};
|
||||
@@ -5,8 +5,10 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"start:simple": "ts-node src/simple-server.ts",
|
||||
"build": "tsc",
|
||||
"dev": "ts-node-dev src/index.ts",
|
||||
"dev:simple": "ts-node-dev src/simple-server.ts",
|
||||
"test": "jest",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
|
||||
74
server/src/__tests__/DataIsolationService.test.ts
Normal file
74
server/src/__tests__/DataIsolationService.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { DataIsolationService } from '../../services/integration/DataIsolationService';
|
||||
|
||||
// 模拟数据库连接
|
||||
const mockDb = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
first: jest.fn(),
|
||||
select: jest.fn(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
table: jest.fn().mockReturnThis(),
|
||||
raw: jest.fn(),
|
||||
schema: {
|
||||
hasTable: jest.fn()
|
||||
},
|
||||
pluck: jest.fn(),
|
||||
whereIn: jest.fn().mockReturnThis()
|
||||
};
|
||||
|
||||
// 模拟RedisService
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
deletePattern: jest.fn()
|
||||
};
|
||||
|
||||
// 模拟数据库和Redis服务
|
||||
jest.mock('../../config/database', () => mockDb);
|
||||
jest.mock('../../services/utils/RedisService', () => mockRedisService);
|
||||
|
||||
describe('DataIsolationService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('buildIsolationQuery should work with tenant isolation', () => {
|
||||
const context = {
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
role: 'OPERATOR',
|
||||
permissions: [],
|
||||
hierarchyPath: ''
|
||||
};
|
||||
|
||||
const result = DataIsolationService.buildIsolationQuery('cf_product', context);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test('buildIsolationQuery should work with department isolation', () => {
|
||||
const context = {
|
||||
tenantId: 'tenant-1',
|
||||
departmentId: 'dept-1',
|
||||
userId: 'user-1',
|
||||
role: 'MANAGER',
|
||||
permissions: [],
|
||||
hierarchyPath: ''
|
||||
};
|
||||
|
||||
const result = DataIsolationService.buildIsolationQuery('cf_product', context);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test('buildIsolationQuery should work with multiple departments isolation', () => {
|
||||
const context = {
|
||||
tenantId: 'tenant-1',
|
||||
departmentIds: ['dept-1', 'dept-2'],
|
||||
userId: 'user-1',
|
||||
role: 'MANAGER',
|
||||
permissions: [],
|
||||
hierarchyPath: ''
|
||||
};
|
||||
|
||||
const result = DataIsolationService.buildIsolationQuery('cf_product', context);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
169
server/src/api/controllers/DepartmentController.ts
Normal file
169
server/src/api/controllers/DepartmentController.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { HierarchyService } from '../../services/tenant/HierarchyService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export class DepartmentController {
|
||||
static async createDepartment(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
const { name, parentId, managerId } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ success: false, error: '部门名称不能为空' });
|
||||
}
|
||||
|
||||
const department = await HierarchyService.createDepartment(
|
||||
tenantId,
|
||||
name,
|
||||
parentId || null,
|
||||
managerId
|
||||
);
|
||||
|
||||
res.json({ success: true, data: department });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Create department failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '创建部门失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async updateDepartment(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const department = await HierarchyService.updateDepartment(
|
||||
id,
|
||||
tenantId,
|
||||
updates
|
||||
);
|
||||
|
||||
res.json({ success: true, data: department });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Update department failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '更新部门失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteDepartment(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
const { id } = req.params;
|
||||
|
||||
await HierarchyService.deleteDepartment(id, tenantId);
|
||||
|
||||
res.json({ success: true, message: '部门删除成功' });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Delete department failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '删除部门失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getDepartmentTree(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
|
||||
const tree = await HierarchyService.getDepartmentTree(tenantId);
|
||||
|
||||
res.json({ success: true, data: tree });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Get department tree failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取部门树失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async assignManager(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId, id: assignedBy } = req.user;
|
||||
const { departmentId } = req.params;
|
||||
const { managerId } = req.body;
|
||||
|
||||
if (!managerId) {
|
||||
return res.status(400).json({ success: false, error: '负责人ID不能为空' });
|
||||
}
|
||||
|
||||
const department = await HierarchyService.assignDepartmentManager(
|
||||
departmentId,
|
||||
tenantId,
|
||||
managerId,
|
||||
assignedBy
|
||||
);
|
||||
|
||||
res.json({ success: true, data: department });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Assign manager failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '设置部门负责人失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getManager(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
const { departmentId } = req.params;
|
||||
|
||||
const manager = await HierarchyService.getDepartmentManager(
|
||||
departmentId,
|
||||
tenantId
|
||||
);
|
||||
|
||||
res.json({ success: true, data: manager });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Get manager failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取部门负责人失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getDepartmentStats(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
const { departmentId } = req.params;
|
||||
|
||||
const stats = await HierarchyService.getDepartmentStats(
|
||||
departmentId,
|
||||
tenantId
|
||||
);
|
||||
|
||||
res.json({ success: true, data: stats });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Get department stats failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取部门统计失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getHierarchyStats(req: Request, res: Response) {
|
||||
try {
|
||||
const { tenantId } = req.user;
|
||||
|
||||
const stats = await HierarchyService.getHierarchyStats(tenantId);
|
||||
|
||||
res.json({ success: true, data: stats });
|
||||
} catch (error: unknown) {
|
||||
logger.error('[DepartmentController] Get hierarchy stats failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取层级统计失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
18
server/src/api/routes/department.ts
Normal file
18
server/src/api/routes/department.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { DepartmentController } from '../controllers/DepartmentController';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.post('/', DepartmentController.createDepartment);
|
||||
router.put('/:id', DepartmentController.updateDepartment);
|
||||
router.delete('/:id', DepartmentController.deleteDepartment);
|
||||
router.get('/tree', DepartmentController.getDepartmentTree);
|
||||
router.get('/stats/hierarchy', DepartmentController.getHierarchyStats);
|
||||
router.post('/:departmentId/manager', DepartmentController.assignManager);
|
||||
router.get('/:departmentId/manager', DepartmentController.getManager);
|
||||
router.get('/:departmentId/stats', DepartmentController.getDepartmentStats);
|
||||
|
||||
export default router;
|
||||
@@ -4,6 +4,7 @@ import productRoutes from './product';
|
||||
import orderRoutes from './order';
|
||||
import financeRoutes from './finance';
|
||||
import syncRoutes from './sync';
|
||||
import departmentRoutes from './department';
|
||||
// import monitoringRoutes from './monitoring';
|
||||
// import operationAgentRoutes from './operation-agent';
|
||||
// import aiSelfImprovementRoutes from './ai-self-improvement';
|
||||
@@ -26,6 +27,7 @@ router.use('/products', productRoutes);
|
||||
router.use('/orders', orderRoutes);
|
||||
router.use('/finance', financeRoutes);
|
||||
router.use('/sync', syncRoutes);
|
||||
router.use('/departments', departmentRoutes);
|
||||
// router.use('/monitoring', monitoringRoutes);
|
||||
// router.use('/telemetry', telemetryRoutes);
|
||||
// router.use('/ai', aiRoutes);
|
||||
|
||||
@@ -115,6 +115,80 @@ const createTables = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 部门表
|
||||
if (!(await db.schema.hasTable('cf_department'))) {
|
||||
await db.schema.createTable('cf_department', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('tenant_id').notNullable();
|
||||
table.string('name').notNullable();
|
||||
table.string('parent_id').nullable();
|
||||
table.string('path').notNullable();
|
||||
table.integer('depth').notNullable();
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
table.timestamp('deleted_at').nullable();
|
||||
});
|
||||
}
|
||||
|
||||
// 店铺表
|
||||
if (!(await db.schema.hasTable('cf_shop'))) {
|
||||
await db.schema.createTable('cf_shop', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('tenant_id').notNullable();
|
||||
table.string('department_id').notNullable();
|
||||
table.string('name').notNullable();
|
||||
table.string('platform').notNullable();
|
||||
table.string('shop_id').notNullable();
|
||||
table.string('status').notNullable();
|
||||
table.json('config').nullable();
|
||||
table.string('path').notNullable();
|
||||
table.integer('depth').notNullable();
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
table.timestamp('deleted_at').nullable();
|
||||
|
||||
// 唯一约束
|
||||
table.unique(['platform', 'shop_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// 用户-部门关联表
|
||||
if (!(await db.schema.hasTable('cf_user_department'))) {
|
||||
await db.schema.createTable('cf_user_department', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('tenant_id').notNullable();
|
||||
table.string('user_id').notNullable();
|
||||
table.string('department_id').notNullable();
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
|
||||
// 唯一约束
|
||||
table.unique(['user_id', 'department_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// 店铺成员表
|
||||
if (!(await db.schema.hasTable('cf_shop_member'))) {
|
||||
await db.schema.createTable('cf_shop_member', (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('shop_id').notNullable();
|
||||
table.string('user_id').notNullable();
|
||||
table.enum('role', ['owner', 'admin', 'operator', 'viewer']).notNullable();
|
||||
table.json('permissions').notNullable();
|
||||
table.string('assigned_by').notNullable();
|
||||
table.timestamp('assigned_at').notNullable();
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
|
||||
// 索引
|
||||
table.index(['shop_id']);
|
||||
table.index(['user_id']);
|
||||
|
||||
// 唯一约束
|
||||
table.unique(['shop_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// 主权授信池表
|
||||
if (!(await db.schema.hasTable('cf_sov_credit_pool'))) {
|
||||
await db.schema.createTable('cf_sov_credit_pool', (table) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import db from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AuditService } from '../../services/audit/AuditService';
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
@@ -304,6 +305,130 @@ export class RBACEngine {
|
||||
}
|
||||
}
|
||||
|
||||
static async createRole(roleCode: string, roleName: string, permissions: string[], tenantId?: string): Promise<boolean> {
|
||||
try {
|
||||
// 检查角色是否已存在
|
||||
const existingRole = await db(this.ROLES_TABLE).where('code', roleCode).first();
|
||||
if (existingRole) {
|
||||
logger.error(`[RBAC] Role ${roleCode} already exists`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await db(this.ROLES_TABLE).insert({
|
||||
code: roleCode,
|
||||
name: roleName,
|
||||
permissions: JSON.stringify(permissions)
|
||||
});
|
||||
|
||||
logger.info(`[RBAC] Created role ${roleCode} with ${permissions.length} permissions`);
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[RBAC] Failed to create role: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateRole(roleCode: string, roleName?: string, permissions?: string[]): Promise<boolean> {
|
||||
try {
|
||||
const updateData: any = {};
|
||||
if (roleName) updateData.name = roleName;
|
||||
if (permissions) updateData.permissions = JSON.stringify(permissions);
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await db(this.ROLES_TABLE)
|
||||
.where('code', roleCode)
|
||||
.update(updateData);
|
||||
|
||||
if (result > 0) {
|
||||
logger.info(`[RBAC] Updated role ${roleCode}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.error(`[RBAC] Role ${roleCode} not found`);
|
||||
return false;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[RBAC] Failed to update role: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteRole(roleCode: string): Promise<boolean> {
|
||||
try {
|
||||
// 不允许删除系统预设角色
|
||||
const systemRoles = Object.keys(ROLE_PERMISSIONS);
|
||||
if (systemRoles.includes(roleCode)) {
|
||||
logger.error(`[RBAC] Cannot delete system role ${roleCode}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有用户使用此角色
|
||||
const userRolesCount = await db(this.USER_ROLES_TABLE)
|
||||
.where('role_code', roleCode)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (userRolesCount && (userRolesCount.count as number) > 0) {
|
||||
logger.error(`[RBAC] Cannot delete role ${roleCode} - it's assigned to users`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await db(this.ROLES_TABLE)
|
||||
.where('code', roleCode)
|
||||
.delete();
|
||||
|
||||
if (result > 0) {
|
||||
logger.info(`[RBAC] Deleted role ${roleCode}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.error(`[RBAC] Role ${roleCode} not found`);
|
||||
return false;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[RBAC] Failed to delete role: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async getRole(roleCode: string): Promise<Role | null> {
|
||||
try {
|
||||
const role = await db(this.ROLES_TABLE).where('code', roleCode).first();
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
permissions: JSON.parse(role.permissions || '[]')
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[RBAC] Failed to get role: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllRoles(): Promise<Role[]> {
|
||||
try {
|
||||
const roles = await db(this.ROLES_TABLE).select();
|
||||
return roles.map(role => ({
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
permissions: JSON.parse(role.permissions || '[]')
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[RBAC] Failed to get all roles: ${errorMessage}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async getUserRoles(userId: string, tenantId: string): Promise<string[]> {
|
||||
const userRoles = await db(this.USER_ROLES_TABLE)
|
||||
.where({ user_id: userId, tenant_id: tenantId })
|
||||
|
||||
@@ -33,6 +33,7 @@ export default class DatabaseSchema {
|
||||
await this.initCompetitorPriceTables();
|
||||
await this.initCurrencyTables();
|
||||
await this.initBatchOperationTables();
|
||||
await this.initInvitationTables();
|
||||
|
||||
logger.info('[DatabaseSchema] All tables initialized successfully');
|
||||
}
|
||||
@@ -1278,4 +1279,46 @@ export default class DatabaseSchema {
|
||||
logger.info('[DatabaseSchema] Table cf_competitor_monitor_configs created');
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请表
|
||||
private static async initInvitationTables(): Promise<void> {
|
||||
// 邀请表
|
||||
const hasInvitationTable = await db.schema.hasTable('cf_invitation');
|
||||
if (!hasInvitationTable) {
|
||||
logger.info('[DatabaseSchema] Creating cf_invitation table...');
|
||||
await db.schema.createTable('cf_invitation', (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('inviter_id', 36).notNullable();
|
||||
table.string('email', 128).notNullable().index();
|
||||
table.string('code', 64).notNullable().unique().index();
|
||||
table.string('token', 128).notNullable().unique();
|
||||
table.string('role', 32).defaultTo('OPERATOR');
|
||||
table.integer('expires_in').defaultTo(7 * 24 * 60 * 60); // 7天
|
||||
table.enum('status', ['PENDING', 'ACCEPTED', 'EXPIRED', 'CANCELLED']).defaultTo('PENDING');
|
||||
table.string('created_by', 36).notNullable();
|
||||
table.string('updated_by', 36).notNullable();
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info('[DatabaseSchema] Table cf_invitation created');
|
||||
}
|
||||
|
||||
// 邀请记录表
|
||||
const hasInvitationLogTable = await db.schema.hasTable('cf_invitation_log');
|
||||
if (!hasInvitationLogTable) {
|
||||
logger.info('[DatabaseSchema] Creating cf_invitation_log table...');
|
||||
await db.schema.createTable('cf_invitation_log', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('invitation_id', 36).notNullable().index();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('action', 32).notNullable();
|
||||
table.string('user_id', 36).notNullable();
|
||||
table.string('ip', 64);
|
||||
table.text('metadata');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info('[DatabaseSchema] Table cf_invitation_log created');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { MailService } from '../../core/mail/MailService';
|
||||
import db from '../../config/database';
|
||||
import { InvitationService } from '../../services/auth/InvitationService';
|
||||
|
||||
@Injectable()
|
||||
export class RegistrationService {
|
||||
private readonly logger = new Logger(RegistrationService.name);
|
||||
private readonly mailService: MailService;
|
||||
private readonly invitationService: InvitationService;
|
||||
|
||||
constructor() {
|
||||
this.mailService = MailService.getInstance();
|
||||
this.invitationService = new InvitationService();
|
||||
}
|
||||
|
||||
async register(
|
||||
@@ -21,7 +24,8 @@ export class RegistrationService {
|
||||
companyName: string,
|
||||
phone: string,
|
||||
businessType: string,
|
||||
): Promise<{ userId: string; verificationToken: string }> {
|
||||
inviteToken?: string
|
||||
): Promise<{ userId: string; verificationToken: string; tenantId: string }> {
|
||||
try {
|
||||
const existingUser = await db('cf_user')
|
||||
.where('username', username)
|
||||
@@ -39,6 +43,33 @@ export class RegistrationService {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const verificationToken = uuidv4();
|
||||
const userId = uuidv4();
|
||||
let tenantId: string;
|
||||
let role = 'OPERATOR';
|
||||
|
||||
if (inviteToken) {
|
||||
// 处理邀请注册
|
||||
const invitation = await this.invitationService.verifyInvitation(inviteToken);
|
||||
tenantId = invitation.tenant_id;
|
||||
role = invitation.role;
|
||||
} else {
|
||||
// 处理新租户注册
|
||||
tenantId = `tenant-${userId}`;
|
||||
|
||||
// 创建租户
|
||||
await db('cf_tenant').insert({
|
||||
id: tenantId,
|
||||
name: companyName,
|
||||
domain: `${username.toLowerCase()}.crawlful.com`,
|
||||
contact_name: username,
|
||||
contact_email: email,
|
||||
contact_phone: phone,
|
||||
status: 'ACTIVE',
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
await db('cf_user').insert({
|
||||
id: userId,
|
||||
@@ -48,17 +79,22 @@ export class RegistrationService {
|
||||
companyName,
|
||||
phone,
|
||||
businessType,
|
||||
role: 'OPERATOR',
|
||||
role,
|
||||
status: 'active',
|
||||
tenantId: `tenant-${userId}`,
|
||||
tenantId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
if (inviteToken) {
|
||||
// 接受邀请
|
||||
await this.invitationService.acceptInvitation(inviteToken, userId);
|
||||
}
|
||||
|
||||
await this.mailService.sendWelcomeEmail(email, username);
|
||||
|
||||
this.logger.log(`Registered new user: ${username} (${email})`);
|
||||
return { userId, verificationToken };
|
||||
return { userId, verificationToken, tenantId };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to register user', error);
|
||||
throw error;
|
||||
|
||||
493
server/src/services/analytics/DataStatisticsService.ts
Normal file
493
server/src/services/analytics/DataStatisticsService.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* [BE-DS001] 数据统计服务
|
||||
* 负责多维度数据统计,解决多用户共享店铺的数据虚高问题
|
||||
* AI注意: 统计时必须对店铺进行去重,避免数据虚高
|
||||
*/
|
||||
|
||||
import db from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { DataIsolationService } from '../integration/DataIsolationService';
|
||||
|
||||
export interface ShopStatistics {
|
||||
totalShops: number;
|
||||
activeShops: number;
|
||||
inactiveShops: number;
|
||||
shopDetails: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
status: string;
|
||||
departmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OrderStatistics {
|
||||
totalOrders: number;
|
||||
totalAmount: number;
|
||||
pendingOrders: number;
|
||||
completedOrders: number;
|
||||
cancelledOrders: number;
|
||||
orderDetails: Array<{
|
||||
id: string;
|
||||
orderId: string;
|
||||
shopId: string;
|
||||
platform: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserStatistics {
|
||||
userId: string;
|
||||
userName: string;
|
||||
shopCount: number;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
uniqueShops: string[];
|
||||
}
|
||||
|
||||
export interface DepartmentStatistics {
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
userCount: number;
|
||||
shopCount: number;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
uniqueShops: string[];
|
||||
}
|
||||
|
||||
export class DataStatisticsService {
|
||||
/**
|
||||
* 获取用户维度的店铺统计(去重)
|
||||
* 解决多用户共享同一店铺导致的虚高问题
|
||||
*/
|
||||
static async getUserShopStatistics(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<ShopStatistics> {
|
||||
try {
|
||||
const context = {
|
||||
tenantId,
|
||||
userId,
|
||||
role: 'OPERATOR',
|
||||
permissions: [],
|
||||
hierarchyPath: '',
|
||||
};
|
||||
|
||||
const isolationQuery = DataIsolationService.buildIsolationQuery('cf_shop', context);
|
||||
|
||||
const shops = await db('cf_shop')
|
||||
.where(isolationQuery.where)
|
||||
.modify(isolationQuery.modify)
|
||||
.select('id', 'name', 'platform', 'status', 'department_id');
|
||||
|
||||
const totalShops = shops.length;
|
||||
const activeShops = shops.filter(s => s.status === 'active').length;
|
||||
const inactiveShops = shops.filter(s => s.status === 'inactive').length;
|
||||
|
||||
logger.info('[DataStatisticsService] User shop statistics calculated', {
|
||||
userId,
|
||||
tenantId,
|
||||
totalShops,
|
||||
activeShops,
|
||||
inactiveShops,
|
||||
});
|
||||
|
||||
return {
|
||||
totalShops,
|
||||
activeShops,
|
||||
inactiveShops,
|
||||
shopDetails: shops.map(shop => ({
|
||||
id: shop.id,
|
||||
name: shop.name,
|
||||
platform: shop.platform,
|
||||
status: shop.status,
|
||||
departmentId: shop.department_id,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DataStatisticsService] Failed to calculate user shop statistics', {
|
||||
userId,
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户维度的订单统计(去重)
|
||||
* 解决多用户共享店铺导致的订单虚高问题
|
||||
*/
|
||||
static async getUserOrderStatistics(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<OrderStatistics> {
|
||||
try {
|
||||
const context = {
|
||||
tenantId,
|
||||
userId,
|
||||
role: 'OPERATOR',
|
||||
permissions: [],
|
||||
hierarchyPath: '',
|
||||
};
|
||||
|
||||
const isolationQuery = DataIsolationService.buildIsolationQuery('cf_order', context);
|
||||
|
||||
let query = db('cf_order')
|
||||
.where(isolationQuery.where)
|
||||
.modify(isolationQuery.modify);
|
||||
|
||||
if (startDate) {
|
||||
query = query.where('created_at', '>=', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
query = query.where('created_at', '<=', endDate);
|
||||
}
|
||||
|
||||
const orders = await query.select(
|
||||
'id',
|
||||
'order_id',
|
||||
'shop_id',
|
||||
'platform',
|
||||
'total_amount',
|
||||
'status',
|
||||
'created_at'
|
||||
);
|
||||
|
||||
const totalOrders = orders.length;
|
||||
const totalAmount = orders.reduce((sum, order) => sum + Number(order.total_amount), 0);
|
||||
const pendingOrders = orders.filter(o => o.status === 'pending').length;
|
||||
const completedOrders = orders.filter(o => o.status === 'completed').length;
|
||||
const cancelledOrders = orders.filter(o => o.status === 'cancelled').length;
|
||||
|
||||
logger.info('[DataStatisticsService] User order statistics calculated', {
|
||||
userId,
|
||||
tenantId,
|
||||
totalOrders,
|
||||
totalAmount,
|
||||
pendingOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
});
|
||||
|
||||
return {
|
||||
totalOrders,
|
||||
totalAmount,
|
||||
pendingOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
orderDetails: orders.map(order => ({
|
||||
id: order.id,
|
||||
orderId: order.order_id,
|
||||
shopId: order.shop_id,
|
||||
platform: order.platform,
|
||||
amount: Number(order.total_amount),
|
||||
status: order.status,
|
||||
createdAt: order.created_at,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DataStatisticsService] Failed to calculate user order statistics', {
|
||||
userId,
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门维度的统计(去重)
|
||||
* 解决部门内多用户共享店铺导致的虚高问题
|
||||
*/
|
||||
static async getDepartmentStatistics(
|
||||
departmentId: string,
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<DepartmentStatistics> {
|
||||
try {
|
||||
const context = {
|
||||
tenantId,
|
||||
userId: '',
|
||||
role: 'MANAGER',
|
||||
permissions: [],
|
||||
hierarchyPath: '',
|
||||
};
|
||||
|
||||
const shopIsolationQuery = DataIsolationService.buildIsolationQuery('cf_shop', context);
|
||||
const orderIsolationQuery = DataIsolationService.buildIsolationQuery('cf_order', context);
|
||||
|
||||
const shops = await db('cf_shop')
|
||||
.where('department_id', departmentId)
|
||||
.where(shopIsolationQuery.where)
|
||||
.modify(shopIsolationQuery.modify)
|
||||
.select('id', 'name', 'platform', 'status');
|
||||
|
||||
let orderQuery = db('cf_order')
|
||||
.whereIn('shop_id', shops.map(s => s.id))
|
||||
.where(orderIsolationQuery.where)
|
||||
.modify(orderIsolationQuery.modify);
|
||||
|
||||
if (startDate) {
|
||||
orderQuery = orderQuery.where('created_at', '>=', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
orderQuery = orderQuery.where('created_at', '<=', endDate);
|
||||
}
|
||||
|
||||
const orders = await orderQuery.select('id', 'shop_id', 'total_amount', 'status');
|
||||
|
||||
const users = await db('cf_user_department')
|
||||
.where('department_id', departmentId)
|
||||
.join('cf_user', 'cf_user.id', 'cf_user_department.user_id')
|
||||
.select('cf_user.id', 'cf_user.name');
|
||||
|
||||
const shopCount = shops.length;
|
||||
const orderCount = orders.length;
|
||||
const totalAmount = orders.reduce((sum, order) => sum + Number(order.total_amount), 0);
|
||||
const uniqueShops = Array.from(new Set(orders.map(order => order.shop_id)));
|
||||
|
||||
logger.info('[DataStatisticsService] Department statistics calculated', {
|
||||
departmentId,
|
||||
tenantId,
|
||||
userCount: users.length,
|
||||
shopCount,
|
||||
orderCount,
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
return {
|
||||
departmentId,
|
||||
departmentName: shops[0]?.name || 'Unknown',
|
||||
userCount: users.length,
|
||||
shopCount,
|
||||
orderCount,
|
||||
totalAmount,
|
||||
uniqueShops,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DataStatisticsService] Failed to calculate department statistics', {
|
||||
departmentId,
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多用户维度的汇总统计(去重)
|
||||
* 解决多用户共享店铺导致的汇总虚高问题
|
||||
*/
|
||||
static async getMultiUserStatistics(
|
||||
userIds: string[],
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<{
|
||||
userStatistics: UserStatistics[];
|
||||
totalShops: number;
|
||||
totalOrders: number;
|
||||
totalAmount: number;
|
||||
uniqueShops: string[];
|
||||
}> {
|
||||
try {
|
||||
const userStatistics: UserStatistics[] = [];
|
||||
const allShopIds = new Set<string>();
|
||||
const allOrderIds = new Set<string>();
|
||||
let totalAmount = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
const shopStats = await this.getUserShopStatistics(userId, tenantId);
|
||||
const orderStats = await this.getUserOrderStatistics(userId, tenantId, startDate, endDate);
|
||||
|
||||
const userShopIds = shopStats.shopDetails.map(s => s.id);
|
||||
const userOrderIds = orderStats.orderDetails.map(o => o.id);
|
||||
|
||||
userShopIds.forEach(shopId => allShopIds.add(shopId));
|
||||
userOrderIds.forEach(orderId => allOrderIds.add(orderId));
|
||||
|
||||
const user = await db('cf_user').where('id', userId).first();
|
||||
|
||||
userStatistics.push({
|
||||
userId,
|
||||
userName: user?.name || 'Unknown',
|
||||
shopCount: userShopIds.length,
|
||||
orderCount: userOrderIds.length,
|
||||
totalAmount: orderStats.totalAmount,
|
||||
uniqueShops: userShopIds,
|
||||
});
|
||||
|
||||
totalAmount += orderStats.totalAmount;
|
||||
}
|
||||
|
||||
logger.info('[DataStatisticsService] Multi-user statistics calculated', {
|
||||
userIds,
|
||||
tenantId,
|
||||
totalShops: allShopIds.size,
|
||||
totalOrders: allOrderIds.size,
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
return {
|
||||
userStatistics,
|
||||
totalShops: allShopIds.size,
|
||||
totalOrders: allOrderIds.size,
|
||||
totalAmount,
|
||||
uniqueShops: Array.from(allShopIds),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DataStatisticsService] Failed to calculate multi-user statistics', {
|
||||
userIds,
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户维度的全局统计(去重)
|
||||
* 解决租户内多部门多用户共享店铺导致的虚高问题
|
||||
*/
|
||||
static async getTenantStatistics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<{
|
||||
totalUsers: number;
|
||||
totalDepartments: number;
|
||||
totalShops: number;
|
||||
totalOrders: number;
|
||||
totalAmount: number;
|
||||
platformDistribution: Record<string, number>;
|
||||
}> {
|
||||
try {
|
||||
const users = await db('cf_user').where('tenant_id', tenantId);
|
||||
const departments = await db('cf_department').where('tenant_id', tenantId);
|
||||
const shops = await db('cf_shop').where('tenant_id', tenantId);
|
||||
|
||||
let orderQuery = db('cf_order').where('tenant_id', tenantId);
|
||||
|
||||
if (startDate) {
|
||||
orderQuery = orderQuery.where('created_at', '>=', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
orderQuery = orderQuery.where('created_at', '<=', endDate);
|
||||
}
|
||||
|
||||
const orders = await orderQuery.select('id', 'total_amount', 'platform');
|
||||
|
||||
const totalAmount = orders.reduce((sum, order) => sum + Number(order.total_amount), 0);
|
||||
|
||||
const platformDistribution: Record<string, number> = {};
|
||||
shops.forEach(shop => {
|
||||
platformDistribution[shop.platform] = (platformDistribution[shop.platform] || 0) + 1;
|
||||
});
|
||||
|
||||
logger.info('[DataStatisticsService] Tenant statistics calculated', {
|
||||
tenantId,
|
||||
totalUsers: users.length,
|
||||
totalDepartments: departments.length,
|
||||
totalShops: shops.length,
|
||||
totalOrders: orders.length,
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
return {
|
||||
totalUsers: users.length,
|
||||
totalDepartments: departments.length,
|
||||
totalShops: shops.length,
|
||||
totalOrders: orders.length,
|
||||
totalAmount,
|
||||
platformDistribution,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[DataStatisticsService] Failed to calculate tenant statistics', {
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取店铺的实际拥有者统计
|
||||
* 区分店铺的实际归属,避免重复计算
|
||||
*/
|
||||
static async getShopOwnershipStatistics(
|
||||
tenantId: string
|
||||
): Promise<Array<{
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
platform: string;
|
||||
owners: Array<{
|
||||
userId: string;
|
||||
userName: string;
|
||||
role: string;
|
||||
assignedAt: string;
|
||||
}>;
|
||||
primaryOwner: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
role: string;
|
||||
};
|
||||
}>> {
|
||||
try {
|
||||
const shops = await db('cf_shop').where('tenant_id', tenantId).select('id', 'name', 'platform');
|
||||
|
||||
const shopOwnership = await Promise.all(
|
||||
shops.map(async (shop) => {
|
||||
const members = await db('cf_shop_member')
|
||||
.where('shop_id', shop.id)
|
||||
.join('cf_user', 'cf_user.id', 'cf_shop_member.user_id')
|
||||
.select(
|
||||
'cf_shop_member.user_id',
|
||||
'cf_user.name as user_name',
|
||||
'cf_shop_member.role',
|
||||
'cf_shop_member.assigned_at'
|
||||
);
|
||||
|
||||
const primaryOwner = members.find(m => m.role === 'owner') || members[0];
|
||||
|
||||
return {
|
||||
shopId: shop.id,
|
||||
shopName: shop.name,
|
||||
platform: shop.platform,
|
||||
owners: members.map(m => ({
|
||||
userId: m.user_id,
|
||||
userName: m.user_name,
|
||||
role: m.role,
|
||||
assignedAt: m.assigned_at,
|
||||
})),
|
||||
primaryOwner: primaryOwner ? {
|
||||
userId: primaryOwner.user_id,
|
||||
userName: primaryOwner.user_name,
|
||||
role: primaryOwner.role,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
logger.info('[DataStatisticsService] Shop ownership statistics calculated', {
|
||||
tenantId,
|
||||
totalShops: shopOwnership.length,
|
||||
});
|
||||
|
||||
return shopOwnership;
|
||||
} catch (error) {
|
||||
logger.error('[DataStatisticsService] Failed to calculate shop ownership statistics', {
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
317
server/src/services/audit/AuditService.ts
Normal file
317
server/src/services/audit/AuditService.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import db from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
oldValue?: any;
|
||||
newValue?: any;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
traceId: string;
|
||||
businessType: 'TOC' | 'TOB';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
private static readonly AUDIT_LOG_TABLE = 'cf_audit_log';
|
||||
|
||||
/**
|
||||
* 记录权限变更审计日志
|
||||
*/
|
||||
static async logPermissionChange(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
oldValue: any,
|
||||
newValue: any,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
traceId: string,
|
||||
businessType: 'TOC' | 'TOB' = 'TOC'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await db(this.AUDIT_LOG_TABLE).insert({
|
||||
id: this.generateId(),
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
action,
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId,
|
||||
old_value: oldValue ? JSON.stringify(oldValue) : null,
|
||||
new_value: newValue ? JSON.stringify(newValue) : null,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
trace_id: traceId,
|
||||
business_type: businessType,
|
||||
created_at: new Date()
|
||||
});
|
||||
|
||||
logger.info(`[AuditService] Logged permission change: ${action} on ${resourceType}:${resourceId}`, {
|
||||
tenantId,
|
||||
userId,
|
||||
resourceId,
|
||||
traceId
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[AuditService] Failed to log permission change: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录角色创建审计日志
|
||||
*/
|
||||
static async logRoleCreation(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
roleCode: string,
|
||||
roleName: string,
|
||||
permissions: string[],
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
traceId: string
|
||||
): Promise<boolean> {
|
||||
return this.logPermissionChange(
|
||||
tenantId,
|
||||
userId,
|
||||
'ROLE_CREATED',
|
||||
'ROLE',
|
||||
roleCode,
|
||||
null,
|
||||
{
|
||||
roleCode,
|
||||
roleName,
|
||||
permissions
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录角色更新审计日志
|
||||
*/
|
||||
static async logRoleUpdate(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
roleCode: string,
|
||||
oldRole: any,
|
||||
newRole: any,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
traceId: string
|
||||
): Promise<boolean> {
|
||||
return this.logPermissionChange(
|
||||
tenantId,
|
||||
userId,
|
||||
'ROLE_UPDATED',
|
||||
'ROLE',
|
||||
roleCode,
|
||||
oldRole,
|
||||
newRole,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录角色删除审计日志
|
||||
*/
|
||||
static async logRoleDeletion(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
roleCode: string,
|
||||
roleData: any,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
traceId: string
|
||||
): Promise<boolean> {
|
||||
return this.logPermissionChange(
|
||||
tenantId,
|
||||
userId,
|
||||
'ROLE_DELETED',
|
||||
'ROLE',
|
||||
roleCode,
|
||||
roleData,
|
||||
null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户角色分配审计日志
|
||||
*/
|
||||
static async logUserRoleAssignment(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
targetUserId: string,
|
||||
roleCode: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
traceId: string
|
||||
): Promise<boolean> {
|
||||
return this.logPermissionChange(
|
||||
tenantId,
|
||||
userId,
|
||||
'USER_ROLE_ASSIGNED',
|
||||
'USER_ROLE',
|
||||
`${targetUserId}:${roleCode}`,
|
||||
null,
|
||||
{
|
||||
targetUserId,
|
||||
roleCode
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户角色移除审计日志
|
||||
*/
|
||||
static async logUserRoleRemoval(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
targetUserId: string,
|
||||
roleCode: string,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
traceId: string
|
||||
): Promise<boolean> {
|
||||
return this.logPermissionChange(
|
||||
tenantId,
|
||||
userId,
|
||||
'USER_ROLE_REMOVED',
|
||||
'USER_ROLE',
|
||||
`${targetUserId}:${roleCode}`,
|
||||
{
|
||||
targetUserId,
|
||||
roleCode
|
||||
},
|
||||
null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取权限变更审计日志
|
||||
*/
|
||||
static async getPermissionAuditLogs(
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<AuditLog[]> {
|
||||
try {
|
||||
const logs = await db(this.AUDIT_LOG_TABLE)
|
||||
.where('tenant_id', tenantId)
|
||||
.whereIn('action', [
|
||||
'ROLE_CREATED',
|
||||
'ROLE_UPDATED',
|
||||
'ROLE_DELETED',
|
||||
'USER_ROLE_ASSIGNED',
|
||||
'USER_ROLE_REMOVED'
|
||||
])
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select();
|
||||
|
||||
return logs.map(log => ({
|
||||
id: log.id,
|
||||
tenantId: log.tenant_id,
|
||||
userId: log.user_id,
|
||||
action: log.action,
|
||||
resourceType: log.resource_type,
|
||||
resourceId: log.resource_id,
|
||||
oldValue: log.old_value ? JSON.parse(log.old_value) : null,
|
||||
newValue: log.new_value ? JSON.parse(log.new_value) : null,
|
||||
ipAddress: log.ip_address,
|
||||
userAgent: log.user_agent,
|
||||
traceId: log.trace_id,
|
||||
businessType: log.business_type,
|
||||
createdAt: new Date(log.created_at)
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[AuditService] Failed to get permission audit logs: ${errorMessage}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索权限变更审计日志
|
||||
*/
|
||||
static async searchPermissionAuditLogs(
|
||||
tenantId: string,
|
||||
searchTerm: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<AuditLog[]> {
|
||||
try {
|
||||
const logs = await db(this.AUDIT_LOG_TABLE)
|
||||
.where('tenant_id', tenantId)
|
||||
.whereIn('action', [
|
||||
'ROLE_CREATED',
|
||||
'ROLE_UPDATED',
|
||||
'ROLE_DELETED',
|
||||
'USER_ROLE_ASSIGNED',
|
||||
'USER_ROLE_REMOVED'
|
||||
])
|
||||
.andWhere(qb => {
|
||||
qb.where('action', 'like', `%${searchTerm}%`)
|
||||
.orWhere('resource_id', 'like', `%${searchTerm}%`)
|
||||
.orWhere('user_id', 'like', `%${searchTerm}%`);
|
||||
})
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.select();
|
||||
|
||||
return logs.map(log => ({
|
||||
id: log.id,
|
||||
tenantId: log.tenant_id,
|
||||
userId: log.user_id,
|
||||
action: log.action,
|
||||
resourceType: log.resource_type,
|
||||
resourceId: log.resource_id,
|
||||
oldValue: log.old_value ? JSON.parse(log.old_value) : null,
|
||||
newValue: log.new_value ? JSON.parse(log.new_value) : null,
|
||||
ipAddress: log.ip_address,
|
||||
userAgent: log.user_agent,
|
||||
traceId: log.trace_id,
|
||||
businessType: log.business_type,
|
||||
createdAt: new Date(log.created_at)
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`[AuditService] Failed to search permission audit logs: ${errorMessage}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
private static generateId(): string {
|
||||
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuditService;
|
||||
258
server/src/services/auth/InvitationService.ts
Normal file
258
server/src/services/auth/InvitationService.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as crypto from 'crypto';
|
||||
import db from '../../config/database';
|
||||
import { MailService } from '../../core/mail/MailService';
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
inviter_id: string;
|
||||
email: string;
|
||||
code: string;
|
||||
token: string;
|
||||
role: string;
|
||||
expires_in: number;
|
||||
status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'CANCELLED';
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface InviteOptions {
|
||||
tenantId: string;
|
||||
inviterId: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InvitationService {
|
||||
private readonly logger = new Logger(InvitationService.name);
|
||||
private readonly mailService: MailService;
|
||||
|
||||
constructor() {
|
||||
this.mailService = MailService.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成邀请链接
|
||||
*/
|
||||
async generateInvitation(options: InviteOptions): Promise<{ invitation: Invitation; inviteLink: string }> {
|
||||
try {
|
||||
const { tenantId, inviterId, email, role = 'OPERATOR', expiresIn = 7 * 24 * 60 * 60 } = options;
|
||||
|
||||
// 检查租户是否存在
|
||||
const tenant = await db('cf_tenant').where('id', tenantId).first();
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
// 检查邀请者是否存在
|
||||
const inviter = await db('cf_user').where('id', inviterId).first();
|
||||
if (!inviter) {
|
||||
throw new NotFoundException('Inviter not found');
|
||||
}
|
||||
|
||||
// 生成邀请码和token
|
||||
const code = this.generateInviteCode();
|
||||
const token = uuidv4();
|
||||
|
||||
const invitationId = uuidv4();
|
||||
|
||||
// 创建邀请记录
|
||||
await db('cf_invitation').insert({
|
||||
id: invitationId,
|
||||
tenant_id: tenantId,
|
||||
inviter_id: inviterId,
|
||||
email,
|
||||
code,
|
||||
token,
|
||||
role,
|
||||
expires_in: expiresIn,
|
||||
status: 'PENDING',
|
||||
created_by: inviterId,
|
||||
updated_by: inviterId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 生成邀请链接
|
||||
const inviteLink = `${process.env.FRONTEND_URL || 'http://localhost:8000'}/auth/register?invite=${token}&tenant=${tenantId}`;
|
||||
|
||||
// 发送邀请邮件
|
||||
await this.mailService.sendInvitationEmail(email, inviter.username, tenant.name, inviteLink, expiresIn);
|
||||
|
||||
// 记录邀请日志
|
||||
await this.logInvitationAction(invitationId, tenantId, inviterId, 'INVITE_SENT', {
|
||||
email,
|
||||
role,
|
||||
expiresIn
|
||||
});
|
||||
|
||||
const invitation = await db('cf_invitation').where('id', invitationId).first();
|
||||
|
||||
this.logger.log(`Generated invitation for ${email} to join tenant ${tenantId}`);
|
||||
return { invitation, inviteLink };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate invitation', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邀请码
|
||||
*/
|
||||
async verifyInvitation(token: string): Promise<Invitation> {
|
||||
try {
|
||||
const invitation = await db('cf_invitation')
|
||||
.where('token', token)
|
||||
.where('status', 'PENDING')
|
||||
.first();
|
||||
|
||||
if (!invitation) {
|
||||
throw new BadRequestException('Invalid or expired invitation');
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
const expiresAt = new Date(invitation.created_at);
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + invitation.expires_in);
|
||||
|
||||
if (expiresAt < new Date()) {
|
||||
// 标记为过期
|
||||
await db('cf_invitation')
|
||||
.where('id', invitation.id)
|
||||
.update({
|
||||
status: 'EXPIRED',
|
||||
updated_at: new Date()
|
||||
});
|
||||
throw new BadRequestException('Invitation has expired');
|
||||
}
|
||||
|
||||
return invitation;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to verify invitation', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受邀请
|
||||
*/
|
||||
async acceptInvitation(token: string, userId: string): Promise<void> {
|
||||
try {
|
||||
const invitation = await this.verifyInvitation(token);
|
||||
|
||||
// 更新邀请状态
|
||||
await db('cf_invitation')
|
||||
.where('id', invitation.id)
|
||||
.update({
|
||||
status: 'ACCEPTED',
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 更新用户租户信息
|
||||
await db('cf_user')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
tenant_id: invitation.tenant_id,
|
||||
role: invitation.role,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 记录接受日志
|
||||
await this.logInvitationAction(invitation.id, invitation.tenant_id, userId, 'INVITE_ACCEPTED', {
|
||||
userId
|
||||
});
|
||||
|
||||
this.logger.log(`User ${userId} accepted invitation to join tenant ${invitation.tenant_id}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to accept invitation', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消邀请
|
||||
*/
|
||||
async cancelInvitation(invitationId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
const invitation = await db('cf_invitation').where('id', invitationId).first();
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
}
|
||||
|
||||
await db('cf_invitation')
|
||||
.where('id', invitationId)
|
||||
.update({
|
||||
status: 'CANCELLED',
|
||||
updated_by: userId,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 记录取消日志
|
||||
await this.logInvitationAction(invitationId, invitation.tenant_id, userId, 'INVITE_CANCELLED', {});
|
||||
|
||||
this.logger.log(`Invitation ${invitationId} cancelled by ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to cancel invitation', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取租户的邀请列表
|
||||
*/
|
||||
async getTenantInvitations(tenantId: string): Promise<Invitation[]> {
|
||||
try {
|
||||
const invitations = await db('cf_invitation')
|
||||
.where('tenant_id', tenantId)
|
||||
.orderBy('created_at', 'desc')
|
||||
.select();
|
||||
|
||||
return invitations;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get tenant invitations', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成邀请码
|
||||
*/
|
||||
private generateInviteCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录邀请操作日志
|
||||
*/
|
||||
private async logInvitationAction(
|
||||
invitationId: string,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db('cf_invitation_log').insert({
|
||||
invitation_id: invitationId,
|
||||
tenant_id: tenantId,
|
||||
action,
|
||||
user_id: userId,
|
||||
metadata: JSON.stringify(metadata),
|
||||
created_at: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to log invitation action', error);
|
||||
// 日志记录失败不影响主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
127
server/src/services/auth/InvitationStatsService.ts
Normal file
127
server/src/services/auth/InvitationStatsService.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import db from '../../config/database';
|
||||
|
||||
@Injectable()
|
||||
export class InvitationStatsService {
|
||||
private readonly logger = new Logger(InvitationStatsService.name);
|
||||
|
||||
/**
|
||||
* 获取租户邀请统计
|
||||
*/
|
||||
async getTenantInvitationStats(tenantId: string): Promise<any> {
|
||||
try {
|
||||
const stats = await db('cf_invitation')
|
||||
.where('tenant_id', tenantId)
|
||||
.select(
|
||||
db.raw('COUNT(*) as total'),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending', ['PENDING']),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as accepted', ['ACCEPTED']),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired', ['EXPIRED']),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled', ['CANCELLED'])
|
||||
)
|
||||
.first();
|
||||
|
||||
const recentInvitations = await db('cf_invitation')
|
||||
.where('tenant_id', tenantId)
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(10)
|
||||
.select(
|
||||
'id',
|
||||
'email',
|
||||
'role',
|
||||
'status',
|
||||
'created_at',
|
||||
'expires_in'
|
||||
);
|
||||
|
||||
return {
|
||||
...stats,
|
||||
recentInvitations
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get tenant invitation stats', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户邀请记录
|
||||
*/
|
||||
async getUserInvitationHistory(userId: string): Promise<any[]> {
|
||||
try {
|
||||
const invitations = await db('cf_invitation')
|
||||
.where('inviter_id', userId)
|
||||
.orWhere('created_by', userId)
|
||||
.orderBy('created_at', 'desc')
|
||||
.select(
|
||||
'id',
|
||||
'tenant_id',
|
||||
'email',
|
||||
'role',
|
||||
'status',
|
||||
'created_at',
|
||||
'expires_in'
|
||||
);
|
||||
|
||||
return invitations;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get user invitation history', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统邀请统计
|
||||
*/
|
||||
async getSystemInvitationStats(): Promise<any> {
|
||||
try {
|
||||
const stats = await db('cf_invitation')
|
||||
.select(
|
||||
db.raw('COUNT(*) as total'),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending', ['PENDING']),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as accepted', ['ACCEPTED']),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired', ['EXPIRED']),
|
||||
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled', ['CANCELLED'])
|
||||
)
|
||||
.first();
|
||||
|
||||
const monthlyStats = await db('cf_invitation')
|
||||
.select(
|
||||
db.raw('DATE_FORMAT(created_at, ?) as month', ['%Y-%m']),
|
||||
db.raw('COUNT(*) as count')
|
||||
)
|
||||
.groupBy(db.raw('DATE_FORMAT(created_at, ?)', ['%Y-%m']))
|
||||
.orderBy('month', 'desc')
|
||||
.limit(6);
|
||||
|
||||
return {
|
||||
...stats,
|
||||
monthlyStats
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get system invitation stats', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期邀请
|
||||
*/
|
||||
async cleanupExpiredInvitations(): Promise<number> {
|
||||
try {
|
||||
const result = await db('cf_invitation')
|
||||
.where('status', 'PENDING')
|
||||
.whereRaw('DATE_ADD(created_at, INTERVAL expires_in SECOND) < NOW()')
|
||||
.update({
|
||||
status: 'EXPIRED',
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
this.logger.log(`Cleaned up ${result} expired invitations`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to cleanup expired invitations', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import db from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { RBACEngine, Permission, DataScope, UserRoleAssignment } from '../../core/auth/RBACEngine';
|
||||
import { AuditService } from '../audit/AuditService';
|
||||
|
||||
export enum Role {
|
||||
ADMIN = 'ADMIN',
|
||||
@@ -164,6 +165,70 @@ export class RBACService {
|
||||
return roleDefs[role] || [];
|
||||
}
|
||||
|
||||
static async createRole(roleCode: string, roleName: string, permissions: string[], userId: string, ipAddress: string = 'unknown', userAgent: string = 'unknown', traceId: string = 'unknown'): Promise<boolean> {
|
||||
const success = await RBACEngine.createRole(roleCode, roleName, permissions);
|
||||
if (success) {
|
||||
// 记录审计日志
|
||||
await AuditService.logRoleCreation(
|
||||
'system', // 暂时使用system作为tenantId,实际应从上下文获取
|
||||
userId,
|
||||
roleCode,
|
||||
roleName,
|
||||
permissions,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
static async updateRole(roleCode: string, roleName?: string, permissions?: string[], userId: string = 'system', ipAddress: string = 'unknown', userAgent: string = 'unknown', traceId: string = 'unknown'): Promise<boolean> {
|
||||
const oldRole = await RBACEngine.getRole(roleCode);
|
||||
const success = await RBACEngine.updateRole(roleCode, roleName, permissions);
|
||||
if (success && oldRole) {
|
||||
const newRole = await RBACEngine.getRole(roleCode);
|
||||
// 记录审计日志
|
||||
await AuditService.logRoleUpdate(
|
||||
'system', // 暂时使用system作为tenantId,实际应从上下文获取
|
||||
userId,
|
||||
roleCode,
|
||||
oldRole,
|
||||
newRole,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
static async deleteRole(roleCode: string, userId: string = 'system', ipAddress: string = 'unknown', userAgent: string = 'unknown', traceId: string = 'unknown'): Promise<boolean> {
|
||||
const roleData = await RBACEngine.getRole(roleCode);
|
||||
const success = await RBACEngine.deleteRole(roleCode);
|
||||
if (success && roleData) {
|
||||
// 记录审计日志
|
||||
await AuditService.logRoleDeletion(
|
||||
'system', // 暂时使用system作为tenantId,实际应从上下文获取
|
||||
userId,
|
||||
roleCode,
|
||||
roleData,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
traceId
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
static async getRole(roleCode: string): Promise<any> {
|
||||
return RBACEngine.getRole(roleCode);
|
||||
}
|
||||
|
||||
static async getAllRoles(): Promise<any[]> {
|
||||
return RBACEngine.getAllRoles();
|
||||
}
|
||||
|
||||
static async updateUserRoleScope(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import db from '../../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { cacheService } from '../CacheService';
|
||||
import { BullMQService } from '../core/BullMQService';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -12,7 +14,18 @@ export interface User {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
role?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
private static readonly CACHE_PREFIX = 'user:';
|
||||
private static readonly CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
static async createUser(userData: Partial<User> & { password: string }): Promise<{ success: boolean; userId?: string; error?: string }> {
|
||||
try {
|
||||
const userId = uuidv4();
|
||||
@@ -26,6 +39,10 @@ export class UserService {
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 清除相关缓存
|
||||
await this.clearUserCache(userData.tenantId);
|
||||
|
||||
return { success: true, userId };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
@@ -33,7 +50,21 @@ export class UserService {
|
||||
}
|
||||
|
||||
static async getUserById(userId: string): Promise<User | null> {
|
||||
const cacheKey = `${this.CACHE_PREFIX}${userId}`;
|
||||
|
||||
// 尝试从缓存获取
|
||||
const cachedUser = await cacheService.get<User>(cacheKey);
|
||||
if (cachedUser) {
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
// 从数据库获取
|
||||
const user = await db('cf_users').where({ id: userId }).first();
|
||||
if (user) {
|
||||
// 存入缓存
|
||||
await cacheService.set(cacheKey, user, { ttl: this.CACHE_TTL });
|
||||
}
|
||||
|
||||
return user || null;
|
||||
}
|
||||
|
||||
@@ -42,15 +73,164 @@ export class UserService {
|
||||
...updateData,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 清除相关缓存
|
||||
const cacheKey = `${this.CACHE_PREFIX}${userId}`;
|
||||
await cacheService.delete(cacheKey);
|
||||
|
||||
// 清除租户用户列表缓存
|
||||
const user = await db('cf_users').where({ id: userId }).first();
|
||||
if (user) {
|
||||
await this.clearUserCache(user.tenant_id);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
static async listUsers(tenantId: string): Promise<User[]> {
|
||||
return await db('cf_users').where({ tenant_id: tenantId });
|
||||
static async listUsers(tenantId: string, params: UserListParams = {}): Promise<{ users: User[]; total: number }> {
|
||||
const { page = 1, pageSize = 10, role, status, search } = params;
|
||||
const cacheKey = `${this.CACHE_PREFIX}list:${tenantId}:${page}:${pageSize}:${role || ''}:${status || ''}:${search || ''}`;
|
||||
|
||||
// 尝试从缓存获取
|
||||
const cachedResult = await cacheService.get<{ users: User[]; total: number }>(cacheKey);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
let query = db('cf_users').where({ tenant_id: tenantId });
|
||||
|
||||
if (role) {
|
||||
query = query.where({ role });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
// 假设 status 字段存在,实际根据数据库结构调整
|
||||
query = query.where({ status });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query = query.where((builder) => {
|
||||
builder.where('username', 'like', `%${search}%`)
|
||||
.orWhere('email', 'like', `%${search}%`);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const total = await query.clone().count('id as count').first();
|
||||
|
||||
// 获取分页数据
|
||||
const users = await query.offset((page - 1) * pageSize).limit(pageSize).orderBy('created_at', 'desc');
|
||||
|
||||
const result = {
|
||||
users,
|
||||
total: Number(total?.count || 0)
|
||||
};
|
||||
|
||||
// 存入缓存
|
||||
await cacheService.set(cacheKey, result, { ttl: this.CACHE_TTL });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async deleteUser(userId: string): Promise<void> {
|
||||
// 获取用户信息以清除相关缓存
|
||||
const user = await db('cf_users').where({ id: userId }).first();
|
||||
|
||||
await db('cf_users').where({ id: userId }).del();
|
||||
|
||||
// 清除相关缓存
|
||||
const cacheKey = `${this.CACHE_PREFIX}${userId}`;
|
||||
await cacheService.delete(cacheKey);
|
||||
|
||||
if (user) {
|
||||
await this.clearUserCache(user.tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
static async batchCreateUsers(usersData: Array<Partial<User> & { password: string }>): Promise<{ success: boolean; createdIds: string[]; error?: string }> {
|
||||
try {
|
||||
const createdIds: string[] = [];
|
||||
const tenantId = usersData[0]?.tenantId;
|
||||
|
||||
// 批量插入
|
||||
for (const userData of usersData) {
|
||||
const userId = uuidv4();
|
||||
await db('cf_users').insert({
|
||||
id: userId,
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
password_hash: userData.password,
|
||||
role: userData.role || 'OPERATOR',
|
||||
tenant_id: userData.tenantId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
createdIds.push(userId);
|
||||
}
|
||||
|
||||
// 清除相关缓存
|
||||
if (tenantId) {
|
||||
await this.clearUserCache(tenantId);
|
||||
}
|
||||
|
||||
return { success: true, createdIds };
|
||||
} catch (error: any) {
|
||||
return { success: false, createdIds: [], error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async batchUpdateUsers(usersData: Array<{ id: string; updates: Partial<User> }>): Promise<{ success: boolean; updatedCount: number; error?: string }> {
|
||||
try {
|
||||
let updatedCount = 0;
|
||||
const tenantIds = new Set<string>();
|
||||
|
||||
// 批量更新
|
||||
for (const { id, updates } of usersData) {
|
||||
const result = await db('cf_users').where({ id }).update({
|
||||
...updates,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
if (result > 0) {
|
||||
updatedCount++;
|
||||
|
||||
// 清除用户缓存
|
||||
const cacheKey = `${this.CACHE_PREFIX}${id}`;
|
||||
await cacheService.delete(cacheKey);
|
||||
|
||||
// 获取租户ID以清除列表缓存
|
||||
const user = await db('cf_users').where({ id }).first();
|
||||
if (user) {
|
||||
tenantIds.add(user.tenant_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清除租户用户列表缓存
|
||||
for (const tenantId of tenantIds) {
|
||||
await this.clearUserCache(tenantId);
|
||||
}
|
||||
|
||||
return { success: true, updatedCount };
|
||||
} catch (error: any) {
|
||||
return { success: false, updatedCount: 0, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async importUsers(usersData: Array<Partial<User> & { password: string }>): Promise<{ jobId: string }> {
|
||||
// 创建异步任务
|
||||
const jobId = await BullMQService.addJob('user:import', {
|
||||
usersData,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
private static async clearUserCache(tenantId: string): Promise<void> {
|
||||
// 清除租户相关的所有用户缓存
|
||||
await cacheService.deleteByTag(`user:tenant:${tenantId}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface HierarchyNode {
|
||||
export interface DataIsolationContext {
|
||||
tenantId: string;
|
||||
departmentId?: string;
|
||||
departmentIds?: string[];
|
||||
shopId?: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
@@ -134,9 +135,32 @@ export class DataIsolationService {
|
||||
if (context.shopId && rule.shopColumn) {
|
||||
// 店铺级别用户只能看到自己店铺的数据
|
||||
query = query.where(rule.shopColumn, context.shopId);
|
||||
} else if (context.departmentId && rule.departmentColumn) {
|
||||
// 部门级别用户只能看到自己部门的数据
|
||||
query = query.where(rule.departmentColumn, context.departmentId);
|
||||
} else if (context.departmentIds && context.departmentIds.length > 0) {
|
||||
// 多部门管理员可以看到多个部门及其下属店铺的数据
|
||||
if (rule.departmentColumn) {
|
||||
query = query.whereIn(rule.departmentColumn, context.departmentIds);
|
||||
} else if (rule.shopColumn) {
|
||||
// 获取所有授权部门的店铺
|
||||
const shopIds = db('cf_shop')
|
||||
.whereIn('department_id', context.departmentIds)
|
||||
.where('tenant_id', context.tenantId)
|
||||
.whereNull('deleted_at')
|
||||
.pluck('id');
|
||||
query = query.whereIn(rule.shopColumn, shopIds);
|
||||
}
|
||||
} else if (context.departmentId) {
|
||||
if (rule.departmentColumn) {
|
||||
// 部门级别用户可以看到自己部门的数据
|
||||
query = query.where(rule.departmentColumn, context.departmentId);
|
||||
} else if (rule.shopColumn) {
|
||||
// 部门级别用户可以看到下属店铺的数据
|
||||
const shopIds = db('cf_shop')
|
||||
.where('department_id', context.departmentId)
|
||||
.where('tenant_id', context.tenantId)
|
||||
.whereNull('deleted_at')
|
||||
.pluck('id');
|
||||
query = query.whereIn(rule.shopColumn, shopIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,15 +216,39 @@ export class DataIsolationService {
|
||||
else if (context.role === 'ADMIN') {
|
||||
hasAccess = true;
|
||||
}
|
||||
// 检查店铺隔离
|
||||
// 检查店铺隔离 - 店铺用户只能访问自己店铺的数据
|
||||
else if (context.shopId && rule.shopColumn) {
|
||||
hasAccess = record[rule.shopColumn] === context.shopId;
|
||||
}
|
||||
// 检查部门隔离
|
||||
else if (context.departmentId && rule.departmentColumn) {
|
||||
hasAccess = record[rule.departmentColumn] === context.departmentId;
|
||||
// 检查多部门隔离 - 多部门管理员可以访问多个部门及其下属店铺的数据
|
||||
else if (context.departmentIds && context.departmentIds.length > 0) {
|
||||
if (rule.departmentColumn) {
|
||||
// 检查记录是否属于任何一个授权部门
|
||||
hasAccess = context.departmentIds.includes(record[rule.departmentColumn]);
|
||||
} else if (rule.shopColumn) {
|
||||
// 检查记录所属店铺是否属于任何一个授权部门
|
||||
const shop = await db('cf_shop')
|
||||
.where('id', record[rule.shopColumn])
|
||||
.whereIn('department_id', context.departmentIds)
|
||||
.first();
|
||||
hasAccess = !!shop;
|
||||
}
|
||||
}
|
||||
// 仅有租户隔离
|
||||
// 检查部门隔离 - 部门用户可以访问部门及其下属店铺的数据
|
||||
else if (context.departmentId) {
|
||||
if (rule.departmentColumn) {
|
||||
// 直接部门匹配
|
||||
hasAccess = record[rule.departmentColumn] === context.departmentId;
|
||||
} else if (rule.shopColumn) {
|
||||
// 部门用户可以访问下属店铺的数据
|
||||
const shop = await db('cf_shop')
|
||||
.where('id', record[rule.shopColumn])
|
||||
.where('department_id', context.departmentId)
|
||||
.first();
|
||||
hasAccess = !!shop;
|
||||
}
|
||||
}
|
||||
// 仅有租户隔离 - 租户级用户可以访问所有数据
|
||||
else {
|
||||
hasAccess = true;
|
||||
}
|
||||
@@ -275,6 +323,41 @@ export class DataIsolationService {
|
||||
depth: shop.depth,
|
||||
});
|
||||
}
|
||||
} else if (context.departmentIds && context.departmentIds.length > 0) {
|
||||
// 多部门管理员可以看到多个部门及其下属店铺
|
||||
if (!nodeType || nodeType === 'DEPARTMENT') {
|
||||
const departments = await db('cf_department')
|
||||
.whereIn('id', context.departmentIds)
|
||||
.where('tenant_id', context.tenantId)
|
||||
.whereNull('deleted_at')
|
||||
.select('id', 'name', 'parent_id', 'path', 'depth');
|
||||
|
||||
nodes.push(...departments.map(d => ({
|
||||
id: d.id,
|
||||
type: 'DEPARTMENT' as HierarchyLevel,
|
||||
parentId: d.parent_id,
|
||||
name: d.name,
|
||||
path: d.path,
|
||||
depth: d.depth,
|
||||
})));
|
||||
}
|
||||
|
||||
if (!nodeType || nodeType === 'SHOP') {
|
||||
const shops = await db('cf_shop')
|
||||
.whereIn('department_id', context.departmentIds)
|
||||
.where('tenant_id', context.tenantId)
|
||||
.whereNull('deleted_at')
|
||||
.select('id', 'name', 'department_id as parentId', 'path', 'depth');
|
||||
|
||||
nodes.push(...shops.map(s => ({
|
||||
id: s.id,
|
||||
type: 'SHOP' as HierarchyLevel,
|
||||
parentId: s.parentId,
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
depth: s.depth,
|
||||
})));
|
||||
}
|
||||
} else if (context.departmentId) {
|
||||
// 部门用户可以看到部门及其下属店铺
|
||||
if (!nodeType || nodeType === 'DEPARTMENT') {
|
||||
@@ -428,6 +511,13 @@ export class DataIsolationService {
|
||||
hasAccess = true;
|
||||
} else if (context.shopId && rule.shopColumn) {
|
||||
hasAccess = record[rule.shopColumn] === context.shopId;
|
||||
} else if (context.departmentIds && context.departmentIds.length > 0) {
|
||||
if (rule.departmentColumn) {
|
||||
hasAccess = context.departmentIds.includes(record[rule.departmentColumn]);
|
||||
} else if (rule.shopColumn) {
|
||||
// 这里简化处理,实际应该通过子查询获取所有授权部门的店铺
|
||||
hasAccess = true;
|
||||
}
|
||||
} else if (context.departmentId && rule.departmentColumn) {
|
||||
hasAccess = record[rule.departmentColumn] === context.departmentId;
|
||||
} else {
|
||||
|
||||
265
server/src/services/platform/ShopMemberService.ts
Normal file
265
server/src/services/platform/ShopMemberService.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* [BE-SM001] 店铺成员管理服务
|
||||
* 负责店铺成员的添加、权限分配和权限继承
|
||||
* AI注意: 店铺授权后需要自动给部门主管分配权限
|
||||
*/
|
||||
|
||||
import db from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { DataIsolationService } from '../integration/DataIsolationService';
|
||||
|
||||
export interface ShopMember {
|
||||
id: string;
|
||||
shopId: string;
|
||||
userId: string;
|
||||
role: 'owner' | 'admin' | 'operator' | 'viewer';
|
||||
permissions: string[];
|
||||
assignedBy: string;
|
||||
assignedAt: string;
|
||||
}
|
||||
|
||||
export interface ShopMemberCreate {
|
||||
shopId: string;
|
||||
userId: string;
|
||||
role: 'owner' | 'admin' | 'operator' | 'viewer';
|
||||
permissions: string[];
|
||||
assignedBy: string;
|
||||
}
|
||||
|
||||
export class ShopMemberService {
|
||||
private static readonly TABLE = 'cf_shop_member';
|
||||
private static readonly CACHE_PREFIX = 'shop_member:';
|
||||
private static readonly CACHE_TTL = 1800;
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('shop_id', 64).notNullable();
|
||||
table.string('user_id', 64).notNullable();
|
||||
table.enum('role', ['owner', 'admin', 'operator', 'viewer']).notNullable();
|
||||
table.json('permissions').notNullable();
|
||||
table.string('assigned_by', 64).notNullable();
|
||||
table.timestamp('assigned_at').notNullable();
|
||||
table.timestamps(true, true);
|
||||
|
||||
table.index(['shop_id']);
|
||||
table.index(['user_id']);
|
||||
table.unique(['shop_id', 'user_id']);
|
||||
});
|
||||
logger.info('✅ Table cf_shop_member created');
|
||||
}
|
||||
}
|
||||
|
||||
static async create(member: ShopMemberCreate): Promise<ShopMember> {
|
||||
const id = this.generateId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const [newMember] = await db(this.TABLE)
|
||||
.insert({
|
||||
id,
|
||||
shop_id: member.shopId,
|
||||
user_id: member.userId,
|
||||
role: member.role,
|
||||
permissions: JSON.stringify(member.permissions),
|
||||
assigned_by: member.assignedBy,
|
||||
assigned_at: now,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
logger.info('[ShopMemberService] Shop member created', {
|
||||
shopId: member.shopId,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
});
|
||||
|
||||
return this.mapToShopMember(newMember);
|
||||
}
|
||||
|
||||
static async getShopMembers(shopId: string): Promise<ShopMember[]> {
|
||||
const members = await db(this.TABLE)
|
||||
.where('shop_id', shopId)
|
||||
.orderBy('assigned_at', 'desc');
|
||||
|
||||
return members.map(m => this.mapToShopMember(m));
|
||||
}
|
||||
|
||||
static async getUserShops(userId: string): Promise<ShopMember[]> {
|
||||
const members = await db(this.TABLE)
|
||||
.where('user_id', userId)
|
||||
.orderBy('assigned_at', 'desc');
|
||||
|
||||
return members.map(m => this.mapToShopMember(m));
|
||||
}
|
||||
|
||||
static async getMember(shopId: string, userId: string): Promise<ShopMember | null> {
|
||||
const member = await db(this.TABLE)
|
||||
.where({ shop_id: shopId, user_id: userId })
|
||||
.first();
|
||||
|
||||
return member ? this.mapToShopMember(member) : null;
|
||||
}
|
||||
|
||||
static async updateMember(shopId: string, userId: string, updates: {
|
||||
role?: 'owner' | 'admin' | 'operator' | 'viewer';
|
||||
permissions?: string[];
|
||||
}): Promise<ShopMember> {
|
||||
const updateData: Record<string, any> = { updated_at: new Date().toISOString() };
|
||||
if (updates.role) updateData.role = updates.role;
|
||||
if (updates.permissions) updateData.permissions = JSON.stringify(updates.permissions);
|
||||
|
||||
const [member] = await db(this.TABLE)
|
||||
.where({ shop_id: shopId, user_id: userId })
|
||||
.update(updateData)
|
||||
.returning('*');
|
||||
|
||||
if (!member) {
|
||||
throw new Error('Shop member not found');
|
||||
}
|
||||
|
||||
logger.info('[ShopMemberService] Shop member updated', {
|
||||
shopId,
|
||||
userId,
|
||||
updates,
|
||||
});
|
||||
|
||||
return this.mapToShopMember(member);
|
||||
}
|
||||
|
||||
static async removeMember(shopId: string, userId: string): Promise<void> {
|
||||
await db(this.TABLE)
|
||||
.where({ shop_id: shopId, user_id: userId })
|
||||
.delete();
|
||||
|
||||
logger.info('[ShopMemberService] Shop member removed', {
|
||||
shopId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
static async assignShopToDepartment(shopId: string, departmentId: string, tenantId: string): Promise<void> {
|
||||
const shop = await db('cf_shop').where('id', shopId).first();
|
||||
if (!shop) {
|
||||
throw new Error('Shop not found');
|
||||
}
|
||||
|
||||
await db('cf_shop')
|
||||
.where('id', shopId)
|
||||
.update({
|
||||
department_id: departmentId,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.info('[ShopMemberService] Shop assigned to department', {
|
||||
shopId,
|
||||
departmentId,
|
||||
tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
static async autoAssignPermissionsToDepartment(shopId: string, departmentId: string, tenantId: string): Promise<void> {
|
||||
const shop = await db('cf_shop').where('id', shopId).first();
|
||||
if (!shop) {
|
||||
throw new Error('Shop not found');
|
||||
}
|
||||
|
||||
const department = await db('cf_department').where('id', departmentId).first();
|
||||
if (!department) {
|
||||
throw new Error('Department not found');
|
||||
}
|
||||
|
||||
const context = {
|
||||
tenantId,
|
||||
departmentId,
|
||||
userId: 'system',
|
||||
role: 'ADMIN',
|
||||
permissions: ['*'],
|
||||
hierarchyPath: '',
|
||||
};
|
||||
|
||||
const nodes = await DataIsolationService.getVisibleNodes(context, 'DEPARTMENT');
|
||||
const departmentNode = nodes.find(n => n.id === departmentId);
|
||||
|
||||
if (departmentNode) {
|
||||
const departmentMembers = await db('cf_user_department')
|
||||
.where('department_id', departmentId)
|
||||
.pluck('user_id');
|
||||
|
||||
const departmentUsers = await db('cf_user')
|
||||
.whereIn('id', departmentMembers)
|
||||
.where('role', 'MANAGER')
|
||||
.select('id', 'name', 'email');
|
||||
|
||||
for (const user of departmentUsers) {
|
||||
const existingMember = await this.getMember(shopId, user.id);
|
||||
if (!existingMember) {
|
||||
await this.create({
|
||||
shopId,
|
||||
userId: user.id,
|
||||
role: 'admin',
|
||||
permissions: ['product:read', 'product:write', 'order:read', 'order:write', 'inventory:read', 'inventory:write'],
|
||||
assignedBy: 'system',
|
||||
});
|
||||
|
||||
logger.info('[ShopMemberService] Auto-assigned shop permissions to department manager', {
|
||||
shopId,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
departmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async getShopPermissions(shopId: string, userId: string): Promise<string[]> {
|
||||
const member = await this.getMember(shopId, userId);
|
||||
if (!member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return member.permissions;
|
||||
}
|
||||
|
||||
static async hasShopPermission(shopId: string, userId: string, permission: string): Promise<boolean> {
|
||||
const permissions = await this.getShopPermissions(shopId, userId);
|
||||
return permissions.includes('*') || permissions.includes(permission);
|
||||
}
|
||||
|
||||
static async getShopStats(shopId: string): Promise<{
|
||||
totalMembers: number;
|
||||
owners: number;
|
||||
admins: number;
|
||||
operators: number;
|
||||
viewers: number;
|
||||
}> {
|
||||
const members = await this.getShopMembers(shopId);
|
||||
|
||||
return {
|
||||
totalMembers: members.length,
|
||||
owners: members.filter(m => m.role === 'owner').length,
|
||||
admins: members.filter(m => m.role === 'admin').length,
|
||||
operators: members.filter(m => m.role === 'operator').length,
|
||||
viewers: members.filter(m => m.role === 'viewer').length,
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `sm_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private static mapToShopMember(data: any): ShopMember {
|
||||
return {
|
||||
id: data.id,
|
||||
shopId: data.shop_id,
|
||||
userId: data.user_id,
|
||||
role: data.role,
|
||||
permissions: typeof data.permissions === 'string' ? JSON.parse(data.permissions) : data.permissions,
|
||||
assignedBy: data.assigned_by,
|
||||
assignedAt: data.assigned_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,132 @@ export class HierarchyService {
|
||||
logger.info(`[Hierarchy] Deleted department ${departmentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-04-01] 设置部门负责人
|
||||
*/
|
||||
static async assignDepartmentManager(
|
||||
departmentId: string,
|
||||
tenantId: string,
|
||||
managerId: string,
|
||||
assignedBy: string
|
||||
): Promise<Department> {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!department) {
|
||||
throw new Error('部门不存在');
|
||||
}
|
||||
|
||||
const user = await db('cf_user')
|
||||
.where('id', managerId)
|
||||
.where('tenant_id', tenantId)
|
||||
.where('status', 'ACTIVE')
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在或未激活');
|
||||
}
|
||||
|
||||
await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.update({
|
||||
manager_id: managerId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
const updatedDepartment = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
await this.clearHierarchyCache(tenantId);
|
||||
|
||||
await EventBusService.publish({
|
||||
type: 'hierarchy.department.manager_assigned',
|
||||
data: {
|
||||
departmentId,
|
||||
tenantId,
|
||||
managerId,
|
||||
assignedBy,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Hierarchy] Assigned manager ${managerId} to department ${departmentId}`);
|
||||
|
||||
return updatedDepartment;
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-04-02] 获取部门负责人
|
||||
*/
|
||||
static async getDepartmentManager(departmentId: string, tenantId: string): Promise<any> {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!department || !department.manager_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manager = await db('cf_user')
|
||||
.where('id', department.manager_id)
|
||||
.where('tenant_id', tenantId)
|
||||
.select('id', 'username', 'email', 'role', 'status')
|
||||
.first();
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-04-03] 获取部门统计信息
|
||||
*/
|
||||
static async getDepartmentStats(departmentId: string, tenantId: string): Promise<any> {
|
||||
const department = await db('cf_department')
|
||||
.where('id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.first();
|
||||
|
||||
if (!department) {
|
||||
throw new Error('部门不存在');
|
||||
}
|
||||
|
||||
const [userCount, shopCount, subDepartmentCount] = await Promise.all([
|
||||
db('cf_user')
|
||||
.where('department_id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.count('id as count')
|
||||
.first(),
|
||||
|
||||
db('cf_shop')
|
||||
.where('department_id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.count('id as count')
|
||||
.first(),
|
||||
|
||||
db('cf_department')
|
||||
.where('parent_id', departmentId)
|
||||
.where('tenant_id', tenantId)
|
||||
.count('id as count')
|
||||
.first(),
|
||||
]);
|
||||
|
||||
return {
|
||||
departmentId,
|
||||
departmentName: department.name,
|
||||
userCount: Number(userCount?.count || 0),
|
||||
shopCount: Number(shopCount?.count || 0),
|
||||
subDepartmentCount: Number(subDepartmentCount?.count || 0),
|
||||
managerId: department.manager_id,
|
||||
status: department.status,
|
||||
createdAt: department.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-MT002-05] 创建店铺
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
@@ -12,24 +14,36 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: 'Backend service is running' });
|
||||
});
|
||||
|
||||
// 模拟登录接口
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
// 模拟登录接口 - 支持 /api/auth/login 和 /api/v1/auth/login
|
||||
app.post('/api/auth/login', handleLogin);
|
||||
app.post('/api/v1/auth/login', handleLogin);
|
||||
|
||||
function handleLogin(req: express.Request, res: express.Response) {
|
||||
const { username, password } = req.body;
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: 'mock-token-123',
|
||||
refreshToken: 'mock-refresh-token-123',
|
||||
user: {
|
||||
id: '1',
|
||||
username: username || 'admin',
|
||||
role: 'ADMIN',
|
||||
tenantId: 'default-tenant',
|
||||
shopId: 'default-shop'
|
||||
|
||||
// 验证用户名和密码
|
||||
if (username === 'admin' && password === 'admin123') {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: 'mock-token-' + Date.now(),
|
||||
refreshToken: 'mock-refresh-token-' + Date.now(),
|
||||
user: {
|
||||
id: 'USER-ADMIN-001',
|
||||
username: username,
|
||||
role: 'ADMIN',
|
||||
tenantId: 'default-tenant',
|
||||
shopId: 'default-shop'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: '用户名或密码错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
|
||||
1
server/src/types/index.d.ts
vendored
Normal file
1
server/src/types/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
// 基本类型定义
|
||||
@@ -86,11 +86,19 @@ export class RedisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORE_DEV_12] Get 缓存
|
||||
* 生成租户隔离的缓存键
|
||||
*/
|
||||
static async get(key: string): Promise<string | null> {
|
||||
static generateTenantKey(tenantId: string, key: string): string {
|
||||
return `tenant:${tenantId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORE_DEV_12] Get 缓存(支持租户隔离)
|
||||
*/
|
||||
static async get(key: string, tenantId?: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.getClient().get(key);
|
||||
const finalKey = tenantId ? this.generateTenantKey(tenantId, key) : key;
|
||||
return await this.getClient().get(finalKey);
|
||||
} catch (err: any) {
|
||||
logger.error(`[Redis] Get Error: ${err.message}`);
|
||||
return null;
|
||||
@@ -98,32 +106,33 @@ export class RedisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORE_DEV_12] Set 缓存
|
||||
* [CORE_DEV_12] Set 缓存(支持租户隔离)
|
||||
*/
|
||||
static async set(key: string, value: string, ttl?: number, mode?: 'NX' | 'XX'): Promise<string | null> {
|
||||
static async set(key: string, value: string, ttl?: number, mode?: 'NX' | 'XX', tenantId?: string): Promise<string | null> {
|
||||
try {
|
||||
const finalKey = tenantId ? this.generateTenantKey(tenantId, key) : key;
|
||||
if (mode === 'NX') {
|
||||
if (ttl) {
|
||||
const result = await this.getClient().set(key, value, 'PX', ttl * 1000, 'NX');
|
||||
const result = await this.getClient().set(finalKey, value, 'PX', ttl * 1000, 'NX');
|
||||
return result;
|
||||
} else {
|
||||
const result = await this.getClient().set(key, value, 'NX');
|
||||
const result = await this.getClient().set(finalKey, value, 'NX');
|
||||
return result;
|
||||
}
|
||||
} else if (mode === 'XX') {
|
||||
if (ttl) {
|
||||
const result = await this.getClient().set(key, value, 'PX', ttl * 1000, 'XX');
|
||||
const result = await this.getClient().set(finalKey, value, 'PX', ttl * 1000, 'XX');
|
||||
return result;
|
||||
} else {
|
||||
const result = await this.getClient().set(key, value, 'XX');
|
||||
const result = await this.getClient().set(finalKey, value, 'XX');
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
if (ttl) {
|
||||
await this.getClient().setex(key, ttl, value);
|
||||
await this.getClient().setex(finalKey, ttl, value);
|
||||
return 'OK';
|
||||
} else {
|
||||
await this.getClient().set(key, value);
|
||||
await this.getClient().set(finalKey, value);
|
||||
return 'OK';
|
||||
}
|
||||
}
|
||||
@@ -134,11 +143,12 @@ export class RedisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除键
|
||||
* 删除键(支持租户隔离)
|
||||
*/
|
||||
static async del(key: string): Promise<number> {
|
||||
static async del(key: string, tenantId?: string): Promise<number> {
|
||||
try {
|
||||
return await this.getClient().del(key);
|
||||
const finalKey = tenantId ? this.generateTenantKey(tenantId, key) : key;
|
||||
return await this.getClient().del(finalKey);
|
||||
} catch (err: any) {
|
||||
logger.error(`[Redis] Del Error: ${err.message}`);
|
||||
return 0;
|
||||
@@ -146,11 +156,12 @@ export class RedisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键的TTL
|
||||
* 获取键的TTL(支持租户隔离)
|
||||
*/
|
||||
static async ttl(key: string): Promise<number> {
|
||||
static async ttl(key: string, tenantId?: string): Promise<number> {
|
||||
try {
|
||||
return await this.getClient().ttl(key);
|
||||
const finalKey = tenantId ? this.generateTenantKey(tenantId, key) : key;
|
||||
return await this.getClient().ttl(finalKey);
|
||||
} catch (err: any) {
|
||||
logger.error(`[Redis] TTL Error: ${err.message}`);
|
||||
return -1;
|
||||
@@ -158,11 +169,12 @@ export class RedisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置键的过期时间
|
||||
* 设置键的过期时间(支持租户隔离)
|
||||
*/
|
||||
static async expire(key: string, seconds: number): Promise<boolean> {
|
||||
static async expire(key: string, seconds: number, tenantId?: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.getClient().expire(key, seconds);
|
||||
const finalKey = tenantId ? this.generateTenantKey(tenantId, key) : key;
|
||||
const result = await this.getClient().expire(finalKey, seconds);
|
||||
return result === 1;
|
||||
} catch (err: any) {
|
||||
logger.error(`[Redis] Expire Error: ${err.message}`);
|
||||
|
||||
Reference in New Issue
Block a user