import { createHash, createHmac, randomUUID } from 'node:crypto'; import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Inject, Injectable } from '@nestjs/common'; import type { CompleteMediaUploadIntentRequest, CreateMediaAccessUrlRequest, CreateMediaUploadIntentRequest, ImportProductImageRequest, } from '@atlas/contracts'; import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; import { DATABASE } from '../database/database.module.js'; import { detectImage } from './media-image-validation.js'; import { downloadProductImage } from './product-image-download.js'; type IntentRow = { id: string; purpose: 'watermark_image' | 'product_image'; storage_provider: string; bucket: string; object_key: string; expected_content_type: string; expected_byte_size: string; expected_checksum_sha256: string; status: 'pending' | 'ready' | 'failed' | 'expired'; expires_at: Date | string; asset_id: string | null; }; type AssetRow = { id: string; content_type: string; byte_size: string; width_px: number | null; height_px: number | null; created_at: Date | string; }; @Injectable() export class MediaService { private readonly s3: S3Client; private readonly legacyMinioS3: S3Client | null; private bucketReady: Promise | null = null; public constructor( @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database, @Inject(APP_CONFIG) private readonly config: AppConfig, ) { this.s3 = new S3Client({ endpoint: config.OBJECT_STORAGE_ENDPOINT, region: config.OBJECT_STORAGE_REGION, forcePathStyle: config.objectStorageForcePathStyle, credentials: { accessKeyId: config.objectStorageAccessKey, secretAccessKey: config.objectStorageSecretKey, }, }); // 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。 this.legacyMinioS3 = config.NODE_ENV !== 'production' && config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' && config.MINIO_ROOT_USER && config.MINIO_ROOT_PASSWORD ? new S3Client({ endpoint: config.LEGACY_MINIO_ENDPOINT, region: 'us-east-1', forcePathStyle: true, credentials: { accessKeyId: config.MINIO_ROOT_USER, secretAccessKey: config.MINIO_ROOT_PASSWORD, }, }) : null; } public async createIntent( workspaceId: string, userId: string, key: string, input: CreateMediaUploadIntentRequest, ) { return this.idempotency.run( { workspaceId, userId, method: 'POST', path: '/api/media/upload-intents', key, request: input, }, async (tx) => { if (input.purpose === 'product_image') { if (!input.product_id) throw new ApiError(422, 'VALIDATION_FAILED', '商品图片必须绑定商品'); await this.requireProduct(tx, input.product_id); } await this.ensureBucket(); const id = randomUUID(); const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : 'watermark-images'}/${id}`; const expiresAt = new Date(Date.now() + 10 * 60_000); await tx.query( `INSERT INTO atlas.media_upload_intents (id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type, expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id, product_id) VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10, $3, $4, $5, $6, $7, $8, $9, $11)`, [ id, input.purpose, this.config.OBJECT_STORAGE_BUCKET, objectKey, input.content_type, input.byte_size, input.checksum_sha256, expiresAt, userId, this.config.OBJECT_STORAGE_PROVIDER, input.product_id ?? null, ], ); const url = await getSignedUrl( this.s3, new PutObjectCommand({ Bucket: this.config.OBJECT_STORAGE_BUCKET, Key: objectKey, ContentType: input.content_type, }), { expiresIn: 600 }, ); return { status: 201, body: { data: { upload_id: id, expires_at: expiresAt.toISOString(), upload: { method: 'PUT', url, headers: { 'content-type': input.content_type }, }, }, }, }; }, ); } public async completeIntent( workspaceId: string, userId: string, intentId: string, key: string, input: CompleteMediaUploadIntentRequest, ) { try { return await this.idempotency.run( { workspaceId, userId, method: 'POST', path: `/api/media/upload-intents/${intentId}/complete`, key, request: input, }, async (tx) => { const intent = await this.lockIntent(tx, intentId); if (intent.status === 'ready' && intent.asset_id) { return { status: 201, body: { data: { asset: await this.requireAsset( tx, intent.asset_id, ), }, }, }; } if (intent.status === 'failed') { throw new ApiError( 422, 'UPLOAD_VALIDATION_FAILED', '该上传意图的文件未通过安全校验,请重新上传', ); } if (intent.status === 'expired') { throw new ApiError( 422, 'UPLOAD_EXPIRED', '上传意图已过期,请重新发起上传', ); } if (new Date(intent.expires_at).getTime() <= Date.now()) { throw new ApiError( 422, 'UPLOAD_EXPIRED', '上传意图已过期,请重新发起上传', ); } if ( input.checksum_sha256 !== intent.expected_checksum_sha256 ) { throw new ApiError( 422, 'UPLOAD_CHECKSUM_MISMATCH', '完成请求的文件摘要与上传意图不一致', ); } const inspected = await this.inspectObject(intent); if ( inspected.byteSize !== Number(intent.expected_byte_size) || inspected.checksum !== intent.expected_checksum_sha256 || inspected.contentType !== intent.expected_content_type ) { throw new ApiError( 422, 'UPLOAD_VALIDATION_FAILED', '上传文件未通过安全校验', ); } const existing = await tx.query<{ id: string }>( `SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1`, [inspected.checksum], ); const assetId = existing.rows[0]?.id ?? randomUUID(); if (!existing.rows[0]) { await tx.query( `INSERT INTO atlas.media_assets (id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px) VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8)`, [ assetId, intent.bucket, intent.object_key, inspected.contentType, inspected.byteSize, inspected.checksum, inspected.width, inspected.height, intent.storage_provider, ], ); } await tx.query( `UPDATE atlas.media_upload_intents SET status = 'ready', asset_id = $2, completed_at = now(), error_code = NULL, error_message = NULL WHERE id = $1`, [intentId, assetId], ); return { status: 201, body: { data: { asset: await this.requireAsset(tx, assetId), }, }, }; }, ); } catch (error) { const code = apiErrorCode(error); if ( code === 'UPLOAD_EXPIRED' || code === 'UPLOAD_OBJECT_MISSING' || code === 'UPLOAD_VALIDATION_FAILED' ) { await this.persistTerminalIntentStatus( workspaceId, intentId, code, ); } throw error; } } public async accessUrl( workspaceId: string, assetId: string, purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview', ) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const asset = await tx.query<{ storage_provider: string; bucket: string; object_key: string; content_type: string; }>( `SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`, [assetId], ); if (!asset.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在'); const extension = ({ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', } as Record)[asset.rows[0].content_type] ?? 'bin'; const expiresAt = new Date(Date.now() + 10 * 60_000); const url = await this.signedReadUrl( asset.rows[0], expiresAt, purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined, ); return { url, expires_at: expiresAt.toISOString() }; }); } /** 返回当前商品最近上传的素材,预览 URL 仅在本次响应中签发。 */ public async productUploads(workspaceId: string, productId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const uploads = await tx.query( `SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at FROM atlas.media_assets a JOIN atlas.media_upload_intents i ON i.asset_id = a.id WHERE i.purpose = 'product_image' AND i.status = 'ready' AND i.product_id = $1 AND a.deleted_at IS NULL GROUP BY a.id ORDER BY uploaded_at DESC, a.id DESC LIMIT 100`, [productId], ); const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); return Promise.all(uploads.rows.map(async (asset) => ({ id: asset.id, content_type: asset.content_type, byte_size: Number(asset.byte_size), width_px: asset.width_px, height_px: asset.height_px, created_at: asset.created_at instanceof Date ? asset.created_at.toISOString() : asset.created_at, preview_url: await this.signedReadUrl(asset, new Date(expiresAt)), preview_url_expires_at: expiresAt, }))); }); } /** 网络图片先做公网下载与内容校验,再登记为工作空间正式素材。 */ public async importProductImage( workspaceId: string, userId: string, key: string, input: ImportProductImageRequest, ) { return this.idempotency.run( { workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input }, async (tx) => { await this.requireProduct(tx, input.product_id); const image = await downloadProductImage(input.url); const checksum = createHash('sha256').update(image.bytes).digest('hex'); const existing = await tx.query<{ id: string }>( 'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum], ); let assetId = existing.rows[0]?.id ?? randomUUID(); if (!existing.rows[0]) { await this.ensureBucket(); const objectKey = `product-imports/${workspaceId}/${checksum}/${randomUUID()}`; await this.s3.send(new PutObjectCommand({ Bucket: this.config.OBJECT_STORAGE_BUCKET, Key: objectKey, Body: image.bytes, ContentType: image.contentType, })); const inserted = await tx.query<{ id: string }>( `INSERT INTO atlas.media_assets (id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px) VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING RETURNING id`, [assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType, image.bytes.length, checksum, image.width, image.height, this.config.OBJECT_STORAGE_PROVIDER], ); assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>( 'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum], )).rows[0].id; } await tx.query( `INSERT INTO atlas.product_image_imports (workspace_id, source_url, asset_id, created_by_user_id, product_id) VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4) ON CONFLICT (workspace_id, product_id, source_url) DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`, [input.url, assetId, userId, input.product_id], ); const asset = await this.requireAsset(tx, assetId); const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>( 'SELECT storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1', [assetId], ); const expiresAt = new Date(Date.now() + 10 * 60_000); const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt); return { status: 201, body: { data: { ...asset, source_url: input.url, preview_url: previewUrl, preview_url_expires_at: expiresAt.toISOString() } }, }; }, ); } public async productImports(workspaceId: string, productId: string) { return this.database.workspaceTransaction(workspaceId, async (tx) => { const imports = await tx.query( `SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px, a.created_at, a.bucket, a.object_key, i.source_url FROM ( SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id FROM atlas.product_image_imports WHERE product_id = $1 ORDER BY asset_id, updated_at DESC, id DESC ) i JOIN atlas.media_assets a ON a.id = i.asset_id AND a.deleted_at IS NULL ORDER BY i.updated_at DESC, i.id DESC LIMIT 100`, [productId], ); const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); return Promise.all(imports.rows.map(async (asset) => ({ id: asset.id, content_type: asset.content_type, byte_size: Number(asset.byte_size), width_px: asset.width_px, height_px: asset.height_px, created_at: asset.created_at instanceof Date ? asset.created_at.toISOString() : asset.created_at, source_url: asset.source_url, preview_url: await this.signedReadUrl(asset, new Date(expiresAt)), preview_url_expires_at: expiresAt, }))); }); } private async requireProduct(tx: DatabaseTransaction, productId: string) { const result = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]); if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在'); } private async lockIntent( tx: DatabaseTransaction, intentId: string, ): Promise { const rows = await tx.query( `SELECT id, purpose, storage_provider, bucket, object_key, expected_content_type, expected_byte_size, expected_checksum_sha256, status, expires_at, asset_id FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`, [intentId], ); if (!rows.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '上传意图不存在'); return rows.rows[0]; } private async requireAsset(tx: DatabaseTransaction, assetId: string) { const rows = await tx.query( `SELECT id, content_type, byte_size, width_px, height_px, created_at FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`, [assetId], ); if (!rows.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在'); const asset = rows.rows[0]; return { id: asset.id, content_type: asset.content_type, byte_size: Number(asset.byte_size), width_px: asset.width_px, height_px: asset.height_px, created_at: asset.created_at instanceof Date ? asset.created_at.toISOString() : asset.created_at, }; } private async inspectObject(intent: IntentRow) { let head; try { head = await this.s3.send( new HeadObjectCommand({ Bucket: intent.bucket, Key: intent.object_key, }), ); } catch { throw new ApiError( 422, 'UPLOAD_OBJECT_MISSING', '尚未找到已上传文件', ); } const byteLimit = intent.purpose === 'product_image' ? 20 * 1024 * 1024 : 5 * 1024 * 1024; if (!head.ContentLength || head.ContentLength > byteLimit) { throw new ApiError( 422, 'UPLOAD_VALIDATION_FAILED', '上传文件大小不符合要求', ); } const declaredContentType = head.ContentType?.split( ';', 1, )[0]?.toLowerCase(); if (declaredContentType !== intent.expected_content_type) { throw new ApiError( 422, 'UPLOAD_VALIDATION_FAILED', '上传文件声明类型不符合要求', ); } const object = await this.s3.send( new GetObjectCommand({ Bucket: intent.bucket, Key: intent.object_key, }), ); if (!object.Body) throw new ApiError( 422, 'UPLOAD_OBJECT_MISSING', '尚未找到已上传文件', ); const chunks: Buffer[] = []; let byteSize = 0; for await (const chunk of object.Body as AsyncIterable) { byteSize += chunk.byteLength; // HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。 if (byteSize > byteLimit) { throw new ApiError( 422, 'UPLOAD_VALIDATION_FAILED', '上传文件大小不符合要求', ); } chunks.push(Buffer.from(chunk)); } const bytes = Buffer.concat(chunks, byteSize); const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192); if ( !detected || detected.contentType !== intent.expected_content_type ) { throw new ApiError( 422, 'UPLOAD_VALIDATION_FAILED', '上传文件类型或内容不符合要求', ); } return { contentType: detected.contentType, width: detected.width, height: detected.height, byteSize, checksum: createHash('sha256').update(bytes).digest('hex'), }; } /** 七牛图片从绑定域名签发短时下载凭证;MinIO 素材仍使用 S3 预签名。 */ private async signedReadUrl( asset: { storage_provider: string; bucket: string; object_key: string }, expiresAt: Date, downloadName?: string, ): Promise { if ( asset.storage_provider === 'qiniu_kodo' && asset.bucket === this.config.OBJECT_STORAGE_BUCKET && this.config.qiniuDownloadOrigin ) { const origin = new URL(this.config.qiniuDownloadOrigin).origin; const path = asset.object_key.split('/').map(encodeURIComponent).join('/'); const deadline = Math.floor(expiresAt.getTime() / 1000); const attachment = downloadName ? `attname=${encodeURIComponent(downloadName)}&` : ''; const unsignedUrl = `${origin}/${path}?${attachment}e=${deadline}`; const signature = createHmac('sha1', this.config.qiniuDownloadSecretKey) .update(unsignedUrl) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); return `${unsignedUrl}&token=${this.config.qiniuDownloadAccessKey}:${signature}`; } return getSignedUrl( this.storageForPreview(asset), new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key, ...(downloadName ? { ResponseContentDisposition: `attachment; filename="${downloadName}"`, } : {}), }), { expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) }, ); } private storageForPreview(asset: { storage_provider: string; bucket: string }): S3Client { if ( asset.storage_provider === 'local_minio' && asset.bucket === 'atlas-assets' && this.legacyMinioS3 ) { return this.legacyMinioS3; } return this.s3; } private async ensureBucket() { if (!this.bucketReady) this.bucketReady = (async () => { try { await this.s3.send( new HeadBucketCommand({ Bucket: this.config.OBJECT_STORAGE_BUCKET, }), ); } catch { await this.s3.send( new CreateBucketCommand({ Bucket: this.config.OBJECT_STORAGE_BUCKET, }), ); } })(); return this.bucketReady; } private async persistTerminalIntentStatus( workspaceId: string, intentId: string, code: | 'UPLOAD_EXPIRED' | 'UPLOAD_OBJECT_MISSING' | 'UPLOAD_VALIDATION_FAILED', ) { await this.database.workspaceTransaction(workspaceId, async (tx) => { const expired = code === 'UPLOAD_EXPIRED'; await tx.query( `UPDATE atlas.media_upload_intents SET status = $2, error_code = $3, error_message = $4, completed_at = now() WHERE id = $1 AND status = 'pending'`, [ intentId, expired ? 'expired' : 'failed', code, expired ? '上传意图已过期' : '上传文件未通过安全校验', ], ); }); } } function apiErrorCode(error: unknown): string | undefined { if (!(error instanceof ApiError)) return undefined; const response = error.getResponse(); if (typeof response !== 'object' || !response || !('error' in response)) return undefined; const apiError = response.error; return typeof apiError === 'object' && apiError && 'code' in apiError && typeof apiError.code === 'string' ? apiError.code : undefined; }