import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; import argon2 from 'argon2'; import { AuditService } from '../audit/audit.service.js'; import type { SafeUser } from '../auth/auth.types.js'; import { DEMO_DEFAULT_REPORT_CONTENT, DEMO_SYSTEM_SETTINGS, DEMO_TEMPLATE_ID, } from '../demo/demo-defaults.js'; import { isSuper } from '../permissions/permissions.policy.js'; import { PrismaService } from '../prisma/prisma.service.js'; import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js'; const DEFAULT_AI_PROVIDERS = { kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'moonshot-v1-32k' }, deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, custom: { endpoint: '', apiKey: '', modelName: '' }, }; const DEFAULT_SETTINGS: SystemSettingsInput = { frameCount: DEMO_SYSTEM_SETTINGS.frameCount, framePositions: [...DEMO_SYSTEM_SETTINGS.framePositions], defaultTemplate: DEMO_SYSTEM_SETTINGS.defaultTemplate, frameMode: 'keep', activeAiProvider: DEMO_SYSTEM_SETTINGS.activeAiProvider, aiProviders: DEFAULT_AI_PROVIDERS, autoInsertFrames: DEMO_SYSTEM_SETTINGS.autoInsertFrames, autoInsertDelay: DEMO_SYSTEM_SETTINGS.autoInsertDelay, autoInsertFrameIndices: [...DEMO_SYSTEM_SETTINGS.autoInsertFrameIndices], xfSpeechConfig: DEMO_SYSTEM_SETTINGS.xfSpeechConfig, }; @Injectable() export class SettingsService { constructor( private readonly prisma: PrismaService, private readonly audit?: AuditService, ) {} async getSystemSettings(actor: SafeUser, options: { includeSecrets?: boolean } = {}) { const globalSettings = await this.getSettingValue(actor.tenantId, 'global', 'systemSettings'); const userDefaultTemplate = await this.getSettingValue( actor.tenantId, this.userScope(actor.id), 'defaultTemplate', ); const merged = this.normalize({ ...DEFAULT_SETTINGS, ...(this.isObject(globalSettings) ? globalSettings : {}), }); if (typeof userDefaultTemplate === 'string') { merged.defaultTemplate = userDefaultTemplate; } return options.includeSecrets || isSuper(this.actorToPolicy(actor)) ? merged : this.redactSecrets(merged); } async updateSystemSettings(actor: SafeUser, rawInput: unknown) { const result = systemSettingsSchema.safeParse(rawInput); if (!result.success) { throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(';')); } const input = this.normalize(result.data); if (isSuper(this.actorToPolicy(actor))) { await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(input)); if (input.defaultTemplate !== undefined) { await this.setSettingValue( actor.tenantId, this.userScope(actor.id), 'defaultTemplate', input.defaultTemplate || '', ); } await this.audit?.record({ actor, action: 'settings.system.update', targetType: 'SystemSetting', targetId: 'systemSettings', metadata: { scope: 'global', defaultTemplate: input.defaultTemplate }, }); return this.getSystemSettings(actor); } if (input.defaultTemplate === undefined) { throw new ForbiddenException('只能修改个人默认模板'); } await this.setSettingValue( actor.tenantId, this.userScope(actor.id), 'defaultTemplate', input.defaultTemplate || '', ); await this.audit?.record({ actor, action: 'settings.default_template.update', targetType: 'SystemSetting', targetId: 'defaultTemplate', metadata: { defaultTemplate: input.defaultTemplate }, }); return this.getSystemSettings(actor); } async resetSystemSettings(actor: SafeUser) { if (!isSuper(this.actorToPolicy(actor))) { throw new ForbiddenException('只有超级管理员可以重置系统设置'); } await this.resetDemoData(actor.tenantId); return this.getSystemSettings(actor); } private async resetDemoData(tenantId: string) { const passwordHash = await argon2.hash('123456'); await this.prisma.$transaction(async (tx) => { const tenant = await tx.tenant.upsert({ where: { code: 'default' }, update: {}, create: { code: 'default', name: '默认医院' }, }); const effectiveTenantId = tenantId || tenant.id; const demoTemplateId = effectiveTenantId === tenant.id ? DEMO_TEMPLATE_ID : `${DEMO_TEMPLATE_ID}_${effectiveTenantId}`; const adminDepartment = await tx.department.upsert({ where: { tenantId_code: { tenantId: effectiveTenantId, code: 'admin' } }, update: { name: '管理部门' }, create: { tenantId: effectiveTenantId, code: 'admin', name: '管理部门' }, }); const surgeryDepartment = await tx.department.upsert({ where: { tenantId_code: { tenantId: effectiveTenantId, code: 'surgery' } }, update: { name: '外科' }, create: { tenantId: effectiveTenantId, code: 'surgery', name: '外科' }, }); await tx.report.deleteMany({ where: { tenantId: effectiveTenantId } }); await tx.fileResource.deleteMany({ where: { tenantId: effectiveTenantId } }); await tx.templateDepartmentPermission.deleteMany({ where: { template: { tenantId: effectiveTenantId } }, }); await tx.template.deleteMany({ where: { tenantId: effectiveTenantId } }); await tx.auditLog.deleteMany({ where: { tenantId: effectiveTenantId } }); await tx.systemSetting.deleteMany({ where: { tenantId: effectiveTenantId } }); await tx.userSession.deleteMany({ where: { user: { tenantId: effectiveTenantId } } }); await tx.user.deleteMany({ where: { tenantId: effectiveTenantId, username: { notIn: ['admin', 'manager', '0001'] }, }, }); await tx.user.upsert({ where: { tenantId_username: { tenantId: effectiveTenantId, username: 'admin' } }, update: { departmentId: adminDepartment.id, passwordHash, role: 'SUPER', name: '超级管理员', status: 'ACTIVE', phone: null, email: null, signatureFileId: null, }, create: { tenantId: effectiveTenantId, departmentId: adminDepartment.id, username: 'admin', passwordHash, role: 'SUPER', name: '超级管理员', status: 'ACTIVE', }, }); await tx.user.upsert({ where: { tenantId_username: { tenantId: effectiveTenantId, username: 'manager' } }, update: { departmentId: surgeryDepartment.id, passwordHash, role: 'ADMIN', name: '科室管理员', status: 'ACTIVE', phone: null, email: null, signatureFileId: null, }, create: { tenantId: effectiveTenantId, departmentId: surgeryDepartment.id, username: 'manager', passwordHash, role: 'ADMIN', name: '科室管理员', status: 'ACTIVE', }, }); await tx.user.upsert({ where: { tenantId_username: { tenantId: effectiveTenantId, username: '0001' } }, update: { departmentId: surgeryDepartment.id, passwordHash, role: 'DOCTOR', name: '张医生', status: 'ACTIVE', phone: null, email: null, signatureFileId: null, }, create: { tenantId: effectiveTenantId, departmentId: surgeryDepartment.id, username: '0001', passwordHash, role: 'DOCTOR', name: '张医生', status: 'ACTIVE', }, }); await tx.department.deleteMany({ where: { tenantId: effectiveTenantId, code: { notIn: ['admin', 'surgery'] }, }, }); const template = await tx.template.create({ data: { id: demoTemplateId, tenantId: effectiveTenantId, name: '腹腔镜胆囊切除术报告', description: '标准手术记录模板', content: DEMO_DEFAULT_REPORT_CONTENT, fields: [], scope: 'DEPARTMENT', ownerDepartmentId: surgeryDepartment.id, ownerUserId: null, }, }); await tx.templateDepartmentPermission.create({ data: { templateId: template.id, departmentId: surgeryDepartment.id, canUse: true, canManage: true, }, }); await tx.systemSetting.create({ data: { tenantId: effectiveTenantId, scope: 'global', key: 'systemSettings', value: this.toJson({ ...DEFAULT_SETTINGS, defaultTemplate: demoTemplateId }), }, }); }); } private normalize(input: SystemSettingsInput): SystemSettingsInput { const aiProviders = { ...DEFAULT_AI_PROVIDERS, ...(input.aiProviders || {}), }; const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)] .map((value) => Math.round(value * 100) / 100); return { ...DEFAULT_SETTINGS, ...input, framePositions, frameCount: framePositions.length || input.frameCount || DEFAULT_SETTINGS.frameCount, frameMode: input.frameMode || 'keep', activeAiProvider: input.activeAiProvider || 'kimi', aiProviders, xfSpeechConfig: input.xfSpeechConfig || DEFAULT_SETTINGS.xfSpeechConfig, }; } private redactSecrets(settings: SystemSettingsInput): SystemSettingsInput { const aiProviders = Object.fromEntries( Object.entries(settings.aiProviders || {}).map(([key, provider]) => [ key, { ...provider, apiKey: '' }, ]), ); return { ...settings, aiProviders, xfSpeechConfig: settings.xfSpeechConfig ? { ...settings.xfSpeechConfig, apiKey: '', apiSecret: '' } : settings.xfSpeechConfig, }; } private async getSettingValue(tenantId: string, scope: string, key: string) { const setting = await this.prisma.systemSetting.findFirst({ where: { tenantId, scope, departmentId: null, key }, }); return setting?.value; } private async setSettingValue( tenantId: string, scope: string, key: string, value: Prisma.InputJsonValue, ) { const existing = await this.prisma.systemSetting.findFirst({ where: { tenantId, scope, departmentId: null, key }, }); if (existing) { await this.prisma.systemSetting.update({ where: { id: existing.id }, data: { value }, }); return; } await this.prisma.systemSetting.create({ data: { tenantId, scope, key, value, }, }); } private toJson(value: unknown): Prisma.InputJsonValue { return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; } private isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } private userScope(userId: string) { return `user:${userId}`; } private actorToPolicy(actor: SafeUser) { return { id: actor.id, tenantId: actor.tenantId, departmentId: actor.departmentId, role: actor.role, }; } }