Files
AIclinicalresearch/backend/src/common/auth/module.service.ts
HaHafeng 71d32d11ee feat(iit): V3.2 data consistency + project isolation + admin config redesign + Chinese labels
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
2026-03-02 14:29:59 +08:00

318 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 模块权限服务
*
* 管理用户可访问的模块,支持多租户模块权限合并
*/
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();