361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|