Files
atlas-seller-kit/apps/api/src/collection/collection.controller.ts
T
2026-09-15 18:56:13 +08:00

229 lines
7.9 KiB
TypeScript

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]);
}