import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Query, Req, Res, } from '@nestjs/common'; import { z } from 'zod'; import { bulkDeleteCollectionItemsRequestSchema, clearCollectionItemsRequestSchema, createCollectionItemRequestSchema, listCollectionItemsQuerySchema, materializeCollectionAssetsRequestSchema, retryCollectionAssetsRequestSchema, updateCollectionItemRequestSchema, type Workspace, } from '@atlas/contracts'; import { ApiError } from '../common/api-error.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { CollectionService } from './collection.service.js'; type Response = { status: (value: number) => Response }; @Controller('collection-items') export class CollectionController { /** 注入采集箱接口共用的应用服务。 */ public constructor( @Inject(CollectionService) private readonly collection: CollectionService, ) {} /** 按页头平台上下文查询当前工作空间的采集箱。 */ @Get() async list( @Query() query: unknown, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace, ) { if (!platformId) throw new ApiError( 400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id', ); return this.collection.list( workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query), ); } /** 返回采集记录、解析证据、SKU 与当前快照的素材任务。 */ @Get(':id') async detail( @Param('id') id: string, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.collection.detail( workspace.id, requireUuid('collection_item_id', id), ), }; } /** 幂等地创建手工采集草稿及其默认商品和 SKU。 */ @Post() async create( @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response, ) { const result = await this.collection.create( workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body), ); response.status(result.status); return result.body; } /** 通过 If-Match 乐观锁更新当前允许编辑的采集记录字段。 */ @Patch(':id') async update( @Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Headers('if-match') match: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { return ( await this.collection.update( workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), revision(match), parse(updateCollectionItemRequestSchema, body), ) ).body; } /** 幂等地软删除用户指定的多条采集记录。 */ @Post('bulk-delete') async bulkDelete( @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response, ) { const result = await this.collection.bulkDelete( workspace.id, request.auth.userId, requireKey(key), parse(bulkDeleteCollectionItemsRequestSchema, body), ); response.status(result.status); return result.body; } /** 在数量确认未过期时,幂等地清空当前工作空间的采集箱。 */ @Post('clear') async clear( @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response, ) { const result = await this.collection.clear( workspace.id, request.auth.userId, requireKey(key), parse(clearCollectionItemsRequestSchema, body), ); response.status(result.status); return result.body; } /** 将当前采集快照中失败或取消的素材任务重新投递给 Worker。 */ @Post(':id/asset-retries') async retry( @Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response, ) { const result = await this.collection.retryAssets( workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(retryCollectionAssetsRequestSchema, body), ); response.status(result.status); return result.body; } /** 将当前采集快照中用户选定的来源素材请求转存为 Atlas 素材。 */ @Post(':id/asset-materializations') async materialize( @Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response, ) { const result = await this.collection.materializeAssets( workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(materializeCollectionAssetsRequestSchema, body), ); response.status(result.status); return result.body; } } /** 使用契约 Schema 校验请求体或查询参数,并转换为统一错误响应。 */ 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 never; throw new ApiError( 422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((x) => ({ field: x.path.join('.'), code: 'INVALID_FIELD', message: x.message, })), ); } /** 校验路径、请求头中的 UUID 标识。 */ function requireUuid(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 requireKey(value: string | undefined) { if (!value) throw new ApiError( 400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key', ); return requireUuid('Idempotency-Key', value); } /** 解析单资源更新使用的 If-Match 版本号。 */ function revision(value: string | undefined) { const valueMatch = value?.match(/^"([1-9]\d*)"$/); if (!valueMatch) throw new ApiError( 400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match', ); return Number(valueMatch[1]); }