368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
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<T['safeParse']> 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);
|
|
}
|