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,299 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('SUPER', 'ADMIN', 'DOCTOR');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('DRAFT', 'COMPLETED');
-- CreateEnum
CREATE TYPE "TemplateScope" AS ENUM ('DEPARTMENT', 'PERSONAL');
-- CreateEnum
CREATE TYPE "FileKind" AS ENUM ('SIGNATURE', 'TEMPLATE_ASSET', 'VIDEO', 'FRAME', 'REPORT_EXPORT');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL,
"name" TEXT NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"phone" TEXT,
"email" TEXT,
"signatureFileId" TEXT,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"content" TEXT NOT NULL,
"fields" JSONB NOT NULL DEFAULT '[]',
"scope" "TemplateScope" NOT NULL,
"ownerDepartmentId" TEXT,
"ownerUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TemplateDepartmentPermission" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"canUse" BOOLEAN NOT NULL DEFAULT true,
"canManage" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TemplateDepartmentPermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"templateId" TEXT,
"title" TEXT NOT NULL,
"patientName" TEXT NOT NULL,
"hospitalId" TEXT NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'DRAFT',
"revision" INTEGER NOT NULL DEFAULT 1,
"content" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3),
"deletedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReportHistory" (
"id" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"action" TEXT NOT NULL,
"updatedById" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReportHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileResource" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"ownerId" TEXT,
"reportId" TEXT,
"kind" "FileKind" NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"checksum" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileResource_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemSetting" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"departmentId" TEXT,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"secretValue" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"actorUserId" TEXT,
"actorRole" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"departmentId" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_code_key" ON "Tenant"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Department_tenantId_code_key" ON "Department"("tenantId", "code");
-- CreateIndex
CREATE INDEX "User_tenantId_departmentId_role_idx" ON "User"("tenantId", "departmentId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_username_key" ON "User"("tenantId", "username");
-- CreateIndex
CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash");
-- CreateIndex
CREATE INDEX "Template_tenantId_scope_idx" ON "Template"("tenantId", "scope");
-- CreateIndex
CREATE INDEX "Template_tenantId_ownerDepartmentId_idx" ON "Template"("tenantId", "ownerDepartmentId");
-- CreateIndex
CREATE INDEX "Template_tenantId_ownerUserId_idx" ON "Template"("tenantId", "ownerUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDepartmentPermission_templateId_departmentId_key" ON "TemplateDepartmentPermission"("templateId", "departmentId");
-- CreateIndex
CREATE INDEX "Report_tenantId_departmentId_status_idx" ON "Report"("tenantId", "departmentId", "status");
-- CreateIndex
CREATE INDEX "Report_tenantId_authorId_idx" ON "Report"("tenantId", "authorId");
-- CreateIndex
CREATE INDEX "Report_tenantId_deletedAt_idx" ON "Report"("tenantId", "deletedAt");
-- CreateIndex
CREATE INDEX "ReportHistory_reportId_revision_idx" ON "ReportHistory"("reportId", "revision");
-- CreateIndex
CREATE INDEX "FileResource_tenantId_kind_idx" ON "FileResource"("tenantId", "kind");
-- CreateIndex
CREATE INDEX "FileResource_tenantId_reportId_idx" ON "FileResource"("tenantId", "reportId");
-- CreateIndex
CREATE UNIQUE INDEX "SystemSetting_tenantId_scope_departmentId_key_key" ON "SystemSetting"("tenantId", "scope", "departmentId", "key");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_action_idx" ON "AuditLog"("tenantId", "action");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_actorUserId_idx" ON "AuditLog"("tenantId", "actorUserId");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_targetType_targetId_idx" ON "AuditLog"("tenantId", "targetType", "targetId");
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerDepartmentId_fkey" FOREIGN KEY ("ownerDepartmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDepartmentPermission" ADD CONSTRAINT "TemplateDepartmentPermission_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDepartmentPermission" ADD CONSTRAINT "TemplateDepartmentPermission_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReportHistory" ADD CONSTRAINT "ReportHistory_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileResource" ADD CONSTRAINT "FileResource_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileResource" ADD CONSTRAINT "FileResource_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SystemSetting" ADD CONSTRAINT "SystemSetting_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SystemSetting" ADD CONSTRAINT "SystemSetting_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- Add a flexible compatibility payload for the existing frontend report object.
ALTER TABLE "Report" ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,130 @@
-- Split report video/keyframe references out of Report.metadata.
CREATE TYPE "ReportMediaKind" AS ENUM ('VIDEO', 'FRAME');
CREATE TABLE "ReportMedia" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"fileId" TEXT,
"kind" "ReportMediaKind" NOT NULL,
"clientId" TEXT NOT NULL,
"name" TEXT,
"url" TEXT,
"time" DOUBLE PRECISION,
"videoIndex" INTEGER,
"videoName" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReportMedia_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ReportMedia_tenantId_reportId_kind_idx" ON "ReportMedia"("tenantId", "reportId", "kind");
CREATE INDEX "ReportMedia_tenantId_fileId_idx" ON "ReportMedia"("tenantId", "fileId");
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "FileResource"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "ReportMedia" (
"id",
"tenantId",
"reportId",
"fileId",
"kind",
"clientId",
"name",
"url",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
concat('rm_', r."id", '_video_', video."ordinality"),
r."tenantId",
r."id",
f."id",
'VIDEO'::"ReportMediaKind",
COALESCE(video."value"->>'id', concat('video-', video."ordinality")),
video."value"->>'name',
video."value"->>'url',
(video."ordinality" - 1)::integer,
jsonb_strip_nulls(jsonb_build_object(
'duration', video."value"->'duration',
'legacyFileId', video."value"->>'fileId'
)),
r."createdAt",
r."updatedAt"
FROM "Report" r
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(r."metadata"->'videos') = 'array' THEN r."metadata"->'videos'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS video("value", "ordinality")
LEFT JOIN "FileResource" f
ON f."id" = video."value"->>'fileId'
AND f."tenantId" = r."tenantId";
INSERT INTO "ReportMedia" (
"id",
"tenantId",
"reportId",
"fileId",
"kind",
"clientId",
"url",
"time",
"videoIndex",
"videoName",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
concat('rm_', r."id", '_frame_', frame."ordinality"),
r."tenantId",
r."id",
f."id",
'FRAME'::"ReportMediaKind",
COALESCE(frame."value"->>'id', concat('frame-', frame."ordinality")),
frame."value"->>'dataUrl',
CASE
WHEN jsonb_typeof(frame."value"->'time') = 'number' THEN (frame."value"->>'time')::double precision
ELSE NULL
END,
CASE
WHEN jsonb_typeof(frame."value"->'videoIndex') = 'number' THEN (frame."value"->>'videoIndex')::integer
ELSE NULL
END,
frame."value"->>'videoName',
(frame."ordinality" - 1)::integer,
jsonb_strip_nulls(jsonb_build_object(
'timeFormatted', frame."value"->>'timeFormatted',
'isManual', frame."value"->'isManual',
'manualOrder', frame."value"->'manualOrder',
'legacyFileId', frame."value"->>'fileId'
)),
r."createdAt",
r."updatedAt"
FROM "Report" r
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(r."metadata"->'capturedFrames') = 'array' THEN r."metadata"->'capturedFrames'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS frame("value", "ordinality")
LEFT JOIN "FileResource" f
ON f."id" = frame."value"->>'fileId'
AND f."tenantId" = r."tenantId";
UPDATE "FileResource" f
SET "reportId" = media."reportId"
FROM "ReportMedia" media
WHERE f."id" = media."fileId"
AND f."tenantId" = media."tenantId"
AND f."reportId" IS NULL;

View File

@@ -0,0 +1,12 @@
-- Persist Express sessions outside the Node.js process.
CREATE TABLE "AppSession" (
"id" TEXT NOT NULL,
"data" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

280
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,280 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
SUPER
ADMIN
DOCTOR
}
enum UserStatus {
ACTIVE
INACTIVE
}
enum ReportStatus {
DRAFT
COMPLETED
}
enum TemplateScope {
DEPARTMENT
PERSONAL
}
enum FileKind {
SIGNATURE
TEMPLATE_ASSET
VIDEO
FRAME
REPORT_EXPORT
}
enum ReportMediaKind {
VIDEO
FRAME
}
model Tenant {
id String @id @default(cuid())
name String
code String @unique
departments Department[]
users User[]
reports Report[]
reportMedia ReportMedia[]
templates Template[]
files FileResource[]
auditLogs AuditLog[]
settings SystemSetting[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Department {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
code String
users User[]
reports Report[]
templates Template[] @relation("TemplateOwnerDepartment")
permissions TemplateDepartmentPermission[]
settings SystemSetting[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, code])
}
model User {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id])
username String
passwordHash String
role UserRole
name String
status UserStatus @default(ACTIVE)
phone String?
email String?
signatureFileId String?
reports Report[] @relation("ReportAuthor")
sessions UserSession[]
personalTemplates Template[] @relation("PersonalTemplates")
auditLogs AuditLog[]
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, username])
@@index([tenantId, departmentId, role])
}
model UserSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
model AppSession {
id String @id
data Json
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([expiresAt])
}
model Template {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
description String?
content String
fields Json @default("[]")
scope TemplateScope
ownerDepartmentId String?
ownerDepartment Department? @relation("TemplateOwnerDepartment", fields: [ownerDepartmentId], references: [id])
ownerUserId String?
ownerUser User? @relation("PersonalTemplates", fields: [ownerUserId], references: [id])
permissions TemplateDepartmentPermission[]
reports Report[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, scope])
@@index([tenantId, ownerDepartmentId])
@@index([tenantId, ownerUserId])
}
model TemplateDepartmentPermission {
id String @id @default(cuid())
templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
canUse Boolean @default(true)
canManage Boolean @default(false)
createdAt DateTime @default(now())
@@unique([templateId, departmentId])
}
model Report {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id])
authorId String
author User @relation("ReportAuthor", fields: [authorId], references: [id])
templateId String?
template Template? @relation(fields: [templateId], references: [id])
title String
patientName String
hospitalId String
status ReportStatus @default(DRAFT)
revision Int @default(1)
content String
metadata Json @default("{}")
histories ReportHistory[]
files FileResource[]
media ReportMedia[]
deletedAt DateTime?
deletedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, departmentId, status])
@@index([tenantId, authorId])
@@index([tenantId, deletedAt])
}
model ReportHistory {
id String @id @default(cuid())
reportId String
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
revision Int
content String
action String
updatedById String
updatedBy String
createdAt DateTime @default(now())
@@index([reportId, revision])
}
model FileResource {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
ownerId String?
reportId String?
report Report? @relation(fields: [reportId], references: [id], onDelete: Cascade)
reportMedia ReportMedia[]
kind FileKind
filename String
mimeType String
size Int
storageKey String
checksum String?
createdAt DateTime @default(now())
@@index([tenantId, kind])
@@index([tenantId, reportId])
}
model ReportMedia {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
reportId String
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
fileId String?
file FileResource? @relation(fields: [fileId], references: [id], onDelete: SetNull)
kind ReportMediaKind
clientId String
name String?
url String?
time Float?
videoIndex Int?
videoName String?
sortOrder Int @default(0)
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, reportId, kind])
@@index([tenantId, fileId])
}
model SystemSetting {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
scope String
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
key String
value Json
secretValue String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, scope, departmentId, key])
}
model AuditLog {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
actorUserId String?
actor User? @relation(fields: [actorUserId], references: [id])
actorRole String?
action String
targetType String
targetId String?
departmentId String?
ip String?
userAgent String?
metadata Json?
createdAt DateTime @default(now())
@@index([tenantId, action])
@@index([tenantId, actorUserId])
@@index([tenantId, targetType, targetId])
}

153
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,153 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import argon2 from 'argon2';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required to seed the database');
}
const prisma = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.DATABASE_URL,
}),
});
const defaultTemplateContent = `
<h1 style="text-align:center;">手术记录</h1>
<p>患者姓名:<span class="field-value" data-bind="patientName" contenteditable="true"></span></p>
<p>住院号:<span class="field-value" data-bind="hospitalId" contenteditable="true"></span></p>
<p>手术名称:<span class="field-value" data-bind="title" contenteditable="true"></span></p>
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤">
<div class="ai-content"><p>请在此处填写手术步骤。</p></div>
</div>
`;
const main = async () => {
const tenant = await prisma.tenant.upsert({
where: { code: 'default' },
update: {},
create: {
code: 'default',
name: '默认医院',
},
});
const adminDepartment = await prisma.department.upsert({
where: { tenantId_code: { tenantId: tenant.id, code: 'admin' } },
update: {},
create: {
tenantId: tenant.id,
code: 'admin',
name: '管理部门',
},
});
const surgeryDepartment = await prisma.department.upsert({
where: { tenantId_code: { tenantId: tenant.id, code: 'surgery' } },
update: {},
create: {
tenantId: tenant.id,
code: 'surgery',
name: '外科',
},
});
const passwordHash = await argon2.hash('123456');
const adminUser = await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: 'admin' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: adminDepartment.id,
username: 'admin',
passwordHash,
role: 'SUPER',
name: '超级管理员',
},
});
await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: 'manager' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: surgeryDepartment.id,
username: 'manager',
passwordHash,
role: 'ADMIN',
name: '科室管理员',
},
});
await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: '0001' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: surgeryDepartment.id,
username: '0001',
passwordHash,
role: 'DOCTOR',
name: '张医生',
},
});
const defaultTemplate = await prisma.template.upsert({
where: { id: 'tpl_default_surgery' },
update: {},
create: {
id: 'tpl_default_surgery',
tenantId: tenant.id,
name: '腹腔镜胆囊切除术报告',
description: '标准手术记录模板',
content: defaultTemplateContent,
fields: [],
scope: 'DEPARTMENT',
ownerDepartmentId: surgeryDepartment.id,
ownerUserId: null,
},
});
await prisma.templateDepartmentPermission.upsert({
where: {
templateId_departmentId: {
templateId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
},
},
update: {
canUse: true,
canManage: true,
},
create: {
templateId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
canUse: true,
canManage: true,
},
});
await prisma.auditLog.create({
data: {
tenantId: tenant.id,
actorUserId: adminUser.id,
actorRole: 'super',
action: 'seed.default_template',
targetType: 'template',
targetId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
metadata: { name: defaultTemplate.name },
},
}).catch(() => {});
};
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

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()}`;
}
}

20
server/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"rootDir": "./src",
"outDir": "./dist",
"noEmit": false,
"declaration": false,
"sourceMap": true,
"allowImportingTsExtensions": false,
"isolatedModules": false,
"emitDecoratorMetadata": true,
"types": ["node", "express-session"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/*.test.ts"]
}