229 lines
7.9 KiB
TypeScript
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]);
|
|
}
|