Summary: - Refactor timeline API to read from qc_field_status (SSOT) instead of qc_logs - Add field-issues paginated API with severity/dimension/recordId filters - Add LEFT JOIN field_metadata + qc_event_status for Chinese display names - Implement per-project ChatOrchestrator cache and SessionMemory isolation - Redesign admin IIT config tabs (REDCap -> Fields -> KB -> Rules -> Members) - Add AI-powered QC rule generation (D3 programmatic + D1/D5/D6 LLM-based) - Add clickable warning/critical detail Modal in ReportsPage - Auto-dispatch eQuery after batch QC via DailyQcOrchestrator - Update module status documentation to v3.2 Backend changes: - iitQcCockpitController: rewrite getTimeline from qc_field_status, add getFieldIssues - iitQcCockpitRoutes: add field-issues route - ChatOrchestrator: per-projectId cached instances - SessionMemory: keyed by userId::projectId - WechatCallbackController: resolve projectId from iitUserMapping - iitRuleSuggestionService: dimension-based suggest + generateD3Rules - iitBatchController: call DailyQcOrchestrator after batch QC Frontend changes: - AiStreamPage: adapt to new timeline structure with dimension tags - ReportsPage: clickable stats cards with issue detail Modal - IitProjectDetailPage: reorder tabs, add AI rule generation UI - iitProjectApi: add TimelineIssue, FieldIssueItem types and APIs Status: TypeScript compilation verified, no new lint errors Made-with: Cursor
318 lines
8.5 KiB
TypeScript
318 lines
8.5 KiB
TypeScript
/**
|
||
* 模块权限服务
|
||
*
|
||
* 管理用户可访问的模块,支持多租户模块权限合并
|
||
*/
|
||
|
||
import { prisma } from '../../config/database.js';
|
||
import { logger } from '../logging/index.js';
|
||
|
||
/**
|
||
* 模块信息
|
||
*/
|
||
export interface ModuleInfo {
|
||
code: string;
|
||
name: string;
|
||
description?: string | null;
|
||
icon?: string | null;
|
||
}
|
||
|
||
/**
|
||
* 用户模块访问结果
|
||
*/
|
||
export interface UserModulesResult {
|
||
modules: string[]; // 可访问的模块代码列表
|
||
moduleDetails: ModuleInfo[]; // 模块详细信息
|
||
tenantModules: { // 按租户分组的模块
|
||
tenantId: string;
|
||
tenantName: string;
|
||
isPrimary: boolean;
|
||
modules: string[];
|
||
}[];
|
||
}
|
||
|
||
class ModuleService {
|
||
/**
|
||
* 获取所有可用模块
|
||
*/
|
||
async getAllModules(): Promise<ModuleInfo[]> {
|
||
const modules = await prisma.modules.findMany({
|
||
where: { is_active: true },
|
||
orderBy: { sort_order: 'asc' },
|
||
select: {
|
||
code: true,
|
||
name: true,
|
||
description: true,
|
||
icon: true,
|
||
},
|
||
});
|
||
return modules;
|
||
}
|
||
|
||
/**
|
||
* 获取用户可访问的所有模块
|
||
* 合并用户所属的所有租户的模块权限
|
||
*/
|
||
async getUserModules(userId: string): Promise<UserModulesResult> {
|
||
try {
|
||
// 1. 获取用户的主租户
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: {
|
||
tenant_id: true,
|
||
tenants: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!user) {
|
||
logger.warn('[ModuleService] 用户不存在', { userId });
|
||
return { modules: [], moduleDetails: [], tenantModules: [] };
|
||
}
|
||
|
||
// 2. 获取用户加入的其他租户
|
||
const memberships = await prisma.tenant_members.findMany({
|
||
where: { user_id: userId },
|
||
select: {
|
||
tenant_id: true,
|
||
tenants: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// 3. 构建租户列表(主租户 + 额外加入的租户)
|
||
const tenantMap = new Map<string, { name: string; isPrimary: boolean }>();
|
||
|
||
// 主租户
|
||
if (user.tenant_id && user.tenants) {
|
||
tenantMap.set(user.tenant_id, {
|
||
name: user.tenants.name,
|
||
isPrimary: true,
|
||
});
|
||
}
|
||
|
||
// 额外加入的租户
|
||
for (const m of memberships) {
|
||
if (!tenantMap.has(m.tenant_id)) {
|
||
tenantMap.set(m.tenant_id, {
|
||
name: m.tenants.name,
|
||
isPrimary: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
const tenantIds = Array.from(tenantMap.keys());
|
||
|
||
if (tenantIds.length === 0) {
|
||
logger.warn('[ModuleService] 用户没有关联任何租户', { userId });
|
||
return { modules: [], moduleDetails: [], tenantModules: [] };
|
||
}
|
||
|
||
// 4. 查询所有租户的已开通模块
|
||
const tenantModulesData = await prisma.tenant_modules.findMany({
|
||
where: {
|
||
tenant_id: { in: tenantIds },
|
||
is_enabled: true,
|
||
OR: [
|
||
{ expires_at: null },
|
||
{ expires_at: { gt: new Date() } },
|
||
],
|
||
},
|
||
select: {
|
||
tenant_id: true,
|
||
module_code: true,
|
||
},
|
||
});
|
||
|
||
// 5. 按租户分组模块
|
||
const tenantModulesGrouped: UserModulesResult['tenantModules'] = [];
|
||
const modulesByTenant = new Map<string, string[]>();
|
||
|
||
for (const tm of tenantModulesData) {
|
||
if (!modulesByTenant.has(tm.tenant_id)) {
|
||
modulesByTenant.set(tm.tenant_id, []);
|
||
}
|
||
modulesByTenant.get(tm.tenant_id)!.push(tm.module_code);
|
||
}
|
||
|
||
Array.from(tenantMap.entries()).forEach(([tenantId, tenantInfo]) => {
|
||
tenantModulesGrouped.push({
|
||
tenantId,
|
||
tenantName: tenantInfo.name,
|
||
isPrimary: tenantInfo.isPrimary,
|
||
modules: modulesByTenant.get(tenantId) || [],
|
||
});
|
||
});
|
||
|
||
// 5.5 查询用户级别的模块权限(精细化控制)
|
||
const userModulesData = await prisma.user_modules.findMany({
|
||
where: {
|
||
user_id: userId,
|
||
tenant_id: { in: tenantIds },
|
||
},
|
||
select: {
|
||
tenant_id: true,
|
||
module_code: true,
|
||
is_enabled: true,
|
||
},
|
||
});
|
||
|
||
// 按租户分组 user_modules
|
||
const userModulesByTenant = new Map<string, Map<string, boolean>>();
|
||
for (const um of userModulesData) {
|
||
if (!userModulesByTenant.has(um.tenant_id)) {
|
||
userModulesByTenant.set(um.tenant_id, new Map());
|
||
}
|
||
userModulesByTenant.get(um.tenant_id)!.set(um.module_code, um.is_enabled);
|
||
}
|
||
|
||
// 6. 合并所有模块(去重),尊重 user_modules 精细化配置
|
||
const moduleSet = new Set<string>();
|
||
|
||
for (const tm of tenantModulesData) {
|
||
const userModulesForTenant = userModulesByTenant.get(tm.tenant_id);
|
||
if (userModulesForTenant && userModulesForTenant.size > 0) {
|
||
const isEnabled = userModulesForTenant.get(tm.module_code);
|
||
if (isEnabled) {
|
||
moduleSet.add(tm.module_code);
|
||
}
|
||
} else {
|
||
moduleSet.add(tm.module_code);
|
||
}
|
||
}
|
||
|
||
// 6.5 补充用户级独立配置的模块(如 AIA_PROTOCOL,租户未订阅但用户单独开通)
|
||
for (const [, userModuleMap] of userModulesByTenant) {
|
||
for (const [moduleCode, isEnabled] of userModuleMap) {
|
||
if (isEnabled) {
|
||
moduleSet.add(moduleCode);
|
||
}
|
||
}
|
||
}
|
||
|
||
const allModuleCodes = Array.from(moduleSet);
|
||
|
||
// 7. 获取模块详细信息
|
||
const moduleDetails = await prisma.modules.findMany({
|
||
where: {
|
||
code: { in: allModuleCodes },
|
||
is_active: true,
|
||
},
|
||
orderBy: { sort_order: 'asc' },
|
||
select: {
|
||
code: true,
|
||
name: true,
|
||
description: true,
|
||
icon: true,
|
||
},
|
||
});
|
||
|
||
logger.debug('[ModuleService] 获取用户模块成功', {
|
||
userId,
|
||
tenantCount: tenantIds.length,
|
||
moduleCount: allModuleCodes.length,
|
||
});
|
||
|
||
return {
|
||
modules: allModuleCodes,
|
||
moduleDetails,
|
||
tenantModules: tenantModulesGrouped,
|
||
};
|
||
} catch (error) {
|
||
logger.error('[ModuleService] 获取用户模块失败', { userId, error });
|
||
return { modules: [], moduleDetails: [], tenantModules: [] };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查用户是否有权访问指定模块
|
||
*/
|
||
async canAccessModule(userId: string, moduleCode: string): Promise<boolean> {
|
||
try {
|
||
const { modules } = await this.getUserModules(userId);
|
||
return modules.includes(moduleCode);
|
||
} catch (error) {
|
||
logger.error('[ModuleService] 检查模块权限失败', { userId, moduleCode, error });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取租户的已开通模块
|
||
*/
|
||
async getTenantModules(tenantId: string): Promise<string[]> {
|
||
const modules = await prisma.tenant_modules.findMany({
|
||
where: {
|
||
tenant_id: tenantId,
|
||
is_enabled: true,
|
||
OR: [
|
||
{ expires_at: null },
|
||
{ expires_at: { gt: new Date() } },
|
||
],
|
||
},
|
||
select: { module_code: true },
|
||
});
|
||
return modules.map(m => m.module_code);
|
||
}
|
||
|
||
/**
|
||
* 设置租户的模块配置
|
||
*/
|
||
async setTenantModules(
|
||
tenantId: string,
|
||
moduleConfigs: { code: string; enabled: boolean; expiresAt?: Date | null }[]
|
||
): Promise<void> {
|
||
for (const config of moduleConfigs) {
|
||
// 先查询是否存在
|
||
const existing = await prisma.tenant_modules.findUnique({
|
||
where: {
|
||
tenant_id_module_code: {
|
||
tenant_id: tenantId,
|
||
module_code: config.code,
|
||
},
|
||
},
|
||
});
|
||
|
||
if (existing) {
|
||
// 更新
|
||
await prisma.tenant_modules.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
is_enabled: config.enabled,
|
||
expires_at: config.expiresAt,
|
||
},
|
||
});
|
||
} else {
|
||
// 创建(使用 crypto.randomUUID 生成 id)
|
||
const { randomUUID } = await import('crypto');
|
||
await prisma.tenant_modules.create({
|
||
data: {
|
||
id: randomUUID(),
|
||
tenant_id: tenantId,
|
||
module_code: config.code,
|
||
is_enabled: config.enabled,
|
||
expires_at: config.expiresAt,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
logger.info('[ModuleService] 更新租户模块配置', {
|
||
tenantId,
|
||
moduleCount: moduleConfigs.length,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const moduleService = new ModuleService();
|
||
|