feat(edit): 服务端代码拆分、优化

This commit is contained in:
R524809
2026-09-15 18:56:13 +08:00
parent da7342b466
commit 7f68aad9f3
15 changed files with 1635 additions and 586 deletions
+9 -5
View File
@@ -1,14 +1,18 @@
# AGENTS.md — 工作区工作约定 # AGENTS.md — 工作区工作约定
本工作区(`atlas-seller-kit-frontend` worktree,`zcode/frontend` 分支)的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。 本工作区的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
## 1. 只改前端 ## 1. 可修改全部项目代码
- 只修改 `apps/web/` 及前端相关文档(即协作约定中「前端体验」领域,见 `docs/project/development-collaboration-contract.md` §2)。 - 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。
- `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/` 等服务端领域的代码一律不改;发现问题时在回复中说明,由维护者/后端处理。 - 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。
## 2. 不要启动服务端和 Worker ## 2. 不要启动服务端和 Worker
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。 - API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。 - 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。
- 前端 vite dev server 属于前端领域,可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。 - 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
## 3. 代码注释
- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。
+218 -32
View File
@@ -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 { 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 { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.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 }; type Response = { status: (value: number) => Response };
@Controller('collection-items') @Controller('collection-items')
export class CollectionController { 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) { public constructor(
if (!platformId) throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id'); @Inject(CollectionService)
return this.collection.list(workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query)); private readonly collection: CollectionService,
} ) {}
@Get(':id') async detail(@Param('id') id: string, @CurrentWorkspace() workspace: Workspace) { /** 按页头平台上下文查询当前工作空间的采集箱。 */
return { data: await this.collection.detail(workspace.id, requireUuid('collection_item_id', id)) }; @Get() async list(
} @Query() query: unknown,
@Post() async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) { @Headers('x-atlas-platform-id') platformId: string | undefined,
const result = await this.collection.create(workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body)); response.status(result.status); return result.body; @CurrentWorkspace() workspace: Workspace,
} ) {
@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) { if (!platformId)
return (await this.collection.update(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), revision(match), parse(updateCollectionItemRequestSchema, body))).body; throw new ApiError(
} 400,
@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) { 'PLATFORM_CONTEXT_REQUIRED',
const result = await this.collection.bulkDelete(workspace.id, request.auth.userId, requireKey(key), parse(bulkDeleteCollectionItemsRequestSchema, body)); response.status(result.status); return result.body; '请求缺少 X-Atlas-Platform-Id',
} );
@Post('clear') async clear(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) { return this.collection.list(
const result = await this.collection.clear(workspace.id, request.auth.userId, requireKey(key), parse(clearCollectionItemsRequestSchema, body)); response.status(result.status); return result.body; workspace.id,
} requireUuid('X-Atlas-Platform-Id', platformId),
@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) { parse(listCollectionItemsQuerySchema, query),
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) { /** 返回采集记录、解析证据、SKU 与当前快照的素材任务。 */
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; @Get(':id') async detail(
} @Param('id') id: string,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.collection.detail(
workspace.id,
requireUuid('collection_item_id', id),
),
};
}
/** 幂等地创建手工采集草稿及其默认商品和 SKU。 */
@Post() async create(
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.collection.create(
workspace.id,
request.auth.userId,
requireKey(key),
parse(createCollectionItemRequestSchema, body),
);
response.status(result.status);
return result.body;
}
/** 通过 If-Match 乐观锁更新当前允许编辑的采集记录字段。 */
@Patch(':id') async update(
@Param('id') id: string,
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Headers('if-match') match: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return (
await this.collection.update(
workspace.id,
request.auth.userId,
requireUuid('collection_item_id', id),
requireKey(key),
revision(match),
parse(updateCollectionItemRequestSchema, body),
)
).body;
}
/** 幂等地软删除用户指定的多条采集记录。 */
@Post('bulk-delete') async bulkDelete(
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.collection.bulkDelete(
workspace.id,
request.auth.userId,
requireKey(key),
parse(bulkDeleteCollectionItemsRequestSchema, body),
);
response.status(result.status);
return result.body;
}
/** 在数量确认未过期时,幂等地清空当前工作空间的采集箱。 */
@Post('clear') async clear(
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.collection.clear(
workspace.id,
request.auth.userId,
requireKey(key),
parse(clearCollectionItemsRequestSchema, body),
);
response.status(result.status);
return result.body;
}
/** 将当前采集快照中失败或取消的素材任务重新投递给 Worker。 */
@Post(':id/asset-retries') async retry(
@Param('id') id: string,
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.collection.retryAssets(
workspace.id,
request.auth.userId,
requireUuid('collection_item_id', id),
requireKey(key),
parse(retryCollectionAssetsRequestSchema, body),
);
response.status(result.status);
return result.body;
}
/** 将当前采集快照中用户选定的来源素材请求转存为 Atlas 素材。 */
@Post(':id/asset-materializations') async materialize(
@Param('id') id: string,
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.collection.materializeAssets(
workspace.id,
request.auth.userId,
requireUuid('collection_item_id', id),
requireKey(key),
parse(materializeCollectionAssetsRequestSchema, body),
);
response.status(result.status);
return result.body;
}
}
/** 使用契约 Schema 校验请求体或查询参数,并转换为统一错误响应。 */
function parse<
T extends {
safeParse: (input: unknown) => {
success: boolean;
data?: unknown;
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
};
},
>(schema: T, input: unknown) {
const result = schema.safeParse(input);
if (result.success) return result.data as never;
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
result.error?.issues.map((x) => ({
field: x.path.join('.'),
code: 'INVALID_FIELD',
message: x.message,
})),
);
}
/** 校验路径、请求头中的 UUID 标识。 */
function requireUuid(field: string, value: string) {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
/** 要求关键写操作提供可追踪的幂等键。 */
function requireKey(value: string | undefined) {
if (!value)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'请求缺少 Idempotency-Key',
);
return requireUuid('Idempotency-Key', value);
}
/** 解析单资源更新使用的 If-Match 版本号。 */
function revision(value: string | undefined) {
const valueMatch = value?.match(/^"([1-9]\d*)"$/);
if (!valueMatch)
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求缺少或格式错误的 If-Match',
);
return Number(valueMatch[1]);
} }
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) { const result = schema.safeParse(input); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((x) => ({ field: x.path.join('.'), code: 'INVALID_FIELD', message: x.message }))); }
function requireUuid(field: string, value: string) { if (z.uuid().safeParse(value).success) return value; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]); }
function requireKey(value: string | undefined) { if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key'); return requireUuid('Idempotency-Key', value); }
function revision(value: string | undefined) { const valueMatch = value?.match(/^"([1-9]\d*)"$/); if (!valueMatch) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); return Number(valueMatch[1]); }
+4 -1
View File
@@ -3,5 +3,8 @@ import { IdempotencyService } from '../common/idempotency.service.js';
import { CollectionController } from './collection.controller.js'; import { CollectionController } from './collection.controller.js';
import { CollectionService } from './collection.service.js'; import { CollectionService } from './collection.service.js';
@Module({ controllers: [CollectionController], providers: [IdempotencyService, CollectionService] }) @Module({
controllers: [CollectionController],
providers: [IdempotencyService, CollectionService],
})
export class CollectionModule {} export class CollectionModule {}
+531 -51
View File
@@ -1,67 +1,547 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; 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 type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { throwRevisionConflict } from '../common/revision.js'; import { throwRevisionConflict } from '../common/revision.js';
import { DATABASE } from '../database/database.module.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() @Injectable()
export class CollectionService { 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) { public constructor(
const cursor = query.cursor ? decodeCursor(query.cursor) : null; @Inject(IdempotencyService)
return this.database.workspaceTransaction(workspaceId, async (tx) => { private readonly idempotency: IdempotencyService,
await this.requireHeaderPlatform(tx, platformId); @Inject(DATABASE) private readonly database: Database,
const rows = await tx.query<Row>(`${selectRow} ) {}
/** 分页查询未删除的采集记录,并返回当前工作空间的配额摘要。 */
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<Row>(
`${selectRow}
WHERE c.deleted_at IS NULL AND ($1::text IS NULL OR c.source_title ILIKE '%' || $1 || '%') 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 ($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)) 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]); ${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`,
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'); query.q ?? 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 } }; query.source_platform_code ?? null,
}); query.listing_status ?? null,
} cursor?.at ?? null,
async create(workspaceId: string, userId: string, key: string, input: CreateCollectionItemRequest) { cursor?.id ?? null,
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/collection-items', key, request: input }, async (tx) => { query.limit + 1,
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]); const items = rows.rows.slice(0, query.limit).map(toItem);
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}` } } }; const last = items.at(-1);
}); const count = await tx.query<{
} count: string;
async detail(workspaceId: string, id: string) { collection_item_limit: number;
return this.database.workspaceTransaction(workspaceId, async (tx) => { }>(
const item = await this.requireItem(tx, id); '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',
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]); return {
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]); data: items,
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 })) }; meta: {
}); has_more: rows.rows.length > query.limit,
} next_cursor:
async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) { rows.rows.length > query.limit && last
return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => { ? encodeCursor(last.updated_at, last.id)
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]); : null,
if (!result.rowCount) await throwRevisionConflict(tx, 'collection_item', id, expected); active_count: Number(count.rows[0]?.count ?? 0),
return { status: 200, body: { data: await this.requireItem(tx, id) } }; collection_limit:
}); count.rows[0]?.collection_item_limit ?? 1000,
} },
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 } } }; }); } /** 原子创建手工采集记录、商品和默认 SKU,供后续编辑流程使用。 */
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 } } }; }); } async create(
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 }; } workspaceId: string,
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}`]); } userId: string,
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 上下文'); } key: string,
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${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]); } 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<Row>(
`${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 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'; 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 toItem(row: Row) {
function encodeCursor(at: string, id: string) { return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); } return {
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', '分页游标无效'); } } 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', '分页游标无效');
}
}
+45 -10
View File
@@ -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 type { Database } from '@atlas/database';
import { AuthService } from '../auth/auth.service.js'; import { AuthService } from '../auth/auth.service.js';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
@@ -6,32 +12,61 @@ import { DATABASE } from '../database/database.module.js';
export type ExtensionAuthenticatedRequest = { export type ExtensionAuthenticatedRequest = {
header: (name: string) => string | undefined; header: (name: string) => string | undefined;
extensionAuth: { userId: string; workspaceId: string; sessionId: string; clientId: string }; extensionAuth: {
userId: string;
workspaceId: string;
sessionId: string;
clientId: string;
};
}; };
@Injectable() @Injectable()
export class ExtensionAccessGuard implements CanActivate { 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) { public async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<ExtensionAuthenticatedRequest>(); const request = context
.switchToHttp()
.getRequest<ExtensionAuthenticatedRequest>();
// 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。 // 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。
// AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。 // AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。
let authenticated: { userId: string; sessionId: string }; let authenticated: { userId: string; sessionId: string };
try { try {
authenticated = await this.auth.authenticate(request.header('authorization')); authenticated = await this.auth.authenticate(
request.header('authorization'),
);
} catch (error) { } catch (error) {
// AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口 // AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口
// 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。 // 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。
if (error instanceof UnauthorizedException) if (error instanceof UnauthorizedException)
throw new ApiError(401, 'SESSION_EXPIRED', 'Atlas ERP 登录已过期,请重新登录'); throw new ApiError(
401,
'SESSION_EXPIRED',
'Atlas ERP 登录已过期,请重新登录',
);
throw error; 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 JOIN atlas.workspaces w ON w.id = m.workspace_id
WHERE m.user_id = $1 AND w.status = 'active' WHERE m.user_id = $1 AND w.status = 'active'
ORDER BY m.joined_at LIMIT 1`, [authenticated.userId]); ORDER BY m.joined_at LIMIT 1`,
if (!workspace.rows[0]) throw new ApiError(403, 'WORKSPACE_MEMBERSHIP_REQUIRED', '当前账户没有可用工作空间'); [authenticated.userId],
request.extensionAuth = { userId: authenticated.userId, workspaceId: workspace.rows[0].id, sessionId: authenticated.sessionId, clientId: 'atlas-web' }; );
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; return true;
} }
} }
+78 -10
View File
@@ -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 { z } from 'zod';
import { collectedProductPayloadSchema } from '@atlas/contracts'; import { collectedProductPayloadSchema } from '@atlas/contracts';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
import { Public } from '../common/public.decorator.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'; import { ExtensionService } from './extension.service.js';
type ResponseLike = { status: (code: number) => ResponseLike }; type ResponseLike = { status: (code: number) => ResponseLike };
@Controller('extension') @Controller('extension')
export class ExtensionController { export class ExtensionController {
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {} public constructor(
@Get('session') @Public() @UseGuards(ExtensionAccessGuard) @Inject(ExtensionService) private readonly extension: ExtensionService,
public session(@Req() request: ExtensionAuthenticatedRequest) { return { data: { authenticated: true, workspace: { id: request.extensionAuth.workspaceId, available: true }, can_collect: true } }; } ) {}
@Post('materials') @Public() @UseGuards(ExtensionAccessGuard) @Get('session')
public async materials(@Req() request: ExtensionAuthenticatedRequest, @Headers('idempotency-key') key: string | undefined, @Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) { @Public()
if (!key || !z.uuid().safeParse(key).success) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少或格式错误的 Idempotency-Key'); @UseGuards(ExtensionAccessGuard)
const result = await this.extension.ingest(request.extensionAuth.workspaceId, request.extensionAuth.userId, key, parse(collectedProductPayloadSchema, body)); response.status(result.status); return result.body; 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<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 }))); } 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,
})),
);
}
+5 -1
View File
@@ -3,5 +3,9 @@ import { AuthModule } from '../auth/auth.module.js';
import { ExtensionAccessGuard } from './extension-auth.guard.js'; import { ExtensionAccessGuard } from './extension-auth.guard.js';
import { ExtensionController } from './extension.controller.js'; import { ExtensionController } from './extension.controller.js';
import { ExtensionService } from './extension.service.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 {} export class ExtensionModule {}
@@ -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<ProfileRow>;
requireEditableStore: (
transaction: DatabaseTransaction,
storeId: string,
platformId: string,
) => Promise<void>;
requireActiveCategory: (
transaction: DatabaseTransaction,
storeId: string,
categoryId: string,
) => Promise<unknown>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
storeId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
requestCatalogSync: (input: CatalogSyncInput) => Promise<CatalogSyncResult>;
};
/**
* 处理商品编辑器的类目、属性定义和字典缓存读取。
* 数据校验仍复用工作流服务的统一访问边界,避免目录读取绕过平台和店铺隔离。
*/
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(),
});
}
}
@@ -25,11 +25,13 @@ type Response = { status: (value: number) => Response };
@Controller() @Controller()
export class ProductEditorController { export class ProductEditorController {
/** 注入所有商品编辑接口共用的工作流服务。 */
public constructor( public constructor(
@Inject(ProductEditorService) @Inject(ProductEditorService)
private readonly editor: ProductEditorService, private readonly editor: ProductEditorService,
) {} ) {}
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */
@Post('collection-items/:collectionItemId/editor-contexts') @Post('collection-items/:collectionItemId/editor-contexts')
async enter( async enter(
@Param('collectionItemId') collectionItemId: string, @Param('collectionItemId') collectionItemId: string,
@@ -50,6 +52,7 @@ export class ProductEditorController {
return result.body; return result.body;
} }
/** 返回当前平台下完整的商品编辑读模型。 */
@Get('products/:productId/editor') @Get('products/:productId/editor')
async read( async read(
@Param('productId') productId: string, @Param('productId') productId: string,
@@ -65,6 +68,7 @@ export class ProductEditorController {
}; };
} }
/** 返回目标店铺已缓存的类目树,供编辑器选择。 */
@Get('product-platform-profiles/:profileId/category-options') @Get('product-platform-profiles/:profileId/category-options')
async categoryOptions( async categoryOptions(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@@ -84,6 +88,7 @@ export class ProductEditorController {
}; };
} }
/** 返回所选类目的缓存动态属性定义。 */
@Get('product-platform-profiles/:profileId/attribute-schema') @Get('product-platform-profiles/:profileId/attribute-schema')
async attributeSchema( async attributeSchema(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@@ -103,6 +108,7 @@ export class ProductEditorController {
}; };
} }
/** 幂等地创建所选类目属性定义的刷新任务。 */
@Post('product-platform-profiles/:profileId/attribute-schema-syncs') @Post('product-platform-profiles/:profileId/attribute-schema-syncs')
async attributeSchemaSync( async attributeSchemaSync(
@Param('profileId') profileId: string, @Param('profileId') profileId: string,
@@ -125,6 +131,7 @@ export class ProductEditorController {
return result.body; return result.body;
} }
/** 从本地缓存中查询属性字典值。 */
@Get( @Get(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId', 'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
) )
@@ -152,6 +159,7 @@ export class ProductEditorController {
}; };
} }
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
@Post( @Post(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs', 'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
) )
@@ -182,6 +190,7 @@ export class ProductEditorController {
return result.body; return result.body;
} }
/** 校验并原子保存商品及其上架草稿。 */
@Put('products/:productId/editor-draft') @Put('products/:productId/editor-draft')
async save( async save(
@Param('productId') productId: string, @Param('productId') productId: string,
@@ -219,6 +228,7 @@ export class ProductEditorController {
} }
} }
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
function requiredUuid(field: string, value: string | undefined) { function requiredUuid(field: string, value: string | undefined) {
if (value && z.uuid().safeParse(value).success) return value; if (value && z.uuid().safeParse(value).success) return value;
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [ 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) { function optionalUuid(field: string, value: string | undefined) {
if (value === undefined || value === '') return undefined; if (value === undefined || value === '') return undefined;
return requiredUuid(field, value); return requiredUuid(field, value);
} }
/** 解析 If-Match 中成对传递的商品和发布配置版本号。 */
function parseEditorRevision(value: string | undefined) { function parseEditorRevision(value: string | undefined) {
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/); const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
if (!match) if (!match)
@@ -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`;
@@ -1,11 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import {
productAiSourceMaterialOverridesSchema,
productAiSourceMaterialSchema,
} from '@atlas/contracts';
import type { import type {
ProductAiSourceField,
ProductAiSourceMaterial,
ProductEditorContext, ProductEditorContext,
ProductEditorSnapshot, ProductEditorSnapshot,
SaveProductEditorDraftRequest, SaveProductEditorDraftRequest,
@@ -15,68 +9,51 @@ import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js'; import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js'; import { DATABASE } from '../database/database.module.js';
import {
type ProductRow = { type OperationRow,
id: string; type ProductRow,
collection_item_id: string; type ProfileRow,
revision: number; profileSelect,
source_title: string | null; } from './product-editor.models.js';
brand: string | null; import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
model: string | null; import {
barcode: string | null; latestProductAiSourceMaterial,
purchase_url: string | null; normalizedCategoryPath as normalizeCapturedCategoryPath,
supply_note: string | null; productAiSourceMaterial as resolveSourceMaterial,
default_physical_length_mm: string | null; productAiSourceOverrides as selectSourceMaterialOverrides,
default_physical_width_mm: string | null; } from './product-editor.source-material.js';
default_physical_height_mm: string | null; import {
default_net_weight_g: string | null; effectivePackageSpec as resolveEffectivePackageSpec,
default_package_length_mm: string | null; effectivePhysicalSpec as resolveEffectivePhysicalSpec,
default_package_width_mm: string | null; productPackageSpec as mapProductPackageSpec,
default_package_height_mm: string | null; productPhysicalSpec as mapProductPhysicalSpec,
default_shipping_weight_g: string | null; specificationValues as filterSpecificationValues,
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; variantPackageSpec as mapVariantPackageSpec,
package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; variantPhysicalSpec as mapVariantPhysicalSpec,
ai_source_material_overrides: unknown; } from './product-editor.specifications.js';
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;
};
@Injectable() @Injectable()
export class ProductEditorService { export class ProductEditorService {
private readonly catalog: ProductEditorCatalogOperations;
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
public constructor( public constructor(
@Inject(IdempotencyService) @Inject(IdempotencyService)
private readonly idempotency: IdempotencyService, private readonly idempotency: IdempotencyService,
@Inject(DATABASE) private readonly database: Database, @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( async enter(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -169,6 +146,7 @@ export class ProductEditorService {
); );
} }
/** 构建工作空间隔离的编辑读模型,并计算 SKU 继承规格。 */
async read( async read(
workspaceId: string, workspaceId: string,
productId: string, productId: string,
@@ -340,9 +318,9 @@ export class ProductEditorService {
WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`, WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`,
[profile.id], [profile.id],
); );
const defaultPhysicalSpec = productPhysicalSpec(product); const defaultPhysicalSpec = mapProductPhysicalSpec(product);
const defaultPackageSpec = productPackageSpec(product); const defaultPackageSpec = mapProductPackageSpec(product);
const sourceMaterialResult = productAiSourceMaterial(product); const sourceMaterialResult = resolveSourceMaterial(product);
const sourceMaterial = sourceMaterialResult.effective; const sourceMaterial = sourceMaterialResult.effective;
const editorVariants: ProductEditorSnapshot['variants'] = const editorVariants: ProductEditorSnapshot['variants'] =
variants.rows.map((variant) => ({ variants.rows.map((variant) => ({
@@ -352,16 +330,16 @@ export class ProductEditorService {
display_name: variant.display_name, display_name: variant.display_name,
sort_order: variant.sort_order, sort_order: variant.sort_order,
purchase_cost_cny: variant.purchase_cost_cny, purchase_cost_cny: variant.purchase_cost_cny,
specification_values: specificationValues( specification_values: filterSpecificationValues(
variant.specification_values, variant.specification_values,
), ),
physical_spec: variantPhysicalSpec(variant), physical_spec: mapVariantPhysicalSpec(variant),
package_spec: variantPackageSpec(variant), package_spec: mapVariantPackageSpec(variant),
effective_physical_spec: effectivePhysicalSpec( effective_physical_spec: resolveEffectivePhysicalSpec(
product, product,
variant, variant,
), ),
effective_package_spec: effectivePackageSpec( effective_package_spec: resolveEffectivePackageSpec(
product, product,
variant, variant,
), ),
@@ -475,6 +453,7 @@ export class ProductEditorService {
); );
} }
/** 读取发布配置当前或指定目标店铺的缓存类目。 */
async categoryOptions( async categoryOptions(
workspaceId: string, workspaceId: string,
profileId: string, profileId: string,
@@ -482,39 +461,16 @@ export class ProductEditorService {
search: string | undefined, search: string | undefined,
storeId?: string, storeId?: string,
) { ) {
return this.database.workspaceTransaction( return this.catalog.categoryOptions(
workspaceId, workspaceId,
async (transaction) => { profileId,
const profile = await this.requireProfileForPlatform( platformId,
transaction, search,
profileId, storeId,
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;
},
); );
} }
/** 获取有效启用类目的缓存动态属性定义。 */
async attributeSchema( async attributeSchema(
workspaceId: string, workspaceId: string,
profileId: string, profileId: string,
@@ -522,40 +478,16 @@ export class ProductEditorService {
categoryId?: string, categoryId?: string,
storeId?: string, storeId?: string,
) { ) {
return this.database.workspaceTransaction( return this.catalog.attributeSchema(
workspaceId, workspaceId,
async (transaction) => { profileId,
const profile = await this.requireProfileForPlatform( platformId,
transaction, categoryId,
profileId, storeId,
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,
);
},
); );
} }
/** 仅从店铺本地缓存查询当前类目的字典值。 */
async attributeDictionary( async attributeDictionary(
workspaceId: string, workspaceId: string,
profileId: string, profileId: string,
@@ -565,66 +497,18 @@ export class ProductEditorService {
categoryId?: string, categoryId?: string,
storeId?: string, storeId?: string,
) { ) {
return this.database.workspaceTransaction( return this.catalog.attributeDictionary(
workspaceId, workspaceId,
async (transaction) => { profileId,
const profile = await this.requireProfileForPlatform( platformId,
transaction, dictionaryId,
profileId, search,
platformId, categoryId,
); storeId,
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 };
},
); );
} }
/** 幂等地调度 Worker 刷新类目属性定义。 */
async requestAttributeSchemaSync( async requestAttributeSchemaSync(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -633,18 +517,17 @@ export class ProductEditorService {
key: string, key: string,
categoryId: string, categoryId: string,
) { ) {
return this.requestCatalogSync({ return this.catalog.requestAttributeSchemaSync(
workspaceId, workspaceId,
userId, userId,
profileId, profileId,
platformId, platformId,
key, key,
scope: 'attributes',
path: 'attribute-schema-syncs',
categoryId, categoryId,
}); );
} }
/** 在查询词有效后幂等地调度远程字典检索。 */
async requestAttributeDictionarySync( async requestAttributeDictionarySync(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -655,27 +538,19 @@ export class ProductEditorService {
dictionaryId: string, dictionaryId: string,
query: string, query: string,
) { ) {
if (query.trim().length < 2) { return this.catalog.requestAttributeDictionarySync(
throw new ApiError(
422,
'DICTIONARY_SEARCH_REQUIRED',
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
);
}
return this.requestCatalogSync({
workspaceId, workspaceId,
userId, userId,
profileId, profileId,
platformId, platformId,
key, key,
scope: 'dictionary',
path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
categoryId, categoryId,
dictionaryId, dictionaryId,
query: query.trim(), query,
}); );
} }
/** 原子校验并保存商品、发布配置、SKU、媒体和动态属性。 */
async save( async save(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -700,10 +575,8 @@ export class ProductEditorService {
}, },
async (transaction) => { async (transaction) => {
await this.requireTargetPlatform(transaction, platformId); await this.requireTargetPlatform(transaction, platformId);
// Lock the current source row before comparing its snapshot // 先锁定来源快照,再比较版本和保存覆盖字段;并发复采只能先完成
// revision and persisting overrides. A simultaneous re-capture // 并让本请求返回 409,或等待本次原子草稿保存提交,避免覆盖错位。
// then either completes first (and this request returns 409),
// or waits until this atomic draft save commits.
const product = await this.requireProduct( const product = await this.requireProduct(
transaction, transaction,
productId, productId,
@@ -792,7 +665,7 @@ export class ProductEditorService {
platformId, platformId,
input, input,
); );
const sourceMaterialOverrides = productAiSourceOverrides( const sourceMaterialOverrides = selectSourceMaterialOverrides(
input.ai_source_material, input.ai_source_material,
input.ai_source_overridden_fields, input.ai_source_overridden_fields,
); );
@@ -1028,9 +901,8 @@ export class ProductEditorService {
], ],
); );
} }
// A removed, never-imported SKU must release its draft offer_id; // 已删除且未导入的 SKU 必须释放草稿 offer_id;否则同店替换 SKU
// otherwise a replacement SKU in the same store would hit the // 会因持久化唯一约束失败,即使用户已在本次草稿中删除原 SKU。
// durable uniqueness rule despite the user having deleted it.
await transaction.query( await transaction.query(
`UPDATE atlas.product_variant_platform_listings l `UPDATE atlas.product_variant_platform_listings l
SET offer_id = NULL, revision = revision + 1, updated_at = now() SET offer_id = NULL, revision = revision + 1, updated_at = now()
@@ -1078,6 +950,7 @@ export class ProductEditorService {
); );
} }
/** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */
private async ensureListings( private async ensureListings(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
productId: string, productId: string,
@@ -1101,10 +974,8 @@ export class ProductEditorService {
} }
/** /**
* Persist final image order only after every supplied asset has passed the * 仅在每个素材都通过商品/SKU 来源校验后保存最终排序。已完成的转存是
* product/SKU provenance check. A completed transfer is the bridge from * 不可信来源 URL 转为 Atlas 素材的边界,调用方不能任意关联工作空间素材。
* an untrusted source URL to an Atlas-owned media asset; callers cannot
* attach arbitrary workspace assets to a listing.
*/ */
private async persistListingMedia( private async persistListingMedia(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
@@ -1257,6 +1128,7 @@ export class ProductEditorService {
} }
} }
/** 按类目定义保存草稿属性,并保留无法映射的历史属性。 */
private async persistDynamicAttributes( private async persistDynamicAttributes(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
input: { input: {
@@ -1388,10 +1260,8 @@ export class ProductEditorService {
'SKU 属性必须关联当前商品中未删除的 SKU', 'SKU 属性必须关联当前商品中未删除的 SKU',
); );
if (!input.retainedVariantIds.includes(attribute.variant_id)) { if (!input.retainedVariantIds.includes(attribute.variant_id)) {
// A removed SKU takes its old attributes with it. Reject a // 删除 SKU 时其旧属性应一并删除;拒绝伪造的新引用,但不能因
// newly fabricated reference, but do not make the normal // 表单快照仍带着该 SKU 的旧属性而使正常的删除操作失败。
// “delete SKU” operation fail because the form snapshot
// still contains that SKU's previous attribute row.
if (!existingByKey.has(savedKey)) if (!existingByKey.has(savedKey))
throw new ApiError( throw new ApiError(
422, 422,
@@ -1411,9 +1281,8 @@ export class ProductEditorService {
valid.push(attribute); 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) { for (const attribute of existing.rows) {
const key = savedAttributeKey(attribute); const key = savedAttributeKey(attribute);
if ( if (
@@ -1480,6 +1349,7 @@ export class ProductEditorService {
} }
} }
/** 校验草稿属性的类型、数量、字典值与 SKU 归属。 */
private async assertAttributeValues( private async assertAttributeValues(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
storeId: string, storeId: string,
@@ -1546,6 +1416,7 @@ export class ProductEditorService {
); );
} }
/** 返回店铺有效类目的标准化属性定义。 */
private async attributeSchemaForCategory( private async attributeSchemaForCategory(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
storeId: string, storeId: string,
@@ -1573,6 +1444,7 @@ export class ProductEditorService {
return result.rows; return result.rows;
} }
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
private async requestCatalogSync(input: { private async requestCatalogSync(input: {
workspaceId: string; workspaceId: string;
userId: string; userId: string;
@@ -1709,9 +1581,8 @@ export class ProductEditorService {
} }
/** /**
* Ozon capture records breadcrumb names rather than a cache UUID. Resolve * Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时,
* that evidence only while a profile has no saved category; a later * 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。
* recapture must never replace a user or previously persisted selection.
*/ */
private async applyCapturedCategory( private async applyCapturedCategory(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
@@ -1720,7 +1591,7 @@ export class ProductEditorService {
): Promise<ProfileRow> { ): Promise<ProfileRow> {
if (profile.store_product_category_id) return profile; if (profile.store_product_category_id) return profile;
const categories = latestProductAiSourceMaterial(product).categories; const categories = latestProductAiSourceMaterial(product).categories;
const sourcePath = normalizedCategoryPath(categories); const sourcePath = normalizeCapturedCategoryPath(categories);
if (!sourcePath) return profile; if (!sourcePath) return profile;
const sourcePlatform = await transaction.query<{ code: string }>( const sourcePlatform = await transaction.query<{ code: string }>(
@@ -1761,6 +1632,7 @@ export class ProductEditorService {
: profile; : profile;
} }
/** 确认类目属于目标店铺且仍处于启用状态。 */
private async requireActiveCategory( private async requireActiveCategory(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
storeId: string, storeId: string,
@@ -1784,6 +1656,7 @@ export class ProductEditorService {
return result.rows[0]; return result.rows[0];
} }
/** 读取发布配置,并拒绝跨平台上下文访问。 */
private async requireProfileForPlatform( private async requireProfileForPlatform(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
profileId: string, profileId: string,
@@ -1796,6 +1669,7 @@ export class ProductEditorService {
return profile; return profile;
} }
/** 确认页头当前平台已启用且可作为发布目标。 */
private async requireTargetPlatform( private async requireTargetPlatform(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
platformId: string, platformId: string,
@@ -1815,6 +1689,7 @@ export class ProductEditorService {
); );
} }
/** 读取创建发布配置时必需的、已验证默认店铺。 */
private async requireDefaultStore( private async requireDefaultStore(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
platformId: string, platformId: string,
@@ -1835,6 +1710,7 @@ export class ProductEditorService {
return store.rows[0]; return store.rows[0];
} }
/** 确认指定店铺属于平台、可用且凭据已验证。 */
private async requireEditableStore( private async requireEditableStore(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
storeId: string, storeId: string,
@@ -1854,6 +1730,7 @@ export class ProductEditorService {
); );
} }
/** 在保存草稿前拒绝过期或跨商品的 SKU 标识。 */
private async ensureVariantIdsBelongToProduct( private async ensureVariantIdsBelongToProduct(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
productId: string, productId: string,
@@ -1881,6 +1758,7 @@ export class ProductEditorService {
} }
} }
/** 确认草稿 offer_id 在目标店铺内保持唯一。 */
private async assertOfferIdsAvailable( private async assertOfferIdsAvailable(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
productId: string, productId: string,
@@ -1920,6 +1798,7 @@ export class ProductEditorService {
} }
} }
/** 实物或包装规格标记为已确认时,要求尺寸和重量完整填写。 */
private assertConfirmedSpecsComplete(input: SaveProductEditorDraftRequest) { private assertConfirmedSpecsComplete(input: SaveProductEditorDraftRequest) {
const check = ( const check = (
status: 'missing' | 'ambiguous' | 'confirmed' | null, status: 'missing' | 'ambiguous' | 'confirmed' | null,
@@ -1985,6 +1864,7 @@ export class ProductEditorService {
}); });
} }
/** 使用数据库当前版本返回结构化的商品并发冲突。 */
private async throwProductRevisionConflict( private async throwProductRevisionConflict(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
productId: string, productId: string,
@@ -2007,6 +1887,7 @@ export class ProductEditorService {
]); ]);
} }
/** 使用数据库当前版本返回结构化的发布配置并发冲突。 */
private async throwProfileRevisionConflict( private async throwProfileRevisionConflict(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
profileId: string, profileId: string,
@@ -2029,6 +1910,7 @@ export class ProductEditorService {
]); ]);
} }
/** 读取关联到有效采集记录的编辑商品。 */
private async requireProductByCollectionItem( private async requireProductByCollectionItem(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
collectionItemId: string, collectionItemId: string,
@@ -2052,6 +1934,7 @@ export class ProductEditorService {
return product.rows[0]; return product.rows[0];
} }
/** 读取有效编辑商品;保存草稿时可锁定其来源快照。 */
private async requireProduct( private async requireProduct(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
productId: string, productId: string,
@@ -2076,6 +1959,7 @@ export class ProductEditorService {
return product.rows[0]; return product.rows[0];
} }
/** 查询商品在目标平台的发布配置;不存在时不视为异常。 */
private async findProfile( private async findProfile(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
productId: string, productId: string,
@@ -2089,6 +1973,7 @@ export class ProductEditorService {
return profile.rows[0] ?? null; return profile.rows[0] ?? null;
} }
/** 读取单个发布配置;并发删除时返回失败。 */
private async requireProfile( private async requireProfile(
transaction: DatabaseTransaction, transaction: DatabaseTransaction,
profileId: string, profileId: string,
@@ -2101,6 +1986,7 @@ export class ProductEditorService {
return profile.rows[0]; return profile.rows[0];
} }
/** 将持久化发布配置转换为进入编辑器后的稳定上下文。 */
private toContext( private toContext(
collectionItemId: string, collectionItemId: string,
productId: 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<string, unknown> = {};
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<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function attributeKey(platformAttributeId: string, complexId: string) { function attributeKey(platformAttributeId: string, complexId: string) {
return `${platformAttributeId}\u0000${complexId}`; return `${platformAttributeId}\u0000${complexId}`;
} }
@@ -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<string, unknown> = {};
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<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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;
}
@@ -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',
);
}
+4 -3
View File
@@ -52,7 +52,8 @@ export function decryptStoreSecret(
const tag = ciphertextWithTag.subarray(-authenticationTagLength); const tag = ciphertextWithTag.subarray(-authenticationTagLength);
const decipher = createDecipheriv('aes-256-gcm', key, nonce); const decipher = createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( return Buffer.concat([
'utf8', decipher.update(ciphertext),
); decipher.final(),
]).toString('utf8');
} }
+5 -1
View File
@@ -235,7 +235,11 @@ export class StoresController {
@CurrentWorkspace() workspace: Workspace, @CurrentWorkspace() workspace: Workspace,
) { ) {
if (!platformId) { 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 { return {
data: await this.storesService.fetchDefaultCategoryTreeDebug( data: await this.storesService.fetchDefaultCategoryTreeDebug(