import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { API_ERROR_CODES, type ConfirmUploadResponse, type CreateUploadIntentResponse, type CreateMediaUploadIntentRequest, type ServiceRecordAttachment, } from '@lehuo/contracts'; import { ApplicationError } from '../common/application-error'; import type { AuthContext, RequestContext } from '../common/request-context'; import { FilesRepository } from '../files/files.repository'; import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter'; import type { File, FileBusinessType } from '../generated/prisma/client'; import { SensitiveDataService } from '../security/sensitive-data.service'; const EXTENSIONS: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', }; @Injectable() export class MediaService { constructor( @Inject(FilesRepository) private readonly files: FilesRepository, @Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter, @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService, ) {} async createIntent( input: CreateMediaUploadIntentRequest, actor: AuthContext, request: RequestContext, ): Promise { this.assertConfigured(); const filename = this.safeFilename(input.filename); const extension = EXTENSIONS[input.mimeType]; if (!extension || !this.extensionMatches(filename, extension)) { throw this.validation('文件扩展名与图片类型不一致'); } const fingerprint = this.sensitive.lookupHash( 'cover-upload-intent', JSON.stringify({ ...input, filename }), ); const previous = await this.files.findByIdempotency(actor.userId, input.clientRequestId); if (previous) { if ( previous.requestFingerprint !== fingerprint || previous.businessType !== input.businessType ) { throw this.duplicate(); } return this.uploadResponse(previous, true); } const now = new Date(); const prefix = input.businessType === 'OLD_ITEM_DONATION_IMAGE' ? 'private-old-items' : 'public-covers'; const objectKey = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`; const file = await this.files.createPending( { publicId: randomUUID(), businessType: input.businessType, clientRequestId: input.clientRequestId, requestFingerprint: fingerprint, provider: this.storage.providerName, bucket: this.storage.bucket ?? '', objectKey, filenameEncrypted: this.sensitive.encrypt(filename), mimeType: input.mimeType, size: BigInt(input.size), sha256: input.sha256, }, actor, request, ); return this.uploadResponse(file, false); } async confirm( publicId: string, actor: AuthContext, request: RequestContext, ): Promise { this.assertConfigured(); const file = await this.files.findByPublicId(publicId); if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId) throw this.notFound(); if ( file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER' && file.businessType !== 'OLD_ITEM_DONATION_IMAGE' ) { throw this.notFound(); } if (file.status === 'AVAILABLE') return { file: this.attachment(file) }; if (file.status !== 'UPLOAD_PENDING') throw this.notReady(); let metadata; try { metadata = await this.storage.headObject(file.objectKey); } catch { throw this.storageUnavailable(); } if (!metadata) throw this.notReady(); if ( metadata.contentLength !== Number(file.size) || metadata.contentType !== file.mimeType || (file.sha256 !== null && metadata.sha256 !== file.sha256) ) { await this.files.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH'); throw this.validation('图片校验未通过,请重新选择文件'); } return { file: this.attachment(await this.files.markAvailable(publicId, actor, request)) }; } async requireCover( publicId: string | undefined, businessType: FileBusinessType, actor: AuthContext, ): Promise { if (!publicId) return null; const file = await this.files.findByPublicId(publicId); if ( !file || file.status !== 'AVAILABLE' || file.businessType !== businessType || file.uploaderUserId !== actor.userId || file.caseId !== null ) { throw this.validation('封面图片无效或尚未确认上传'); } return file; } async publicUrl(file: Pick | null): Promise { if (!file || file.status !== 'AVAILABLE' || !this.storage.configured) return null; try { return await this.storage.createDownloadUrl(file.objectKey, 600); } catch { return null; } } private async uploadResponse( file: File, idempotentReplay: boolean, ): Promise { // 缩短上传 URL 有效期至 120 秒,降低截获后滥用的风险窗口。 const expiresIn = 120; let uploadUrl: string; try { uploadUrl = await this.storage.createUploadUrl( file.objectKey, file.mimeType, file.sha256 ?? undefined, expiresIn, ); } catch { throw this.storageUnavailable(); } return { file: this.attachment(file), uploadUrl, uploadHeaders: { 'Content-Type': file.mimeType, ...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}), }, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), idempotentReplay, }; } private attachment(file: File): ServiceRecordAttachment { return { publicId: file.publicId, filename: this.sensitive.decrypt(file.originalFilenameEncrypted), mimeType: file.mimeType, size: Number(file.size), }; } private safeFilename(value: string): string { const filename = value.trim(); if ( !filename || filename.includes('/') || filename.includes('\\') || Array.from(filename).some((character) => character.charCodeAt(0) <= 31) ) { throw this.validation('文件名不合法'); } return filename; } private extensionMatches(filename: string, extension: string): boolean { const lower = filename.toLowerCase(); return lower.endsWith(`.${extension}`) || (extension === 'jpg' && lower.endsWith('.jpeg')); } private assertConfigured(): void { if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable(); } private validation(message: string): ApplicationError { return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST); } private duplicate(): ApplicationError { return new ApplicationError( API_ERROR_CODES.DUPLICATE_REQUEST, '请求标识已用于其他文件', HttpStatus.CONFLICT, ); } private notFound(): ApplicationError { return new ApplicationError( API_ERROR_CODES.FILE_NOT_FOUND, '未找到该图片', HttpStatus.NOT_FOUND, ); } private notReady(): ApplicationError { return new ApplicationError( API_ERROR_CODES.UPLOAD_NOT_READY, '尚未收到上传文件,请完成上传后重试', HttpStatus.CONFLICT, ); } private storageUnavailable(): ApplicationError { return new ApplicationError( API_ERROR_CODES.STORAGE_UNAVAILABLE, '图片服务尚未配置或暂时不可用', HttpStatus.SERVICE_UNAVAILABLE, ); } }