242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
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<string, string> = {
|
|
'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<CreateUploadIntentResponse> {
|
|
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<ConfirmUploadResponse> {
|
|
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<File | null> {
|
|
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<File, 'objectKey' | 'status'> | null): Promise<string | null> {
|
|
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<CreateUploadIntentResponse> {
|
|
// 缩短上传 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,
|
|
);
|
|
}
|
|
}
|