Initialize backendized SurClaw report system

- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows.

- Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization.

- Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration.

- Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress.

- Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { toFrontendTemplate, toTemplateResource } from './template.mapper';
const now = new Date('2026-05-01T00:00:00.000Z');
const baseTemplate = {
id: 'tpl1',
tenantId: 'tenant-a',
name: '外科模板',
description: '标准模板',
content: '<p>模板</p>',
fields: [{ key: 'patientName' }],
scope: 'DEPARTMENT' as const,
ownerDepartmentId: 'dept-surgery',
ownerUserId: null,
createdAt: now,
updatedAt: now,
ownerDepartment: {
id: 'dept-surgery',
tenantId: 'tenant-a',
name: '外科',
code: 'surgery',
createdAt: now,
updatedAt: now,
},
ownerUser: null,
permissions: [
{
id: 'perm1',
templateId: 'tpl1',
departmentId: 'dept-surgery',
canUse: true,
canManage: true,
createdAt: now,
},
],
};
describe('template mapper', () => {
it('maps Prisma templates to frontend template objects and policy resources', () => {
expect(toFrontendTemplate(baseTemplate)).toMatchObject({
id: 'tpl1',
name: '外科模板',
desc: '标准模板',
scope: 'department',
department: '外科',
fields: [{ key: 'patientName' }],
});
expect(toTemplateResource(baseTemplate)).toMatchObject({
tenantId: 'tenant-a',
scope: 'department',
ownerDepartmentId: 'dept-surgery',
permittedDepartmentIds: ['dept-surgery'],
manageableDepartmentIds: ['dept-surgery'],
});
});
});

View File

@@ -0,0 +1,36 @@
import type { Prisma } from '@prisma/client';
export const templateInclude = {
ownerDepartment: true,
ownerUser: true,
permissions: true,
} satisfies Prisma.TemplateInclude;
export type TemplateWithRelations = Prisma.TemplateGetPayload<{ include: typeof templateInclude }>;
export const toTemplateResource = (template: TemplateWithRelations) => ({
tenantId: template.tenantId,
scope: template.scope.toLowerCase() as 'department' | 'personal',
ownerDepartmentId: template.ownerDepartmentId,
ownerUserId: template.ownerUserId,
permittedDepartmentIds: template.permissions
.filter((permission) => permission.canUse)
.map((permission) => permission.departmentId),
manageableDepartmentIds: template.permissions
.filter((permission) => permission.canManage)
.map((permission) => permission.departmentId),
});
export const toFrontendTemplate = (template: TemplateWithRelations) => ({
id: template.id,
name: template.name,
desc: template.description ?? '',
content: template.content,
createdAt: template.createdAt.toISOString(),
updatedAt: template.updatedAt.toISOString(),
author: template.ownerUser?.username || 'admin',
fields: Array.isArray(template.fields) ? template.fields : [],
scope: template.scope.toLowerCase(),
ownerUser: template.ownerUser?.username,
department: template.ownerDepartment?.name ?? '',
});

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { TemplatesService } from './templates.service.js';
@Controller('templates')
export class TemplatesController {
constructor(
private readonly authService: AuthService,
private readonly templatesService: TemplatesService,
) {}
@Get()
async list(@Req() request: Request, @Query() query: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.templatesService.list(actor, query) };
}
@Get(':id')
async get(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
return { data: { template: await this.templatesService.get(actor, id) } };
}
@Post()
async create(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { template: await this.templatesService.create(actor, body) } };
}
@Patch(':id')
async update(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { template: await this.templatesService.update(actor, id, body) } };
}
@Delete(':id')
async remove(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.templatesService.remove(actor, id);
return { data: null };
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { TemplatesController } from './templates.controller.js';
import { TemplatesService } from './templates.service.js';
@Module({
imports: [AuthModule],
controllers: [TemplatesController],
providers: [TemplatesService],
})
export class TemplatesModule {}

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
const templateScopeSchema = z.enum(['department', 'personal']);
export const listTemplatesQuerySchema = z.object({
access: z.enum(['use', 'manage']).default('use'),
});
export const templateInputSchema = z.object({
name: z.string().trim().min(1, '模板名称不能为空'),
desc: z.string().trim().optional(),
content: z.string().default(''),
fields: z.array(z.unknown()).default([]),
scope: templateScopeSchema.default('department'),
department: z.string().trim().optional(),
}).passthrough();
export const templateUpdateSchema = templateInputSchema.partial().extend({
scope: templateScopeSchema.optional(),
});
export type ListTemplatesQuery = z.infer<typeof listTemplatesQuerySchema>;
export type TemplateInput = z.infer<typeof templateInputSchema>;
export type TemplateUpdateInput = z.infer<typeof templateUpdateSchema>;

View File

@@ -0,0 +1,224 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Prisma, TemplateScope } from '@prisma/client';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { sanitizeReportHtml } from '../common/html-sanitizer.js';
import { canManageTemplate, canUseTemplate, isDoctor } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { templateInclude, toFrontendTemplate, toTemplateResource } from './template.mapper.js';
import {
listTemplatesQuerySchema,
templateInputSchema,
templateUpdateSchema,
type ListTemplatesQuery,
} from './templates.schemas.js';
@Injectable()
export class TemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async list(actor: SafeUser, rawQuery: unknown) {
const query = this.parseListQuery(rawQuery);
const templates = await this.prisma.template.findMany({
where: { tenantId: actor.tenantId },
include: templateInclude,
orderBy: { updatedAt: 'desc' },
});
const policyActor = this.actorToPolicy(actor);
const filtered = templates.filter((template) => {
const resource = toTemplateResource(template);
return query.access === 'manage'
? canManageTemplate(policyActor, resource)
: canUseTemplate(policyActor, resource);
});
return {
items: filtered.map(toFrontendTemplate),
total: filtered.length,
};
}
async get(actor: SafeUser, id: string) {
const template = await this.findTemplate(actor.tenantId, id);
if (!canUseTemplate(this.actorToPolicy(actor), toTemplateResource(template))) {
throw new ForbiddenException('无权查看此模板');
}
return toFrontendTemplate(template);
}
async create(actor: SafeUser, rawInput: unknown) {
const result = templateInputSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const input = result.data;
if (input.scope === 'department' && isDoctor(this.actorToPolicy(actor))) {
throw new ForbiddenException('医生不能创建部门模板');
}
const ownerDepartmentId =
input.scope === 'department'
? await this.resolveOwnerDepartmentId(actor, input.department)
: null;
const ownerUserId = input.scope === 'personal' ? actor.id : null;
const template = await this.prisma.template.create({
data: {
tenantId: actor.tenantId,
name: input.name,
description: input.desc || null,
content: sanitizeReportHtml(input.content),
fields: input.fields as Prisma.InputJsonValue,
scope: this.toDbScope(input.scope),
ownerDepartmentId,
ownerUserId,
permissions: ownerDepartmentId
? {
create: {
departmentId: ownerDepartmentId,
canUse: true,
canManage: true,
},
}
: undefined,
},
include: templateInclude,
});
await this.audit?.record({
actor,
action: 'template.create',
targetType: 'Template',
targetId: template.id,
departmentId: ownerDepartmentId,
metadata: { scope: template.scope, name: template.name },
});
return toFrontendTemplate(template);
}
async update(actor: SafeUser, id: string, rawInput: unknown) {
const result = templateUpdateSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const existing = await this.findTemplate(actor.tenantId, id);
if (!canManageTemplate(this.actorToPolicy(actor), toTemplateResource(existing))) {
throw new ForbiddenException('无权修改此模板');
}
const input = result.data;
const updated = await this.prisma.template.update({
where: { id },
data: {
name: input.name ?? existing.name,
description: input.desc ?? existing.description,
content: input.content === undefined ? existing.content : sanitizeReportHtml(input.content),
fields: input.fields ? (input.fields as Prisma.InputJsonValue) : existing.fields,
},
include: templateInclude,
});
await this.audit?.record({
actor,
action: 'template.update',
targetType: 'Template',
targetId: updated.id,
departmentId: updated.ownerDepartmentId,
metadata: { scope: updated.scope, name: updated.name },
});
return toFrontendTemplate(updated);
}
async remove(actor: SafeUser, id: string) {
const existing = await this.findTemplate(actor.tenantId, id);
if (!canManageTemplate(this.actorToPolicy(actor), toTemplateResource(existing))) {
throw new ForbiddenException('无权删除此模板');
}
await this.prisma.$transaction([
this.prisma.report.updateMany({
where: { tenantId: actor.tenantId, templateId: id },
data: { templateId: null },
}),
this.prisma.template.delete({ where: { id } }),
]);
await this.audit?.record({
actor,
action: 'template.delete',
targetType: 'Template',
targetId: existing.id,
departmentId: existing.ownerDepartmentId,
metadata: { scope: existing.scope, name: existing.name },
});
return null;
}
private parseListQuery(rawQuery: unknown): ListTemplatesQuery {
const result = listTemplatesQuerySchema.safeParse(rawQuery);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
return result.data;
}
private async findTemplate(tenantId: string, id: string) {
const template = await this.prisma.template.findFirst({
where: { id, tenantId },
include: templateInclude,
});
if (!template) {
throw new NotFoundException('模板不存在');
}
return template;
}
private async resolveOwnerDepartmentId(actor: SafeUser, departmentName?: string) {
if (actor.role === 'admin') {
return actor.departmentId;
}
if (!departmentName) {
return null;
}
const department = await this.prisma.department.findFirst({
where: {
tenantId: actor.tenantId,
name: departmentName,
},
});
if (!department) {
throw new BadRequestException('部门不存在');
}
return department.id;
}
private toDbScope(scope: 'department' | 'personal'): TemplateScope {
return scope === 'personal' ? 'PERSONAL' : 'DEPARTMENT';
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
}