feat(edit): 服务端代码拆分、优化
This commit is contained in:
@@ -1,6 +1,27 @@
|
||||
import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Query, Req, Res } from '@nestjs/common';
|
||||
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 {
|
||||
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';
|
||||
@@ -9,34 +30,199 @@ 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));
|
||||
}
|
||||
@Get(':id') async detail(@Param('id') id: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.collection.detail(workspace.id, requireUuid('collection_item_id', id)) };
|
||||
}
|
||||
@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;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
/** 注入采集箱接口共用的应用服务。 */
|
||||
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]);
|
||||
}
|
||||
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 }))); }
|
||||
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); }
|
||||
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]); }
|
||||
|
||||
Reference in New Issue
Block a user