Add demo mode factory reset

- Align the backend seeded default surgery template with the report editor's default report content.

- Add backend demo defaults for the default template, Kimi provider, and Xunfei speech proxy configuration.

- Change system reset into a super-admin demo mode factory reset that clears reports, audit logs, files, custom templates, and non-default users.

- Keep only the default admin, manager, doctor, and default surgery template after demo reset.

- Replace the old local-only reset all data button with a two-confirmation backend reset flow.

- Add tests covering demo default alignment and database-backed demo reset behavior.

- Update docs to describe demo mode reset semantics and production credential cautions.
This commit is contained in:
2026-05-02 02:52:30 +08:00
parent bc235b2358
commit 911b96b883
17 changed files with 361 additions and 85 deletions

View File

@@ -1,6 +1,11 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import argon2 from 'argon2';
import {
DEMO_DEFAULT_REPORT_CONTENT,
DEMO_SYSTEM_SETTINGS,
DEMO_TEMPLATE_ID,
} from '../src/demo/demo-defaults.js';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required to seed the database');
@@ -12,16 +17,6 @@ const prisma = new PrismaClient({
}),
});
const defaultTemplateContent = `
<h1 style="text-align:center;">手术记录</h1>
<p>患者姓名:<span class="field-value" data-bind="patientName" contenteditable="true"></span></p>
<p>住院号:<span class="field-value" data-bind="hospitalId" contenteditable="true"></span></p>
<p>手术名称:<span class="field-value" data-bind="title" contenteditable="true"></span></p>
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤">
<div class="ai-content"><p>请在此处填写手术步骤。</p></div>
</div>
`;
const main = async () => {
const tenant = await prisma.tenant.upsert({
where: { code: 'default' },
@@ -94,14 +89,22 @@ const main = async () => {
});
const defaultTemplate = await prisma.template.upsert({
where: { id: 'tpl_default_surgery' },
update: {},
where: { id: DEMO_TEMPLATE_ID },
update: {
name: '腹腔镜胆囊切除术报告',
description: '标准手术记录模板',
content: DEMO_DEFAULT_REPORT_CONTENT,
fields: [],
scope: 'DEPARTMENT',
ownerDepartmentId: surgeryDepartment.id,
ownerUserId: null,
},
create: {
id: 'tpl_default_surgery',
id: DEMO_TEMPLATE_ID,
tenantId: tenant.id,
name: '腹腔镜胆囊切除术报告',
description: '标准手术记录模板',
content: defaultTemplateContent,
content: DEMO_DEFAULT_REPORT_CONTENT,
fields: [],
scope: 'DEPARTMENT',
ownerDepartmentId: surgeryDepartment.id,
@@ -128,6 +131,25 @@ const main = async () => {
},
});
const existingSystemSettings = await prisma.systemSetting.findFirst({
where: { tenantId: tenant.id, scope: 'global', departmentId: null, key: 'systemSettings' },
});
if (existingSystemSettings) {
await prisma.systemSetting.update({
where: { id: existingSystemSettings.id },
data: { value: DEMO_SYSTEM_SETTINGS },
});
} else {
await prisma.systemSetting.create({
data: {
tenantId: tenant.id,
scope: 'global',
key: 'systemSettings',
value: DEMO_SYSTEM_SETTINGS,
},
});
}
await prisma.auditLog.create({
data: {
tenantId: tenant.id,

View File

@@ -11,6 +11,11 @@ import type { SafeUser } from './auth/auth.types.js';
import { DashboardService } from './dashboard/dashboard.service.js';
import { FilesService } from './files/files.service.js';
import { ReportsService } from './reports/reports.service.js';
import {
DEMO_DEFAULT_REPORT_CONTENT,
DEMO_XF_SPEECH_CONFIG,
} from './demo/demo-defaults.js';
import { SettingsService } from './settings/settings.service.js';
import { TemplatesService } from './templates/templates.service.js';
const databaseUrl =
@@ -229,6 +234,63 @@ describe('Prisma-backed service integration', () => {
await filesService.deleteFile(doctorActor, file.id);
await expect(filesService.readFile(doctorActor, file.id)).rejects.toThrow('文件不存在');
});
it('resets the tenant back to demo mode defaults', async () => {
const settingsService = new SettingsService(prisma as never);
await prisma.auditLog.create({
data: {
tenantId,
actorUserId: null,
actorRole: 'super',
action: 'test.audit',
targetType: 'Test',
targetId: 'test',
},
});
await prisma.user.create({
data: {
tenantId,
departmentId: surgeryDepartmentId,
username: `${userPrefix}_temporary`,
passwordHash: await argon2.hash('123456'),
role: 'DOCTOR',
name: '临时医生',
},
});
await prisma.template.create({
data: {
tenantId,
name: '临时模板',
content: '<p>temporary</p>',
fields: [],
scope: 'DEPARTMENT',
ownerDepartmentId: surgeryDepartmentId,
},
});
const resetSettings = await settingsService.resetSystemSettings(superActor);
await expect(prisma.report.count({ where: { tenantId } })).resolves.toBe(0);
await expect(prisma.auditLog.count({ where: { tenantId } })).resolves.toBe(0);
const users = await prisma.user.findMany({ where: { tenantId }, orderBy: { username: 'asc' } });
expect(users.map((user) => [user.username, user.name, user.role, user.status])).toEqual([
['0001', '张医生', 'DOCTOR', 'ACTIVE'],
['admin', '超级管理员', 'SUPER', 'ACTIVE'],
['manager', '科室管理员', 'ADMIN', 'ACTIVE'],
]);
const templates = await prisma.template.findMany({ where: { tenantId } });
expect(templates).toHaveLength(1);
expect(templates[0]).toMatchObject({
name: '腹腔镜胆囊切除术报告',
content: DEMO_DEFAULT_REPORT_CONTENT,
scope: 'DEPARTMENT',
});
expect(resetSettings.defaultTemplate).toBe(templates[0].id);
expect(resetSettings.aiProviders.kimi.apiKey).not.toBe('');
expect(resetSettings.xfSpeechConfig).toEqual(DEMO_XF_SPEECH_CONFIG);
});
});
const toActor = (

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { defaultReportContent } from '../../../src/utils/defaultContent';
import {
DEMO_DEFAULT_REPORT_CONTENT,
DEMO_SYSTEM_SETTINGS,
DEMO_TEMPLATE_ID,
DEMO_XF_SPEECH_CONFIG,
} from './demo-defaults';
describe('demo defaults', () => {
it('keeps the backend default template aligned with the report editor default content', () => {
expect(DEMO_TEMPLATE_ID).toBe('tpl_default_surgery');
expect(DEMO_DEFAULT_REPORT_CONTENT).toBe(defaultReportContent);
expect(DEMO_DEFAULT_REPORT_CONTENT).toContain('西 安 交 通 大 学 第 一 附 属 医 院');
expect(DEMO_DEFAULT_REPORT_CONTENT).toContain('data-ai-id="手术步骤"');
});
it('includes complete demo speech configuration for the backend proxy', () => {
expect(DEMO_SYSTEM_SETTINGS.defaultTemplate).toBe(DEMO_TEMPLATE_ID);
expect(DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey).not.toBe('');
expect(DEMO_SYSTEM_SETTINGS.xfSpeechConfig).toEqual(DEMO_XF_SPEECH_CONFIG);
expect(DEMO_XF_SPEECH_CONFIG.appId).not.toBe('');
expect(DEMO_XF_SPEECH_CONFIG.apiKey).not.toBe('');
expect(DEMO_XF_SPEECH_CONFIG.apiSecret).not.toBe('');
});
});

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +1,35 @@
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: '', modelName: 'moonshot-v1-32k-vision-preview' },
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'moonshot-v1-32k-vision-preview' },
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: 12,
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85, 96.3, 98.6],
defaultTemplate: '',
frameCount: DEMO_SYSTEM_SETTINGS.frameCount,
framePositions: [...DEMO_SYSTEM_SETTINGS.framePositions],
defaultTemplate: DEMO_SYSTEM_SETTINGS.defaultTemplate,
frameMode: 'keep',
activeAiProvider: 'kimi',
activeAiProvider: DEMO_SYSTEM_SETTINGS.activeAiProvider,
aiProviders: DEFAULT_AI_PROVIDERS,
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' },
autoInsertFrames: DEMO_SYSTEM_SETTINGS.autoInsertFrames,
autoInsertDelay: DEMO_SYSTEM_SETTINGS.autoInsertDelay,
autoInsertFrameIndices: [...DEMO_SYSTEM_SETTINGS.autoInsertFrameIndices],
xfSpeechConfig: DEMO_SYSTEM_SETTINGS.xfSpeechConfig,
};
@Injectable()
@@ -106,17 +112,160 @@ export class SettingsService {
if (!isSuper(this.actorToPolicy(actor))) {
throw new ForbiddenException('只有超级管理员可以重置系统设置');
}
await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(DEFAULT_SETTINGS));
await this.audit?.record({
actor,
action: 'settings.system.reset',
targetType: 'SystemSetting',
targetId: 'systemSettings',
metadata: { scope: 'global' },
});
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,