import { ProductImageDeletionService } from './product-image-deletion.service.js'; import { Body, Controller, Headers, Inject, Get, Param, Post, Query, Req, Res, } from '@nestjs/common'; import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; import { deleteProductImageRequestSchema, completeMediaUploadIntentRequestSchema, createMediaAccessUrlRequestSchema, createMediaUploadIntentRequestSchema, importProductImageRequestSchema, createAspectRatioDerivativeRequestSchema, createWatermarkDerivativeRequestSchema, createMediaAiEditRequestSchema, listProductImageTasksQuerySchema, imageSuitePlanInputSchema, createImageSuiteSchema, type CreateMediaAccessUrlRequest, type Workspace, } from '@atlas/contracts'; import { z } from 'zod'; import { ApiError } from '../common/api-error.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { MediaService } from './media.service.js'; import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js'; import { WatermarkDerivativeService } from './watermark-derivative.service.js'; import { MediaAiEditService } from './media-ai-edit.service.js'; import { ProductImageTaskService } from './product-image-task.service.js'; import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js'; import { ImageSuiteService } from './image-suite.service.js'; @ApiTags('Media') @ApiBearerAuth('access-token') @Controller() export class MediaController { public constructor( @Inject(ProductImageDeletionService) private readonly imageDeletions: ProductImageDeletionService, @Inject(MediaService) private readonly media: MediaService, @Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService, @Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService, @Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService, @Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService, @Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService, @Inject(ImageSuiteService) private readonly suites: ImageSuiteService, ) {} @Post('products/:productId/image-deletions') public async deleteProductImage(@Param('productId') productId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { const result = await this.imageDeletions.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(deleteProductImageRequestSchema, body)); response.status(result.status); return result.body; } @Get('products/:productId/image-deletions') public async pendingImageDeletions(@Param('productId') productId: string, @CurrentWorkspace() workspace: Workspace) { return { data: await this.imageDeletions.pending(workspace.id, uuid('product_id', productId)) }; } @Get('products/:productId/image-deletions/:deletionId') public async readImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string, @CurrentWorkspace() workspace: Workspace) { return { data: await this.imageDeletions.read(workspace.id, uuid('product_id', productId), uuid('deletion_id', id)) }; } @Post('products/:productId/image-deletions/:deletionId/retry') public async retryImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { const result = await this.imageDeletions.retry(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('deletion_id', id), idempotencyKey(key)); response.status(result.status); return result.body; } @Post('products/:productId/ai/suite-plans') public async planSuite(@Param('productId') productId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(imageSuitePlanInputSchema, body), 'plan'); response.status(result.status); return result.body; } @Post('products/:productId/ai/image-suites') public async generateSuite(@Param('productId') productId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) { const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(createImageSuiteSchema, body), 'generate'); response.status(result.status); return result.body; } @Get('products/:productId/ai/image-suites') public async listSuites(@Param('productId') productId: string, @Query() query: unknown, @CurrentWorkspace() workspace: Workspace) { return this.suites.list(workspace.id, uuid('product_id', productId), parse(listProductImageTasksQuerySchema, query)); } @Get('products/:productId/ai/image-suites/:suiteId') public async readSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @CurrentWorkspace() workspace: Workspace) { return { data: await this.suites.read(workspace.id, uuid('product_id', productId), uuid('suite_id', suiteId)) }; } @Post('products/:productId/ai/image-suites/:suiteId/cancel') public async cancelSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'cancel')).body; } @Post('products/:productId/ai/image-suites/:suiteId/retry') public async retrySuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'retry')).body; } @Get('products/:productId/image-derivatives') public async listProductImageDerivatives( @Param('productId') productId: string, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.derivativeHistory.list(workspace.id, uuid('product_id', productId)) }; } @Get('products/:productId/image-tasks') public async listProductImageTasks( @Param('productId') productId: string, @CurrentWorkspace() workspace: Workspace, @Query() query: unknown, ) { return this.productImageTasks.list(workspace.id, uuid('product_id', productId), parse(listProductImageTasksQuerySchema, query)); } @Post('media-assets/:assetId/ai-edits') public async createAiEdit( @Param('assetId') assetId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }, ) { const result = await this.aiEdits.create( workspace.id, request.auth.userId, uuid('asset_id', assetId), idempotencyKey(key), parse(createMediaAiEditRequestSchema, body), ); response.status(result.status); return result.body; } @Get('media-ai-edits/:editId') public async aiEdit( @Param('editId') editId: string, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.aiEdits.read(workspace.id, uuid('edit_id', editId)) }; } @Get('media-ai-edits') public async listAiEdits( @Query('product_id') productId: string, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.aiEdits.list(workspace.id, uuid('product_id', productId)) }; } @Post('media-ai-edits/:editId/cancel') public async cancelAiEdit( @Param('editId') editId: string, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { return (await this.aiEdits.cancel( workspace.id, request.auth.userId, uuid('edit_id', editId), idempotencyKey(key), )).body; } @Post('media-assets/:assetId/watermark-derivatives') public async createWatermarkDerivative( @Param('assetId') assetId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }, ) { const result = await this.watermarkDerivatives.create( workspace.id, request.auth.userId, uuid('asset_id', assetId), idempotencyKey(key), parse(createWatermarkDerivativeRequestSchema, body), ); response.status(result.status); return result.body; } @Get('media-watermark-derivatives/:derivativeId') public async watermarkDerivative( @Param('derivativeId') derivativeId: string, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.watermarkDerivatives.read(workspace.id, uuid('derivative_id', derivativeId)) }; } @Post('media-assets/:assetId/aspect-ratio-derivatives') public async createAspectRatioDerivative( @Param('assetId') assetId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }, ) { const result = await this.derivatives.create( workspace.id, request.auth.userId, uuid('asset_id', assetId), idempotencyKey(key), parse(createAspectRatioDerivativeRequestSchema, body), ); response.status(result.status); return result.body; } @Get('media-derivatives/:derivativeId') public async aspectRatioDerivative( @Param('derivativeId') derivativeId: string, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.derivatives.read(workspace.id, uuid('derivative_id', derivativeId)) }; } @Get('media-assets/product-uploads') @ApiOkResponse({ description: '当前商品最近上传的图片及短时预览地址' }) public async productUploads(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) { return { data: await this.media.productUploads(workspace.id, uuid('product_id', productId)) }; } @Get('media-assets/product-imports') @ApiOkResponse({ description: '当前商品最近导入的网络图片及短时预览地址' }) public async productImports(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) { return { data: await this.media.productImports(workspace.id, uuid('product_id', productId)) }; } @Post('media-assets/product-imports') @ApiCreatedResponse({ description: '已下载并校验网络图片,创建正式素材' }) public async importProductImage( @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { return (await this.media.importProductImage( workspace.id, request.auth.userId, idempotencyKey(key), parse(importProductImageRequestSchema, body), )).body; } @Post('media/upload-intents') @ApiCreatedResponse({ description: '已创建一次性预签名上传意图' }) public async createIntent( @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { return ( await this.media.createIntent( workspace.id, request.auth.userId, idempotencyKey(key), parse(createMediaUploadIntentRequestSchema, body), ) ).body; } @Post('media/upload-intents/:uploadId/complete') @ApiCreatedResponse({ description: '已完成对象安全校验并创建素材' }) public async complete( @Param('uploadId') uploadId: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { return ( await this.media.completeIntent( workspace.id, request.auth.userId, uuid('upload_id', uploadId), idempotencyKey(key), parse(completeMediaUploadIntentRequestSchema, body), ) ).body; } @Post('media-assets/:assetId/access-urls') @ApiOkResponse({ description: '已签发短时预览或下载 URL' }) public async accessUrl( @Param('assetId') assetId: string, @Body() body: unknown, @CurrentWorkspace() workspace: Workspace, ) { const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest; return { data: await this.media.accessUrl( workspace.id, uuid('asset_id', assetId), input.purpose, ), }; } } function parse< T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> }; }; }, >(schema: T, input: unknown) { const result = schema.safeParse(input); if (result.success) return result.data as ReturnType extends { data: infer Data; } ? Data : never; throw new ApiError( 422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((issue) => ({ field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message, })), ); } function uuid(field: string, value: string) { if (z.uuid().safeParse(value).success) return value; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [ { field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }, ]); } function idempotencyKey(value: string | undefined) { if (!value) throw new ApiError( 400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key', ); return uuid('Idempotency-Key', value); }