- 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.
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|