Files
Mdeical_Sur_Report/server/src/files/files.service.ts
admin 014aca8619 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.
2026-05-02 01:41:57 +08:00

352 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}
}