feat: complete V1 phases 0-7
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type ConfirmUploadResponse,
|
||||
type CreateUploadIntentResponse,
|
||||
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';
|
||||
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
|
||||
|
||||
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: CreateMediaUploadIntentDto,
|
||||
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 objectKey = `public-covers/${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') {
|
||||
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> {
|
||||
const expiresIn = 600;
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user