Files
atlas-seller-kit/apps/api/src/product-editor/product-editor.controller.ts
T

274 lines
10 KiB
TypeScript

import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { z } from 'zod';
import {
saveProductEditorDraftRequestSchema,
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 { ProductEditorService } from './product-editor.service.js';
type Response = { status: (value: number) => Response };
@Controller()
export class ProductEditorController {
/** 注入所有商品编辑接口共用的工作流服务。 */
public constructor(
@Inject(ProductEditorService)
private readonly editor: ProductEditorService,
) {}
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */
@Post('collection-items/:collectionItemId/editor-contexts')
async enter(
@Param('collectionItemId') collectionItemId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.enter(
workspace.id,
request.auth.userId,
requiredUuid('collection_item_id', collectionItemId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
);
response.status(result.status);
return result.body;
}
/** 返回当前平台下完整的商品编辑读模型。 */
@Get('products/:productId/editor')
async read(
@Param('productId') productId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.read(
workspace.id,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 返回目标店铺已缓存的类目树,供编辑器选择。 */
@Get('product-platform-profiles/:profileId/category-options')
async categoryOptions(
@Param('profileId') profileId: string,
@Query('q') query: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.categoryOptions(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
query,
optionalUuid('store_id', storeId),
),
};
}
/** 返回所选类目的缓存动态属性定义。 */
@Get('product-platform-profiles/:profileId/attribute-schema')
async attributeSchema(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.attributeSchema(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
optionalUuid('category_id', categoryId),
optionalUuid('store_id', storeId),
),
};
}
/** 返回首次上架前的服务端发布就绪校验。 */
@Get('product-platform-profiles/:profileId/validation')
async validation(
@Param('profileId') profileId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.validation(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
/** 幂等地创建所选类目属性定义的刷新任务。 */
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
async attributeSchemaSync(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.requestAttributeSchemaSync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
/** 从本地缓存中查询属性字典值。 */
@Get(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
)
async attributeDictionary(
@Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string,
@Query('q') query: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('category_id') categoryId: string | undefined,
@Query('store_id') storeId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
if (!dictionaryId.trim() || dictionaryId.length > 200)
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
return {
data: await this.editor.attributeDictionary(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
dictionaryId,
query,
cursor,
optionalUuid('category_id', categoryId),
optionalUuid('store_id', storeId),
),
};
}
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
@Post(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
)
async attributeDictionarySync(
@Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string,
@Query('category_id') categoryId: string | undefined,
@Query('q') query: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
if (!dictionaryId.trim() || dictionaryId.length > 200)
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
const result = await this.editor.requestAttributeDictionarySync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
dictionaryId,
query ?? '',
);
response.status(result.status);
return result.body;
}
/** 校验并原子保存商品及其上架草稿。 */
@Put('products/:productId/editor-draft')
async save(
@Param('productId') productId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
const parsed = saveProductEditorDraftRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
return (
await this.editor.save(
workspace.id,
request.auth.userId,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
parseEditorRevision(ifMatch),
parsed.data,
)
).body;
}
}
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
function requiredUuid(field: string, value: string | undefined) {
if (value && z.uuid().safeParse(value).success) return value;
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
/** 将缺省查询参数保留为 undefined;有值时校验其 UUID 格式。 */
function optionalUuid(field: string, value: string | undefined) {
if (value === undefined || value === '') return undefined;
return requiredUuid(field, value);
}
/** 解析 If-Match 中成对传递的商品和发布配置版本号。 */
function parseEditorRevision(value: string | undefined) {
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
if (!match)
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求缺少或格式错误的 If-Match',
);
return { product: Number(match[1]), profile: Number(match[2]) };
}