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,25 @@
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { AiService } from './ai.service.js';
@Controller('ai')
export class AiController {
constructor(
private readonly authService: AuthService,
private readonly aiService: AiService,
) {}
@Get('models')
async listModels(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.aiService.listModels(actor) };
}
@Post('chat')
async chat(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.aiService.chat(actor, body) };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { SettingsModule } from '../settings/settings.module.js';
import { AiController } from './ai.controller.js';
import { AiService } from './ai.service.js';
@Module({
imports: [AuthModule, PrismaModule, SettingsModule],
controllers: [AiController],
providers: [AiService],
})
export class AiModule {}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { aiChatSchema } from './ai.schemas';
describe('AI schemas', () => {
it('accepts OpenAI-compatible chat payloads with multimodal content', () => {
const parsed = aiChatSchema.parse({
model: 'ignored-by-proxy',
messages: [
{ role: 'system', content: 'system prompt' },
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA==' } },
{ type: 'text', text: '生成报告' },
],
},
],
temperature: 0.3,
});
expect(parsed.messages).toHaveLength(2);
expect(parsed.temperature).toBe(0.3);
});
it('rejects empty message arrays', () => {
expect(() => aiChatSchema.parse({ messages: [] })).toThrow();
});
});

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const aiChatSchema = z.object({
messages: z.array(z.unknown()).min(1, '消息不能为空'),
model: z.string().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
}).passthrough();
export type AiChatInput = z.infer<typeof aiChatSchema>;

126
server/src/ai/ai.service.ts Normal file
View File

@@ -0,0 +1,126 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { SafeUser } from '../auth/auth.types.js';
import { SettingsService } from '../settings/settings.service.js';
import { aiChatSchema } from './ai.schemas.js';
interface AiProvider {
endpoint: string;
apiKey: string;
modelName: string;
}
@Injectable()
export class AiService {
constructor(private readonly settingsService: SettingsService) {}
async listModels(actor: SafeUser) {
const provider = await this.getActiveProvider(actor);
const response = await this.fetchProvider(`${provider.endpoint}/models`, {
method: 'GET',
headers: this.headers(provider),
});
const payload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, payload));
}
const models = Array.isArray((payload as { data?: unknown[] }).data)
? ((payload as { data: Array<{ id?: string }> }).data)
.map((model) => model.id)
.filter((id): id is string => Boolean(id))
: [];
return {
models,
provider: {
endpoint: provider.endpoint,
modelName: provider.modelName,
},
raw: payload,
};
}
async chat(actor: SafeUser, rawInput: unknown) {
const result = aiChatSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const provider = await this.getActiveProvider(actor);
const input = result.data;
const payload = {
...input,
model: provider.modelName || input.model,
};
const response = await this.fetchProvider(`${provider.endpoint}/chat/completions`, {
method: 'POST',
headers: this.headers(provider),
body: JSON.stringify(payload),
});
const responsePayload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, responsePayload));
}
return responsePayload;
}
private async getActiveProvider(actor: SafeUser): Promise<AiProvider> {
const settings = await this.settingsService.getSystemSettings(actor, { includeSecrets: true });
const activeProvider = settings.activeAiProvider || 'kimi';
const provider = settings.aiProviders?.[activeProvider];
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
const apiKey = provider?.apiKey || '';
const modelName = provider?.modelName || '';
if (!endpoint) {
throw new BadRequestException('尚未配置 AI 接口地址');
}
if (!apiKey) {
throw new BadRequestException('尚未配置 AI API Key');
}
if (!modelName) {
throw new BadRequestException('尚未配置 AI 模型名称');
}
return { endpoint, apiKey, modelName };
}
private headers(provider: AiProvider) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
};
}
private async parseProviderResponse(response: Response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return { message: text };
}
}
private async fetchProvider(url: string, init: RequestInit) {
try {
return await fetch(url, init);
} catch (error) {
throw new BadRequestException(`AI 服务连接失败:${error instanceof Error ? error.message : String(error)}`);
}
}
private formatProviderError(status: number, payload: unknown) {
const message =
typeof payload === 'object' && payload !== null && 'error' in payload
? JSON.stringify((payload as { error: unknown }).error)
: typeof payload === 'object' && payload !== null && 'message' in payload
? String((payload as { message: unknown }).message)
: JSON.stringify(payload);
return `AI 服务请求失败:${status}${message ? ` - ${message}` : ''}`;
}
}

37
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AiModule } from './ai/ai.module.js';
import { AuditModule } from './audit/audit.module.js';
import { AuthModule } from './auth/auth.module.js';
import { DashboardModule } from './dashboard/dashboard.module.js';
import { FilesModule } from './files/files.module.js';
import { HealthModule } from './health/health.module.js';
import { LibraryModule } from './library/library.module.js';
import { PermissionsModule } from './permissions/permissions.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { ReportsModule } from './reports/reports.module.js';
import { SettingsModule } from './settings/settings.module.js';
import { SpeechModule } from './speech/speech.module.js';
import { TemplatesModule } from './templates/templates.module.js';
import { UsersModule } from './users/users.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
HealthModule,
DashboardModule,
LibraryModule,
AuthModule,
ReportsModule,
TemplatesModule,
UsersModule,
SettingsModule,
FilesModule,
AiModule,
SpeechModule,
PermissionsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module.js';
import { AuditService } from './audit.service.js';
@Global()
@Module({
imports: [PrismaModule],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { SafeUser } from '../auth/auth.types.js';
import { PrismaService } from '../prisma/prisma.service.js';
export interface AuditInput {
actor?: SafeUser | null;
action: string;
targetType: string;
targetId?: string | null;
departmentId?: string | null;
metadata?: Record<string, unknown>;
ip?: string | null;
userAgent?: string | null;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async record(input: AuditInput) {
const actor = input.actor || null;
const tenantId = actor?.tenantId;
if (!tenantId) return;
await this.prisma.auditLog.create({
data: {
tenantId,
actorUserId: actor.id,
actorRole: actor.role,
action: input.action,
targetType: input.targetType,
targetId: input.targetId || null,
departmentId: input.departmentId || actor.departmentId || null,
ip: input.ip || null,
userAgent: input.userAgent || null,
metadata: input.metadata ? (cleanJson(input.metadata) as Prisma.InputJsonValue) : undefined,
},
}).catch(() => {
// Auditing must not break the clinical workflow. Operational alerts can watch failed inserts.
});
}
}
const cleanJson = (value: Record<string, unknown>) =>
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
HttpCode,
InternalServerErrorException,
Post,
Req,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditService } from '../audit/audit.service.js';
import { AuthService } from './auth.service.js';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly audit?: AuditService,
) {}
@Post('login')
@HttpCode(200)
async login(@Body() body: unknown, @Req() request: Request) {
const user = await this.authService.login(body);
request.session.userId = user.id;
await this.saveSession(request);
await this.audit?.record({
actor: user,
action: 'auth.login',
targetType: 'User',
targetId: user.id,
metadata: { username: user.username },
ip: request.ip,
userAgent: request.get('user-agent'),
});
return {
data: {
user,
},
};
}
@Get('me')
async me(@Req() request: Request) {
if (!request.session.userId) {
throw new UnauthorizedException('未登录');
}
return {
data: {
user: await this.authService.findMe(request.session.userId),
},
};
}
@Post('logout')
@HttpCode(200)
async logout(@Req() request: Request) {
const actor = request.session.userId
? await this.authService.findMe(request.session.userId).catch(() => null)
: null;
await new Promise<void>((resolve, reject) => {
request.session.destroy((error) => {
if (error) reject(error);
else resolve();
});
});
await this.audit?.record({
actor,
action: 'auth.logout',
targetType: 'User',
targetId: actor?.id,
ip: request.ip,
userAgent: request.get('user-agent'),
});
return { data: null };
}
private async saveSession(request: Request) {
await new Promise<void>((resolve, reject) => {
request.session.save((error) => {
if (error) reject(error);
else resolve();
});
}).catch(() => {
throw new InternalServerErrorException('保存登录态失败');
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().trim().min(1, '用户名不能为空'),
password: z.string().min(1, '密码不能为空'),
});
export type LoginInput = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,86 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import argon2 from 'argon2';
import { PrismaService } from '../prisma/prisma.service.js';
import { loginSchema, type LoginInput } from './auth.schemas.js';
import type { SafeUser } from './auth.types.js';
@Injectable()
export class AuthService {
constructor(private readonly prisma: PrismaService) {}
async login(input: unknown) {
const result = loginSchema.safeParse(input);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const user = await this.findUserForLogin(result.data);
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
const isPasswordValid = await argon2.verify(user.passwordHash, result.data.password);
if (!isPasswordValid) {
throw new UnauthorizedException('用户名或密码错误');
}
if (user.status !== 'ACTIVE') {
throw new ForbiddenException('账号已禁用');
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return this.toSafeUser(user);
}
async findMe(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { department: true },
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('登录态已失效');
}
return this.toSafeUser(user);
}
private findUserForLogin(input: LoginInput) {
return this.prisma.user.findFirst({
where: { username: input.username },
include: { department: true },
});
}
private toSafeUser(user: Awaited<ReturnType<AuthService['findUserForLogin']>>): SafeUser {
if (!user) {
throw new UnauthorizedException('登录态已失效');
}
return {
id: user.id,
username: user.username,
role: user.role.toLowerCase() as SafeUser['role'],
name: user.name,
tenantId: user.tenantId,
departmentId: user.departmentId,
departmentName: user.department.name,
status: user.status.toLowerCase() as SafeUser['status'],
phone: user.phone ?? undefined,
email: user.email ?? undefined,
signatureFileId: user.signatureFileId ?? undefined,
signature: user.signatureFileId ? `/api/files/${user.signatureFileId}/content` : undefined,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,18 @@
import type { UserRole, UserStatus } from '@prisma/client';
export interface SafeUser {
id: string;
username: string;
role: Lowercase<UserRole>;
name: string;
tenantId: string;
departmentId: string;
departmentName: string;
status: Lowercase<UserStatus>;
phone?: string;
email?: string;
signatureFileId?: string;
signature?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,15 @@
import { UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service.js';
import type { SafeUser } from './auth.types.js';
export const getSessionUser = async (
request: Request,
authService: AuthService,
): Promise<SafeUser> => {
if (!request.session.userId) {
throw new UnauthorizedException('未登录');
}
return authService.findMe(request.session.userId);
};

View File

@@ -0,0 +1,54 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { Response } from 'express';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const payload = exception instanceof HttpException ? exception.getResponse() : undefined;
const message =
typeof payload === 'object' && payload !== null && 'message' in payload
? String((payload as { message: unknown }).message)
: exception instanceof Error
? exception.message
: '服务器内部错误';
response.status(status).json({
error: {
code: this.resolveCode(status),
message,
},
requestId: response.getHeader('x-request-id') ?? undefined,
});
}
private resolveCode(status: number) {
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
case HttpStatus.UNAUTHORIZED:
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.CONFLICT:
return 'CONFLICT';
case HttpStatus.UNPROCESSABLE_ENTITY:
return 'VALIDATION_ERROR';
default:
return 'INTERNAL_SERVER_ERROR';
}
}
}

View File

@@ -0,0 +1,68 @@
import sanitizeHtml from 'sanitize-html';
const allowedTags = [
...sanitizeHtml.defaults.allowedTags,
'img',
'span',
'div',
'section',
'article',
'header',
'footer',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'colgroup',
'col',
];
const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
...sanitizeHtml.defaults.allowedAttributes,
'*': [
'class',
'style',
'title',
'data-bind',
'data-ai-id',
'data-mode',
'data-placeholder-id',
'contenteditable',
'colspan',
'rowspan',
'align',
],
img: ['src', 'alt', 'width', 'height', 'class', 'style', 'data-*'],
a: ['href', 'name', 'target', 'rel'],
};
export const sanitizeReportHtml = (html: string) =>
sanitizeHtml(html || '', {
allowedTags,
allowedAttributes,
allowedSchemes: ['http', 'https', 'data'],
allowedSchemesByTag: {
img: ['http', 'https', 'data'],
},
allowedStyles: {
'*': {
color: [/^#[0-9a-f]{3,8}$/iu, /^rgb\(/iu, /^rgba\(/iu, /^[a-z]+$/iu],
'background-color': [/^#[0-9a-f]{3,8}$/iu, /^rgb\(/iu, /^rgba\(/iu, /^[a-z]+$/iu],
'font-size': [/^\d+(\.\d+)?(px|pt|em|rem|%)$/u],
'font-weight': [/^\d+$/u, /^(normal|bold|bolder|lighter)$/u],
'text-align': [/^(left|right|center|justify)$/u],
width: [/^\d+(\.\d+)?(px|%|em|rem)$/u],
height: [/^\d+(\.\d+)?(px|%|em|rem)$/u],
margin: [/^[\d.\spxemrem%auto-]+$/u],
padding: [/^[\d.\spxemrem%-]+$/u],
border: [/^[\w\s#().,%/-]+$/u],
'border-collapse': [/^collapse$/u],
display: [/^(block|inline|inline-block|flex|table|table-row|table-cell)$/u],
},
},
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer' }),
},
});

View File

@@ -0,0 +1,19 @@
import { Controller, Get, 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 { DashboardService } from './dashboard.service.js';
@Controller('dashboard')
export class DashboardController {
constructor(
private readonly authService: AuthService,
private readonly dashboardService: DashboardService,
) {}
@Get('stats')
async stats(@Req() request: Request, @Query('range') range?: string) {
const actor = await getSessionUser(request, this.authService);
return { data: { stats: await this.dashboardService.getStats(actor, { range }) } };
}
}

View File

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

View File

@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { SafeUser } from '../auth/auth.types.js';
import { canUseTemplate } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { templateInclude, toTemplateResource } from '../templates/template.mapper.js';
export interface DashboardStats {
totalCount: number;
monthCount: number;
templateCount: number;
userCount: number;
todayCount: number;
trend: number[];
trendLabels: string[];
trendFullDates: string[];
maxTrend: number;
}
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats(actor: SafeUser, query: { range?: string } = {}): Promise<DashboardStats> {
const daysCount = query.range === '1month' ? 30 : 7;
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const trendStart = new Date(startOfToday);
trendStart.setDate(trendStart.getDate() - (daysCount - 1));
const reportWhere: Prisma.ReportWhereInput = {
tenantId: actor.tenantId,
deletedAt: null,
...this.visibilityWhere(actor),
};
const [totalCount, monthCount, todayCount, trendReports, userCount, templates] = await this.prisma.$transaction([
this.prisma.report.count({ where: reportWhere }),
this.prisma.report.count({ where: { ...reportWhere, createdAt: { gte: startOfMonth } } }),
this.prisma.report.count({ where: { ...reportWhere, createdAt: { gte: startOfToday } } }),
this.prisma.report.findMany({
where: { ...reportWhere, createdAt: { gte: trendStart } },
select: { createdAt: true },
}),
this.prisma.user.count({ where: this.visibleUserWhere(actor) }),
this.prisma.template.findMany({
where: { tenantId: actor.tenantId },
include: templateInclude,
}),
]);
const trendFullDates: string[] = [];
const trendLabels: string[] = [];
const trend = Array.from({ length: daysCount }, (_, index) => {
const day = new Date(trendStart);
day.setDate(trendStart.getDate() + index);
const key = toDateKey(day);
trendFullDates.push(key);
trendLabels.push(daysCount === 7 ? `${day.getMonth() + 1}/${day.getDate()}` : `${day.getDate()}`);
return trendReports.filter((report) => toDateKey(report.createdAt) === key).length;
});
return {
totalCount,
monthCount,
todayCount,
templateCount: templates.filter((template) => canUseTemplate(this.actorToPolicy(actor), toTemplateResource(template))).length,
userCount,
trend,
trendLabels,
trendFullDates,
maxTrend: Math.max(...trend, 1),
};
}
private visibilityWhere(actor: SafeUser): Prisma.ReportWhereInput {
if (actor.role === 'super') return {};
if (actor.role === 'admin') return { departmentId: actor.departmentId };
return { authorId: actor.id };
}
private visibleUserWhere(actor: SafeUser): Prisma.UserWhereInput {
if (actor.role === 'super') return { tenantId: actor.tenantId };
if (actor.role === 'admin') {
return {
tenantId: actor.tenantId,
OR: [
{ id: actor.id },
{ departmentId: actor.departmentId, role: 'DOCTOR' },
],
};
}
return { tenantId: actor.tenantId, id: actor.id };
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
}
const toDateKey = (date: Date) => {
const pad = (value: number) => value.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};

View File

@@ -0,0 +1,254 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import argon2 from 'argon2';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuditService } from './audit/audit.service.js';
import { AuthService } from './auth/auth.service.js';
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 { TemplatesService } from './templates/templates.service.js';
const databaseUrl =
process.env.DATABASE_URL ||
'postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public';
describe('Prisma-backed service integration', () => {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: databaseUrl }),
});
const tenantCode = `test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const userPrefix = tenantCode.replace(/[^a-zA-Z0-9_]/g, '_');
let tenantId = '';
let adminDepartmentId = '';
let surgeryDepartmentId = '';
let otherDepartmentId = '';
let superActor: SafeUser;
let doctorActor: SafeUser;
let managerActor: SafeUser;
let otherManagerActor: SafeUser;
let tempDir = '';
beforeAll(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'surclaw-files-'));
process.env.FILE_STORAGE_DIR = tempDir;
await prisma.$connect();
const tenant = await prisma.tenant.create({
data: { code: tenantCode, name: '测试医院' },
});
tenantId = tenant.id;
const [adminDepartment, surgeryDepartment, otherDepartment] = await Promise.all([
prisma.department.create({ data: { tenantId, code: 'admin', name: '管理部门' } }),
prisma.department.create({ data: { tenantId, code: 'surgery', name: '外科' } }),
prisma.department.create({ data: { tenantId, code: 'internal', name: '内科' } }),
]);
adminDepartmentId = adminDepartment.id;
surgeryDepartmentId = surgeryDepartment.id;
otherDepartmentId = otherDepartment.id;
const passwordHash = await argon2.hash('123456');
const [superUser, managerUser, doctorUser, otherManagerUser] = await Promise.all([
prisma.user.create({
data: { tenantId, departmentId: adminDepartmentId, username: `${userPrefix}_admin`, passwordHash, role: 'SUPER', name: '超级管理员' },
}),
prisma.user.create({
data: { tenantId, departmentId: surgeryDepartmentId, username: `${userPrefix}_manager`, passwordHash, role: 'ADMIN', name: '外科管理员' },
}),
prisma.user.create({
data: { tenantId, departmentId: surgeryDepartmentId, username: `${userPrefix}_doctor`, passwordHash, role: 'DOCTOR', name: '张医生' },
}),
prisma.user.create({
data: { tenantId, departmentId: otherDepartmentId, username: `${userPrefix}_manager2`, passwordHash, role: 'ADMIN', name: '内科管理员' },
}),
]);
superActor = toActor(superUser, adminDepartment.name);
managerActor = toActor(managerUser, surgeryDepartment.name);
doctorActor = toActor(doctorUser, surgeryDepartment.name);
otherManagerActor = toActor(otherManagerUser, otherDepartment.name);
});
afterAll(async () => {
if (tenantId) {
await prisma.tenant.delete({ where: { id: tenantId } }).catch(() => {});
}
await prisma.$disconnect();
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
it('authenticates real hashed users and rejects disabled accounts', async () => {
const authService = new AuthService(prisma as never);
const loggedIn = await authService.login({ username: `${userPrefix}_admin`, password: '123456' });
expect(loggedIn.username).toBe(`${userPrefix}_admin`);
expect(loggedIn.role).toBe('super');
await prisma.user.update({
where: { tenantId_username: { tenantId, username: `${userPrefix}_doctor` } },
data: { status: 'INACTIVE' },
});
await expect(authService.login({ username: `${userPrefix}_doctor`, password: '123456' })).rejects.toThrow('账号已禁用');
await prisma.user.update({
where: { tenantId_username: { tenantId, username: `${userPrefix}_doctor` } },
data: { status: 'ACTIVE' },
});
});
it('filters reports by role and keeps completed report revisions', async () => {
const auditService = new AuditService(prisma as never);
const reportsService = new ReportsService(prisma as never, auditService);
const filesService = new FilesService(prisma as never);
const videoFile = await filesService.uploadFile(doctorActor, {
kind: 'VIDEO',
filename: 'operation.mp4',
dataUrl: 'data:video/mp4;base64,AA==',
});
const frameFile = await filesService.uploadFile(doctorActor, {
kind: 'FRAME',
filename: 'frame.png',
dataUrl: 'data:image/png;base64,AA==',
});
const ownReport = await reportsService.create(doctorActor, {
title: '外科报告',
patientName: '患者甲',
hospitalId: 'H001',
content: '<p>draft</p><script>alert(1)</script>',
status: 'completed',
videos: [{ id: 'video-1', name: 'operation.mp4', duration: 18, url: 'blob:local', fileId: videoFile.id }],
capturedFrames: [{
id: 1001,
videoIndex: 0,
videoName: 'operation.mp4',
time: 2,
timeFormatted: '0:02',
dataUrl: 'data:image/png;base64,AA==',
fileId: frameFile.id,
}],
});
expect(ownReport.videos?.[0]).toMatchObject({ id: 'video-1', fileId: videoFile.id, url: `/api/files/${videoFile.id}/content` });
expect(ownReport.capturedFrames?.[0]).toMatchObject({ id: 1001, fileId: frameFile.id, dataUrl: `/api/files/${frameFile.id}/content` });
expect(ownReport.content).not.toContain('<script>');
const mediaRows = await prisma.reportMedia.findMany({ where: { reportId: ownReport.id }, orderBy: { kind: 'desc' } });
expect(mediaRows.map((row) => row.kind).sort()).toEqual(['FRAME', 'VIDEO']);
const persistedReport = await prisma.report.findUniqueOrThrow({ where: { id: ownReport.id } });
expect(persistedReport.metadata).not.toHaveProperty('videos');
expect(persistedReport.metadata).not.toHaveProperty('capturedFrames');
await expect(prisma.auditLog.count({ where: { tenantId, action: 'report.complete', targetId: ownReport.id } })).resolves.toBe(1);
await reportsService.create(otherManagerActor, {
title: '内科报告',
patientName: '患者乙',
hospitalId: 'H002',
content: '<p>other</p>',
status: 'draft',
});
const managerList = await reportsService.list(managerActor, {});
expect(managerList.items.map((item) => item.id)).toContain(ownReport.id);
expect(managerList.items.every((item) => item.department === '外科')).toBe(true);
const doctorList = await reportsService.list(doctorActor, {});
expect(doctorList.items.map((item) => item.id)).toEqual([ownReport.id]);
const updated = await reportsService.update(doctorActor, ownReport.id, {
title: '外科报告',
patientName: '患者甲',
hospitalId: 'H001',
content: '<p>updated</p>',
status: 'completed',
});
expect(updated.revision).toBe(2);
expect(updated.history).toHaveLength(1);
const dashboardService = new DashboardService(prisma as never);
const managerStats = await dashboardService.getStats(managerActor, { range: '7days' });
expect(managerStats.totalCount).toBeGreaterThanOrEqual(1);
expect(managerStats.trend).toHaveLength(7);
expect(managerStats.templateCount).toBeGreaterThanOrEqual(0);
});
it('stores department and personal templates with real permission filtering', async () => {
const templatesService = new TemplatesService(prisma as never);
const departmentTemplate = await templatesService.create(managerActor, {
name: '外科部门模板',
desc: '部门模板',
content: '<p>template</p>',
fields: [],
scope: 'department',
});
const personalTemplate = await templatesService.create(doctorActor, {
name: '我的模板',
desc: '个人模板',
content: '<p>personal</p>',
fields: [],
scope: 'personal',
});
const doctorUseList = await templatesService.list(doctorActor, { access: 'use' });
expect(doctorUseList.items.map((item) => item.id)).toEqual(
expect.arrayContaining([departmentTemplate.id, personalTemplate.id]),
);
const otherUseList = await templatesService.list(otherManagerActor, { access: 'use' });
expect(otherUseList.items.map((item) => item.id)).not.toContain(personalTemplate.id);
});
it('uploads, reads and deletes report files through FileResource', async () => {
const reportsService = new ReportsService(prisma as never);
const filesService = new FilesService(prisma as never);
const report = await reportsService.create(doctorActor, {
title: '含图片报告',
patientName: '患者丙',
hospitalId: 'H003',
content: '<p>file</p>',
status: 'draft',
});
const file = await filesService.uploadFile(doctorActor, {
kind: 'FRAME',
filename: 'frame.png',
dataUrl: 'data:image/png;base64,AA==',
reportId: report.id,
});
expect(file.url).toContain(`/api/files/${file.id}/content`);
const read = await filesService.readFile(doctorActor, file.id);
expect(read.file.mimeType).toBe('image/png');
expect(read.buffer.length).toBe(1);
await filesService.deleteFile(doctorActor, file.id);
await expect(filesService.readFile(doctorActor, file.id)).rejects.toThrow('文件不存在');
});
});
const toActor = (
user: {
id: string;
username: string;
role: string;
name: string;
tenantId: string;
departmentId: string;
status: string;
createdAt: Date;
updatedAt: Date;
},
departmentName: string,
): SafeUser => ({
id: user.id,
username: user.username,
role: user.role.toLowerCase() as SafeUser['role'],
name: user.name,
tenantId: user.tenantId,
departmentId: user.departmentId,
departmentName,
status: user.status.toLowerCase() as SafeUser['status'],
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
});

View File

@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { FilesService } from './files.service.js';
@Controller()
export class FilesController {
constructor(
private readonly authService: AuthService,
private readonly filesService: FilesService,
) {}
@Get('files')
async listFiles(@Req() request: Request, @Query('kind') kind?: string) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.filesService.listFiles(actor, { kind }) };
}
@Post('files')
async uploadFile(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { file: await this.filesService.uploadFile(actor, body) } };
}
@Delete('files/:id')
async deleteFile(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.filesService.deleteFile(actor, id);
return { data: null };
}
@Post('users/:id/signature')
async uploadSignature(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { file: await this.filesService.uploadSignature(actor, id, body) } };
}
@Delete('users/:id/signature')
async deleteSignature(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.filesService.deleteSignature(actor, id);
return { data: null };
}
@Get('files/:id/content')
async getFileContent(
@Req() request: Request,
@Param('id') id: string,
@Res() response: Response,
) {
const actor = await getSessionUser(request, this.authService);
const { file, buffer } = await this.filesService.readFile(actor, id);
response.type(file.mimeType).send(buffer);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { FilesController } from './files.controller.js';
import { FilesService } from './files.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [FilesController],
providers: [FilesService],
})
export class FilesModule {}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { fileUploadSchema, signatureUploadSchema } from './files.schemas.js';
describe('files schemas', () => {
it('accepts generic template asset uploads', () => {
const parsed = fileUploadSchema.parse({
dataUrl: 'data:image/png;base64,AA==',
filename: 'asset.png',
kind: 'TEMPLATE_ASSET',
});
expect(parsed.kind).toBe('TEMPLATE_ASSET');
});
it('keeps signatures on the dedicated signature schema only', () => {
expect(signatureUploadSchema.parse({ dataUrl: 'data:image/png;base64,AA==' }).dataUrl).toContain('data:');
expect(() => fileUploadSchema.parse({ dataUrl: 'data:image/png;base64,AA==', kind: 'SIGNATURE' })).toThrow();
});
});

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const signatureUploadSchema = z.object({
dataUrl: z.string().startsWith('data:', '签名图片格式不正确'),
filename: z.string().trim().optional(),
}).passthrough();
export type SignatureUploadInput = z.infer<typeof signatureUploadSchema>;
export const fileKindSchema = z.enum(['SIGNATURE', 'TEMPLATE_ASSET', 'VIDEO', 'FRAME', 'REPORT_EXPORT']);
export const fileUploadSchema = z.object({
dataUrl: z.string().startsWith('data:', '文件必须是 base64 Data URL'),
filename: z.string().trim().min(1).optional(),
kind: fileKindSchema.exclude(['SIGNATURE']).default('TEMPLATE_ASSET'),
reportId: z.string().trim().optional(),
}).passthrough();
export type FileUploadInput = z.infer<typeof fileUploadSchema>;

View File

@@ -0,0 +1,351 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomUUID, createHash } from 'node:crypto';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { canManageUser, canViewReport, isAdmin, isSuper, type AppRole } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { fileUploadSchema, signatureUploadSchema } from './files.schemas.js';
const MIME_EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'video/mp4': 'mp4',
'video/webm': 'webm',
'application/json': 'json',
};
const FILE_KIND_DIRECTORIES: Record<string, string> = {
TEMPLATE_ASSET: 'template-assets',
VIDEO: 'videos',
FRAME: 'frames',
REPORT_EXPORT: 'report-exports',
};
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async uploadFile(actor: SafeUser, rawInput: unknown) {
const result = fileUploadSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const report = result.data.reportId
? await this.prisma.report.findFirst({ where: { id: result.data.reportId, tenantId: actor.tenantId } })
: null;
if (result.data.reportId && !report) {
throw new NotFoundException('报告不存在');
}
if (report && !canViewReport(this.actorToPolicy(actor), report)) {
throw new ForbiddenException('无权为此报告上传文件');
}
const decoded = this.decodeDataUrl(result.data.dataUrl, { label: '文件', maxBytes: this.maxBytesForKind(result.data.kind) });
const id = randomUUID();
const extension = MIME_EXTENSIONS[decoded.mimeType] || 'bin';
const directory = FILE_KIND_DIRECTORIES[result.data.kind] || 'files';
const storageKey = path.posix.join(directory, actor.tenantId, actor.id, `${id}.${extension}`);
const absolutePath = this.resolveStoragePath(storageKey);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, decoded.buffer);
const file = await this.prisma.fileResource.create({
data: {
id,
tenantId: actor.tenantId,
ownerId: actor.id,
reportId: report?.id,
kind: result.data.kind,
filename: result.data.filename || `${result.data.kind.toLowerCase()}.${extension}`,
mimeType: decoded.mimeType,
size: decoded.buffer.length,
storageKey,
checksum: createHash('sha256').update(decoded.buffer).digest('hex'),
},
});
await this.audit?.record({
actor,
action: 'file.upload',
targetType: 'FileResource',
targetId: file.id,
metadata: { kind: file.kind, filename: file.filename, reportId: file.reportId },
});
return this.toFileDto(file);
}
async listFiles(actor: SafeUser, query: { kind?: string } = {}) {
const files = await this.prisma.fileResource.findMany({
where: {
tenantId: actor.tenantId,
...(query.kind ? { kind: query.kind as never } : {}),
OR: [
{ kind: 'TEMPLATE_ASSET' },
{ ownerId: actor.id },
...(isSuper(this.actorToPolicy(actor)) ? [{}] : []),
],
},
orderBy: { createdAt: 'desc' },
take: 200,
});
return { items: files.map((file) => this.toFileDto(file)) };
}
async deleteFile(actor: SafeUser, id: string) {
const file = await this.prisma.fileResource.findFirst({ where: { id, tenantId: actor.tenantId } });
if (!file) {
throw new NotFoundException('文件不存在');
}
if (!this.canManageFile(actor, file)) {
throw new ForbiddenException('无权删除此文件');
}
await this.removeStoredFile(actor.tenantId, file.id);
await this.audit?.record({
actor,
action: 'file.delete',
targetType: 'FileResource',
targetId: file.id,
metadata: { kind: file.kind, filename: file.filename },
});
return null;
}
async uploadSignature(actor: SafeUser, userId: string, rawInput: unknown) {
const result = signatureUploadSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const target = await this.findUser(actor.tenantId, userId);
if (!canManageUser(this.actorToPolicy(actor), this.userToPolicy(target))) {
throw new ForbiddenException('无权修改此用户签名');
}
const decoded = this.decodeDataUrl(result.data.dataUrl, { label: '签名图片', allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 1024 * 1024 });
const id = randomUUID();
const extension = MIME_EXTENSIONS[decoded.mimeType];
const storageKey = path.posix.join('signatures', actor.tenantId, target.id, `${id}.${extension}`);
const absolutePath = this.resolveStoragePath(storageKey);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, decoded.buffer);
if (target.signatureFileId) {
await this.removeStoredFile(actor.tenantId, target.signatureFileId).catch(() => {});
}
const file = await this.prisma.fileResource.create({
data: {
id,
tenantId: actor.tenantId,
ownerId: target.id,
kind: 'SIGNATURE',
filename: result.data.filename || `signature.${extension}`,
mimeType: decoded.mimeType,
size: decoded.buffer.length,
storageKey,
checksum: createHash('sha256').update(decoded.buffer).digest('hex'),
},
});
await this.prisma.user.update({
where: { id: target.id },
data: { signatureFileId: file.id },
});
await this.audit?.record({
actor,
action: 'user.signature.upload',
targetType: 'User',
targetId: target.id,
departmentId: target.departmentId,
metadata: { fileId: file.id, filename: file.filename },
});
return this.toFileDto(file);
}
async deleteSignature(actor: SafeUser, userId: string) {
const target = await this.findUser(actor.tenantId, userId);
if (!canManageUser(this.actorToPolicy(actor), this.userToPolicy(target))) {
throw new ForbiddenException('无权删除此用户签名');
}
if (target.signatureFileId) {
await this.removeStoredFile(actor.tenantId, target.signatureFileId).catch(() => {});
}
await this.prisma.user.update({
where: { id: target.id },
data: { signatureFileId: null },
});
await this.audit?.record({
actor,
action: 'user.signature.delete',
targetType: 'User',
targetId: target.id,
departmentId: target.departmentId,
});
return null;
}
async readFile(actor: SafeUser, id: string) {
const file = await this.prisma.fileResource.findFirst({
where: { id, tenantId: actor.tenantId },
include: {
report: true,
},
});
if (!file) {
throw new NotFoundException('文件不存在');
}
await this.ensureCanReadFile(actor, file);
return {
file,
buffer: await readFile(this.resolveStoragePath(file.storageKey)),
};
}
private async ensureCanReadFile(
actor: SafeUser,
file: Awaited<ReturnType<FilesService['findFileForRead']>>,
) {
if (!file) throw new NotFoundException('文件不存在');
if (isSuper(this.actorToPolicy(actor))) return;
if (file.kind === 'SIGNATURE') {
if (file.ownerId === actor.id) return;
if (isAdmin(this.actorToPolicy(actor))) {
const owner = file.ownerId
? await this.prisma.user.findFirst({ where: { id: file.ownerId, tenantId: actor.tenantId } })
: null;
if (owner?.departmentId === actor.departmentId) return;
}
}
if (file.kind === 'TEMPLATE_ASSET') return;
if (file.report && canViewReport(this.actorToPolicy(actor), file.report)) return;
if (file.ownerId === actor.id) return;
throw new ForbiddenException('无权读取此文件');
}
private async findFileForRead(tenantId: string, id: string) {
return this.prisma.fileResource.findFirst({
where: { id, tenantId },
include: { report: true },
});
}
private async findUser(tenantId: string, id: string) {
const user = await this.prisma.user.findFirst({
where: { tenantId, OR: [{ id }, { username: id }] },
});
if (!user) {
throw new NotFoundException('用户不存在');
}
return user;
}
private async removeStoredFile(tenantId: string, id: string) {
const file = await this.prisma.fileResource.findFirst({ where: { id, tenantId } });
if (!file) return;
await rm(this.resolveStoragePath(file.storageKey), { force: true });
await this.prisma.fileResource.delete({ where: { id: file.id } });
}
private decodeDataUrl(
dataUrl: string,
options: { label: string; allowedMimeTypes?: string[]; maxBytes: number },
) {
const match = /^data:([^;,]+);base64,(.+)$/u.exec(dataUrl);
if (!match) {
throw new BadRequestException(`${options.label}必须是 base64 Data URL`);
}
const mimeType = match[1];
if (!MIME_EXTENSIONS[mimeType] || (options.allowedMimeTypes && !options.allowedMimeTypes.includes(mimeType))) {
throw new BadRequestException(`${options.label}类型不支持`);
}
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length === 0) {
throw new BadRequestException(`${options.label}不能为空`);
}
if (buffer.length > options.maxBytes) {
throw new BadRequestException(`${options.label}大小超过限制`);
}
return { mimeType, buffer };
}
private maxBytesForKind(kind: string) {
if (kind === 'VIDEO') return 1024 * 1024 * 200;
if (kind === 'REPORT_EXPORT') return 1024 * 1024 * 10;
return 1024 * 1024 * 8;
}
private canManageFile(
actor: SafeUser,
file: { tenantId: string; ownerId: string | null; kind: string },
) {
if (file.tenantId !== actor.tenantId) return false;
if (isSuper(this.actorToPolicy(actor))) return true;
if (file.ownerId === actor.id) return true;
return file.kind === 'TEMPLATE_ASSET' && isAdmin(this.actorToPolicy(actor));
}
private resolveStoragePath(storageKey: string) {
const baseDir = path.resolve(process.env.FILE_STORAGE_DIR || path.join(process.cwd(), 'uploads'));
const absolutePath = path.resolve(baseDir, storageKey);
if (!absolutePath.startsWith(baseDir)) {
throw new BadRequestException('文件路径不合法');
}
return absolutePath;
}
private toFileDto(file: { id: string; filename: string; mimeType: string; size: number; createdAt: Date }) {
return {
id: file.id,
filename: file.filename,
mimeType: file.mimeType,
size: file.size,
url: `/api/files/${file.id}/content`,
createdAt: file.createdAt.toISOString(),
};
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
private userToPolicy(user: { id: string; tenantId: string; departmentId: string; role: string }) {
return {
id: user.id,
tenantId: user.tenantId,
departmentId: user.departmentId,
role: (user.role.toLowerCase() === 'doctor' ? 'doctor' : user.role.toLowerCase()) as AppRole,
};
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
getHealth() {
return {
data: {
status: 'ok',
service: 'surclaw-api',
timestamp: new Date().toISOString(),
},
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller.js';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,169 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { NextFunction, Request, Response } from 'express';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthController } from './auth/auth.controller.js';
import { AuthService } from './auth/auth.service.js';
import type { SafeUser } from './auth/auth.types.js';
import { ApiExceptionFilter } from './common/api-exception.filter.js';
import { FilesController } from './files/files.controller.js';
import { FilesService } from './files/files.service.js';
import { HealthController } from './health/health.controller.js';
import { LibraryController } from './library/library.controller.js';
import { LibraryService } from './library/library.service.js';
import { ReportsController } from './reports/reports.controller.js';
import { ReportsService } from './reports/reports.service.js';
import { TemplatesController } from './templates/templates.controller.js';
import { TemplatesService } from './templates/templates.service.js';
const actor: SafeUser = {
id: 'usr_admin',
username: 'admin',
role: 'super',
name: '系统管理员',
tenantId: 'tenant_1',
departmentId: 'dept_1',
departmentName: '外科',
status: 'active',
createdAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
};
describe('HTTP API integration', () => {
let app: INestApplication;
const authService = {
login: vi.fn(async () => actor),
findMe: vi.fn(async () => actor),
};
const reportsService = {
list: vi.fn(async () => ({ items: [{ id: 'rpt_1' }], total: 1 })),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
const templatesService = {
list: vi.fn(async () => ({ items: [{ id: 'tpl_1' }], total: 1 })),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
const libraryService = {
getFieldLibrary: vi.fn(async () => ({ formFields: [], customTimeFormats: [], multiSelectOptions: {}, anesthesiaOptions: [] })),
updateFieldLibrary: vi.fn(async (_actor, body) => body),
};
const filesService = {
listFiles: vi.fn(async () => ({ items: [] })),
uploadFile: vi.fn(async () => ({ id: 'file_1', filename: 'a.png', mimeType: 'image/png', size: 1, url: '/api/files/file_1/content', createdAt: '2026-05-02T00:00:00.000Z' })),
deleteFile: vi.fn(async () => null),
uploadSignature: vi.fn(),
deleteSignature: vi.fn(),
readFile: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
const moduleRef = await Test.createTestingModule({
controllers: [
HealthController,
AuthController,
ReportsController,
TemplatesController,
LibraryController,
FilesController,
],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: ReportsService, useValue: reportsService },
{ provide: TemplatesService, useValue: templatesService },
{ provide: LibraryService, useValue: libraryService },
{ provide: FilesService, useValue: filesService },
],
}).compile();
(moduleRef.get(AuthController) as unknown as { authService: unknown }).authService = authService;
(moduleRef.get(ReportsController) as unknown as { authService: unknown; reportsService: unknown }).authService = authService;
(moduleRef.get(ReportsController) as unknown as { authService: unknown; reportsService: unknown }).reportsService = reportsService;
(moduleRef.get(TemplatesController) as unknown as { authService: unknown; templatesService: unknown }).authService = authService;
(moduleRef.get(TemplatesController) as unknown as { authService: unknown; templatesService: unknown }).templatesService = templatesService;
(moduleRef.get(LibraryController) as unknown as { authService: unknown; libraryService: unknown }).authService = authService;
(moduleRef.get(LibraryController) as unknown as { authService: unknown; libraryService: unknown }).libraryService = libraryService;
(moduleRef.get(FilesController) as unknown as { authService: unknown; filesService: unknown }).authService = authService;
(moduleRef.get(FilesController) as unknown as { authService: unknown; filesService: unknown }).filesService = filesService;
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
app.useGlobalFilters(new ApiExceptionFilter());
const sessions = new Map<string, { userId?: string }>();
app.use((req: Request, res: Response, next: NextFunction) => {
const cookie = req.headers.cookie || '';
const existingId = /surclaw_test_sid=([^;]+)/u.exec(cookie)?.[1];
const id = existingId || `sid_${Math.random().toString(36).slice(2)}`;
const data = sessions.get(id) || {};
sessions.set(id, data);
req.session = Object.assign(data, {
save: (callback: (error?: unknown) => void) => {
sessions.set(id, data);
res.setHeader('Set-Cookie', `surclaw_test_sid=${id}; Path=/; HttpOnly`);
callback();
},
destroy: (callback: (error?: unknown) => void) => {
sessions.delete(id);
res.setHeader('Set-Cookie', 'surclaw_test_sid=; Path=/; Max-Age=0');
callback();
},
}) as Request['session'];
next();
});
await app.init();
});
afterEach(async () => {
await app.close();
});
it('serves health checks through the API prefix', async () => {
await request(app.getHttpServer())
.get('/api/health')
.expect(200)
.expect((response) => {
expect(response.body.data.service).toBe('surclaw-api');
});
});
it('persists login session cookies for subsequent API calls', async () => {
const agent = request.agent(app.getHttpServer());
await agent.post('/api/auth/login').send({ username: 'admin', password: '123456' }).expect(200);
await agent.get('/api/auth/me').expect(200).expect((response) => {
expect(response.body.data.user.username).toBe('admin');
});
});
it('rejects protected APIs without a session', async () => {
await request(app.getHttpServer())
.get('/api/reports')
.expect(401)
.expect((response) => {
expect(response.body.error.code).toBe('UNAUTHORIZED');
});
});
it('passes the session actor into reports, templates, library and files APIs', async () => {
const agent = request.agent(app.getHttpServer());
await agent.post('/api/auth/login').send({ username: 'admin', password: '123456' }).expect(200);
await agent.get('/api/reports').expect(200);
expect(reportsService.list).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), expect.any(Object));
await agent.get('/api/templates?access=manage').expect(200);
expect(templatesService.list).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), expect.any(Object));
await agent.patch('/api/library/fields').send({ formFields: [] }).expect(200);
expect(libraryService.updateFieldLibrary).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), { formFields: [] });
await agent.post('/api/files').send({ dataUrl: 'data:image/png;base64,AA==', filename: 'a.png', kind: 'TEMPLATE_ASSET' }).expect(201);
expect(filesService.uploadFile).toHaveBeenCalledWith(expect.objectContaining({ id: actor.id }), expect.objectContaining({ filename: 'a.png' }));
});
});

View File

@@ -0,0 +1,25 @@
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { LibraryService } from './library.service.js';
@Controller('library')
export class LibraryController {
constructor(
private readonly authService: AuthService,
private readonly libraryService: LibraryService,
) {}
@Get('fields')
async getFieldLibrary(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { library: await this.libraryService.getFieldLibrary(actor) } };
}
@Patch('fields')
async updateFieldLibrary(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { library: await this.libraryService.updateFieldLibrary(actor, body) } };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { LibraryController } from './library.controller.js';
import { LibraryService } from './library.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [LibraryController],
providers: [LibraryService],
exports: [LibraryService],
})
export class LibraryModule {}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { fieldLibrarySchema } from './library.schemas.js';
describe('fieldLibrarySchema', () => {
it('accepts form field and option library payloads', () => {
const parsed = fieldLibrarySchema.parse({
formFields: [{
key: 'patientName',
label: '患者姓名',
category: '填空',
type: 'text',
visibleInForm: true,
isSystemLocked: true,
}],
customTimeFormats: ['YYYY-MM-DD'],
multiSelectOptions: { surgeon: ['张医生'] },
anesthesiaOptions: ['全麻'],
});
expect(parsed.formFields[0].key).toBe('patientName');
expect(parsed.multiSelectOptions.surgeon).toEqual(['张医生']);
});
it('rejects invalid field types', () => {
expect(() => fieldLibrarySchema.parse({
formFields: [{
key: 'bad',
label: '坏字段',
category: '填空',
type: 'unknown',
visibleInForm: true,
isSystemLocked: false,
}],
})).toThrow();
});
});

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
const fieldTypeSchema = z.enum(['text', 'single_select', 'multi_select', 'time', 'date', 'signature', 'image']);
export const formFieldSchema = z.object({
key: z.string().trim().min(1),
label: z.string().trim().min(1),
category: z.string().trim().min(1),
type: fieldTypeSchema,
visibleInForm: z.boolean(),
isSystemLocked: z.boolean(),
options: z.array(z.string()).optional(),
timeFormat: z.string().optional(),
timeDefault: z.enum(['current', 'specific']).optional(),
fixedTimeValue: z.string().optional(),
hasUnderline: z.boolean().optional(),
});
export const fieldLibrarySchema = z.object({
formFields: z.array(formFieldSchema).default([]),
customTimeFormats: z.array(z.string()).default([]),
multiSelectOptions: z.record(z.string(), z.array(z.string())).default({}),
anesthesiaOptions: z.array(z.string()).default([]),
});
export type FieldLibraryInput = z.infer<typeof fieldLibrarySchema>;

View File

@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { SafeUser } from '../auth/auth.types.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { fieldLibrarySchema, type FieldLibraryInput } from './library.schemas.js';
const DEFAULT_FIELD_LIBRARY: FieldLibraryInput = {
formFields: [],
customTimeFormats: ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'],
multiSelectOptions: {},
anesthesiaOptions: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'],
};
@Injectable()
export class LibraryService {
constructor(private readonly prisma: PrismaService) {}
async getFieldLibrary(actor: SafeUser) {
const saved = await this.getSettingValue(actor.tenantId, 'global', 'fieldLibrary');
return this.normalize(saved);
}
async updateFieldLibrary(actor: SafeUser, rawInput: unknown) {
const current = await this.getFieldLibrary(actor);
const input = this.normalize(rawInput);
const next = this.normalize({
...current,
...input,
});
await this.setSettingValue(actor.tenantId, 'global', 'fieldLibrary', this.toJson(next));
return next;
}
private normalize(input: unknown): FieldLibraryInput {
const result = fieldLibrarySchema.safeParse({
...DEFAULT_FIELD_LIBRARY,
...(this.isObject(input) ? input : {}),
});
if (!result.success) {
return DEFAULT_FIELD_LIBRARY;
}
return {
...result.data,
customTimeFormats: Array.from(new Set(result.data.customTimeFormats.filter(Boolean))),
anesthesiaOptions: Array.from(new Set(result.data.anesthesiaOptions.filter(Boolean))),
multiSelectOptions: Object.fromEntries(
Object.entries(result.data.multiSelectOptions).map(([key, values]) => [
key,
Array.from(new Set(values.filter(Boolean))),
]),
),
};
}
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);
}
}

49
server/src/main.ts Normal file
View File

@@ -0,0 +1,49 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import { AppModule } from './app.module.js';
import { AuthService } from './auth/auth.service.js';
import { ApiExceptionFilter } from './common/api-exception.filter.js';
import { PrismaService } from './prisma/prisma.service.js';
import { PrismaSessionStore } from './session/prisma-session.store.js';
import { attachSpeechProxy } from './speech/speech.gateway.js';
import { SpeechService } from './speech/speech.service.js';
const bootstrap = async () => {
const app = await NestFactory.create(AppModule);
const port = Number(process.env.API_PORT ?? 3000);
app.setGlobalPrefix('api');
app.useGlobalFilters(new ApiExceptionFilter());
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',') ?? ['http://localhost:3001'],
credentials: true,
});
app.use(cookieParser());
const sessionMiddleware = session({
name: 'surclaw.sid',
secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me',
store: new PrismaSessionStore(app.get(PrismaService)),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.SESSION_COOKIE_SECURE === 'true',
maxAge: 1000 * 60 * 60 * 8,
},
});
app.use(sessionMiddleware);
await app.listen(port, '0.0.0.0');
attachSpeechProxy(
app.getHttpServer(),
sessionMiddleware,
app.get(AuthService),
app.get(SpeechService),
);
};
void bootstrap();

View File

@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class PermissionsModule {}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
canCreateAdmin,
canManageTemplate,
canManageUser,
canUseTemplate,
canViewReport,
type Actor,
} from './permissions.policy';
const superAdmin: Actor = {
id: 'u-super',
tenantId: 'tenant-a',
departmentId: 'dept-admin',
role: 'super',
};
const admin: Actor = {
id: 'u-admin',
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
role: 'admin',
};
const doctor: Actor = {
id: 'u-doctor',
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
role: 'doctor',
};
describe('backend permission policy', () => {
it('limits reports by role and department', () => {
const departmentReport = {
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
authorId: 'u-other',
};
const otherDepartmentReport = {
tenantId: 'tenant-a',
departmentId: 'dept-internal',
authorId: 'u-other',
};
const ownReport = {
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
authorId: 'u-doctor',
};
expect(canViewReport(superAdmin, otherDepartmentReport)).toBe(true);
expect(canViewReport(admin, departmentReport)).toBe(true);
expect(canViewReport(admin, otherDepartmentReport)).toBe(false);
expect(canViewReport(doctor, ownReport)).toBe(true);
expect(canViewReport(doctor, departmentReport)).toBe(false);
});
it('keeps tenant boundaries hard', () => {
expect(
canViewReport(superAdmin, {
tenantId: 'tenant-b',
departmentId: 'dept-surgery',
authorId: 'u-doctor',
}),
).toBe(false);
});
it('allows department templates and personal templates by owner', () => {
expect(
canUseTemplate(doctor, {
tenantId: 'tenant-a',
scope: 'department',
ownerDepartmentId: 'dept-surgery',
}),
).toBe(true);
expect(
canUseTemplate(doctor, {
tenantId: 'tenant-a',
scope: 'personal',
ownerUserId: 'u-doctor',
}),
).toBe(true);
expect(
canUseTemplate(doctor, {
tenantId: 'tenant-a',
scope: 'personal',
ownerUserId: 'u-other',
}),
).toBe(false);
});
it('allows only super admins and matching department admins to manage department templates', () => {
const departmentTemplate = {
tenantId: 'tenant-a',
scope: 'department' as const,
ownerDepartmentId: 'dept-surgery',
};
expect(canManageTemplate(superAdmin, departmentTemplate)).toBe(true);
expect(canManageTemplate(admin, departmentTemplate)).toBe(true);
expect(canManageTemplate(doctor, departmentTemplate)).toBe(false);
});
it('keeps admin creation restricted to super admins', () => {
expect(canCreateAdmin(superAdmin)).toBe(true);
expect(canCreateAdmin(admin)).toBe(false);
});
it('lets department admins manage only doctors in their department', () => {
expect(canManageUser(admin, doctor)).toBe(true);
expect(canManageUser(admin, admin)).toBe(true);
expect(canManageUser(admin, { ...doctor, departmentId: 'dept-other' })).toBe(false);
expect(canManageUser(admin, { ...doctor, role: 'admin' })).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
export type AppRole = 'super' | 'admin' | 'doctor' | 'user';
export interface Actor {
id: string;
tenantId: string;
departmentId: string;
role: AppRole;
}
export interface ReportResource {
tenantId: string;
departmentId: string;
authorId: string;
}
export interface TemplateResource {
tenantId: string;
scope: 'department' | 'personal';
ownerDepartmentId?: string | null;
ownerUserId?: string | null;
permittedDepartmentIds?: string[];
manageableDepartmentIds?: string[];
}
const normalizeRole = (role: AppRole) => (role === 'user' ? 'doctor' : role);
export const isSuper = (actor: Actor) => normalizeRole(actor.role) === 'super';
export const isAdmin = (actor: Actor) => normalizeRole(actor.role) === 'admin';
export const isDoctor = (actor: Actor) => normalizeRole(actor.role) === 'doctor';
export const canViewReport = (actor: Actor, report: ReportResource) => {
if (actor.tenantId !== report.tenantId) return false;
if (isSuper(actor)) return true;
if (isAdmin(actor)) return actor.departmentId === report.departmentId;
return actor.id === report.authorId;
};
export const canEditReport = canViewReport;
export const canDeleteReport = canViewReport;
export const canExportReport = canViewReport;
export const canUseTemplate = (actor: Actor, template: TemplateResource) => {
if (actor.tenantId !== template.tenantId) return false;
if (isSuper(actor)) return true;
if (template.scope === 'personal') {
return template.ownerUserId === actor.id;
}
return (
template.ownerDepartmentId === actor.departmentId ||
template.permittedDepartmentIds?.includes(actor.departmentId) === true
);
};
export const canManageTemplate = (actor: Actor, template: TemplateResource) => {
if (actor.tenantId !== template.tenantId) return false;
if (isSuper(actor)) return true;
if (template.scope === 'personal') {
return template.ownerUserId === actor.id;
}
return (
isAdmin(actor) &&
(template.ownerDepartmentId === actor.departmentId ||
template.manageableDepartmentIds?.includes(actor.departmentId) === true)
);
};
export const canManageUser = (actor: Actor, target: Actor) => {
if (actor.tenantId !== target.tenantId) return false;
if (actor.id === target.id) return true;
if (isSuper(actor)) return true;
if (isAdmin(actor)) {
return target.departmentId === actor.departmentId && isDoctor(target);
}
return false;
};
export const canCreateAdmin = (actor: Actor) => isSuper(actor);

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,36 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly pool: Pool;
constructor(configService: ConfigService) {
const connectionString = configService.get<string>('DATABASE_URL');
if (!connectionString) {
throw new Error('DATABASE_URL is required to start the API server');
}
const pool = new Pool({
connectionString,
});
super({
adapter: new PrismaPg(pool),
});
this.pool = pool;
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
}

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest';
import { extractReportMetadata, toFrontendReport } from './report.mapper';
describe('report mapper', () => {
it('keeps frontend compatibility fields in metadata and restores response shape', () => {
expect(extractReportMetadata({
id: 'ignored',
title: '腹腔镜手术',
patientName: '患者',
hospitalId: 'H001',
patientGender: '女',
videos: [{ id: 'v1', name: 'video.mp4' }],
})).toEqual({
patientGender: '女',
});
const now = new Date('2026-05-01T00:00:00.000Z');
const mapped = toFrontendReport({
id: 'r1',
tenantId: 't1',
departmentId: 'd1',
authorId: 'u1',
templateId: null,
title: '腹腔镜手术',
patientName: '患者',
hospitalId: 'H001',
status: 'COMPLETED',
revision: 2,
content: '<p>报告</p>',
metadata: { patientGender: '女' },
deletedAt: null,
deletedBy: null,
createdAt: now,
updatedAt: now,
media: [
{
id: 'm1',
tenantId: 't1',
reportId: 'r1',
fileId: 'f1',
kind: 'VIDEO',
clientId: 'v1',
name: 'video.mp4',
url: 'blob:local',
time: null,
videoIndex: null,
videoName: null,
sortOrder: 0,
metadata: { duration: 12 },
createdAt: now,
updatedAt: now,
file: {
id: 'f1',
tenantId: 't1',
ownerId: 'u1',
reportId: 'r1',
kind: 'VIDEO',
filename: 'video.mp4',
mimeType: 'video/mp4',
size: 100,
storageKey: 'videos/t1/u1/f1.mp4',
checksum: null,
createdAt: now,
},
},
{
id: 'm2',
tenantId: 't1',
reportId: 'r1',
fileId: 'f2',
kind: 'FRAME',
clientId: '101',
name: null,
url: 'data:image/jpeg;base64,old',
time: 3.5,
videoIndex: 0,
videoName: 'video.mp4',
sortOrder: 0,
metadata: { timeFormatted: '0:03', isManual: true, manualOrder: 1 },
createdAt: now,
updatedAt: now,
file: {
id: 'f2',
tenantId: 't1',
ownerId: 'u1',
reportId: 'r1',
kind: 'FRAME',
filename: 'frame.jpg',
mimeType: 'image/jpeg',
size: 100,
storageKey: 'frames/t1/u1/f2.jpg',
checksum: null,
createdAt: now,
},
},
],
author: {
id: 'u1',
tenantId: 't1',
departmentId: 'd1',
username: '0001',
passwordHash: '',
role: 'DOCTOR',
name: '张医生',
status: 'ACTIVE',
phone: null,
email: null,
signatureFileId: null,
lastLoginAt: null,
createdAt: now,
updatedAt: now,
},
department: {
id: 'd1',
tenantId: 't1',
name: '外科',
code: 'surgery',
createdAt: now,
updatedAt: now,
},
histories: [
{
id: 'h1',
reportId: 'r1',
revision: 1,
content: '<p>旧报告</p>',
action: 'complete_report',
updatedById: 'u1',
updatedBy: '张医生',
createdAt: now,
},
],
});
expect(mapped).toMatchObject({
id: 'r1',
patientGender: '女',
author: '0001',
authorName: '张医生',
department: '外科',
status: 'completed',
revision: 2,
videos: [{ id: 'v1', name: 'video.mp4', url: '/api/files/f1/content', duration: 12, fileId: 'f1' }],
capturedFrames: [{
id: 101,
videoIndex: 0,
videoName: 'video.mp4',
time: 3.5,
timeFormatted: '0:03',
dataUrl: '/api/files/f2/content',
fileId: 'f2',
isManual: true,
manualOrder: 1,
}],
history: [{ revision: 1, action: 'complete_report' }],
});
});
});

View File

@@ -0,0 +1,199 @@
import type { Prisma } from '@prisma/client';
const MEDIA_METADATA_KEYS = new Set(['videos', 'capturedFrames']);
const FRONTEND_CANONICAL_KEYS = new Set([
'id',
'title',
'patientName',
'hospitalId',
'content',
'author',
'authorName',
'department',
'createdAt',
'updatedAt',
'status',
'revision',
'history',
...MEDIA_METADATA_KEYS,
]);
export const reportInclude = {
author: true,
department: true,
histories: { orderBy: { createdAt: 'asc' } },
media: {
include: { file: true },
orderBy: [{ kind: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
},
} satisfies Prisma.ReportInclude;
type ReportWithRelations = Prisma.ReportGetPayload<{ include: typeof reportInclude }>;
export interface ExtractedReportMedia {
videos: Array<{
clientId: string;
fileId?: string;
name?: string;
url?: string;
duration?: number;
sortOrder: number;
}>;
frames: Array<{
clientId: string;
fileId?: string;
url?: string;
time?: number;
timeFormatted?: string;
videoIndex?: number;
videoName?: string;
isManual?: boolean;
manualOrder?: number;
sortOrder: number;
}>;
hasPayload: boolean;
}
export const extractReportMetadata = (input: Record<string, unknown>) => {
const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (!FRONTEND_CANONICAL_KEYS.has(key)) {
metadata[key] = value;
}
}
return JSON.parse(JSON.stringify(metadata)) as Record<string, unknown>;
};
export const sanitizeReportMetadata = (metadata: unknown) => {
const next =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? { ...(metadata as Record<string, unknown>) }
: {};
for (const key of MEDIA_METADATA_KEYS) {
delete next[key];
}
return next;
};
export const extractReportMedia = (input: Record<string, unknown>): ExtractedReportMedia => {
const rawVideos = Array.isArray(input.videos) ? input.videos : [];
const rawFrames = Array.isArray(input.capturedFrames) ? input.capturedFrames : [];
return {
videos: rawVideos.map((item, index) => {
const video = objectRecord(item);
return {
clientId: stringValue(video.id) || `video-${index + 1}`,
fileId: stringValue(video.fileId) || undefined,
name: stringValue(video.name) || undefined,
url: stringValue(video.url) || undefined,
duration: numberValue(video.duration),
sortOrder: index,
};
}),
frames: rawFrames.map((item, index) => {
const frame = objectRecord(item);
return {
clientId: stringValue(frame.id) || `frame-${index + 1}`,
fileId: stringValue(frame.fileId) || undefined,
url: stringValue(frame.dataUrl) || undefined,
time: numberValue(frame.time),
timeFormatted: stringValue(frame.timeFormatted) || undefined,
videoIndex: integerValue(frame.videoIndex),
videoName: stringValue(frame.videoName) || undefined,
isManual: booleanValue(frame.isManual),
manualOrder: integerValue(frame.manualOrder),
sortOrder: index,
};
}),
hasPayload: Object.prototype.hasOwnProperty.call(input, 'videos')
|| Object.prototype.hasOwnProperty.call(input, 'capturedFrames'),
};
};
export const toFrontendReport = (report: ReportWithRelations) => {
const metadata = sanitizeReportMetadata(report.metadata);
const legacyMetadata =
report.metadata && typeof report.metadata === 'object' && !Array.isArray(report.metadata)
? (report.metadata as Record<string, unknown>)
: {};
const videos = report.media.filter((item) => item.kind === 'VIDEO').map((item, index) => ({
id: item.clientId,
name: item.name || item.file?.filename || `视频 ${index + 1}`,
url: item.fileId ? `/api/files/${item.fileId}/content` : (item.url || ''),
duration: numberValue(objectRecord(item.metadata).duration) ?? 0,
...(item.fileId ? { fileId: item.fileId } : {}),
}));
const capturedFrames = report.media.filter((item) => item.kind === 'FRAME').map((item, index) => {
const frameMetadata = objectRecord(item.metadata);
return {
id: numericClientId(item.clientId, index + 1),
videoIndex: item.videoIndex ?? 0,
videoName: item.videoName || '',
time: item.time ?? 0,
timeFormatted: stringValue(frameMetadata.timeFormatted) || formatSeconds(item.time ?? 0),
dataUrl: item.fileId ? `/api/files/${item.fileId}/content` : (item.url || ''),
...(item.fileId ? { fileId: item.fileId } : {}),
...(booleanValue(frameMetadata.isManual) !== undefined ? { isManual: booleanValue(frameMetadata.isManual) } : {}),
...(integerValue(frameMetadata.manualOrder) !== undefined ? { manualOrder: integerValue(frameMetadata.manualOrder) } : {}),
};
});
return {
...metadata,
id: report.id,
title: report.title,
patientName: report.patientName,
hospitalId: report.hospitalId,
content: report.content,
author: report.author.username,
authorName: report.author.name,
department: report.department.name,
createdAt: report.createdAt.toISOString(),
updatedAt: report.updatedAt.toISOString(),
status: report.status.toLowerCase(),
revision: report.revision,
videos: videos.length > 0 ? videos : (Array.isArray(legacyMetadata.videos) ? legacyMetadata.videos : []),
capturedFrames: capturedFrames.length > 0
? capturedFrames
: (Array.isArray(legacyMetadata.capturedFrames) ? legacyMetadata.capturedFrames : []),
history: report.histories.map((history) => ({
content: history.content,
updatedAt: history.createdAt.toISOString(),
updatedBy: history.updatedBy,
action: history.action === 'complete_report' ? 'complete_report' : 'save_draft',
revision: history.revision,
})),
};
};
const objectRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const stringValue = (value: unknown) => {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return '';
};
const numberValue = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const integerValue = (value: unknown) =>
typeof value === 'number' && Number.isInteger(value) ? value : undefined;
const booleanValue = (value: unknown) =>
typeof value === 'boolean' ? value : undefined;
const numericClientId = (clientId: string, fallback: number) => {
const parsed = Number(clientId);
return Number.isFinite(parsed) ? parsed : fallback;
};
const formatSeconds = (seconds: number) => {
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
const minutes = Math.floor(safeSeconds / 60);
const rest = safeSeconds % 60;
return `${minutes}:${rest.toString().padStart(2, '0')}`;
};

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 { ReportsService } from './reports.service.js';
@Controller('reports')
export class ReportsController {
constructor(
private readonly authService: AuthService,
private readonly reportsService: ReportsService,
) {}
@Get()
async list(@Req() request: Request, @Query() query: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.reportsService.list(actor, query) };
}
@Get(':id')
async get(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
return { data: { report: await this.reportsService.get(actor, id) } };
}
@Post()
async create(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { report: await this.reportsService.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: { report: await this.reportsService.update(actor, id, body) } };
}
@Delete(':id')
async remove(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.reportsService.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 { ReportsController } from './reports.controller.js';
import { ReportsService } from './reports.service.js';
@Module({
imports: [AuthModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
export const reportStatusSchema = z.enum(['draft', 'completed']);
const reportBaseSchema = z.object({
title: z.string().trim().min(1, '报告标题不能为空'),
patientName: z.string().trim().min(1, '患者姓名不能为空'),
hospitalId: z.string().trim().min(1, '住院号不能为空'),
content: z.string().default(''),
status: reportStatusSchema.default('draft'),
templateId: z.string().trim().optional(),
}).passthrough();
export const createReportSchema = reportBaseSchema;
export const updateReportSchema = reportBaseSchema.partial().extend({
status: reportStatusSchema.optional(),
}).passthrough();
export const listReportsQuerySchema = z.object({
q: z.string().trim().optional(),
status: reportStatusSchema.optional(),
dateRange: z.enum(['today', 'week', 'month']).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(50),
});
export type CreateReportInput = z.infer<typeof createReportSchema>;
export type UpdateReportInput = z.infer<typeof updateReportSchema>;
export type ListReportsQuery = z.infer<typeof listReportsQuerySchema>;

View File

@@ -0,0 +1,371 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Prisma, ReportStatus } 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 { canDeleteReport, canEditReport, canViewReport } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import {
extractReportMedia,
extractReportMetadata,
reportInclude,
sanitizeReportMetadata,
toFrontendReport,
type ExtractedReportMedia,
} from './report.mapper.js';
import {
createReportSchema,
listReportsQuerySchema,
updateReportSchema,
type ListReportsQuery,
} from './reports.schemas.js';
@Injectable()
export class ReportsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async list(actor: SafeUser, rawQuery: unknown) {
const query = this.parseQuery(rawQuery);
const where: Prisma.ReportWhereInput = {
tenantId: actor.tenantId,
deletedAt: null,
...this.visibilityWhere(actor),
...this.searchWhere(query),
};
const [items, total] = await this.prisma.$transaction([
this.prisma.report.findMany({
where,
include: reportInclude,
orderBy: { updatedAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
this.prisma.report.count({ where }),
]);
return {
items: items.map(toFrontendReport),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async get(actor: SafeUser, id: string) {
const report = await this.findReport(id, actor.tenantId);
if (!canViewReport(this.actorToPolicy(actor), report)) {
throw new ForbiddenException('无权查看此报告');
}
return toFrontendReport(report);
}
async create(actor: SafeUser, rawInput: unknown) {
const result = createReportSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const input = result.data;
const status = this.toDbStatus(input.status);
const metadata = extractReportMetadata(input);
const media = extractReportMedia(input);
const content = sanitizeReportHtml(input.content);
const report = await this.prisma.$transaction(async (tx) => {
const created = await tx.report.create({
data: {
tenantId: actor.tenantId,
departmentId: actor.departmentId,
authorId: actor.id,
templateId: input.templateId || null,
title: input.title,
patientName: input.patientName,
hospitalId: input.hospitalId,
content,
status,
revision: 1,
metadata: metadata as Prisma.InputJsonValue,
},
});
await this.replaceReportMedia(tx, actor, created.id, media);
return tx.report.findUniqueOrThrow({
where: { id: created.id },
include: reportInclude,
});
});
await this.audit?.record({
actor,
action: status === 'COMPLETED' ? 'report.complete' : 'report.create',
targetType: 'Report',
targetId: report.id,
metadata: { status: report.status, revision: report.revision },
});
return toFrontendReport(report);
}
async update(actor: SafeUser, id: string, rawInput: unknown) {
const result = updateReportSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const old = await this.findReport(id, actor.tenantId);
if (!canEditReport(this.actorToPolicy(actor), old)) {
throw new ForbiddenException('无权修改此报告');
}
const input = result.data;
const nextStatus = input.status ? this.toDbStatus(input.status) : old.status;
const nextRevision = old.status === 'COMPLETED' ? old.revision + 1 : old.revision;
const media = extractReportMedia(input);
const content = input.content === undefined ? old.content : sanitizeReportHtml(input.content);
const metadata = {
...sanitizeReportMetadata(old.metadata),
...extractReportMetadata(input),
} as Prisma.InputJsonValue;
const updated = await this.prisma.$transaction(async (tx) => {
await tx.reportHistory.create({
data: {
reportId: old.id,
revision: old.revision,
content: old.content,
action: nextStatus === 'COMPLETED' ? 'complete_report' : 'save_draft',
updatedById: actor.id,
updatedBy: actor.name,
},
});
await tx.report.update({
where: { id },
data: {
title: input.title ?? old.title,
patientName: input.patientName ?? old.patientName,
hospitalId: input.hospitalId ?? old.hospitalId,
content,
status: nextStatus,
revision: nextRevision,
metadata,
},
});
if (media.hasPayload) {
await this.replaceReportMedia(tx, actor, old.id, media);
}
return tx.report.findUniqueOrThrow({
where: { id },
include: reportInclude,
});
});
await this.audit?.record({
actor,
action: nextStatus === 'COMPLETED' ? 'report.complete' : 'report.update',
targetType: 'Report',
targetId: updated.id,
metadata: { status: updated.status, revision: updated.revision },
});
return toFrontendReport(updated);
}
async remove(actor: SafeUser, id: string) {
const report = await this.findReport(id, actor.tenantId);
if (!canDeleteReport(this.actorToPolicy(actor), report)) {
throw new ForbiddenException('无权删除此报告');
}
await this.prisma.report.update({
where: { id },
data: {
deletedAt: new Date(),
deletedBy: actor.id,
},
});
await this.audit?.record({
actor,
action: 'report.delete',
targetType: 'Report',
targetId: report.id,
metadata: { title: report.title, status: report.status },
});
return null;
}
private parseQuery(rawQuery: unknown): ListReportsQuery {
const result = listReportsQuerySchema.safeParse(rawQuery);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
return result.data;
}
private visibilityWhere(actor: SafeUser): Prisma.ReportWhereInput {
if (actor.role === 'super') return {};
if (actor.role === 'admin') return { departmentId: actor.departmentId };
return { authorId: actor.id };
}
private searchWhere(query: ListReportsQuery): Prisma.ReportWhereInput {
const filters: Prisma.ReportWhereInput[] = [];
if (query.status) {
filters.push({ status: this.toDbStatus(query.status) });
}
if (query.q) {
filters.push({
OR: [
{ title: { contains: query.q, mode: 'insensitive' } },
{ patientName: { contains: query.q, mode: 'insensitive' } },
{ hospitalId: { contains: query.q, mode: 'insensitive' } },
],
});
}
const since = this.resolveDateRange(query.dateRange);
if (since) {
filters.push({ createdAt: { gte: since } });
}
return filters.length > 0 ? { AND: filters } : {};
}
private resolveDateRange(dateRange?: ListReportsQuery['dateRange']) {
if (!dateRange) return null;
const now = new Date();
if (dateRange === 'today') {
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
if (dateRange === 'week') {
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
}
return new Date(now.getFullYear(), now.getMonth(), 1);
}
private toDbStatus(status: 'draft' | 'completed'): ReportStatus {
return status === 'completed' ? 'COMPLETED' : 'DRAFT';
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
private findReport(id: string, tenantId: string) {
return this.prisma.report.findFirst({
where: { id, tenantId, deletedAt: null },
include: reportInclude,
}).then((report) => {
if (!report) {
throw new NotFoundException('报告不存在');
}
return report;
});
}
private async replaceReportMedia(
tx: Prisma.TransactionClient,
actor: SafeUser,
reportId: string,
media: ExtractedReportMedia,
) {
const rows: Prisma.ReportMediaCreateManyInput[] = [
...media.videos.map((video): Prisma.ReportMediaCreateManyInput => ({
tenantId: actor.tenantId,
reportId,
fileId: video.fileId,
kind: 'VIDEO',
clientId: video.clientId,
name: video.name,
url: video.url,
sortOrder: video.sortOrder,
metadata: cleanJson({ duration: video.duration ?? 0 }) as Prisma.InputJsonValue,
})),
...media.frames.map((frame): Prisma.ReportMediaCreateManyInput => ({
tenantId: actor.tenantId,
reportId,
fileId: frame.fileId,
kind: 'FRAME',
clientId: frame.clientId,
url: frame.url,
time: frame.time,
videoIndex: frame.videoIndex,
videoName: frame.videoName,
sortOrder: frame.sortOrder,
metadata: cleanJson({
timeFormatted: frame.timeFormatted,
isManual: frame.isManual,
manualOrder: frame.manualOrder,
}) as Prisma.InputJsonValue,
})),
];
await this.ensureMediaFilesCanBeLinked(tx, actor, reportId, rows);
await tx.reportMedia.deleteMany({ where: { tenantId: actor.tenantId, reportId } });
if (rows.length > 0) {
await tx.reportMedia.createMany({ data: rows });
}
}
private async ensureMediaFilesCanBeLinked(
tx: Prisma.TransactionClient,
actor: SafeUser,
reportId: string,
rows: Prisma.ReportMediaCreateManyInput[],
) {
const ids = [...new Set(rows.map((row) => row.fileId).filter((id): id is string => typeof id === 'string' && id.length > 0))];
if (ids.length === 0) return;
const files = await tx.fileResource.findMany({
where: { tenantId: actor.tenantId, id: { in: ids } },
select: { id: true, kind: true, reportId: true },
});
const fileById = new Map(files.map((file) => [file.id, file]));
for (const row of rows) {
if (!row.fileId) continue;
const file = fileById.get(row.fileId);
if (!file) {
throw new BadRequestException('报告媒体文件不存在');
}
if (file.kind !== row.kind) {
throw new BadRequestException('报告媒体文件类型不匹配');
}
if (file.reportId && file.reportId !== reportId) {
throw new BadRequestException('报告媒体文件已关联其他报告');
}
}
await tx.fileResource.updateMany({
where: {
tenantId: actor.tenantId,
id: { in: ids },
reportId: null,
},
data: { reportId },
});
}
}
const cleanJson = (value: Record<string, unknown>) =>
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;

View File

@@ -0,0 +1,72 @@
import session from 'express-session';
import type { PrismaService } from '../prisma/prisma.service.js';
type SessionData = session.SessionData & { cookie?: session.Cookie };
export class PrismaSessionStore extends session.Store {
constructor(private readonly prisma: PrismaService) {
super();
}
get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void) {
this.prisma.appSession.findUnique({ where: { id: sid } })
.then((stored) => {
if (!stored || stored.expiresAt.getTime() <= Date.now()) {
if (stored) void this.destroy(sid, () => undefined);
callback(null, null);
return;
}
callback(null, stored.data as unknown as SessionData);
})
.catch((error) => callback(error));
}
set(sid: string, sess: session.SessionData, callback?: (err?: unknown) => void) {
const expiresAt = this.resolveExpiresAt(sess);
this.prisma.appSession.upsert({
where: { id: sid },
create: {
id: sid,
data: JSON.parse(JSON.stringify(sess)),
expiresAt,
},
update: {
data: JSON.parse(JSON.stringify(sess)),
expiresAt,
},
})
.then(() => callback?.())
.catch((error) => callback?.(error));
}
destroy(sid: string, callback?: (err?: unknown) => void) {
this.prisma.appSession.delete({ where: { id: sid } })
.then(() => callback?.())
.catch((error) => {
if (isNotFound(error)) callback?.();
else callback?.(error);
});
}
touch(sid: string, sess: session.SessionData, callback?: () => void) {
this.prisma.appSession.update({
where: { id: sid },
data: {
expiresAt: this.resolveExpiresAt(sess),
data: JSON.parse(JSON.stringify(sess)),
},
})
.then(() => callback?.())
.catch(() => callback?.());
}
private resolveExpiresAt(sess: session.SessionData) {
const cookie = (sess as SessionData).cookie;
if (cookie?.expires) return new Date(cookie.expires);
const maxAge = typeof cookie?.maxAge === 'number' ? cookie.maxAge : 1000 * 60 * 60 * 8;
return new Date(Date.now() + maxAge);
}
}
const isNotFound = (error: unknown) =>
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2025';

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Get, Patch, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { SettingsService } from './settings.service.js';
@Controller('settings')
export class SettingsController {
constructor(
private readonly authService: AuthService,
private readonly settingsService: SettingsService,
) {}
@Get('system')
async getSystem(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.getSystemSettings(actor) } };
}
@Patch('system')
async updateSystem(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.updateSystemSettings(actor, body) } };
}
@Post('system/reset')
async resetSystem(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.resetSystemSettings(actor) } };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { SettingsController } from './settings.controller.js';
import { SettingsService } from './settings.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { systemSettingsSchema } from './settings.schemas';
describe('settings schemas', () => {
it('normalizes partial system settings with safe defaults', () => {
const result = systemSettingsSchema.parse({
frameCount: 2,
framePositions: [75, 25],
activeAiProvider: 'custom',
aiProviders: {
custom: {
endpoint: 'https://example.test/v1',
modelName: 'demo-model',
},
},
});
expect(result.frameCount).toBe(2);
expect(result.framePositions).toEqual([75, 25]);
expect(result.frameMode).toBe('keep');
expect(result.aiProviders.custom.apiKey).toBe('');
});
it('rejects invalid frame positions', () => {
expect(() => systemSettingsSchema.parse({
frameCount: 1,
framePositions: [120],
activeAiProvider: 'kimi',
aiProviders: {},
})).toThrow();
});
});

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
export const aiProviderSchema = z.object({
endpoint: z.string().default(''),
apiKey: z.string().default(''),
modelName: z.string().default(''),
}).passthrough();
export const xfSpeechConfigSchema = z.object({
appId: z.string().default(''),
apiKey: z.string().default(''),
apiSecret: z.string().default(''),
}).passthrough();
export const systemSettingsSchema = z.object({
frameCount: z.number().int().min(1).max(100).default(12),
framePositions: z.array(z.number().min(0).max(100)).default([]),
defaultTemplate: z.string().optional(),
frameMode: z.enum(['uniform', 'keep']).default('keep'),
autoInsertFrames: z.boolean().optional(),
autoInsertFrameIndices: z.array(z.number().int().min(0)).optional(),
autoInsertDelay: z.number().min(0).optional(),
activeAiProvider: z.string().default('kimi'),
aiProviders: z.record(z.string(), aiProviderSchema).default({}),
xfSpeechConfig: xfSpeechConfigSchema.optional(),
}).passthrough();
export type SystemSettingsInput = z.infer<typeof systemSettingsSchema>;

View File

@@ -0,0 +1,212 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.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' },
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: '',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: DEFAULT_AI_PROVIDERS,
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' },
};
@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.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' },
});
return this.getSystemSettings(actor);
}
private normalize(input: SystemSettingsInput): SystemSettingsInput {
const aiProviders = {
...DEFAULT_AI_PROVIDERS,
...(input.aiProviders || {}),
};
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
.map((value) => Math.round(value * 10) / 10)
.sort((a, b) => a - b);
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,
};
}
}

View File

@@ -0,0 +1,57 @@
import type { IncomingMessage, Server } from 'node:http';
import type { Socket } from 'node:net';
import type { RequestHandler } from 'express';
import { WebSocketServer } from 'ws';
import { AuthService } from '../auth/auth.service.js';
import { SpeechService } from './speech.service.js';
interface SessionRequest extends IncomingMessage {
session?: {
userId?: string;
};
}
export const attachSpeechProxy = (
server: Server,
sessionMiddleware: RequestHandler,
authService: AuthService,
speechService: SpeechService,
) => {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request: SessionRequest, socket: Socket, head: Buffer) => {
const url = new URL(request.url || '/', 'http://localhost');
if (url.pathname !== '/api/speech/iat') {
return;
}
sessionMiddleware(request as never, createUpgradeResponse() as never, async () => {
try {
if (!request.session?.userId) {
rejectUpgrade(socket, 401);
return;
}
const actor = await authService.findMe(request.session.userId);
wss.handleUpgrade(request, socket, head, (client) => {
void speechService.handleIatConnection(client, actor);
});
} catch {
rejectUpgrade(socket, 401);
}
});
});
return wss;
};
const createUpgradeResponse = () => ({
getHeader: () => undefined,
setHeader: () => undefined,
writeHead: () => undefined,
});
const rejectUpgrade = (socket: Socket, status: 401 | 500) => {
socket.write(`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Internal Server Error'}\r\n\r\n`);
socket.destroy();
};

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SettingsModule } from '../settings/settings.module.js';
import { SpeechService } from './speech.service.js';
@Module({
imports: [SettingsModule],
providers: [SpeechService],
exports: [SpeechService],
})
export class SpeechModule {}

View File

@@ -0,0 +1,94 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import type { RawData, WebSocket } from 'ws';
import Ws from 'ws';
import type { SafeUser } from '../auth/auth.types.js';
import { SettingsService } from '../settings/settings.service.js';
import { prepareXfIatFrame } from './xf-frame.js';
@Injectable()
export class SpeechService {
constructor(private readonly settingsService: SettingsService) {}
async handleIatConnection(client: WebSocket, actor: SafeUser) {
let upstream: WebSocket | null = null;
const queued: RawData[] = [];
try {
const config = await this.getXfConfig(actor);
upstream = new Ws(this.buildXfAuthUrl(config.apiKey, config.apiSecret));
upstream.on('open', () => {
while (queued.length > 0 && upstream?.readyState === Ws.OPEN) {
upstream.send(prepareXfIatFrame(queued.shift() as RawData, config.appId));
}
});
upstream.on('message', (data) => {
if (client.readyState === Ws.OPEN) {
client.send(data);
}
});
upstream.on('error', (error) => {
this.sendClientError(client, `讯飞语音连接失败:${error.message}`);
});
upstream.on('close', () => {
if (client.readyState === Ws.OPEN || client.readyState === Ws.CONNECTING) {
client.close();
}
});
client.on('message', (data) => {
if (!upstream) return;
if (upstream.readyState === Ws.OPEN) {
upstream.send(prepareXfIatFrame(data, config.appId));
return;
}
queued.push(data);
});
client.on('close', () => {
if (upstream && (upstream.readyState === Ws.OPEN || upstream.readyState === Ws.CONNECTING)) {
upstream.close();
}
});
client.on('error', () => {
if (upstream && (upstream.readyState === Ws.OPEN || upstream.readyState === Ws.CONNECTING)) {
upstream.close();
}
});
} catch (error) {
this.sendClientError(client, error instanceof Error ? error.message : String(error));
client.close();
}
}
private async getXfConfig(actor: SafeUser) {
const settings = await this.settingsService.getSystemSettings(actor, { includeSecrets: true });
const config = settings.xfSpeechConfig;
if (!config?.appId || !config.apiKey || !config.apiSecret) {
throw new BadRequestException('尚未配置讯飞语音 APPID/APIKey/APISecret');
}
return config;
}
private buildXfAuthUrl(apiKey: string, apiSecret: string) {
const host = 'iat-api.xfyun.cn';
const date = new Date().toUTCString();
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
const signature = createHmac('sha256', apiSecret).update(signatureOrigin).digest('base64');
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
const authorization = Buffer.from(authorizationOrigin).toString('base64');
const params = new URLSearchParams({ authorization, date, host });
return `wss://${host}/v2/iat?${params.toString()}`;
}
private sendClientError(client: WebSocket, message: string) {
if (client.readyState === Ws.OPEN) {
client.send(JSON.stringify({ code: -1, message }));
}
}
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { prepareXfIatFrame } from './xf-frame.js';
describe('prepareXfIatFrame', () => {
it('adds server-side app id and default business options to the first frame', () => {
const prepared = prepareXfIatFrame(
JSON.stringify({
data: { status: 0, format: 'audio/L16;rate=16000', encoding: 'raw', audio: 'abc' },
}),
'test-app-id',
);
expect(JSON.parse(prepared as string)).toEqual({
common: { app_id: 'test-app-id' },
business: { language: 'zh_cn', domain: 'iat', accent: 'mandarin' },
data: { status: 0, format: 'audio/L16;rate=16000', encoding: 'raw', audio: 'abc' },
});
});
it('keeps later audio frames unchanged', () => {
const frame = JSON.stringify({
data: { status: 1, format: 'audio/L16;rate=16000', encoding: 'raw', audio: 'abc' },
});
expect(prepareXfIatFrame(frame, 'test-app-id')).toBe(frame);
});
it('keeps non-json payloads unchanged', () => {
expect(prepareXfIatFrame('not-json', 'test-app-id')).toBe('not-json');
});
});

View File

@@ -0,0 +1,38 @@
import type { RawData } from 'ws';
interface XfIatFrame {
common?: Record<string, unknown>;
business?: Record<string, unknown>;
data?: {
status?: number;
};
}
export const prepareXfIatFrame = (data: RawData | string, appId: string) => {
const text = rawDataToText(data);
if (!text) return data;
try {
const frame = JSON.parse(text) as XfIatFrame;
if (frame.data?.status === 0) {
frame.common = { ...(frame.common || {}), app_id: appId };
frame.business = {
language: 'zh_cn',
domain: 'iat',
accent: 'mandarin',
...(frame.business || {}),
};
}
return JSON.stringify(frame);
} catch {
return data;
}
};
const rawDataToText = (data: RawData | string) => {
if (typeof data === 'string') return data;
if (Buffer.isBuffer(data)) return data.toString('utf8');
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
return '';
};

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,
};
}
}

7
server/src/types/express-session.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import 'express-session';
declare module 'express-session' {
interface SessionData {
userId?: string;
}
}

View File

@@ -0,0 +1,83 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { UsersService } from './users.service.js';
@Controller()
export class UsersController {
constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService,
) {}
@Get('users')
async listUsers(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.usersService.listUsers(actor) };
}
@Get('users/:id')
async getUser(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
return { data: { user: await this.usersService.getUser(actor, id) } };
}
@Post('users')
async createUser(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { user: await this.usersService.createUser(actor, body) } };
}
@Patch('users/:id')
async updateUser(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { user: await this.usersService.updateUser(actor, id, body) } };
}
@Delete('users/:id')
async removeUser(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.usersService.removeUser(actor, id);
return { data: null };
}
@Get('departments')
async listDepartments(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: await this.usersService.listDepartments(actor) };
}
@Post('departments')
async createDepartment(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { department: await this.usersService.createDepartment(actor, body) } };
}
@Patch('departments/:id')
async updateDepartment(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { department: await this.usersService.updateDepartment(actor, id, body) } };
}
@Delete('departments/:id')
async removeDepartment(@Req() request: Request, @Param('id') id: string) {
const actor = await getSessionUser(request, this.authService);
await this.usersService.removeDepartment(actor, id);
return { data: null };
}
@Patch('departments/:id/template-permissions')
async updateDepartmentTemplatePermissions(
@Req() request: Request,
@Param('id') id: string,
@Body() body: unknown,
) {
const actor = await getSessionUser(request, this.authService);
return {
data: {
department: await this.usersService.updateDepartmentTemplatePermissions(actor, id, body),
},
};
}
}

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { toFrontendUser, type UserWithRelations } from './users.mapper';
const baseUser = {
id: 'u-admin',
tenantId: 'tenant-a',
departmentId: 'dept-surgery',
username: 'manager',
passwordHash: 'hash',
role: 'ADMIN',
name: '科室管理员',
status: 'ACTIVE',
phone: null,
email: null,
signatureFileId: null,
lastLoginAt: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
department: {
id: 'dept-surgery',
tenantId: 'tenant-a',
name: '外科',
code: 'surgery',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
permissions: [
{
id: 'perm-1',
templateId: 'tpl-use',
departmentId: 'dept-surgery',
canUse: true,
canManage: false,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
{
id: 'perm-2',
templateId: 'tpl-manage',
departmentId: 'dept-surgery',
canUse: true,
canManage: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
},
personalTemplates: [],
} as UserWithRelations;
describe('users mapper', () => {
it('maps department template permissions to frontend user fields', () => {
const user = toFrontendUser(baseUser, [
{ id: 'tpl-owned', ownerDepartmentId: 'dept-surgery' },
{ id: 'tpl-use', ownerDepartmentId: 'dept-other' },
{ id: 'tpl-manage', ownerDepartmentId: 'dept-other' },
]);
expect(user.role).toBe('admin');
expect(user.department).toBe('外科');
expect(user.visibleTemplates).toEqual(expect.arrayContaining(['tpl-owned', 'tpl-use', 'tpl-manage']));
expect(user.manageableTemplates).toEqual(expect.arrayContaining(['tpl-owned', 'tpl-manage']));
});
it('keeps doctor management permissions empty while exposing personal templates', () => {
const user = toFrontendUser({
...baseUser,
id: 'u-doctor',
username: '0001',
role: 'DOCTOR',
personalTemplates: [
{
id: 'tpl-personal',
tenantId: 'tenant-a',
name: '我的模板',
description: null,
content: '',
fields: [],
scope: 'PERSONAL',
ownerDepartmentId: null,
ownerUserId: 'u-doctor',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
} as UserWithRelations, [{ id: 'tpl-use', ownerDepartmentId: 'dept-surgery' }]);
expect(user.role).toBe('user');
expect(user.visibleTemplates).toContain('tpl-personal');
expect(user.manageableTemplates).toEqual([]);
});
});

View File

@@ -0,0 +1,80 @@
import type { Prisma } from '@prisma/client';
export const userInclude = {
department: {
include: {
permissions: true,
},
},
personalTemplates: true,
} satisfies Prisma.UserInclude;
export type UserWithRelations = Prisma.UserGetPayload<{ include: typeof userInclude }>;
export interface TemplatePermissionSeed {
id: string;
ownerDepartmentId?: string | null;
}
export const toFrontendUser = (user: UserWithRelations, allTemplates: TemplatePermissionSeed[]) => {
const role = user.role === 'DOCTOR' ? 'user' : user.role.toLowerCase();
const allTemplateIds = allTemplates.map((template) => template.id);
const departmentTemplateIds = allTemplates
.filter((template) => template.ownerDepartmentId === user.departmentId)
.map((template) => template.id);
const permittedTemplateIds = user.department.permissions
.filter((permission) => permission.canUse || permission.canManage)
.map((permission) => permission.templateId);
const manageableTemplateIds = user.department.permissions
.filter((permission) => permission.canManage)
.map((permission) => permission.templateId);
const personalTemplateIds = user.personalTemplates.map((template) => template.id);
const visibleTemplateIds = Array.from(new Set([...departmentTemplateIds, ...permittedTemplateIds]));
return {
id: user.id,
username: user.username,
role,
name: user.name,
phone: user.phone ?? undefined,
email: user.email ?? undefined,
departmentId: user.departmentId,
department: user.department.name,
status: user.status.toLowerCase(),
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
signatureFileId: user.signatureFileId ?? undefined,
signature: user.signatureFileId ? `/api/files/${user.signatureFileId}/content` : undefined,
visibleTemplates:
role === 'super'
? allTemplateIds
: Array.from(new Set([...visibleTemplateIds, ...personalTemplateIds])),
manageableTemplates:
role === 'super'
? allTemplateIds
: role === 'admin'
? Array.from(new Set([...departmentTemplateIds, ...manageableTemplateIds]))
: [],
};
};
export const toDepartmentResource = (
department: Prisma.DepartmentGetPayload<{ include: { permissions: true } }>,
) => {
const visibleTemplates = department.permissions
.filter((permission) => permission.canUse || permission.canManage)
.map((permission) => permission.templateId);
const manageableTemplates = department.permissions
.filter((permission) => permission.canManage)
.map((permission) => permission.templateId);
return {
id: department.id,
name: department.name,
code: department.code,
visibleTemplates,
manageableTemplates,
createdAt: department.createdAt.toISOString(),
updatedAt: department.updatedAt.toISOString(),
};
};

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
const roleSchema = z.enum(['super', 'admin', 'doctor', 'user']);
const statusSchema = z.enum(['active', 'inactive']);
export const userInputSchema = z.object({
username: z.string().trim().min(1, '用户ID不能为空'),
password: z.string().min(1, '密码不能为空'),
role: roleSchema.default('user'),
name: z.string().trim().min(1, '姓名不能为空'),
phone: z.string().trim().optional(),
email: z.string().trim().email('邮箱格式不正确').optional().or(z.literal('')),
departmentId: z.string().trim().optional(),
department: z.string().trim().optional(),
status: statusSchema.default('active'),
visibleTemplates: z.array(z.string()).default([]),
manageableTemplates: z.array(z.string()).default([]),
}).passthrough();
export const userUpdateSchema = userInputSchema.partial().extend({
password: z.string().optional(),
});
export const departmentInputSchema = z.object({
name: z.string().trim().min(1, '部门名称不能为空'),
code: z.string().trim().min(1, '部门编码不能为空').optional(),
}).passthrough();
export const departmentPermissionSchema = z.object({
visibleTemplates: z.array(z.string()).default([]),
manageableTemplates: z.array(z.string()).default([]),
}).passthrough();
export type UserInput = z.infer<typeof userInputSchema>;
export type UserUpdateInput = z.infer<typeof userUpdateSchema>;
export type DepartmentInput = z.infer<typeof departmentInputSchema>;
export type DepartmentPermissionInput = z.infer<typeof departmentPermissionSchema>;

View File

@@ -0,0 +1,548 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Prisma, UserRole, UserStatus } from '@prisma/client';
import argon2 from 'argon2';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { canManageUser, isAdmin, isDoctor, isSuper, type AppRole } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import {
departmentInputSchema,
departmentPermissionSchema,
userInputSchema,
userUpdateSchema,
type DepartmentPermissionInput,
type UserInput,
type UserUpdateInput,
} from './users.schemas.js';
import { toDepartmentResource, toFrontendUser, userInclude } from './users.mapper.js';
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async listUsers(actor: SafeUser) {
const users = await this.prisma.user.findMany({
where: this.visibleUserWhere(actor),
include: userInclude,
orderBy: [{ role: 'asc' }, { username: 'asc' }],
});
const templates = await this.findTemplateSeeds(actor.tenantId);
return {
items: users.map((user) => toFrontendUser(user, templates)),
total: users.length,
};
}
async getUser(actor: SafeUser, id: string) {
const user = await this.findUser(actor.tenantId, id);
if (!this.canViewUser(actor, user)) {
throw new ForbiddenException('无权查看此用户');
}
const templates = await this.findTemplateSeeds(actor.tenantId);
return toFrontendUser(user, templates);
}
async createUser(actor: SafeUser, rawInput: unknown) {
const input = this.parseCreateInput(rawInput);
if (isDoctor(this.actorToPolicy(actor))) {
throw new ForbiddenException('医生不能创建用户');
}
const role = this.toDbRole(input.role);
if (role === 'SUPER') {
throw new ForbiddenException('不能新增超级管理员');
}
if (role === 'ADMIN' && !isSuper(this.actorToPolicy(actor))) {
throw new ForbiddenException('只有超级管理员能创建管理员');
}
const departmentId = await this.resolveDepartmentId(actor, input, role);
await this.ensureUsernameAvailable(actor.tenantId, input.username);
await this.ensureRoleRules(actor, role, departmentId);
const user = await this.prisma.user.create({
data: {
tenantId: actor.tenantId,
departmentId,
username: input.username,
passwordHash: await argon2.hash(input.password),
role,
name: input.name,
phone: input.phone || null,
email: input.email || null,
status: this.toDbStatus(input.status),
},
include: userInclude,
});
if (role === 'ADMIN' && isSuper(this.actorToPolicy(actor))) {
await this.syncDepartmentTemplatePermissions(actor.tenantId, departmentId, input);
await this.audit?.record({
actor,
action: 'user.create',
targetType: 'User',
targetId: user.id,
departmentId,
metadata: { username: user.username, role: user.role },
});
return this.getUser(actor, user.id);
}
const templates = await this.findTemplateSeeds(actor.tenantId);
await this.audit?.record({
actor,
action: 'user.create',
targetType: 'User',
targetId: user.id,
departmentId,
metadata: { username: user.username, role: user.role },
});
return toFrontendUser(user, templates);
}
async updateUser(actor: SafeUser, id: string, rawInput: unknown) {
const input = this.parseUpdateInput(rawInput);
const existing = await this.findUser(actor.tenantId, id);
if (!canManageUser(this.actorToPolicy(actor), this.userToPolicy(existing))) {
throw new ForbiddenException('无权修改此用户');
}
const targetRole = input.role ? this.toDbRole(input.role) : existing.role;
if (!isSuper(this.actorToPolicy(actor)) && targetRole !== existing.role) {
throw new ForbiddenException('只有超级管理员能修改角色');
}
if (targetRole === 'SUPER' && existing.role !== 'SUPER') {
throw new ForbiddenException('不能提升为超级管理员');
}
const departmentId =
input.departmentId || input.department
? await this.resolveDepartmentId(actor, input, targetRole)
: existing.departmentId;
if (!isSuper(this.actorToPolicy(actor)) && departmentId !== existing.departmentId) {
throw new ForbiddenException('只有超级管理员能调整部门');
}
await this.ensureRoleRules(actor, targetRole, departmentId, existing.id);
const data: Prisma.UserUpdateInput = {
name: input.name ?? existing.name,
phone: input.phone === undefined ? existing.phone : input.phone || null,
email: input.email === undefined ? existing.email : input.email || null,
};
if (isSuper(this.actorToPolicy(actor))) {
data.role = targetRole;
data.status = input.status ? this.toDbStatus(input.status) : existing.status;
data.department = { connect: { id: departmentId } };
} else if (isAdmin(this.actorToPolicy(actor)) && existing.role === 'DOCTOR') {
data.status = input.status ? this.toDbStatus(input.status) : existing.status;
}
if (input.password) {
data.passwordHash = await argon2.hash(input.password);
}
const updated = await this.prisma.user.update({
where: { id: existing.id },
data,
include: userInclude,
});
if (targetRole === 'ADMIN' && isSuper(this.actorToPolicy(actor))) {
await this.syncDepartmentTemplatePermissions(actor.tenantId, departmentId, input);
await this.audit?.record({
actor,
action: 'user.update',
targetType: 'User',
targetId: updated.id,
departmentId,
metadata: { username: updated.username, role: updated.role, status: updated.status },
});
return this.getUser(actor, updated.id);
}
const templates = await this.findTemplateSeeds(actor.tenantId);
await this.audit?.record({
actor,
action: 'user.update',
targetType: 'User',
targetId: updated.id,
departmentId,
metadata: { username: updated.username, role: updated.role, status: updated.status },
});
return toFrontendUser(updated, templates);
}
async removeUser(actor: SafeUser, id: string) {
const existing = await this.findUser(actor.tenantId, id);
if (existing.id === actor.id) {
throw new BadRequestException('不能删除当前登录账号');
}
if (existing.username === 'admin') {
throw new BadRequestException('不能删除默认超级管理员');
}
if (!canManageUser(this.actorToPolicy(actor), this.userToPolicy(existing))) {
throw new ForbiddenException('无权删除此用户');
}
await this.prisma.user.delete({ where: { id: existing.id } }).catch(() => {
throw new ConflictException('用户已有业务数据,请先禁用账号');
});
await this.audit?.record({
actor,
action: 'user.delete',
targetType: 'User',
targetId: existing.id,
departmentId: existing.departmentId,
metadata: { username: existing.username, role: existing.role },
});
return null;
}
async listDepartments(actor: SafeUser) {
const departments = await this.prisma.department.findMany({
where: isSuper(this.actorToPolicy(actor))
? { tenantId: actor.tenantId }
: { tenantId: actor.tenantId, id: actor.departmentId },
include: { permissions: true },
orderBy: { name: 'asc' },
});
return {
items: departments.map(toDepartmentResource),
total: departments.length,
};
}
async createDepartment(actor: SafeUser, rawInput: unknown) {
this.requireSuper(actor);
const input = this.parseDepartmentInput(rawInput);
const code = input.code || this.slugifyDepartmentName(input.name);
const department = await this.prisma.department.create({
data: {
tenantId: actor.tenantId,
name: input.name,
code,
},
include: { permissions: true },
}).catch(() => {
throw new ConflictException('部门编码已存在');
});
await this.audit?.record({
actor,
action: 'department.create',
targetType: 'Department',
targetId: department.id,
departmentId: department.id,
metadata: { name: department.name, code: department.code },
});
return toDepartmentResource(department);
}
async updateDepartment(actor: SafeUser, id: string, rawInput: unknown) {
this.requireSuper(actor);
const input = this.parseDepartmentInput(rawInput);
const existing = await this.findDepartment(actor.tenantId, id);
const department = await this.prisma.department.update({
where: { id: existing.id },
data: {
name: input.name,
code: input.code || existing.code,
},
include: { permissions: true },
}).catch(() => {
throw new ConflictException('部门编码已存在');
});
await this.audit?.record({
actor,
action: 'department.update',
targetType: 'Department',
targetId: department.id,
departmentId: department.id,
metadata: { name: department.name, code: department.code },
});
return toDepartmentResource(department);
}
async removeDepartment(actor: SafeUser, id: string) {
this.requireSuper(actor);
const existing = await this.findDepartment(actor.tenantId, id);
if (existing.id === actor.departmentId) {
throw new BadRequestException('不能删除当前登录用户所在部门');
}
await this.prisma.department.delete({ where: { id: existing.id } }).catch(() => {
throw new ConflictException('部门已有用户、报告或模板,不能删除');
});
await this.audit?.record({
actor,
action: 'department.delete',
targetType: 'Department',
targetId: existing.id,
departmentId: existing.id,
metadata: { name: existing.name, code: existing.code },
});
return null;
}
async updateDepartmentTemplatePermissions(actor: SafeUser, id: string, rawInput: unknown) {
this.requireSuper(actor);
const department = await this.findDepartment(actor.tenantId, id);
const result = departmentPermissionSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
await this.syncDepartmentTemplatePermissions(actor.tenantId, department.id, result.data);
const updated = await this.prisma.department.findUniqueOrThrow({
where: { id: department.id },
include: { permissions: true },
});
await this.audit?.record({
actor,
action: 'department.template_permissions.update',
targetType: 'Department',
targetId: department.id,
departmentId: department.id,
metadata: { visibleTemplates: result.data.visibleTemplates, manageableTemplates: result.data.manageableTemplates },
});
return toDepartmentResource(updated);
}
private visibleUserWhere(actor: SafeUser): Prisma.UserWhereInput {
if (isSuper(this.actorToPolicy(actor))) {
return { tenantId: actor.tenantId };
}
if (isAdmin(this.actorToPolicy(actor))) {
return {
tenantId: actor.tenantId,
OR: [
{ id: actor.id },
{ departmentId: actor.departmentId, role: 'DOCTOR' },
],
};
}
return { tenantId: actor.tenantId, id: actor.id };
}
private canViewUser(actor: SafeUser, target: Awaited<ReturnType<UsersService['findUser']>>) {
if (isSuper(this.actorToPolicy(actor))) return true;
if (isAdmin(this.actorToPolicy(actor))) {
return target.id === actor.id || (target.departmentId === actor.departmentId && target.role === 'DOCTOR');
}
return target.id === actor.id;
}
private async findUser(tenantId: string, id: string) {
const user = await this.prisma.user.findFirst({
where: { tenantId, OR: [{ id }, { username: id }] },
include: userInclude,
});
if (!user) {
throw new NotFoundException('用户不存在');
}
return user;
}
private async findDepartment(tenantId: string, id: string) {
const department = await this.prisma.department.findFirst({
where: {
tenantId,
OR: [{ id }, { name: id }, { code: id }],
},
});
if (!department) {
throw new NotFoundException('部门不存在');
}
return department;
}
private async findTemplateSeeds(tenantId: string) {
return this.prisma.template.findMany({
where: { tenantId },
select: { id: true, ownerDepartmentId: true },
});
}
private parseCreateInput(rawInput: unknown): UserInput {
const result = userInputSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
return result.data;
}
private parseUpdateInput(rawInput: unknown): UserUpdateInput {
const result = userUpdateSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
return result.data;
}
private parseDepartmentInput(rawInput: unknown) {
const result = departmentInputSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
return result.data;
}
private async resolveDepartmentId(actor: SafeUser, input: Partial<UserInput>, role: UserRole) {
if (isAdmin(this.actorToPolicy(actor))) {
return actor.departmentId;
}
if (input.departmentId) {
await this.findDepartment(actor.tenantId, input.departmentId);
return input.departmentId;
}
if (input.department) {
const department = await this.findDepartment(actor.tenantId, input.department);
return department.id;
}
if (role === 'SUPER') {
return actor.departmentId;
}
throw new BadRequestException('部门不能为空');
}
private async ensureUsernameAvailable(tenantId: string, username: string) {
const existing = await this.prisma.user.findUnique({
where: { tenantId_username: { tenantId, username } },
});
if (existing) {
throw new ConflictException('用户ID已存在');
}
}
private async ensureRoleRules(
actor: SafeUser,
role: UserRole,
departmentId: string,
excludeUserId?: string,
) {
if (role === 'ADMIN') {
const existingAdmin = await this.prisma.user.findFirst({
where: {
tenantId: actor.tenantId,
departmentId,
role: 'ADMIN',
id: excludeUserId ? { not: excludeUserId } : undefined,
},
});
if (existingAdmin) {
throw new ConflictException('该部门已存在管理员');
}
}
if (role === 'DOCTOR') {
const departmentAdmin = await this.prisma.user.findFirst({
where: {
tenantId: actor.tenantId,
departmentId,
role: 'ADMIN',
status: 'ACTIVE',
},
});
if (!departmentAdmin) {
throw new BadRequestException('该部门暂无管理员,请先创建部门管理员');
}
}
}
private async syncDepartmentTemplatePermissions(
tenantId: string,
departmentId: string,
input: Partial<DepartmentPermissionInput>,
) {
const visibleIds = new Set(input.visibleTemplates || []);
const manageableIds = new Set(input.manageableTemplates || []);
manageableIds.forEach((id) => visibleIds.add(id));
const templates = await this.prisma.template.findMany({
where: { tenantId },
select: { id: true },
});
const validTemplateIds = new Set(templates.map((template) => template.id));
const desiredIds = new Set([...visibleIds, ...manageableIds].filter((id) => validTemplateIds.has(id)));
await this.prisma.$transaction([
this.prisma.templateDepartmentPermission.deleteMany({
where: {
departmentId,
template: { tenantId },
templateId: { notIn: Array.from(desiredIds) },
},
}),
...Array.from(desiredIds).map((templateId) =>
this.prisma.templateDepartmentPermission.upsert({
where: { templateId_departmentId: { templateId, departmentId } },
update: {
canUse: visibleIds.has(templateId) || manageableIds.has(templateId),
canManage: manageableIds.has(templateId),
},
create: {
templateId,
departmentId,
canUse: visibleIds.has(templateId) || manageableIds.has(templateId),
canManage: manageableIds.has(templateId),
},
}),
),
]);
}
private requireSuper(actor: SafeUser) {
if (!isSuper(this.actorToPolicy(actor))) {
throw new ForbiddenException('只有超级管理员可以管理部门');
}
}
private toDbRole(role: UserInput['role']): UserRole {
if (role === 'user' || role === 'doctor') return 'DOCTOR';
if (role === 'admin') return 'ADMIN';
return 'SUPER';
}
private toDbStatus(status: UserInput['status']): UserStatus {
return status === 'inactive' ? 'INACTIVE' : 'ACTIVE';
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
private userToPolicy(user: Awaited<ReturnType<UsersService['findUser']>>) {
return {
id: user.id,
tenantId: user.tenantId,
departmentId: user.departmentId,
role: (user.role.toLowerCase() === 'doctor' ? 'doctor' : user.role.toLowerCase()) as AppRole,
};
}
private slugifyDepartmentName(name: string) {
return name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9\u4e00-\u9fa5-]/g, '')
.slice(0, 48) || `dept-${Date.now()}`;
}
}