diff --git a/AGENTS.md b/AGENTS.md index 04a6663..c33ad89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,14 +1,18 @@ # AGENTS.md — 工作区工作约定 -本工作区(`atlas-seller-kit-frontend` worktree,`zcode/frontend` 分支)的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。 +本工作区的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。 -## 1. 只改前端 +## 1. 可修改全部项目代码 -- 只修改 `apps/web/` 及前端相关文档(即协作约定中「前端体验」领域,见 `docs/project/development-collaboration-contract.md` §2)。 -- `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/` 等服务端领域的代码一律不改;发现问题时在回复中说明,由维护者/后端处理。 +- 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。 +- 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。 ## 2. 不要启动服务端和 Worker - API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。 - 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。 -- 前端 vite dev server 属于前端领域,可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。 +- 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。 + +## 3. 代码注释 + +- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。 diff --git a/apps/api/src/collection/collection.controller.ts b/apps/api/src/collection/collection.controller.ts index 8f91c47..d0b964a 100644 --- a/apps/api/src/collection/collection.controller.ts +++ b/apps/api/src/collection/collection.controller.ts @@ -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 { 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]); } diff --git a/apps/api/src/collection/collection.module.ts b/apps/api/src/collection/collection.module.ts index 2360505..46474ff 100644 --- a/apps/api/src/collection/collection.module.ts +++ b/apps/api/src/collection/collection.module.ts @@ -3,5 +3,8 @@ import { IdempotencyService } from '../common/idempotency.service.js'; import { CollectionController } from './collection.controller.js'; import { CollectionService } from './collection.service.js'; -@Module({ controllers: [CollectionController], providers: [IdempotencyService, CollectionService] }) +@Module({ + controllers: [CollectionController], + providers: [IdempotencyService, CollectionService], +}) export class CollectionModule {} diff --git a/apps/api/src/collection/collection.service.ts b/apps/api/src/collection/collection.service.ts index 90ab900..4daf0c2 100644 --- a/apps/api/src/collection/collection.service.ts +++ b/apps/api/src/collection/collection.service.ts @@ -1,67 +1,547 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import type { CreateCollectionItemRequest, ListCollectionItemsQuery, MaterializeCollectionAssetsRequest, UpdateCollectionItemRequest } from '@atlas/contracts'; +import type { + CreateCollectionItemRequest, + ListCollectionItemsQuery, + MaterializeCollectionAssetsRequest, + UpdateCollectionItemRequest, +} from '@atlas/contracts'; import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; import { throwRevisionConflict } from '../common/revision.js'; import { DATABASE } from '../database/database.module.js'; -type Row = { id: string; product_id: string; source_kind: 'platform' | 'manual'; platform_code: string | null; source_url: string | null; source_title: string | null; source_min_price: string | null; source_max_price: string | null; source_currency_code: string | null; note: string | null; status: string; created_at: Date | string; updated_at: Date | string; revision: number; variant_count: string; transfer_status: string | null }; +type Row = { + id: string; + product_id: string; + source_kind: 'platform' | 'manual'; + platform_code: string | null; + source_url: string | null; + source_title: string | null; + source_min_price: string | null; + source_max_price: string | null; + source_currency_code: string | null; + note: string | null; + status: string; + created_at: Date | string; + updated_at: Date | string; + revision: number; + variant_count: string; + transfer_status: string | null; +}; @Injectable() export class CollectionService { - public constructor(@Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database) {} - async list(workspaceId: string, platformId: string, query: ListCollectionItemsQuery) { - const cursor = query.cursor ? decodeCursor(query.cursor) : null; - return this.database.workspaceTransaction(workspaceId, async (tx) => { - await this.requireHeaderPlatform(tx, platformId); - const rows = await tx.query(`${selectRow} + /** 注入工作空间事务数据库与关键写操作的幂等边界。 */ + public constructor( + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + @Inject(DATABASE) private readonly database: Database, + ) {} + /** 分页查询未删除的采集记录,并返回当前工作空间的配额摘要。 */ + async list( + workspaceId: string, + platformId: string, + query: ListCollectionItemsQuery, + ) { + const cursor = query.cursor ? decodeCursor(query.cursor) : null; + return this.database.workspaceTransaction(workspaceId, async (tx) => { + await this.requireHeaderPlatform(tx, platformId); + const rows = await tx.query( + `${selectRow} WHERE c.deleted_at IS NULL AND ($1::text IS NULL OR c.source_title ILIKE '%' || $1 || '%') AND ($2::text IS NULL OR p.code = $2) AND ($3::text IS NULL OR $3 = 'not_listed') AND ($4::timestamptz IS NULL OR (c.updated_at, c.id) < ($4, $5::uuid)) - ${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`, [query.q ?? null, query.source_platform_code ?? null, query.listing_status ?? null, cursor?.at ?? null, cursor?.id ?? null, query.limit + 1]); - const items = rows.rows.slice(0, query.limit).map(toItem); const last = items.at(-1); - const count = await tx.query<{ count: string; collection_item_limit: number }>('SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL'); - return { data: items, meta: { has_more: rows.rows.length > query.limit, next_cursor: rows.rows.length > query.limit && last ? encodeCursor(last.updated_at, last.id) : null, active_count: Number(count.rows[0]?.count ?? 0), collection_limit: count.rows[0]?.collection_item_limit ?? 1000 } }; - }); - } - async create(workspaceId: string, userId: string, key: string, input: CreateCollectionItemRequest) { - return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/collection-items', key, request: input }, async (tx) => { - const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`, [randomUUID(), input.title ?? null, input.note ?? null]); - const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, input.title ?? null]); - await tx.query(`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`, [product.rows[0].id]); - return { status: 201, body: { data: { collection_item: await this.requireItem(tx, item.rows[0].id), product_id: product.rows[0].id, editor_entry_path: `/products/${product.rows[0].id}` } } }; - }); - } - async detail(workspaceId: string, id: string) { - return this.database.workspaceTransaction(workspaceId, async (tx) => { - const item = await this.requireItem(tx, id); - const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); - const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]); - const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>(`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, [id, source.rows[0]?.source_snapshot_revision ?? 1]); - return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) }; - }); - } - async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) { - return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => { - const result = await tx.query(`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, [input.note, id, expected]); - if (!result.rowCount) await throwRevisionConflict(tx, 'collection_item', id, expected); - return { status: 200, body: { data: await this.requireItem(tx, id) } }; - }); - } - async bulkDelete(workspaceId: string, userId: string, key: string, ids: { ids: string[] }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/bulk-delete', ids, false); } - async clear(workspaceId: string, userId: string, key: string, input: { confirm: true; expected_active_count: number }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/clear', input, true); } - async retryAssets(workspaceId: string, userId: string, id: string, key: string, input: { transfer_ids: string[] }) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-retries`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集快照中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); } - async materializeAssets(workspaceId: string, userId: string, id: string, key: string, input: MaterializeCollectionAssetsRequest) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-materializations`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_MATERIALIZATION_INVALID', '只能转存当前采集快照中尚未转存的来源媒体'); const operation = await this.operation(tx, userId, 'collection_asset_materialization', id); await this.event(tx, operation.id, 'collection.assets.materialize.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); } - private async removeMany(workspaceId: string, userId: string, key: string, path: string, input: unknown, clear: boolean) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path, key, request: input }, async (tx) => { const requested = input as { ids?: string[]; expected_active_count?: number }; const count = await tx.query<{ count: string }>('SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL'); if (clear && Number(count.rows[0].count) !== requested.expected_active_count) throw new ApiError(409, 'COLLECTION_COUNT_CHANGED', '采集箱数量已变化,请重新确认'); const deleted = await tx.query<{ id: string }>(clear ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, clear ? [] : [requested.ids]); if (!clear && deleted.rows.length !== requested.ids?.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '部分采集记录不存在'); const operation = await this.operation(tx, userId, 'collection_cleanup', deleted.rows[0]?.id ?? null); await this.event(tx, operation.id, 'collection.cleanup.requested', { operation_id: operation.id, collection_item_ids: deleted.rows.map((row) => row.id) }); return { status: 202, body: { data: { deleted_count: deleted.rows.length, operation } } }; }); } - private async operation(tx: DatabaseTransaction, userId: string, kind: string, resourceId: string | null) { const row = await tx.query<{ id: string; status: string; created_at: Date | string }>(`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, [kind, resourceId, userId]); return { id: row.rows[0].id, kind, status: row.rows[0].status, created_at: iso(row.rows[0].created_at), completed_at: null, error: null }; } - private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); } - private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); } - private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); } + ${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`, + [ + query.q ?? null, + query.source_platform_code ?? null, + query.listing_status ?? null, + cursor?.at ?? null, + cursor?.id ?? null, + query.limit + 1, + ], + ); + const items = rows.rows.slice(0, query.limit).map(toItem); + const last = items.at(-1); + const count = await tx.query<{ + count: string; + collection_item_limit: number; + }>( + 'SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL', + ); + return { + data: items, + meta: { + has_more: rows.rows.length > query.limit, + next_cursor: + rows.rows.length > query.limit && last + ? encodeCursor(last.updated_at, last.id) + : null, + active_count: Number(count.rows[0]?.count ?? 0), + collection_limit: + count.rows[0]?.collection_item_limit ?? 1000, + }, + }; + }); + } + /** 原子创建手工采集记录、商品和默认 SKU,供后续编辑流程使用。 */ + async create( + workspaceId: string, + userId: string, + key: string, + input: CreateCollectionItemRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: '/api/collection-items', + key, + request: input, + }, + async (tx) => { + const item = await tx.query<{ id: string }>( + `INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`, + [randomUUID(), input.title ?? null, input.note ?? null], + ); + const product = await tx.query<{ id: string }>( + `INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, + [item.rows[0].id, input.title ?? null], + ); + await tx.query( + `INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`, + [product.rows[0].id], + ); + return { + status: 201, + body: { + data: { + collection_item: await this.requireItem( + tx, + item.rows[0].id, + ), + product_id: product.rows[0].id, + editor_entry_path: `/products/${product.rows[0].id}`, + }, + }, + }; + }, + ); + } + /** 返回当前采集快照的来源证据、SKU 与待转存素材任务。 */ + async detail(workspaceId: string, id: string) { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + const item = await this.requireItem(tx, id); + const source = await tx.query<{ + normalized_payload: unknown; + raw_evidence: unknown; + parser_version: string | null; + collected_at: Date | string | null; + source_snapshot_revision: string | number; + }>( + 'SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', + [id], + ); + const variants = await tx.query<{ + id: string; + source_variant_key: string | null; + source_sku: string | null; + display_name: string | null; + sort_order: number; + }>( + 'SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', + [item.product_id], + ); + const transfers = await tx.query<{ + id: string; + source_variant_id: string | null; + media_type: 'image' | 'video'; + poster_url: string | null; + role: string; + source_url: string; + status: string; + asset_id: string | null; + error_payload: unknown; + created_at: Date | string; + completed_at: Date | string | null; + }>( + `SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, + [id, source.rows[0]?.source_snapshot_revision ?? 1], + ); + return { + ...item, + normalized_payload: source.rows[0]?.normalized_payload ?? {}, + raw_evidence: source.rows[0]?.raw_evidence ?? {}, + parser_version: source.rows[0]?.parser_version ?? null, + collected_at: source.rows[0]?.collected_at + ? iso(source.rows[0].collected_at) + : null, + variants: variants.rows, + asset_transfers: transfers.rows.map((transfer) => ({ + ...transfer, + created_at: iso(transfer.created_at), + completed_at: transfer.completed_at + ? iso(transfer.completed_at) + : null, + })), + }; + }); + } + /** 使用乐观锁更新采集记录备注,避免覆盖并发编辑。 */ + async update( + workspaceId: string, + userId: string, + id: string, + key: string, + expected: number, + input: UpdateCollectionItemRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'PATCH', + path: `/api/collection-items/${id}`, + key, + request: { if_match: expected, ...input }, + }, + async (tx) => { + const result = await tx.query( + `UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, + [input.note, id, expected], + ); + if (!result.rowCount) + await throwRevisionConflict( + tx, + 'collection_item', + id, + expected, + ); + return { + status: 200, + body: { data: await this.requireItem(tx, id) }, + }; + }, + ); + } + /** 请求软删除指定采集记录,并投递异步清理任务。 */ + async bulkDelete( + workspaceId: string, + userId: string, + key: string, + ids: { ids: string[] }, + ) { + return this.removeMany( + workspaceId, + userId, + key, + '/api/collection-items/bulk-delete', + ids, + false, + ); + } + /** 确认采集箱数量未变化后,软删除全部活动记录。 */ + async clear( + workspaceId: string, + userId: string, + key: string, + input: { confirm: true; expected_active_count: number }, + ) { + return this.removeMany( + workspaceId, + userId, + key, + '/api/collection-items/clear', + input, + true, + ); + } + /** 重新投递当前来源快照中失败或取消的素材转存任务。 */ + async retryAssets( + workspaceId: string, + userId: string, + id: string, + key: string, + input: { transfer_ids: string[] }, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/collection-items/${id}/asset-retries`, + key, + request: input, + }, + async (tx) => { + await this.requireItem(tx, id); + const result = await tx.query<{ + source_snapshot_revision: string | number; + }>( + `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, + [id, input.transfer_ids], + ); + if (result.rowCount !== input.transfer_ids.length) + throw new ApiError( + 422, + 'ASSET_RETRY_INVALID', + '只能重试当前采集快照中失败或已取消的素材任务', + ); + const operation = await this.operation( + tx, + userId, + 'collection_asset_retry', + id, + ); + await this.event(tx, id, 'collection.assets.retry.requested', { + operation_id: operation.id, + collection_item_id: id, + source_snapshot_revision: Number( + result.rows[0].source_snapshot_revision, + ), + }); + return { status: 202, body: { data: { operation } } }; + }, + ); + } + /** 将用户选中的候选来源素材转为可供编辑和发布的 Atlas 素材。 */ + async materializeAssets( + workspaceId: string, + userId: string, + id: string, + key: string, + input: MaterializeCollectionAssetsRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/collection-items/${id}/asset-materializations`, + key, + request: input, + }, + async (tx) => { + await this.requireItem(tx, id); + const result = await tx.query<{ + source_snapshot_revision: string | number; + }>( + `UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, + [id, input.transfer_ids], + ); + if (result.rowCount !== input.transfer_ids.length) + throw new ApiError( + 422, + 'ASSET_MATERIALIZATION_INVALID', + '只能转存当前采集快照中尚未转存的来源媒体', + ); + const operation = await this.operation( + tx, + userId, + 'collection_asset_materialization', + id, + ); + await this.event( + tx, + operation.id, + 'collection.assets.materialize.requested', + { + operation_id: operation.id, + collection_item_id: id, + source_snapshot_revision: Number( + result.rows[0].source_snapshot_revision, + ), + }, + ); + return { status: 202, body: { data: { operation } } }; + }, + ); + } + /** 复用批量删除和清空流程,并以 Outbox 触发后续素材清理。 */ + private async removeMany( + workspaceId: string, + userId: string, + key: string, + path: string, + input: unknown, + clear: boolean, + ) { + return this.idempotency.run( + { workspaceId, userId, method: 'POST', path, key, request: input }, + async (tx) => { + const requested = input as { + ids?: string[]; + expected_active_count?: number; + }; + const count = await tx.query<{ count: string }>( + 'SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL', + ); + if ( + clear && + Number(count.rows[0].count) !== + requested.expected_active_count + ) + throw new ApiError( + 409, + 'COLLECTION_COUNT_CHANGED', + '采集箱数量已变化,请重新确认', + ); + const deleted = await tx.query<{ id: string }>( + clear + ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` + : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, + clear ? [] : [requested.ids], + ); + if (!clear && deleted.rows.length !== requested.ids?.length) + throw new ApiError( + 404, + 'RESOURCE_NOT_FOUND', + '部分采集记录不存在', + ); + const operation = await this.operation( + tx, + userId, + 'collection_cleanup', + deleted.rows[0]?.id ?? null, + ); + await this.event( + tx, + operation.id, + 'collection.cleanup.requested', + { + operation_id: operation.id, + collection_item_ids: deleted.rows.map((row) => row.id), + }, + ); + return { + status: 202, + body: { + data: { deleted_count: deleted.rows.length, operation }, + }, + }; + }, + ); + } + /** 创建供客户端轮询的异步操作记录。 */ + private async operation( + tx: DatabaseTransaction, + userId: string, + kind: string, + resourceId: string | null, + ) { + const row = await tx.query<{ + id: string; + status: string; + created_at: Date | string; + }>( + `INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, + [kind, resourceId, userId], + ); + return { + id: row.rows[0].id, + kind, + status: row.rows[0].status, + created_at: iso(row.rows[0].created_at), + completed_at: null, + error: null, + }; + } + /** 在同一事务写入 Outbox 事件,避免业务事实与异步任务脱节。 */ + private async event( + tx: DatabaseTransaction, + aggregateId: string, + type: string, + payload: unknown, + ) { + await tx.query( + `INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`, + [aggregateId, type, payload, `${type}:${aggregateId}`], + ); + } + /** 确认页头平台已启用且允许作为 ERP 上下文。 */ + private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { + const row = await tx.query( + `SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, + [id], + ); + if (!row.rows[0]) + throw new ApiError( + 422, + 'PLATFORM_UNAVAILABLE', + '当前平台不可用于 ERP 上下文', + ); + } + /** 读取工作空间内未删除的采集记录;RLS 负责隔离其他工作空间数据。 */ + private async requireItem(tx: DatabaseTransaction, id: string) { + const row = await tx.query( + `${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, + [id], + ); + if (!row.rows[0]) + throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); + return toItem(row.rows[0]); + } } +/** 采集箱列表和单项读取共用的聚合查询,素材状态只取当前来源快照。 */ const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`; const collectionGroupBy = 'GROUP BY c.id, product.id, p.code'; -function toItem(row: Row) { return { id: row.id, product_id: row.product_id, source: { kind: row.source_kind, platform_code: row.platform_code, url: row.source_url }, title: row.source_title, thumbnail: { asset_id: null, preview_url: null, status: row.transfer_status ?? 'none' }, source_price: { min: row.source_min_price, max: row.source_max_price, currency_code: row.source_currency_code }, variant_summary: { count: Number(row.variant_count), needs_review: row.status === 'needs_review' }, note: row.note, collection_status: row.status, listing_summary: { status: 'not_listed' as const, detail: '等待首次上架', last_error: null }, created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; } -function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; } -function encodeCursor(at: string, id: string) { return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); } -function decodeCursor(cursor: string): { at: string; id: string } { try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { at?: string; id?: string }; if (!value.at || !value.id || Number.isNaN(Date.parse(value.at))) throw new Error(); return { at: value.at, id: value.id }; } catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } } +/** 将数据库聚合行转换为冻结的采集箱列表读模型。 */ +function toItem(row: Row) { + return { + id: row.id, + product_id: row.product_id, + source: { + kind: row.source_kind, + platform_code: row.platform_code, + url: row.source_url, + }, + title: row.source_title, + thumbnail: { + asset_id: null, + preview_url: null, + status: row.transfer_status ?? 'none', + }, + source_price: { + min: row.source_min_price, + max: row.source_max_price, + currency_code: row.source_currency_code, + }, + variant_summary: { + count: Number(row.variant_count), + needs_review: row.status === 'needs_review', + }, + note: row.note, + collection_status: row.status, + listing_summary: { + status: 'not_listed' as const, + detail: '等待首次上架', + last_error: null, + }, + created_at: iso(row.created_at), + updated_at: iso(row.updated_at), + revision: row.revision, + }; +} +/** 统一将数据库时间值序列化为 ISO 8601 字符串。 */ +function iso(value: Date | string) { + return value instanceof Date ? value.toISOString() : value; +} +/** 将排序键编码为不透明游标,避免客户端拼接 SQL 条件。 */ +function encodeCursor(at: string, id: string) { + return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); +} +/** 校验并解析分页游标,拒绝损坏或不完整的客户端输入。 */ +function decodeCursor(cursor: string): { at: string; id: string } { + try { + const value = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as { at?: string; id?: string }; + if (!value.at || !value.id || Number.isNaN(Date.parse(value.at))) + throw new Error(); + return { at: value.at, id: value.id }; + } catch { + throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); + } +} diff --git a/apps/api/src/extension/extension-auth.guard.ts b/apps/api/src/extension/extension-auth.guard.ts index b243a0a..4688aa9 100644 --- a/apps/api/src/extension/extension-auth.guard.ts +++ b/apps/api/src/extension/extension-auth.guard.ts @@ -1,4 +1,10 @@ -import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import type { Database } from '@atlas/database'; import { AuthService } from '../auth/auth.service.js'; import { ApiError } from '../common/api-error.js'; @@ -6,32 +12,61 @@ import { DATABASE } from '../database/database.module.js'; export type ExtensionAuthenticatedRequest = { header: (name: string) => string | undefined; - extensionAuth: { userId: string; workspaceId: string; sessionId: string; clientId: string }; + extensionAuth: { + userId: string; + workspaceId: string; + sessionId: string; + clientId: string; + }; }; @Injectable() export class ExtensionAccessGuard implements CanActivate { - public constructor(@Inject(AuthService) private readonly auth: AuthService, @Inject(DATABASE) private readonly database: Database) {} + public constructor( + @Inject(AuthService) private readonly auth: AuthService, + @Inject(DATABASE) private readonly database: Database, + ) {} public async canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); + const request = context + .switchToHttp() + .getRequest(); // 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。 // AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。 let authenticated: { userId: string; sessionId: string }; try { - authenticated = await this.auth.authenticate(request.header('authorization')); + authenticated = await this.auth.authenticate( + request.header('authorization'), + ); } catch (error) { // AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口 // 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。 if (error instanceof UnauthorizedException) - throw new ApiError(401, 'SESSION_EXPIRED', 'Atlas ERP 登录已过期,请重新登录'); + throw new ApiError( + 401, + 'SESSION_EXPIRED', + 'Atlas ERP 登录已过期,请重新登录', + ); throw error; } - const workspace = await this.database.query<{ id: string }>(`SELECT w.id FROM atlas.workspace_memberships m + const workspace = await this.database.query<{ id: string }>( + `SELECT w.id FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' - ORDER BY m.joined_at LIMIT 1`, [authenticated.userId]); - if (!workspace.rows[0]) throw new ApiError(403, 'WORKSPACE_MEMBERSHIP_REQUIRED', '当前账户没有可用工作空间'); - request.extensionAuth = { userId: authenticated.userId, workspaceId: workspace.rows[0].id, sessionId: authenticated.sessionId, clientId: 'atlas-web' }; + ORDER BY m.joined_at LIMIT 1`, + [authenticated.userId], + ); + if (!workspace.rows[0]) + throw new ApiError( + 403, + 'WORKSPACE_MEMBERSHIP_REQUIRED', + '当前账户没有可用工作空间', + ); + request.extensionAuth = { + userId: authenticated.userId, + workspaceId: workspace.rows[0].id, + sessionId: authenticated.sessionId, + clientId: 'atlas-web', + }; return true; } } diff --git a/apps/api/src/extension/extension.controller.ts b/apps/api/src/extension/extension.controller.ts index 1e8bff8..aa7ff79 100644 --- a/apps/api/src/extension/extension.controller.ts +++ b/apps/api/src/extension/extension.controller.ts @@ -1,22 +1,90 @@ -import { Body, Controller, Get, Headers, Inject, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Headers, + Inject, + Post, + Req, + Res, + UseGuards, +} from '@nestjs/common'; import { z } from 'zod'; import { collectedProductPayloadSchema } from '@atlas/contracts'; import { ApiError } from '../common/api-error.js'; import { Public } from '../common/public.decorator.js'; -import { ExtensionAccessGuard, type ExtensionAuthenticatedRequest } from './extension-auth.guard.js'; +import { + ExtensionAccessGuard, + type ExtensionAuthenticatedRequest, +} from './extension-auth.guard.js'; import { ExtensionService } from './extension.service.js'; type ResponseLike = { status: (code: number) => ResponseLike }; @Controller('extension') export class ExtensionController { - public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {} - @Get('session') @Public() @UseGuards(ExtensionAccessGuard) - public session(@Req() request: ExtensionAuthenticatedRequest) { return { data: { authenticated: true, workspace: { id: request.extensionAuth.workspaceId, available: true }, can_collect: true } }; } - @Post('materials') @Public() @UseGuards(ExtensionAccessGuard) - public async materials(@Req() request: ExtensionAuthenticatedRequest, @Headers('idempotency-key') key: string | undefined, @Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) { - if (!key || !z.uuid().safeParse(key).success) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少或格式错误的 Idempotency-Key'); - const result = await this.extension.ingest(request.extensionAuth.workspaceId, request.extensionAuth.userId, key, parse(collectedProductPayloadSchema, body)); response.status(result.status); return result.body; + public constructor( + @Inject(ExtensionService) private readonly extension: ExtensionService, + ) {} + @Get('session') + @Public() + @UseGuards(ExtensionAccessGuard) + public session(@Req() request: ExtensionAuthenticatedRequest) { + return { + data: { + authenticated: true, + workspace: { + id: request.extensionAuth.workspaceId, + available: true, + }, + can_collect: true, + }, + }; + } + @Post('materials') + @Public() + @UseGuards(ExtensionAccessGuard) + public async materials( + @Req() request: ExtensionAuthenticatedRequest, + @Headers('idempotency-key') key: string | undefined, + @Body() body: unknown, + @Res({ passthrough: true }) response: ResponseLike, + ) { + if (!key || !z.uuid().safeParse(key).success) + throw new ApiError( + 400, + 'IDEMPOTENCY_KEY_REQUIRED', + '请求缺少或格式错误的 Idempotency-Key', + ); + const result = await this.extension.ingest( + request.extensionAuth.workspaceId, + request.extensionAuth.userId, + key, + parse(collectedProductPayloadSchema, body), + ); + response.status(result.status); + return result.body; } } -function parse { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, value: unknown) { const result = schema.safeParse(value); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((item) => ({ field: item.path.join('.'), code: 'INVALID_FIELD', message: item.message }))); } +function parse< + T extends { + safeParse: (value: unknown) => { + success: boolean; + data?: unknown; + error?: { issues: Array<{ path: PropertyKey[]; message: string }> }; + }; + }, +>(schema: T, value: unknown) { + const result = schema.safeParse(value); + if (result.success) return result.data as never; + throw new ApiError( + 422, + 'VALIDATION_FAILED', + '请求参数不符合要求', + result.error?.issues.map((item) => ({ + field: item.path.join('.'), + code: 'INVALID_FIELD', + message: item.message, + })), + ); +} diff --git a/apps/api/src/extension/extension.module.ts b/apps/api/src/extension/extension.module.ts index 766e230..5610099 100644 --- a/apps/api/src/extension/extension.module.ts +++ b/apps/api/src/extension/extension.module.ts @@ -3,5 +3,9 @@ import { AuthModule } from '../auth/auth.module.js'; import { ExtensionAccessGuard } from './extension-auth.guard.js'; import { ExtensionController } from './extension.controller.js'; import { ExtensionService } from './extension.service.js'; -@Module({ imports: [AuthModule], controllers: [ExtensionController], providers: [ExtensionService, ExtensionAccessGuard] }) +@Module({ + imports: [AuthModule], + controllers: [ExtensionController], + providers: [ExtensionService, ExtensionAccessGuard], +}) export class ExtensionModule {} diff --git a/apps/api/src/product-editor/product-editor.catalog-operations.ts b/apps/api/src/product-editor/product-editor.catalog-operations.ts new file mode 100644 index 0000000..5b76d1b --- /dev/null +++ b/apps/api/src/product-editor/product-editor.catalog-operations.ts @@ -0,0 +1,274 @@ +import type { ProductAttributeSchema } from '@atlas/contracts'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import type { ProfileRow } from './product-editor.models.js'; + +type CatalogSyncInput = { + workspaceId: string; + userId: string; + profileId: string; + platformId: string; + key: string; + scope: 'attributes' | 'dictionary'; + path: string; + categoryId?: string; + dictionaryId?: string; + query?: string; +}; + +type CatalogSyncResult = { + status: number; + body: unknown; +}; + +type CatalogAccess = { + requireProfileForPlatform: ( + transaction: DatabaseTransaction, + profileId: string, + platformId: string, + ) => Promise; + requireEditableStore: ( + transaction: DatabaseTransaction, + storeId: string, + platformId: string, + ) => Promise; + requireActiveCategory: ( + transaction: DatabaseTransaction, + storeId: string, + categoryId: string, + ) => Promise; + attributeSchemaForCategory: ( + transaction: DatabaseTransaction, + storeId: string, + categoryId: string, + ) => Promise; + requestCatalogSync: (input: CatalogSyncInput) => Promise; +}; + +/** + * 处理商品编辑器的类目、属性定义和字典缓存读取。 + * 数据校验仍复用工作流服务的统一访问边界,避免目录读取绕过平台和店铺隔离。 + */ +export class ProductEditorCatalogOperations { + /** 注入工作空间事务入口及共享的权限、归属校验能力。 */ + public constructor( + private readonly database: Database, + private readonly access: CatalogAccess, + ) {} + + /** 读取当前或指定目标店铺的完整类目缓存。 */ + async categoryOptions( + workspaceId: string, + profileId: string, + platformId: string, + search: string | undefined, + storeId?: string, + ) { + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const profile = await this.access.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const selectedStoreId = storeId ?? profile.target_store_id; + if (storeId) + await this.access.requireEditableStore( + transaction, + storeId, + platformId, + ); + const categories = await transaction.query<{ + id: string; + parent_id: string | null; + external_category_id: string; + external_type_id: string | null; + name: string; + path: string; + is_active: boolean; + }>( + `SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active + FROM atlas.store_product_categories + WHERE store_id = $1 + ORDER BY path, name, id + LIMIT 10000`, + [selectedStoreId], + ); + // 保留参数以保持既有 HTTP 契约;当前缓存树始终完整返回。 + void search; + return categories.rows; + }, + ); + } + + /** 获取当前或指定类目的已缓存动态属性定义。 */ + async attributeSchema( + workspaceId: string, + profileId: string, + platformId: string, + categoryId?: string, + storeId?: string, + ) { + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const profile = await this.access.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const selectedCategoryId = + categoryId ?? profile.store_product_category_id; + const selectedStoreId = storeId ?? profile.target_store_id; + if (storeId) + await this.access.requireEditableStore( + transaction, + storeId, + platformId, + ); + if (!selectedCategoryId) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '请先选择产品类目', + ); + if (categoryId) + await this.access.requireActiveCategory( + transaction, + selectedStoreId, + categoryId, + ); + return this.access.attributeSchemaForCategory( + transaction, + selectedStoreId, + selectedCategoryId, + ); + }, + ); + } + + /** 在确认字典属于当前类目后,搜索店铺本地缓存的字典值。 */ + async attributeDictionary( + workspaceId: string, + profileId: string, + platformId: string, + dictionaryId: string, + search: string | undefined, + categoryId?: string, + storeId?: string, + ) { + return this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const profile = await this.access.requireProfileForPlatform( + transaction, + profileId, + platformId, + ); + const selectedCategoryId = + categoryId ?? profile.store_product_category_id; + const selectedStoreId = storeId ?? profile.target_store_id; + if (storeId) + await this.access.requireEditableStore( + transaction, + storeId, + platformId, + ); + if (!selectedCategoryId) + throw new ApiError( + 422, + 'CATEGORY_REQUIRED', + '请先选择产品类目', + ); + if (categoryId) + await this.access.requireActiveCategory( + transaction, + selectedStoreId, + categoryId, + ); + const allowed = await transaction.query<{ dictionary_id: string }>( + `SELECT dictionary_id + FROM atlas.store_product_category_attributes + WHERE store_id = $1 AND store_product_category_id = $2 + AND dictionary_id = $3 + LIMIT 1`, + [selectedStoreId, selectedCategoryId, dictionaryId], + ); + if (!allowed.rows[0]) + throw new ApiError( + 422, + 'ATTRIBUTE_DICTIONARY_INVALID', + '字典不属于当前类目的属性', + ); + const needle = search?.trim() ?? ''; + const values = await transaction.query<{ + value_id: string; + value: string; + }>( + `SELECT external_value_id AS value_id, value + FROM atlas.store_attribute_dictionary_values + WHERE store_id = $1 AND store_product_category_id = $2 + AND dictionary_id = $3 AND is_active + AND ($4 = '' OR value ILIKE '%' || $4 || '%') + ORDER BY value, external_value_id + LIMIT 100`, + [selectedStoreId, selectedCategoryId, dictionaryId, needle], + ); + return { items: values.rows, next_cursor: null }; + }, + ); + } + + /** 创建类目属性定义同步任务。 */ + async requestAttributeSchemaSync( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + key: string, + categoryId: string, + ) { + return this.access.requestCatalogSync({ + workspaceId, + userId, + profileId, + platformId, + key, + scope: 'attributes', + path: 'attribute-schema-syncs', + categoryId, + }); + } + + /** 创建带关键词限制的属性字典同步任务。 */ + async requestAttributeDictionarySync( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + key: string, + categoryId: string, + dictionaryId: string, + query: string, + ) { + if (query.trim().length < 2) + throw new ApiError( + 422, + 'DICTIONARY_SEARCH_REQUIRED', + '请至少输入 2 个字符后再从 Ozon 搜索字典值', + ); + return this.access.requestCatalogSync({ + workspaceId, + userId, + profileId, + platformId, + key, + scope: 'dictionary', + path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`, + categoryId, + dictionaryId, + query: query.trim(), + }); + } +} diff --git a/apps/api/src/product-editor/product-editor.controller.ts b/apps/api/src/product-editor/product-editor.controller.ts index 7907500..94aff01 100644 --- a/apps/api/src/product-editor/product-editor.controller.ts +++ b/apps/api/src/product-editor/product-editor.controller.ts @@ -25,11 +25,13 @@ 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, @@ -50,6 +52,7 @@ export class ProductEditorController { return result.body; } + /** 返回当前平台下完整的商品编辑读模型。 */ @Get('products/:productId/editor') async read( @Param('productId') productId: string, @@ -65,6 +68,7 @@ export class ProductEditorController { }; } + /** 返回目标店铺已缓存的类目树,供编辑器选择。 */ @Get('product-platform-profiles/:profileId/category-options') async categoryOptions( @Param('profileId') profileId: string, @@ -84,6 +88,7 @@ export class ProductEditorController { }; } + /** 返回所选类目的缓存动态属性定义。 */ @Get('product-platform-profiles/:profileId/attribute-schema') async attributeSchema( @Param('profileId') profileId: string, @@ -103,6 +108,7 @@ export class ProductEditorController { }; } + /** 幂等地创建所选类目属性定义的刷新任务。 */ @Post('product-platform-profiles/:profileId/attribute-schema-syncs') async attributeSchemaSync( @Param('profileId') profileId: string, @@ -125,6 +131,7 @@ export class ProductEditorController { return result.body; } + /** 从本地缓存中查询属性字典值。 */ @Get( 'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId', ) @@ -152,6 +159,7 @@ export class ProductEditorController { }; } + /** 本地缓存不足时幂等地创建远程字典查询任务。 */ @Post( 'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs', ) @@ -182,6 +190,7 @@ export class ProductEditorController { return result.body; } + /** 校验并原子保存商品及其上架草稿。 */ @Put('products/:productId/editor-draft') async save( @Param('productId') productId: string, @@ -219,6 +228,7 @@ export class ProductEditorController { } } +/** 校验路由参数或请求头中的 UUID,并返回原始值。 */ function requiredUuid(field: string, value: string | undefined) { if (value && z.uuid().safeParse(value).success) return value; throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [ @@ -226,11 +236,13 @@ function requiredUuid(field: string, value: string | undefined) { ]); } +/** 将缺省查询参数保留为 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) diff --git a/apps/api/src/product-editor/product-editor.models.ts b/apps/api/src/product-editor/product-editor.models.ts new file mode 100644 index 0000000..b76ce9f --- /dev/null +++ b/apps/api/src/product-editor/product-editor.models.ts @@ -0,0 +1,69 @@ +/** 商品编辑领域从数据库读取的商品行。 */ +export type ProductRow = { + id: string; + collection_item_id: string; + revision: number; + source_title: string | null; + brand: string | null; + model: string | null; + barcode: string | null; + purchase_url: string | null; + supply_note: string | null; + default_physical_length_mm: string | null; + default_physical_width_mm: string | null; + default_physical_height_mm: string | null; + default_net_weight_g: string | null; + default_package_length_mm: string | null; + default_package_width_mm: string | null; + default_package_height_mm: string | null; + default_shipping_weight_g: string | null; + physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; + package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; + ai_source_material_overrides: unknown; + ai_source_material_base_revision: number | string | null; + normalized_payload: unknown; + source_snapshot_revision: number | string; +}; + +/** 商品在一个发布平台下的编辑配置行。 */ +export type ProfileRow = { + id: string; + revision: number; + platform_id: string; + market_code: string | null; + fulfillment: string; + title: string | null; + description: string | null; + tags: unknown; + store_product_category_id: string | null; + platform_type_id: string | null; + category_source: string | null; + category_name: string | null; + category_path: string | null; + target_store_id: string; + store_name: string; + currency_code: string; +}; + +/** 异步目录任务返回给客户端所需的操作行。 */ +export type OperationRow = { + id: string; + kind: string; + status: string; + created_at: Date | string; + completed_at: Date | string | null; + error_code: string | null; + error_message: string | null; +}; + +/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */ +export const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code, + p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id, + p.platform_type_id, p.category_source, category.name AS category_name, + category.path AS category_path, p.target_store_id, s.name AS store_name, + s.currency_code + FROM atlas.product_platform_profiles p + JOIN atlas.stores s ON s.id = p.target_store_id + LEFT JOIN atlas.store_product_categories category + ON category.id = p.store_product_category_id + AND category.store_id = p.target_store_id`; diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 3ad8f4a..e7c7ee7 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -1,11 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { - productAiSourceMaterialOverridesSchema, - productAiSourceMaterialSchema, -} from '@atlas/contracts'; import type { - ProductAiSourceField, - ProductAiSourceMaterial, ProductEditorContext, ProductEditorSnapshot, SaveProductEditorDraftRequest, @@ -15,68 +9,51 @@ import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; import { DATABASE } from '../database/database.module.js'; - -type ProductRow = { - id: string; - collection_item_id: string; - revision: number; - source_title: string | null; - brand: string | null; - model: string | null; - barcode: string | null; - purchase_url: string | null; - supply_note: string | null; - default_physical_length_mm: string | null; - default_physical_width_mm: string | null; - default_physical_height_mm: string | null; - default_net_weight_g: string | null; - default_package_length_mm: string | null; - default_package_width_mm: string | null; - default_package_height_mm: string | null; - default_shipping_weight_g: string | null; - physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; - package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; - ai_source_material_overrides: unknown; - ai_source_material_base_revision: number | string | null; - normalized_payload: unknown; - source_snapshot_revision: number | string; -}; -type ProfileRow = { - id: string; - revision: number; - platform_id: string; - market_code: string | null; - fulfillment: string; - title: string | null; - description: string | null; - tags: unknown; - store_product_category_id: string | null; - platform_type_id: string | null; - category_source: string | null; - category_name: string | null; - category_path: string | null; - target_store_id: string; - store_name: string; - currency_code: string; -}; -type OperationRow = { - id: string; - kind: string; - status: string; - created_at: Date | string; - completed_at: Date | string | null; - error_code: string | null; - error_message: string | null; -}; +import { + type OperationRow, + type ProductRow, + type ProfileRow, + profileSelect, +} from './product-editor.models.js'; +import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js'; +import { + latestProductAiSourceMaterial, + normalizedCategoryPath as normalizeCapturedCategoryPath, + productAiSourceMaterial as resolveSourceMaterial, + productAiSourceOverrides as selectSourceMaterialOverrides, +} from './product-editor.source-material.js'; +import { + effectivePackageSpec as resolveEffectivePackageSpec, + effectivePhysicalSpec as resolveEffectivePhysicalSpec, + productPackageSpec as mapProductPackageSpec, + productPhysicalSpec as mapProductPhysicalSpec, + specificationValues as filterSpecificationValues, + variantPackageSpec as mapVariantPackageSpec, + variantPhysicalSpec as mapVariantPhysicalSpec, +} from './product-editor.specifications.js'; @Injectable() export class ProductEditorService { + private readonly catalog: ProductEditorCatalogOperations; + + /** 注入编辑工作流所需的事务数据库和幂等边界。 */ public constructor( @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database, - ) {} + ) { + this.catalog = new ProductEditorCatalogOperations(this.database, { + requireProfileForPlatform: (...args) => + this.requireProfileForPlatform(...args), + requireEditableStore: (...args) => this.requireEditableStore(...args), + requireActiveCategory: (...args) => this.requireActiveCategory(...args), + attributeSchemaForCategory: (...args) => + this.attributeSchemaForCategory(...args), + requestCatalogSync: (input) => this.requestCatalogSync(input), + }); + } + /** 创建或复用平台发布配置,并返回编辑器导航上下文。 */ async enter( workspaceId: string, userId: string, @@ -169,6 +146,7 @@ export class ProductEditorService { ); } + /** 构建工作空间隔离的编辑读模型,并计算 SKU 继承规格。 */ async read( workspaceId: string, productId: string, @@ -340,9 +318,9 @@ export class ProductEditorService { WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`, [profile.id], ); - const defaultPhysicalSpec = productPhysicalSpec(product); - const defaultPackageSpec = productPackageSpec(product); - const sourceMaterialResult = productAiSourceMaterial(product); + const defaultPhysicalSpec = mapProductPhysicalSpec(product); + const defaultPackageSpec = mapProductPackageSpec(product); + const sourceMaterialResult = resolveSourceMaterial(product); const sourceMaterial = sourceMaterialResult.effective; const editorVariants: ProductEditorSnapshot['variants'] = variants.rows.map((variant) => ({ @@ -352,16 +330,16 @@ export class ProductEditorService { display_name: variant.display_name, sort_order: variant.sort_order, purchase_cost_cny: variant.purchase_cost_cny, - specification_values: specificationValues( + specification_values: filterSpecificationValues( variant.specification_values, ), - physical_spec: variantPhysicalSpec(variant), - package_spec: variantPackageSpec(variant), - effective_physical_spec: effectivePhysicalSpec( + physical_spec: mapVariantPhysicalSpec(variant), + package_spec: mapVariantPackageSpec(variant), + effective_physical_spec: resolveEffectivePhysicalSpec( product, variant, ), - effective_package_spec: effectivePackageSpec( + effective_package_spec: resolveEffectivePackageSpec( product, variant, ), @@ -475,6 +453,7 @@ export class ProductEditorService { ); } + /** 读取发布配置当前或指定目标店铺的缓存类目。 */ async categoryOptions( workspaceId: string, profileId: string, @@ -482,39 +461,16 @@ export class ProductEditorService { search: string | undefined, storeId?: string, ) { - return this.database.workspaceTransaction( + return this.catalog.categoryOptions( workspaceId, - async (transaction) => { - const profile = await this.requireProfileForPlatform( - transaction, - profileId, - platformId, - ); - const needle = search?.trim() ?? ''; - const selectedStoreId = storeId ?? profile.target_store_id; - if (storeId) - await this.requireEditableStore(transaction, storeId, platformId); - const categories = await transaction.query<{ - id: string; - parent_id: string | null; - external_category_id: string; - external_type_id: string | null; - name: string; - path: string; - is_active: boolean; - }>( - `SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active - FROM atlas.store_product_categories - WHERE store_id = $1 - ORDER BY path, name, id - LIMIT 10000`, - [selectedStoreId], - ); - return categories.rows; - }, + profileId, + platformId, + search, + storeId, ); } + /** 获取有效启用类目的缓存动态属性定义。 */ async attributeSchema( workspaceId: string, profileId: string, @@ -522,40 +478,16 @@ export class ProductEditorService { categoryId?: string, storeId?: string, ) { - return this.database.workspaceTransaction( + return this.catalog.attributeSchema( workspaceId, - async (transaction) => { - const profile = await this.requireProfileForPlatform( - transaction, - profileId, - platformId, - ); - const selectedCategoryId = - categoryId ?? profile.store_product_category_id; - const selectedStoreId = storeId ?? profile.target_store_id; - if (storeId) - await this.requireEditableStore(transaction, storeId, platformId); - if (!selectedCategoryId) - throw new ApiError( - 422, - 'CATEGORY_REQUIRED', - '请先选择产品类目', - ); - if (categoryId) - await this.requireActiveCategory( - transaction, - selectedStoreId, - categoryId, - ); - return this.attributeSchemaForCategory( - transaction, - selectedStoreId, - selectedCategoryId, - ); - }, + profileId, + platformId, + categoryId, + storeId, ); } + /** 仅从店铺本地缓存查询当前类目的字典值。 */ async attributeDictionary( workspaceId: string, profileId: string, @@ -565,66 +497,18 @@ export class ProductEditorService { categoryId?: string, storeId?: string, ) { - return this.database.workspaceTransaction( + return this.catalog.attributeDictionary( workspaceId, - async (transaction) => { - const profile = await this.requireProfileForPlatform( - transaction, - profileId, - platformId, - ); - const selectedCategoryId = - categoryId ?? profile.store_product_category_id; - const selectedStoreId = storeId ?? profile.target_store_id; - if (storeId) - await this.requireEditableStore(transaction, storeId, platformId); - if (!selectedCategoryId) - throw new ApiError( - 422, - 'CATEGORY_REQUIRED', - '请先选择产品类目', - ); - if (categoryId) - await this.requireActiveCategory( - transaction, - selectedStoreId, - categoryId, - ); - const allowed = await transaction.query<{ - dictionary_id: string; - }>( - `SELECT dictionary_id - FROM atlas.store_product_category_attributes - WHERE store_id = $1 AND store_product_category_id = $2 - AND dictionary_id = $3 - LIMIT 1`, - [selectedStoreId, selectedCategoryId, dictionaryId], - ); - if (!allowed.rows[0]) - throw new ApiError( - 422, - 'ATTRIBUTE_DICTIONARY_INVALID', - '字典不属于当前类目的属性', - ); - const needle = search?.trim() ?? ''; - const values = await transaction.query<{ - value_id: string; - value: string; - }>( - `SELECT external_value_id AS value_id, value - FROM atlas.store_attribute_dictionary_values - WHERE store_id = $1 AND store_product_category_id = $2 - AND dictionary_id = $3 AND is_active - AND ($4 = '' OR value ILIKE '%' || $4 || '%') - ORDER BY value, external_value_id - LIMIT 100`, - [selectedStoreId, selectedCategoryId, dictionaryId, needle], - ); - return { items: values.rows, next_cursor: null }; - }, + profileId, + platformId, + dictionaryId, + search, + categoryId, + storeId, ); } + /** 幂等地调度 Worker 刷新类目属性定义。 */ async requestAttributeSchemaSync( workspaceId: string, userId: string, @@ -633,18 +517,17 @@ export class ProductEditorService { key: string, categoryId: string, ) { - return this.requestCatalogSync({ + return this.catalog.requestAttributeSchemaSync( workspaceId, userId, profileId, platformId, key, - scope: 'attributes', - path: 'attribute-schema-syncs', categoryId, - }); + ); } + /** 在查询词有效后幂等地调度远程字典检索。 */ async requestAttributeDictionarySync( workspaceId: string, userId: string, @@ -655,27 +538,19 @@ export class ProductEditorService { dictionaryId: string, query: string, ) { - if (query.trim().length < 2) { - throw new ApiError( - 422, - 'DICTIONARY_SEARCH_REQUIRED', - '请至少输入 2 个字符后再从 Ozon 搜索字典值', - ); - } - return this.requestCatalogSync({ + return this.catalog.requestAttributeDictionarySync( workspaceId, userId, profileId, platformId, key, - scope: 'dictionary', - path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`, categoryId, dictionaryId, - query: query.trim(), - }); + query, + ); } + /** 原子校验并保存商品、发布配置、SKU、媒体和动态属性。 */ async save( workspaceId: string, userId: string, @@ -700,10 +575,8 @@ export class ProductEditorService { }, async (transaction) => { await this.requireTargetPlatform(transaction, platformId); - // Lock the current source row before comparing its snapshot - // revision and persisting overrides. A simultaneous re-capture - // then either completes first (and this request returns 409), - // or waits until this atomic draft save commits. + // 先锁定来源快照,再比较版本和保存覆盖字段;并发复采只能先完成 + // 并让本请求返回 409,或等待本次原子草稿保存提交,避免覆盖错位。 const product = await this.requireProduct( transaction, productId, @@ -792,7 +665,7 @@ export class ProductEditorService { platformId, input, ); - const sourceMaterialOverrides = productAiSourceOverrides( + const sourceMaterialOverrides = selectSourceMaterialOverrides( input.ai_source_material, input.ai_source_overridden_fields, ); @@ -1028,9 +901,8 @@ export class ProductEditorService { ], ); } - // A removed, never-imported SKU must release its draft offer_id; - // otherwise a replacement SKU in the same store would hit the - // durable uniqueness rule despite the user having deleted it. + // 已删除且未导入的 SKU 必须释放草稿 offer_id;否则同店替换 SKU + // 会因持久化唯一约束失败,即使用户已在本次草稿中删除原 SKU。 await transaction.query( `UPDATE atlas.product_variant_platform_listings l SET offer_id = NULL, revision = revision + 1, updated_at = now() @@ -1078,6 +950,7 @@ export class ProductEditorService { ); } + /** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */ private async ensureListings( transaction: DatabaseTransaction, productId: string, @@ -1101,10 +974,8 @@ export class ProductEditorService { } /** - * Persist final image order only after every supplied asset has passed the - * product/SKU provenance check. A completed transfer is the bridge from - * an untrusted source URL to an Atlas-owned media asset; callers cannot - * attach arbitrary workspace assets to a listing. + * 仅在每个素材都通过商品/SKU 来源校验后保存最终排序。已完成的转存是 + * 不可信来源 URL 转为 Atlas 素材的边界,调用方不能任意关联工作空间素材。 */ private async persistListingMedia( transaction: DatabaseTransaction, @@ -1257,6 +1128,7 @@ export class ProductEditorService { } } + /** 按类目定义保存草稿属性,并保留无法映射的历史属性。 */ private async persistDynamicAttributes( transaction: DatabaseTransaction, input: { @@ -1388,10 +1260,8 @@ export class ProductEditorService { 'SKU 属性必须关联当前商品中未删除的 SKU', ); if (!input.retainedVariantIds.includes(attribute.variant_id)) { - // A removed SKU takes its old attributes with it. Reject a - // newly fabricated reference, but do not make the normal - // “delete SKU” operation fail because the form snapshot - // still contains that SKU's previous attribute row. + // 删除 SKU 时其旧属性应一并删除;拒绝伪造的新引用,但不能因 + // 表单快照仍带着该 SKU 的旧属性而使正常的删除操作失败。 if (!existingByKey.has(savedKey)) throw new ApiError( 422, @@ -1411,9 +1281,8 @@ export class ProductEditorService { valid.push(attribute); } - // Attribute definitions can disappear when the category changes (or a - // platform sync retires an attribute). Preserve the existing draft - // rather than allowing it to vanish simply because this save omitted it. + // 类目变更或平台同步下线属性时,定义可能消失;需保留既有草稿,不能因 + // 本次保存未携带它就让用户数据静默丢失。 for (const attribute of existing.rows) { const key = savedAttributeKey(attribute); if ( @@ -1480,6 +1349,7 @@ export class ProductEditorService { } } + /** 校验草稿属性的类型、数量、字典值与 SKU 归属。 */ private async assertAttributeValues( transaction: DatabaseTransaction, storeId: string, @@ -1546,6 +1416,7 @@ export class ProductEditorService { ); } + /** 返回店铺有效类目的标准化属性定义。 */ private async attributeSchemaForCategory( transaction: DatabaseTransaction, storeId: string, @@ -1573,6 +1444,7 @@ export class ProductEditorService { return result.rows; } + /** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */ private async requestCatalogSync(input: { workspaceId: string; userId: string; @@ -1709,9 +1581,8 @@ export class ProductEditorService { } /** - * Ozon capture records breadcrumb names rather than a cache UUID. Resolve - * that evidence only while a profile has no saved category; a later - * recapture must never replace a user or previously persisted selection. + * Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时, + * 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。 */ private async applyCapturedCategory( transaction: DatabaseTransaction, @@ -1720,7 +1591,7 @@ export class ProductEditorService { ): Promise { if (profile.store_product_category_id) return profile; const categories = latestProductAiSourceMaterial(product).categories; - const sourcePath = normalizedCategoryPath(categories); + const sourcePath = normalizeCapturedCategoryPath(categories); if (!sourcePath) return profile; const sourcePlatform = await transaction.query<{ code: string }>( @@ -1761,6 +1632,7 @@ export class ProductEditorService { : profile; } + /** 确认类目属于目标店铺且仍处于启用状态。 */ private async requireActiveCategory( transaction: DatabaseTransaction, storeId: string, @@ -1784,6 +1656,7 @@ export class ProductEditorService { return result.rows[0]; } + /** 读取发布配置,并拒绝跨平台上下文访问。 */ private async requireProfileForPlatform( transaction: DatabaseTransaction, profileId: string, @@ -1796,6 +1669,7 @@ export class ProductEditorService { return profile; } + /** 确认页头当前平台已启用且可作为发布目标。 */ private async requireTargetPlatform( transaction: DatabaseTransaction, platformId: string, @@ -1815,6 +1689,7 @@ export class ProductEditorService { ); } + /** 读取创建发布配置时必需的、已验证默认店铺。 */ private async requireDefaultStore( transaction: DatabaseTransaction, platformId: string, @@ -1835,6 +1710,7 @@ export class ProductEditorService { return store.rows[0]; } + /** 确认指定店铺属于平台、可用且凭据已验证。 */ private async requireEditableStore( transaction: DatabaseTransaction, storeId: string, @@ -1854,6 +1730,7 @@ export class ProductEditorService { ); } + /** 在保存草稿前拒绝过期或跨商品的 SKU 标识。 */ private async ensureVariantIdsBelongToProduct( transaction: DatabaseTransaction, productId: string, @@ -1881,6 +1758,7 @@ export class ProductEditorService { } } + /** 确认草稿 offer_id 在目标店铺内保持唯一。 */ private async assertOfferIdsAvailable( transaction: DatabaseTransaction, productId: string, @@ -1920,6 +1798,7 @@ export class ProductEditorService { } } + /** 实物或包装规格标记为已确认时,要求尺寸和重量完整填写。 */ private assertConfirmedSpecsComplete(input: SaveProductEditorDraftRequest) { const check = ( status: 'missing' | 'ambiguous' | 'confirmed' | null, @@ -1985,6 +1864,7 @@ export class ProductEditorService { }); } + /** 使用数据库当前版本返回结构化的商品并发冲突。 */ private async throwProductRevisionConflict( transaction: DatabaseTransaction, productId: string, @@ -2007,6 +1887,7 @@ export class ProductEditorService { ]); } + /** 使用数据库当前版本返回结构化的发布配置并发冲突。 */ private async throwProfileRevisionConflict( transaction: DatabaseTransaction, profileId: string, @@ -2029,6 +1910,7 @@ export class ProductEditorService { ]); } + /** 读取关联到有效采集记录的编辑商品。 */ private async requireProductByCollectionItem( transaction: DatabaseTransaction, collectionItemId: string, @@ -2052,6 +1934,7 @@ export class ProductEditorService { return product.rows[0]; } + /** 读取有效编辑商品;保存草稿时可锁定其来源快照。 */ private async requireProduct( transaction: DatabaseTransaction, productId: string, @@ -2076,6 +1959,7 @@ export class ProductEditorService { return product.rows[0]; } + /** 查询商品在目标平台的发布配置;不存在时不视为异常。 */ private async findProfile( transaction: DatabaseTransaction, productId: string, @@ -2089,6 +1973,7 @@ export class ProductEditorService { return profile.rows[0] ?? null; } + /** 读取单个发布配置;并发删除时返回失败。 */ private async requireProfile( transaction: DatabaseTransaction, profileId: string, @@ -2101,6 +1986,7 @@ export class ProductEditorService { return profile.rows[0]; } + /** 将持久化发布配置转换为进入编辑器后的稳定上下文。 */ private toContext( collectionItemId: string, productId: string, @@ -2120,256 +2006,6 @@ export class ProductEditorService { } } -const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code, - p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id, - p.platform_type_id, p.category_source, category.name AS category_name, - category.path AS category_path, p.target_store_id, s.name AS store_name, - s.currency_code - FROM atlas.product_platform_profiles p - JOIN atlas.stores s ON s.id = p.target_store_id - LEFT JOIN atlas.store_product_categories category - ON category.id = p.store_product_category_id - AND category.store_id = p.target_store_id`; - -type VariantSpecRow = { - specification_values: unknown; - physical_length_mm: string | null; - physical_width_mm: string | null; - physical_height_mm: string | null; - net_weight_g: string | null; - physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null; - package_length_mm: string | null; - package_width_mm: string | null; - package_height_mm: string | null; - shipping_weight_g: string | null; - package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null; -}; - -function productPhysicalSpec(product: ProductRow) { - return { - length_mm: product.default_physical_length_mm, - width_mm: product.default_physical_width_mm, - height_mm: product.default_physical_height_mm, - net_weight_g: product.default_net_weight_g, - status: product.physical_spec_status, - }; -} - -function productPackageSpec(product: ProductRow) { - return { - length_mm: product.default_package_length_mm, - width_mm: product.default_package_width_mm, - height_mm: product.default_package_height_mm, - shipping_weight_g: product.default_shipping_weight_g, - status: product.package_spec_status, - }; -} - -function variantPhysicalSpec(variant: VariantSpecRow) { - return { - length_mm: variant.physical_length_mm, - width_mm: variant.physical_width_mm, - height_mm: variant.physical_height_mm, - net_weight_g: variant.net_weight_g, - status: variant.physical_spec_status, - }; -} - -function variantPackageSpec(variant: VariantSpecRow) { - return { - length_mm: variant.package_length_mm, - width_mm: variant.package_width_mm, - height_mm: variant.package_height_mm, - shipping_weight_g: variant.shipping_weight_g, - status: variant.package_spec_status, - }; -} - -function effectivePhysicalSpec(product: ProductRow, variant: VariantSpecRow) { - const fallback = productPhysicalSpec(product); - const override = variantPhysicalSpec(variant); - return { - length_mm: override.length_mm ?? fallback.length_mm, - width_mm: override.width_mm ?? fallback.width_mm, - height_mm: override.height_mm ?? fallback.height_mm, - net_weight_g: override.net_weight_g ?? fallback.net_weight_g, - status: override.status ?? fallback.status, - }; -} - -function effectivePackageSpec(product: ProductRow, variant: VariantSpecRow) { - const fallback = productPackageSpec(product); - const override = variantPackageSpec(variant); - return { - length_mm: override.length_mm ?? fallback.length_mm, - width_mm: override.width_mm ?? fallback.width_mm, - height_mm: override.height_mm ?? fallback.height_mm, - shipping_weight_g: - override.shipping_weight_g ?? fallback.shipping_weight_g, - status: override.status ?? fallback.status, - }; -} - -function specificationValues(value: unknown) { - if (!Array.isArray(value)) return []; - return value.filter( - ( - item, - ): item is { - axis_key: string; - value_key: string; - value_name: string; - } => - !!item && - typeof item === 'object' && - typeof (item as { axis_key?: unknown }).axis_key === 'string' && - typeof (item as { value_key?: unknown }).value_key === 'string' && - typeof (item as { value_name?: unknown }).value_name === 'string', - ); -} - -const productAiSourceFields: ProductAiSourceField[] = [ - 'title', - 'description', - 'price', - 'brand', - 'attributes', - 'categories', -]; - -function productAiSourceMaterial(product: ProductRow) { - const latest = latestProductAiSourceMaterial(product); - const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse( - product.ai_source_material_overrides, - ); - const overrides = parsedOverrides.success ? parsedOverrides.data : {}; - const overriddenFields = productAiSourceFields.filter((field) => - Object.prototype.hasOwnProperty.call(overrides, field), - ); - const effective = productAiSourceMaterialSchema.parse({ - ...latest, - ...overrides, - source_snapshot_revision: latest.source_snapshot_revision, - }); - const baseRevision = product.ai_source_material_base_revision - ? Number(product.ai_source_material_base_revision) - : null; - return { - effective, - state: { - latest, - overridden_fields: overriddenFields, - changed_fields: overriddenFields.filter( - (field) => - JSON.stringify(effective[field]) !== - JSON.stringify(latest[field]), - ), - base_source_snapshot_revision: baseRevision, - has_source_updates: - baseRevision !== null && - baseRevision !== latest.source_snapshot_revision, - }, - }; -} - -function productAiSourceOverrides( - material: ProductAiSourceMaterial, - overriddenFields: ProductAiSourceField[], -) { - const overrides: Record = {}; - for (const field of overriddenFields) overrides[field] = material[field]; - return productAiSourceMaterialOverridesSchema.parse(overrides); -} - -function latestProductAiSourceMaterial( - product: ProductRow, -): ProductAiSourceMaterial { - const payload = objectValue(product.normalized_payload); - const common = objectValue(payload?.common); - const title = objectValue(common?.title)?.value; - const description = objectValue(common?.description)?.value; - const sourcePrice = objectValue(common?.sourcePrice); - const attributes = Array.isArray(common?.attributes) - ? common.attributes.flatMap((item) => { - const attribute = objectValue(item); - return typeof attribute?.key === 'string' && - typeof attribute.name === 'string' && - typeof attribute.value === 'string' - ? [ - { - key: attribute.key, - name: attribute.name, - value: attribute.value, - }, - ] - : []; - }) - : []; - const categories = Array.isArray(common?.categories) - ? common.categories.flatMap((item) => { - const category = objectValue(item); - return typeof category?.key === 'string' && - typeof category.name === 'string' - ? [{ key: category.key, name: category.name }] - : []; - }) - : []; - const brand = attributes.find((attribute) => - /brand|品牌/i.test(`${attribute.key} ${attribute.name}`), - )?.value; - const candidate: ProductAiSourceMaterial = { - title: typeof title === 'string' ? title : product.source_title, - description: typeof description === 'string' ? description : null, - price: - typeof sourcePrice?.amount === 'string' && - typeof sourcePrice.currencyCode === 'string' - ? { - amount: sourcePrice.amount, - currency_code: sourcePrice.currencyCode.toUpperCase(), - } - : null, - brand: brand ?? product.brand, - attributes, - categories, - source_snapshot_revision: Number(product.source_snapshot_revision), - }; - const parsed = productAiSourceMaterialSchema.safeParse(candidate); - if (parsed.success) return parsed.data; - return { - title: product.source_title, - description: null, - price: null, - brand: product.brand, - attributes: [], - categories: [], - source_snapshot_revision: Math.max( - 1, - Number(product.source_snapshot_revision) || 1, - ), - }; -} - -function normalizedCategoryPath( - categories: Array<{ name: string }>, -): string | null { - const names = categories - .map((category) => - category.name - .normalize('NFKC') - .trim() - .replace(/\s+/g, ' ') - .toLocaleLowerCase(), - ) - .filter(Boolean); - return names.length ? names.join(' › ') : null; -} - -function objectValue(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function attributeKey(platformAttributeId: string, complexId: string) { return `${platformAttributeId}\u0000${complexId}`; } diff --git a/apps/api/src/product-editor/product-editor.source-material.ts b/apps/api/src/product-editor/product-editor.source-material.ts new file mode 100644 index 0000000..782cb10 --- /dev/null +++ b/apps/api/src/product-editor/product-editor.source-material.ts @@ -0,0 +1,147 @@ +import { + productAiSourceMaterialOverridesSchema, + productAiSourceMaterialSchema, +} from '@atlas/contracts'; +import type { + ProductAiSourceField, + ProductAiSourceMaterial, +} from '@atlas/contracts'; + +type SourceMaterialProduct = { + source_title: string | null; + brand: string | null; + normalized_payload: unknown; + source_snapshot_revision: number | string; + ai_source_material_overrides: unknown; + ai_source_material_base_revision: number | string | null; +}; + +const productAiSourceFields: ProductAiSourceField[] = [ + 'title', + 'description', + 'price', + 'brand', + 'attributes', + 'categories', +]; + +/** 计算包含用户覆盖字段的有效 AI 来源素材及其状态。 */ +export function productAiSourceMaterial(product: SourceMaterialProduct) { + const latest = latestProductAiSourceMaterial(product); + const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse( + product.ai_source_material_overrides, + ); + const overrides = parsedOverrides.success ? parsedOverrides.data : {}; + const overriddenFields = productAiSourceFields.filter((field) => + Object.prototype.hasOwnProperty.call(overrides, field), + ); + const effective = productAiSourceMaterialSchema.parse({ + ...latest, + ...overrides, + source_snapshot_revision: latest.source_snapshot_revision, + }); + const baseRevision = product.ai_source_material_base_revision + ? Number(product.ai_source_material_base_revision) + : null; + return { + effective, + state: { + latest, + overridden_fields: overriddenFields, + changed_fields: overriddenFields.filter( + (field) => + JSON.stringify(effective[field]) !== + JSON.stringify(latest[field]), + ), + base_source_snapshot_revision: baseRevision, + has_source_updates: + baseRevision !== null && + baseRevision !== latest.source_snapshot_revision, + }, + }; +} + +/** 仅提取用户明确覆盖的字段,供草稿持久化。 */ +export function productAiSourceOverrides( + material: ProductAiSourceMaterial, + overriddenFields: ProductAiSourceField[], +) { + const overrides: Record = {}; + for (const field of overriddenFields) overrides[field] = material[field]; + return productAiSourceMaterialOverridesSchema.parse(overrides); +} + +/** 提取未经覆盖的最新采集快照,用于类目匹配和变更检测。 */ +export function latestProductAiSourceMaterial( + product: SourceMaterialProduct, +): ProductAiSourceMaterial { + const payload = objectValue(product.normalized_payload); + const common = objectValue(payload?.common); + const title = objectValue(common?.title)?.value; + const description = objectValue(common?.description)?.value; + const sourcePrice = objectValue(common?.sourcePrice); + const attributes = Array.isArray(common?.attributes) + ? common.attributes.flatMap((item) => { + const attribute = objectValue(item); + return typeof attribute?.key === 'string' && + typeof attribute.name === 'string' && + typeof attribute.value === 'string' + ? [{ key: attribute.key, name: attribute.name, value: attribute.value }] + : []; + }) + : []; + const categories = Array.isArray(common?.categories) + ? common.categories.flatMap((item) => { + const category = objectValue(item); + return typeof category?.key === 'string' && typeof category.name === 'string' + ? [{ key: category.key, name: category.name }] + : []; + }) + : []; + const brand = attributes.find((attribute) => + /brand|品牌/i.test(`${attribute.key} ${attribute.name}`), + )?.value; + const candidate: ProductAiSourceMaterial = { + title: typeof title === 'string' ? title : product.source_title, + description: typeof description === 'string' ? description : null, + price: + typeof sourcePrice?.amount === 'string' && + typeof sourcePrice.currencyCode === 'string' + ? { amount: sourcePrice.amount, currency_code: sourcePrice.currencyCode.toUpperCase() } + : null, + brand: brand ?? product.brand, + attributes, + categories, + source_snapshot_revision: Number(product.source_snapshot_revision), + }; + const parsed = productAiSourceMaterialSchema.safeParse(candidate); + if (parsed.success) return parsed.data; + return { + title: product.source_title, + description: null, + price: null, + brand: product.brand, + attributes: [], + categories: [], + source_snapshot_revision: Math.max(1, Number(product.source_snapshot_revision) || 1), + }; +} + +/** 将未知 JSON 收窄为对象记录,供采集快照解析。 */ +function objectValue(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */ +export function normalizedCategoryPath( + categories: Array<{ name: string }>, +): string | null { + const names = categories + .map((category) => + category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(), + ) + .filter(Boolean); + return names.length ? names.join(' › ') : null; +} diff --git a/apps/api/src/product-editor/product-editor.specifications.ts b/apps/api/src/product-editor/product-editor.specifications.ts new file mode 100644 index 0000000..ff7335c --- /dev/null +++ b/apps/api/src/product-editor/product-editor.specifications.ts @@ -0,0 +1,126 @@ +/** + * 编辑读模型共用的纯规格映射函数。 + * 独立于工作流服务,便于单独审查继承规则。 + */ +type ProductSpecSource = { + default_physical_length_mm: string | null; + default_physical_width_mm: string | null; + default_physical_height_mm: string | null; + default_net_weight_g: string | null; + default_package_length_mm: string | null; + default_package_width_mm: string | null; + default_package_height_mm: string | null; + default_shipping_weight_g: string | null; + physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; + package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; +}; + +type VariantSpecSource = { + specification_values: unknown; + physical_length_mm: string | null; + physical_width_mm: string | null; + physical_height_mm: string | null; + net_weight_g: string | null; + physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null; + package_length_mm: string | null; + package_width_mm: string | null; + package_height_mm: string | null; + shipping_weight_g: string | null; + package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null; +}; + +/** 将持久化的商品默认值映射为对外的实物规格。 */ +export function productPhysicalSpec(product: ProductSpecSource) { + return { + length_mm: product.default_physical_length_mm, + width_mm: product.default_physical_width_mm, + height_mm: product.default_physical_height_mm, + net_weight_g: product.default_net_weight_g, + status: product.physical_spec_status, + }; +} + +/** 将持久化的商品默认值映射为对外的包装规格。 */ +export function productPackageSpec(product: ProductSpecSource) { + return { + length_mm: product.default_package_length_mm, + width_mm: product.default_package_width_mm, + height_mm: product.default_package_height_mm, + shipping_weight_g: product.default_shipping_weight_g, + status: product.package_spec_status, + }; +} + +/** 映射 SKU 可空的实物规格覆盖值,不应用商品继承。 */ +export function variantPhysicalSpec(variant: VariantSpecSource) { + return { + length_mm: variant.physical_length_mm, + width_mm: variant.physical_width_mm, + height_mm: variant.physical_height_mm, + net_weight_g: variant.net_weight_g, + status: variant.physical_spec_status, + }; +} + +/** 映射 SKU 可空的包装规格覆盖值,不应用商品继承。 */ +export function variantPackageSpec(variant: VariantSpecSource) { + return { + length_mm: variant.package_length_mm, + width_mm: variant.package_width_mm, + height_mm: variant.package_height_mm, + shipping_weight_g: variant.shipping_weight_g, + status: variant.package_spec_status, + }; +} + +/** 计算 SKU 继承后的有效实物规格。 */ +export function effectivePhysicalSpec( + product: ProductSpecSource, + variant: VariantSpecSource, +) { + const fallback = productPhysicalSpec(product); + const override = variantPhysicalSpec(variant); + return { + length_mm: override.length_mm ?? fallback.length_mm, + width_mm: override.width_mm ?? fallback.width_mm, + height_mm: override.height_mm ?? fallback.height_mm, + net_weight_g: override.net_weight_g ?? fallback.net_weight_g, + status: override.status ?? fallback.status, + }; +} + +/** 计算 SKU 继承后的有效包装规格。 */ +export function effectivePackageSpec( + product: ProductSpecSource, + variant: VariantSpecSource, +) { + const fallback = productPackageSpec(product); + const override = variantPackageSpec(variant); + return { + length_mm: override.length_mm ?? fallback.length_mm, + width_mm: override.width_mm ?? fallback.width_mm, + height_mm: override.height_mm ?? fallback.height_mm, + shipping_weight_g: + override.shipping_weight_g ?? fallback.shipping_weight_g, + status: override.status ?? fallback.status, + }; +} + +/** 将不可信 JSON 过滤为稳定的来源 SKU 规格值结构。 */ +export function specificationValues(value: unknown) { + if (!Array.isArray(value)) return []; + return value.filter( + ( + item, + ): item is { + axis_key: string; + value_key: string; + value_name: string; + } => + !!item && + typeof item === 'object' && + typeof (item as { axis_key?: unknown }).axis_key === 'string' && + typeof (item as { value_key?: unknown }).value_key === 'string' && + typeof (item as { value_name?: unknown }).value_name === 'string', + ); +} diff --git a/apps/api/src/stores/store-secret.crypto.ts b/apps/api/src/stores/store-secret.crypto.ts index 25f2af8..81cfc0e 100644 --- a/apps/api/src/stores/store-secret.crypto.ts +++ b/apps/api/src/stores/store-secret.crypto.ts @@ -52,7 +52,8 @@ export function decryptStoreSecret( const tag = ciphertextWithTag.subarray(-authenticationTagLength); const decipher = createDecipheriv('aes-256-gcm', key, nonce); decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( - 'utf8', - ); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); } diff --git a/apps/api/src/stores/stores.controller.ts b/apps/api/src/stores/stores.controller.ts index 8ac859f..ef30cee 100644 --- a/apps/api/src/stores/stores.controller.ts +++ b/apps/api/src/stores/stores.controller.ts @@ -235,7 +235,11 @@ export class StoresController { @CurrentWorkspace() workspace: Workspace, ) { if (!platformId) { - throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id'); + throw new ApiError( + 400, + 'PLATFORM_CONTEXT_REQUIRED', + '请求缺少 X-Atlas-Platform-Id', + ); } return { data: await this.storesService.fetchDefaultCategoryTreeDebug(