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:
299
server/prisma/migrations/20260501145311_init/migration.sql
Normal file
299
server/prisma/migrations/20260501145311_init/migration.sql
Normal 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;
|
||||
@@ -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 '{}';
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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
280
server/prisma/schema.prisma
Normal 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
153
server/prisma/seed.ts
Normal 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);
|
||||
});
|
||||
25
server/src/ai/ai.controller.ts
Normal file
25
server/src/ai/ai.controller.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
13
server/src/ai/ai.module.ts
Normal file
13
server/src/ai/ai.module.ts
Normal 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 {}
|
||||
28
server/src/ai/ai.schemas.test.ts
Normal file
28
server/src/ai/ai.schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
server/src/ai/ai.schemas.ts
Normal file
12
server/src/ai/ai.schemas.ts
Normal 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
126
server/src/ai/ai.service.ts
Normal 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
37
server/src/app.module.ts
Normal 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 {}
|
||||
11
server/src/audit/audit.module.ts
Normal file
11
server/src/audit/audit.module.ts
Normal 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 {}
|
||||
46
server/src/audit/audit.service.ts
Normal file
46
server/src/audit/audit.service.ts
Normal 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>;
|
||||
91
server/src/auth/auth.controller.ts
Normal file
91
server/src/auth/auth.controller.ts
Normal 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('保存登录态失败');
|
||||
});
|
||||
}
|
||||
}
|
||||
10
server/src/auth/auth.module.ts
Normal file
10
server/src/auth/auth.module.ts
Normal 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 {}
|
||||
8
server/src/auth/auth.schemas.ts
Normal file
8
server/src/auth/auth.schemas.ts
Normal 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>;
|
||||
86
server/src/auth/auth.service.ts
Normal file
86
server/src/auth/auth.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
server/src/auth/auth.types.ts
Normal file
18
server/src/auth/auth.types.ts
Normal 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;
|
||||
}
|
||||
15
server/src/auth/session-user.ts
Normal file
15
server/src/auth/session-user.ts
Normal 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);
|
||||
};
|
||||
54
server/src/common/api-exception.filter.ts
Normal file
54
server/src/common/api-exception.filter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
68
server/src/common/html-sanitizer.ts
Normal file
68
server/src/common/html-sanitizer.ts
Normal 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' }),
|
||||
},
|
||||
});
|
||||
19
server/src/dashboard/dashboard.controller.ts
Normal file
19
server/src/dashboard/dashboard.controller.ts
Normal 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 }) } };
|
||||
}
|
||||
}
|
||||
11
server/src/dashboard/dashboard.module.ts
Normal file
11
server/src/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
110
server/src/dashboard/dashboard.service.ts
Normal file
110
server/src/dashboard/dashboard.service.ts
Normal 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())}`;
|
||||
};
|
||||
254
server/src/database.integration.test.ts
Normal file
254
server/src/database.integration.test.ts
Normal 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(),
|
||||
});
|
||||
56
server/src/files/files.controller.ts
Normal file
56
server/src/files/files.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
server/src/files/files.module.ts
Normal file
12
server/src/files/files.module.ts
Normal 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 {}
|
||||
19
server/src/files/files.schemas.test.ts
Normal file
19
server/src/files/files.schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
server/src/files/files.schemas.ts
Normal file
19
server/src/files/files.schemas.ts
Normal 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>;
|
||||
351
server/src/files/files.service.ts
Normal file
351
server/src/files/files.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
server/src/health/health.controller.ts
Normal file
15
server/src/health/health.controller.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
7
server/src/health/health.module.ts
Normal file
7
server/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
169
server/src/http.integration.test.ts
Normal file
169
server/src/http.integration.test.ts
Normal 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' }));
|
||||
});
|
||||
});
|
||||
25
server/src/library/library.controller.ts
Normal file
25
server/src/library/library.controller.ts
Normal 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) } };
|
||||
}
|
||||
}
|
||||
13
server/src/library/library.module.ts
Normal file
13
server/src/library/library.module.ts
Normal 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 {}
|
||||
36
server/src/library/library.schemas.test.ts
Normal file
36
server/src/library/library.schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
server/src/library/library.schemas.ts
Normal file
26
server/src/library/library.schemas.ts
Normal 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>;
|
||||
98
server/src/library/library.service.ts
Normal file
98
server/src/library/library.service.ts
Normal 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
49
server/src/main.ts
Normal 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();
|
||||
4
server/src/permissions/permissions.module.ts
Normal file
4
server/src/permissions/permissions.module.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class PermissionsModule {}
|
||||
114
server/src/permissions/permissions.policy.test.ts
Normal file
114
server/src/permissions/permissions.policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
server/src/permissions/permissions.policy.ts
Normal file
85
server/src/permissions/permissions.policy.ts
Normal 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);
|
||||
9
server/src/prisma/prisma.module.ts
Normal file
9
server/src/prisma/prisma.module.ts
Normal 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 {}
|
||||
36
server/src/prisma/prisma.service.ts
Normal file
36
server/src/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
158
server/src/reports/report.mapper.test.ts
Normal file
158
server/src/reports/report.mapper.test.ts
Normal 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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
199
server/src/reports/report.mapper.ts
Normal file
199
server/src/reports/report.mapper.ts
Normal 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')}`;
|
||||
};
|
||||
44
server/src/reports/reports.controller.ts
Normal file
44
server/src/reports/reports.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
11
server/src/reports/reports.module.ts
Normal file
11
server/src/reports/reports.module.ts
Normal 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 {}
|
||||
30
server/src/reports/reports.schemas.ts
Normal file
30
server/src/reports/reports.schemas.ts
Normal 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>;
|
||||
371
server/src/reports/reports.service.ts
Normal file
371
server/src/reports/reports.service.ts
Normal 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>;
|
||||
72
server/src/session/prisma-session.store.ts
Normal file
72
server/src/session/prisma-session.store.ts
Normal 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';
|
||||
31
server/src/settings/settings.controller.ts
Normal file
31
server/src/settings/settings.controller.ts
Normal 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) } };
|
||||
}
|
||||
}
|
||||
13
server/src/settings/settings.module.ts
Normal file
13
server/src/settings/settings.module.ts
Normal 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 {}
|
||||
32
server/src/settings/settings.schemas.test.ts
Normal file
32
server/src/settings/settings.schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
server/src/settings/settings.schemas.ts
Normal file
28
server/src/settings/settings.schemas.ts
Normal 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>;
|
||||
212
server/src/settings/settings.service.ts
Normal file
212
server/src/settings/settings.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
57
server/src/speech/speech.gateway.ts
Normal file
57
server/src/speech/speech.gateway.ts
Normal 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();
|
||||
};
|
||||
10
server/src/speech/speech.module.ts
Normal file
10
server/src/speech/speech.module.ts
Normal 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 {}
|
||||
94
server/src/speech/speech.service.ts
Normal file
94
server/src/speech/speech.service.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
31
server/src/speech/xf-frame.test.ts
Normal file
31
server/src/speech/xf-frame.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
38
server/src/speech/xf-frame.ts
Normal file
38
server/src/speech/xf-frame.ts
Normal 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 '';
|
||||
};
|
||||
58
server/src/templates/template.mapper.test.ts
Normal file
58
server/src/templates/template.mapper.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
36
server/src/templates/template.mapper.ts
Normal file
36
server/src/templates/template.mapper.ts
Normal 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 ?? '',
|
||||
});
|
||||
44
server/src/templates/templates.controller.ts
Normal file
44
server/src/templates/templates.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
11
server/src/templates/templates.module.ts
Normal file
11
server/src/templates/templates.module.ts
Normal 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 {}
|
||||
24
server/src/templates/templates.schemas.ts
Normal file
24
server/src/templates/templates.schemas.ts
Normal 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>;
|
||||
224
server/src/templates/templates.service.ts
Normal file
224
server/src/templates/templates.service.ts
Normal 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
7
server/src/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'express-session';
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: string;
|
||||
}
|
||||
}
|
||||
83
server/src/users/users.controller.ts
Normal file
83
server/src/users/users.controller.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
89
server/src/users/users.mapper.test.ts
Normal file
89
server/src/users/users.mapper.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
80
server/src/users/users.mapper.ts
Normal file
80
server/src/users/users.mapper.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
12
server/src/users/users.module.ts
Normal file
12
server/src/users/users.module.ts
Normal 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 {}
|
||||
37
server/src/users/users.schemas.ts
Normal file
37
server/src/users/users.schemas.ts
Normal 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>;
|
||||
548
server/src/users/users.service.ts
Normal file
548
server/src/users/users.service.ts
Normal 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
20
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user