Files
Mdeical_Sur_Report/server/src/settings/settings.service.ts
2026-05-11 17:47:08 +08:00

361 lines
12 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 { 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<string, unknown> {
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,
};
}
}