feat(ai-edit): 开发 AI 填报功能
This commit is contained in:
@@ -84,3 +84,8 @@ MINIO_CONSOLE_PORT=9001
|
||||
WEB_PORT=8100
|
||||
# Local WXT browser-extension development port.
|
||||
EXTENSION_PORT=8110
|
||||
|
||||
# DeepSeek API Key
|
||||
DEEPSEEK_API_KEY=
|
||||
# DeepSeek Model Name
|
||||
DEEPSEEK_MODEL_NAME=deepseek-flash
|
||||
@@ -7,10 +7,10 @@
|
||||
- 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。
|
||||
- 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。
|
||||
|
||||
## 2. 不要启动服务端和 Worker
|
||||
## 2. 服务端、Worker 与前端验证
|
||||
|
||||
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。
|
||||
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。
|
||||
- API(NestJS)和 Worker 可在开发、联调或验证需要时由 AI 启动、重启或停止;操作前应先确认目标进程与端口,避免误杀无关进程。
|
||||
- API 或 Worker 已由维护者启动时,可在本任务需要时重启,但应在完成后说明实际操作和运行状态。
|
||||
- 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
|
||||
|
||||
## 3. 代码注释
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ProductAiTask, ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { ProductEditorService } from './product-editor.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class ProductAiService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(ProductEditorService)
|
||||
private readonly editor: ProductEditorService,
|
||||
) {}
|
||||
|
||||
/** 固化当前编辑快照并在同一事务写入任务与 Outbox。 */
|
||||
async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantId: string | null,
|
||||
key: string,
|
||||
hint: string | undefined,
|
||||
) {
|
||||
const profile = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) => {
|
||||
const row = await tx.query<{ product_id: string }>(
|
||||
`SELECT product_id FROM atlas.product_platform_profiles
|
||||
WHERE id = $1 AND platform_id = $2`,
|
||||
[profileId, platformId],
|
||||
);
|
||||
if (!row.rows[0])
|
||||
throw new ApiError(
|
||||
404,
|
||||
'PROFILE_NOT_FOUND',
|
||||
'商品发布配置不存在',
|
||||
);
|
||||
return row.rows[0];
|
||||
},
|
||||
);
|
||||
const snapshot = await this.editor.read(
|
||||
workspaceId,
|
||||
profile.product_id,
|
||||
platformId,
|
||||
);
|
||||
if (snapshot.profile.id !== profileId)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'PROFILE_CHANGED',
|
||||
'商品发布配置已变化,请刷新页面',
|
||||
);
|
||||
if (
|
||||
variantId &&
|
||||
!snapshot.variants.some((variant) => variant.id === variantId)
|
||||
)
|
||||
throw new ApiError(404, 'VARIANT_NOT_FOUND', 'SKU 不属于当前商品');
|
||||
const kind = variantId ? 'sku_copy' : 'autofill';
|
||||
const path = variantId
|
||||
? `/api/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`
|
||||
: `/api/product-platform-profiles/${profileId}/ai/autofills`;
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path,
|
||||
key,
|
||||
request: { platform_id: platformId, hint: hint ?? null },
|
||||
},
|
||||
async (tx) => {
|
||||
const current = await tx.query<{
|
||||
product_revision: number;
|
||||
profile_revision: number;
|
||||
source_snapshot_revision: number;
|
||||
}>(
|
||||
`SELECT product.revision AS product_revision,
|
||||
profile.revision AS profile_revision,
|
||||
item.source_snapshot_revision
|
||||
FROM atlas.product_platform_profiles profile
|
||||
JOIN atlas.products product ON product.id = profile.product_id
|
||||
JOIN atlas.collection_items item ON item.id = product.collection_item_id
|
||||
WHERE profile.id = $1 AND profile.platform_id = $2
|
||||
FOR UPDATE OF profile, product, item`,
|
||||
[profileId, platformId],
|
||||
);
|
||||
const row = current.rows[0];
|
||||
if (
|
||||
!row ||
|
||||
Number(row.product_revision) !==
|
||||
snapshot.product.revision ||
|
||||
Number(row.profile_revision) !==
|
||||
snapshot.profile.revision ||
|
||||
Number(row.source_snapshot_revision) !==
|
||||
snapshot.source_material.source_snapshot_revision
|
||||
) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'AI_INPUT_CHANGED',
|
||||
'商品资料已变化,请重新点击 AI 填报',
|
||||
);
|
||||
}
|
||||
const input = taskInput(snapshot, variantId, hint);
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_ai_tasks
|
||||
(workspace_id, product_id, profile_id, variant_id, kind, input_snapshot, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5::jsonb, $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
profile.product_id,
|
||||
profileId,
|
||||
variantId,
|
||||
kind,
|
||||
JSON.stringify(input),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
const taskId = inserted.rows[0].id;
|
||||
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, 'product_ai_task', $1,
|
||||
'product_ai_task.requested', $2::jsonb, $3)`,
|
||||
[
|
||||
taskId,
|
||||
JSON.stringify({ task_id: taskId }),
|
||||
`product_ai_task:${taskId}`,
|
||||
],
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: { data: { ai_task_id: taskId, status: 'queued' } },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 只返回已校验结果和输入版本,不暴露采集快照与模型提示词。 */
|
||||
async read(
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
platformId: string,
|
||||
): Promise<ProductAiTask> {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const result = await tx.query<{
|
||||
id: string;
|
||||
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
|
||||
status: ProductAiTask['status'];
|
||||
variant_id: string | null;
|
||||
input_snapshot: Record<string, unknown>;
|
||||
result: ProductAiTask['result'];
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
}>(
|
||||
`SELECT task.id, task.kind, task.status, task.variant_id,
|
||||
task.input_snapshot, task.result, task.error_code, task.error_message
|
||||
FROM atlas.product_ai_tasks task
|
||||
JOIN atlas.product_platform_profiles profile ON profile.id = task.profile_id
|
||||
WHERE task.id = $1 AND profile.platform_id = $2`,
|
||||
[taskId, platformId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row)
|
||||
throw new ApiError(404, 'AI_TASK_NOT_FOUND', 'AI 任务不存在');
|
||||
const input = row.input_snapshot as {
|
||||
revision?: ProductAiTask['input_revision'];
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
input_revision: input.revision ?? {
|
||||
product: 0,
|
||||
profile: 0,
|
||||
source_snapshot: 0,
|
||||
},
|
||||
variant_id: row.variant_id,
|
||||
result:
|
||||
row.status === 'succeeded' || row.status === 'partial'
|
||||
? row.result
|
||||
: null,
|
||||
error_code: row.error_code,
|
||||
error_message: row.error_message,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function taskInput(
|
||||
snapshot: ProductEditorSnapshot,
|
||||
variantId: string | null,
|
||||
hint?: string,
|
||||
) {
|
||||
return {
|
||||
revision: {
|
||||
product: snapshot.product.revision,
|
||||
profile: snapshot.profile.revision,
|
||||
source_snapshot: snapshot.source_material.source_snapshot_revision,
|
||||
},
|
||||
platform_id: snapshot.profile.platform_id,
|
||||
product_id: snapshot.product.id,
|
||||
profile_id: snapshot.profile.id,
|
||||
variant_id: variantId,
|
||||
hint: hint ?? null,
|
||||
source_material: snapshot.source_material,
|
||||
common: snapshot.product.common,
|
||||
profile: {
|
||||
title: snapshot.profile.title,
|
||||
tags: snapshot.profile.tags,
|
||||
description: snapshot.profile.description,
|
||||
category: snapshot.profile.category,
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
},
|
||||
variants: snapshot.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
display_name: variant.display_name,
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
listing: {
|
||||
status: variant.listing.status,
|
||||
offer_id: variant.listing.offer_id,
|
||||
},
|
||||
title: variant.title,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
package_spec: variant.effective_package_spec,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productPublishPreflightRequestSchema,
|
||||
requestProductAiTaskSchema,
|
||||
saveProductEditorDraftRequestSchema,
|
||||
saveProductSourceDescriptionRequestSchema,
|
||||
type Workspace,
|
||||
@@ -22,6 +23,7 @@ import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import { ProductEditorService } from './product-editor.service.js';
|
||||
import { ProductAiService } from './product-ai.service.js';
|
||||
|
||||
type Response = { status: (value: number) => Response };
|
||||
|
||||
@@ -31,6 +33,8 @@ export class ProductEditorController {
|
||||
public constructor(
|
||||
@Inject(ProductEditorService)
|
||||
private readonly editor: ProductEditorService,
|
||||
@Inject(ProductAiService)
|
||||
private readonly productAi: ProductAiService,
|
||||
) {}
|
||||
|
||||
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */
|
||||
@@ -70,6 +74,85 @@ export class ProductEditorController {
|
||||
};
|
||||
}
|
||||
|
||||
/** 用户主动创建 SPU AI 填报任务。 */
|
||||
@Post('product-platform-profiles/:profileId/ai/autofills')
|
||||
async aiAutofill(
|
||||
@Param('profileId') profileId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const parsed = requestProductAiTaskSchema.safeParse(body ?? {});
|
||||
if (!parsed.success)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'AI 请求参数不符合要求',
|
||||
);
|
||||
const result = await this.productAi.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
null,
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
parsed.data.hint,
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 用户主动创建单个 SKU 的文案任务,不影响其他 SKU。 */
|
||||
@Post('product-platform-profiles/:profileId/variants/:variantId/ai/copy')
|
||||
async aiSkuCopy(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('variantId') variantId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const parsed = requestProductAiTaskSchema.safeParse(body ?? {});
|
||||
if (!parsed.success)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'AI 请求参数不符合要求',
|
||||
);
|
||||
const result = await this.productAi.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('variant_id', variantId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
parsed.data.hint,
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 查询当前工作空间和平台的 AI 任务状态。 */
|
||||
@Get('product-ai-tasks/:taskId')
|
||||
async aiTask(
|
||||
@Param('taskId') taskId: string,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.productAi.read(
|
||||
workspace.id,
|
||||
requiredUuid('ai_task_id', taskId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回当前平台共享的类目树,供编辑器选择。 */
|
||||
@Get('product-platform-profiles/:profileId/category-options')
|
||||
async categoryOptions(
|
||||
@@ -288,7 +371,8 @@ export class ProductEditorController {
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const parsed = saveProductSourceDescriptionRequestSchema.safeParse(body);
|
||||
const parsed =
|
||||
saveProductSourceDescriptionRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { ProductEditorController } from './product-editor.controller.js';
|
||||
import { ProductEditorService } from './product-editor.service.js';
|
||||
import { ProductAiService } from './product-ai.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductEditorController],
|
||||
providers: [IdempotencyService, ProductEditorService],
|
||||
providers: [IdempotencyService, ProductEditorService, ProductAiService],
|
||||
})
|
||||
export class ProductEditorModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
ProductCopyTranslation,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductSourceDescriptionRequest,
|
||||
} from '@atlas/contracts';
|
||||
@@ -214,6 +215,7 @@ export class ProductEditorService {
|
||||
purchase_cost_cny: string | null;
|
||||
listing_id: string;
|
||||
offer_id: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
status: string;
|
||||
@@ -236,7 +238,7 @@ export class ProductEditorService {
|
||||
}>(
|
||||
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
|
||||
v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
|
||||
l.description, l.tags, l.status, l.selling_price::text,
|
||||
l.title, l.description, l.tags, l.status, l.selling_price::text,
|
||||
l.list_price::text, l.currency_code, v.specification_values,
|
||||
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
|
||||
v.net_weight_g::text, v.physical_spec_status,
|
||||
@@ -350,6 +352,17 @@ export class ProductEditorService {
|
||||
WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`,
|
||||
[profile.id],
|
||||
);
|
||||
const translations = await transaction.query<{
|
||||
variant_id: string | null;
|
||||
field: 'title' | 'tags' | 'description';
|
||||
russian_value: string | string[];
|
||||
chinese_value: string | string[];
|
||||
source_task_id: string | null;
|
||||
}>(
|
||||
`SELECT variant_id, field, russian_value, chinese_value, source_task_id
|
||||
FROM atlas.product_copy_translations WHERE profile_id = $1`,
|
||||
[profile.id],
|
||||
);
|
||||
const defaultPhysicalSpec = mapProductPhysicalSpec(product);
|
||||
const defaultPackageSpec = mapProductPackageSpec(product);
|
||||
const sourceMaterialResult = resolveSourceMaterial(product);
|
||||
@@ -362,6 +375,7 @@ export class ProductEditorService {
|
||||
display_name: variant.display_name,
|
||||
sort_order: variant.sort_order,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
title: variant.title,
|
||||
tags: Array.isArray(variant.tags)
|
||||
? variant.tags.filter(
|
||||
(value): value is string =>
|
||||
@@ -466,6 +480,7 @@ export class ProductEditorService {
|
||||
source_material: sourceMaterial,
|
||||
source_material_state: sourceMaterialResult.state,
|
||||
captured_category_match: capturedCategoryMatch,
|
||||
copy_translations: translations.rows,
|
||||
ai_input: {
|
||||
source_material: sourceMaterial,
|
||||
default_physical_spec: defaultPhysicalSpec,
|
||||
@@ -883,13 +898,16 @@ export class ProductEditorService {
|
||||
await transaction.query(
|
||||
`UPDATE atlas.product_variant_platform_listings
|
||||
SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4,
|
||||
title = CASE WHEN $5::boolean THEN $6 ELSE title END,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE profile_id = $5 AND product_variant_id = $6 AND status <> 'imported'`,
|
||||
WHERE profile_id = $7 AND product_variant_id = $8 AND status <> 'imported'`,
|
||||
[
|
||||
variant.offer_id,
|
||||
input.profile.target_store_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
variant.title !== undefined,
|
||||
variant.title ?? null,
|
||||
profile.id,
|
||||
variant.id,
|
||||
],
|
||||
@@ -974,9 +992,9 @@ export class ProductEditorService {
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.product_variant_platform_listings
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id,
|
||||
currency_code, tags, description)
|
||||
currency_code, tags, description, title)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code,
|
||||
$7::jsonb, $8
|
||||
$7::jsonb, $8, $9
|
||||
FROM atlas.stores s WHERE s.id = $5`,
|
||||
[
|
||||
productId,
|
||||
@@ -987,6 +1005,7 @@ export class ProductEditorService {
|
||||
variant.offer_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
variant.title ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1019,6 +1038,15 @@ export class ProductEditorService {
|
||||
profile,
|
||||
variants: variantMedia,
|
||||
});
|
||||
if (input.copy_translations) {
|
||||
await this.persistCopyTranslations(
|
||||
transaction,
|
||||
productId,
|
||||
profile.id,
|
||||
retainedIds,
|
||||
input.copy_translations,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
@@ -1038,6 +1066,68 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 中俄对照与草稿同一事务保存,继承的 SKU 字段不创建副本。 */
|
||||
private async persistCopyTranslations(
|
||||
transaction: DatabaseTransaction,
|
||||
productId: string,
|
||||
profileId: string,
|
||||
retainedVariantIds: string[],
|
||||
translations: ProductCopyTranslation[],
|
||||
) {
|
||||
const retained = new Set(retainedVariantIds);
|
||||
const seen = new Set<string>();
|
||||
for (const item of translations) {
|
||||
if (item.variant_id && !retained.has(item.variant_id))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'INVALID_TRANSLATION_VARIANT',
|
||||
'中文对照关联了无效 SKU',
|
||||
);
|
||||
const identity = `${item.variant_id ?? 'spu'}:${item.field}`;
|
||||
if (seen.has(identity))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'DUPLICATE_TRANSLATION',
|
||||
'同一字段不能重复保存中文对照',
|
||||
);
|
||||
seen.add(identity);
|
||||
if (item.source_task_id) {
|
||||
const task = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.product_ai_tasks WHERE id = $1 AND profile_id = $2`,
|
||||
[item.source_task_id, profileId],
|
||||
);
|
||||
if (!task.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'INVALID_TRANSLATION_TASK',
|
||||
'中文对照来源任务不属于当前商品',
|
||||
);
|
||||
}
|
||||
}
|
||||
await transaction.query(
|
||||
`DELETE FROM atlas.product_copy_translations WHERE profile_id = $1`,
|
||||
[profileId],
|
||||
);
|
||||
for (const item of translations) {
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.product_copy_translations
|
||||
(workspace_id, product_id, profile_id, variant_id, field,
|
||||
russian_value, chinese_value, source_task_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||
$5::jsonb, $6::jsonb, $7)`,
|
||||
[
|
||||
productId,
|
||||
profileId,
|
||||
item.variant_id,
|
||||
item.field,
|
||||
JSON.stringify(item.russian_value),
|
||||
JSON.stringify(item.chinese_value),
|
||||
item.source_task_id,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存采集描述覆盖,避免未完成的整页草稿阻塞该独立编辑动作。 */
|
||||
async saveSourceDescription(
|
||||
workspaceId: string,
|
||||
@@ -1063,8 +1153,16 @@ export class ProductEditorService {
|
||||
},
|
||||
async (transaction) => {
|
||||
await this.requireTargetPlatform(transaction, platformId);
|
||||
const product = await this.requireProduct(transaction, productId, true);
|
||||
const profile = await this.findProfile(transaction, productId, platformId);
|
||||
const product = await this.requireProduct(
|
||||
transaction,
|
||||
productId,
|
||||
true,
|
||||
);
|
||||
const profile = await this.findProfile(
|
||||
transaction,
|
||||
productId,
|
||||
platformId,
|
||||
);
|
||||
if (!profile)
|
||||
throw new ApiError(
|
||||
409,
|
||||
@@ -1096,24 +1194,36 @@ export class ProductEditorService {
|
||||
field: 'source_snapshot_revision',
|
||||
code: 'SOURCE_SNAPSHOT_CHANGED',
|
||||
message: `当前采集版本为 ${product.source_snapshot_revision}`,
|
||||
current_revision: Number(product.source_snapshot_revision),
|
||||
expected_revision: input.source_snapshot_revision,
|
||||
current_revision: Number(
|
||||
product.source_snapshot_revision,
|
||||
),
|
||||
expected_revision:
|
||||
input.source_snapshot_revision,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
const currentSource = resolveSourceMaterial(product);
|
||||
const overriddenFields = new Set(currentSource.state.overridden_fields);
|
||||
if (input.description === currentSource.state.latest.description) {
|
||||
const overriddenFields = new Set(
|
||||
currentSource.state.overridden_fields,
|
||||
);
|
||||
if (
|
||||
input.description === currentSource.state.latest.description
|
||||
) {
|
||||
overriddenFields.delete('description');
|
||||
} else {
|
||||
overriddenFields.add('description');
|
||||
}
|
||||
const overrides = selectSourceMaterialOverrides(
|
||||
{ ...currentSource.effective, description: input.description },
|
||||
{
|
||||
...currentSource.effective,
|
||||
description: input.description,
|
||||
},
|
||||
[...overriddenFields],
|
||||
);
|
||||
const updatedProduct = await transaction.query<{ revision: number }>(
|
||||
const updatedProduct = await transaction.query<{
|
||||
revision: number;
|
||||
}>(
|
||||
`UPDATE atlas.products
|
||||
SET ai_source_material_overrides = $1::jsonb,
|
||||
ai_source_material_base_revision = $2,
|
||||
@@ -1136,13 +1246,16 @@ export class ProductEditorService {
|
||||
const savedSource = resolveSourceMaterial({
|
||||
...product,
|
||||
ai_source_material_overrides: overrides,
|
||||
ai_source_material_base_revision: input.source_snapshot_revision,
|
||||
ai_source_material_base_revision:
|
||||
input.source_snapshot_revision,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
product_revision: Number(updatedProduct.rows[0].revision),
|
||||
product_revision: Number(
|
||||
updatedProduct.rows[0].revision,
|
||||
),
|
||||
profile_revision: Number(profile.revision),
|
||||
source_material: savedSource.effective,
|
||||
source_material_state: savedSource.state,
|
||||
@@ -1796,7 +1909,8 @@ export class ProductEditorService {
|
||||
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
||||
[product.collection_item_id],
|
||||
);
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return unavailable;
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON')
|
||||
return unavailable;
|
||||
|
||||
const signals = capturedOzonCategorySignals(
|
||||
latestProductAiSourceMaterial(product),
|
||||
@@ -1851,7 +1965,8 @@ export class ProductEditorService {
|
||||
LIMIT 20`,
|
||||
[platformId, signals.typeName, signals.rootCategoryName],
|
||||
);
|
||||
const status = candidates.rows.length === 1
|
||||
const status =
|
||||
candidates.rows.length === 1
|
||||
? 'matched'
|
||||
: candidates.rows.length
|
||||
? 'ambiguous'
|
||||
|
||||
@@ -205,7 +205,13 @@ export class ProductEditorValidationOperations {
|
||||
issues.push({
|
||||
code: 'TITLE_REQUIRED',
|
||||
field: 'common.source_title',
|
||||
message: '请填写标题',
|
||||
message: '请填写中文商品名',
|
||||
});
|
||||
if (!profile.title?.trim())
|
||||
issues.push({
|
||||
code: 'PUBLISH_TITLE_REQUIRED',
|
||||
field: 'profile.title',
|
||||
message: '请填写俄文发布标题',
|
||||
});
|
||||
if (!product.model?.trim())
|
||||
issues.push({
|
||||
|
||||
@@ -452,6 +452,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
],
|
||||
purchase_cost_cny: '88.50',
|
||||
tags: [],
|
||||
description: null,
|
||||
physical_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
@@ -476,6 +478,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
source_sku: null,
|
||||
specification_values: [],
|
||||
purchase_cost_cny: '99.00',
|
||||
tags: [],
|
||||
description: null,
|
||||
physical_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
@@ -707,6 +711,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
source_sku: null,
|
||||
specification_values: [],
|
||||
purchase_cost_cny: '99.00',
|
||||
tags: [],
|
||||
description: null,
|
||||
physical_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
@@ -805,6 +811,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
]
|
||||
: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec:
|
||||
index === 0
|
||||
? {
|
||||
@@ -994,6 +1002,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec,
|
||||
offer_id: variant.listing.offer_id,
|
||||
@@ -1862,6 +1872,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
source_sku: item.source_sku,
|
||||
specification_values: item.specification_values,
|
||||
purchase_cost_cny: item.purchase_cost_cny,
|
||||
tags: item.tags,
|
||||
description: item.description,
|
||||
physical_spec: item.physical_spec,
|
||||
package_spec: item.package_spec,
|
||||
offer_id: item.listing.offer_id,
|
||||
@@ -1955,4 +1967,66 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'独立持久化后的采集商品描述',
|
||||
);
|
||||
});
|
||||
|
||||
it('俄文发布标题为空时发布就绪校验提示必填,填写后移除提示', async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const store = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
WHERE workspace_id = current_setting('app.workspace_id')::uuid
|
||||
AND platform_id = $1 AND is_default = true
|
||||
LIMIT 1`,
|
||||
[platformId],
|
||||
);
|
||||
if (!store.rows.length)
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores
|
||||
(workspace_id, platform_id, name, platform_client_id,
|
||||
currency_code, is_default, status, credential_status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1,
|
||||
'俄文标题测试店铺', $2, 'RUB', true, 'active', 'valid')`,
|
||||
[platformId, `editor-title-${randomUUID()}`],
|
||||
);
|
||||
});
|
||||
const created = await createCollectionItem('俄文发布标题校验商品');
|
||||
const entered = await request(
|
||||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(entered.response.status, 201);
|
||||
const profileId = entered.body.data.profile_id as string;
|
||||
const missing = await request(
|
||||
`/api/product-platform-profiles/${profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(missing.response.status, 200);
|
||||
assert.ok(
|
||||
missing.body.data.profile.issues.some(
|
||||
(issue: { code: string; field: string }) =>
|
||||
issue.code === 'PUBLISH_TITLE_REQUIRED' &&
|
||||
issue.field === 'profile.title',
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_platform_profiles SET title = $1 WHERE id = $2`,
|
||||
['Название товара', profileId],
|
||||
),
|
||||
);
|
||||
const filled = await request(
|
||||
`/api/product-platform-profiles/${profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(filled.response.status, 200);
|
||||
assert.ok(
|
||||
!filled.body.data.profile.issues.some(
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-7
@@ -1,10 +1,20 @@
|
||||
# Web 前端交接:商品编辑器与门店类目
|
||||
# Web 前端交接:商品编辑器与 AI 填报
|
||||
|
||||
更新日期:2026-09-11
|
||||
更新日期:2026-09-23
|
||||
|
||||
本文只覆盖 `apps/web/` 当前未提交的前端改动。开始继续开发前,请先查看仓库根目录的 `git status --short`;工作区同时存在服务端、Worker 和文档改动,它们不属于前端接手者的修改范围。
|
||||
本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。
|
||||
|
||||
## 当前完成状态
|
||||
## 当前状态(2026-09-23)
|
||||
|
||||
- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 生图、首次上架仍是占位/不可用入口。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。
|
||||
- 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。
|
||||
- SPU 的「中文商品名」和「俄文发布标题」在页面中均标为必填;发布就绪校验也要求俄文标题。标题、标签、描述各有中文对照入口;标题旁有加号,可开启 SKU 标题覆盖。SKU 默认继承 SPU 文案,用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。
|
||||
- SKU 的 Ozon `is_aspect` 字段现在直接放在展开卡片的「SKU 信息」中,不另显示「Ozon 变体属性」标题。产品类目 Cascader 的清除事件按空类目处理,避免读取 `undefined.at`。来源采集信息侧栏仍展示来源资料与差异处理。
|
||||
- 关键组件:`ProductMainInfoSection.tsx`(名称、类目、SPU 标题等)、`ProductAttributeSection.tsx`(SPU 属性、标签与描述)、`ProductVariantSection.tsx`(SKU 信息及动态变体字段)、`ProductCopyAiDialogs.tsx`(SKU AI 生成与中俄对照)。商品编辑流程编排仍主要在 `ProductEditPage.tsx`。
|
||||
|
||||
下一轮前端回归应覆盖固定 footer 在不同视口/侧栏状态下不遮挡内容、类目清除和候选确认、AI 任务失败/部分成功/版本过期、SPU/SKU 文案继承与中文对照保存后重读。俄文禁汉字的最终拦截在 Worker,旧文案不会由前端自动清洗。本次更新只修改交接文档,未重新运行浏览器回归。
|
||||
|
||||
## 2026-09-11 历史前端记录(以下内容仅供追溯)
|
||||
|
||||
### 商品编辑器
|
||||
|
||||
@@ -51,8 +61,8 @@ git diff --check
|
||||
|
||||
两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。
|
||||
|
||||
## 前端边界
|
||||
## 当前工作约定
|
||||
|
||||
- 仅修改 `apps/web/` 及前端相关文档。
|
||||
- 不启动、停止或修改 API/Worker;若类目刷新或 schema 请求失败,应先检查服务端运行状态与 API 契约,再交由服务端维护者处理。
|
||||
- 当前任务可按仓库 `AGENTS.md` 修改相关项目代码;跨领域改动需遵守 [`../../docs/project/development-collaboration-contract.md`](../../docs/project/development-collaboration-contract.md) 并说明影响范围。
|
||||
- 验证需要时可在确认目标进程和端口后启动、停止或重启 API/Worker;完成后说明实际操作与状态。上方 2026-09-11 的验证记录不能替代新改动的检查。
|
||||
- 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
.product-editor {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 92px;
|
||||
/* 固定操作条不参与文档流,末尾内容需保留可滚动的安全空间。 */
|
||||
padding-bottom: 132px;
|
||||
}
|
||||
|
||||
/* 编辑器快照加载前占满内容可视区,避免加载图标受页面留白影响而偏移。 */
|
||||
@@ -165,16 +166,63 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* AI 类目候选为整行单选项,确认按钮只在用户明确选择后可用。 */
|
||||
.ai-category-modal .ant-modal-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ai-category-modal-intro {
|
||||
margin: 0 0 18px;
|
||||
color: #667085;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ai-category-candidates.ant-radio-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-height: min(50vh, 400px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-category-candidate.ant-radio-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #e4e7ec;
|
||||
border-radius: 10px;
|
||||
color: #344054;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.ai-category-candidate.ant-radio-wrapper:hover {
|
||||
border-color: #a99bea;
|
||||
background: #faf9ff;
|
||||
}
|
||||
.ai-category-candidate.ant-radio-wrapper:has(.ant-radio-checked) {
|
||||
border-color: #7958e5;
|
||||
background: #f7f4ff;
|
||||
}
|
||||
.ai-category-candidate .ant-radio {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.ai-category-candidate > span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 底部操作条 */
|
||||
.editor-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
left: 220px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
margin-top: 0;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #e4e0eb;
|
||||
border-radius: 12px;
|
||||
@@ -182,6 +230,11 @@
|
||||
box-shadow: 0 -8px 24px rgba(38, 27, 56, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 左侧导航折叠后,让固定操作条与实际内容区的左边缘对齐。 */
|
||||
.main-sider.ant-layout-sider-collapsed ~ .main-content-layout .editor-footer {
|
||||
left: 80px;
|
||||
}
|
||||
.footer-ai-actions {
|
||||
display: flex;
|
||||
}
|
||||
@@ -220,7 +273,17 @@
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
}
|
||||
/* 移动端导航改为抽屉,固定条回到完整视口宽度。 */
|
||||
@media (max-width: 767px) {
|
||||
.editor-footer {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.product-editor {
|
||||
padding-bottom: 188px;
|
||||
}
|
||||
.editor-header-main,
|
||||
.editor-footer {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Alert, App as AntdApp, Button, Form, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { Alert, App as AntdApp, Button, Form, Modal, Radio, Space, Spin, Tag, Typography } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileTextOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
AiCopyOutput,
|
||||
CollectionItemInspection,
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductAiSourceField,
|
||||
ProductAiSourceMaterial,
|
||||
ProductAiTask,
|
||||
ProductAiAutofillResult,
|
||||
ProductAiSkuCopyResult,
|
||||
ProductCopyField,
|
||||
ProductCopyTranslation,
|
||||
ProductEditorSnapshot,
|
||||
ProductEditorCategoryOption,
|
||||
SaveProductEditorDraftRequest,
|
||||
@@ -30,6 +35,11 @@ import { ProductAttributeSection } from './components/ProductAttributeSection';
|
||||
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
|
||||
import { ProductOtherSection } from './components/ProductOtherSection';
|
||||
import { ProductVariantSection } from './components/ProductVariantSection';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
type ComparisonRow,
|
||||
} from './components/ProductCopyAiDialogs';
|
||||
import './ProductEditPage.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
@@ -137,6 +147,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
title: variant.title,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
@@ -274,6 +285,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
source_sku: nullable(variant.source_sku),
|
||||
purchase_cost_cny: nullable(variant.purchase_cost_cny),
|
||||
offer_id: nullable(variant.offer_id),
|
||||
title: nullable(variant.title),
|
||||
specification_values: variant.specification_values.map((item) => ({
|
||||
axis_key: item.axis_key.trim(),
|
||||
value_key: item.value_key.trim(),
|
||||
@@ -424,6 +436,22 @@ const ProductEditPage = () => {
|
||||
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
|
||||
const [usesVariantTags, setUsesVariantTags] = useState(false);
|
||||
const [usesVariantDescription, setUsesVariantDescription] = useState(false);
|
||||
const [usesVariantTitle, setUsesVariantTitle] = useState(false);
|
||||
const [copyTranslations, setCopyTranslations] = useState<ProductCopyTranslation[]>([]);
|
||||
const [aiAutofillLoading, setAiAutofillLoading] = useState(false);
|
||||
const [aiAutofillStatus, setAiAutofillStatus] = useState('');
|
||||
const [categoryCandidates, setCategoryCandidates] = useState<
|
||||
NonNullable<ProductAiAutofillResult['category_candidates']> | null
|
||||
>(null);
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState<string | null>(null);
|
||||
const [comparison, setComparison] = useState<{
|
||||
variantId: string | null;
|
||||
field: ProductCopyField;
|
||||
} | null>(null);
|
||||
const [skuCopyVariantId, setSkuCopyVariantId] = useState<string | null>(null);
|
||||
const [skuCopyLoading, setSkuCopyLoading] = useState(false);
|
||||
const [skuCopyResult, setSkuCopyResult] = useState<AiCopyOutput | null>(null);
|
||||
const [skuCopyError, setSkuCopyError] = useState<string | null>(null);
|
||||
const blocker = useBlocker(dirty);
|
||||
const sourceSpecsApplied = useRef(false);
|
||||
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
|
||||
@@ -470,6 +498,8 @@ const ProductEditPage = () => {
|
||||
);
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
|
||||
setCopyTranslations(next.copy_translations);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
@@ -765,35 +795,13 @@ const ProductEditPage = () => {
|
||||
setUsesVariantPackageDimensions(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 主题标签复制到各 SKU;后续可在每个 SKU 内独立维护。 */
|
||||
const useVariantTags = () => {
|
||||
const tags = (form.getFieldValue(['profile', 'tags']) ?? []) as string[];
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => ({
|
||||
...variant,
|
||||
tags: variant.tags.length ? variant.tags : tags,
|
||||
})),
|
||||
});
|
||||
setUsesVariantTags(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 产品描述复制到各 SKU;后续可在每个 SKU 内独立维护。 */
|
||||
const useVariantDescription = () => {
|
||||
const description = form.getFieldValue(['profile', 'description']) as string | null;
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => ({
|
||||
...variant,
|
||||
description: variant.description ?? description,
|
||||
})),
|
||||
});
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
|
||||
const useVariantTags = () => setUsesVariantTags(true);
|
||||
const useVariantDescription = () => setUsesVariantDescription(true);
|
||||
const useVariantTitle = () => setUsesVariantTitle(true);
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
if (!productId || !currentPlatform || !snapshot) return;
|
||||
if (!productId || !currentPlatform || !snapshot) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
@@ -804,11 +812,17 @@ const ProductEditPage = () => {
|
||||
...values,
|
||||
ai_source_material: sourceDraft ?? snapshot.source_material,
|
||||
ai_source_overridden_fields: sourceOverriddenFields,
|
||||
copy_translations: copyTranslations.filter(
|
||||
(item) =>
|
||||
!item.variant_id ||
|
||||
values.variants.some((variant) => variant.id === item.variant_id)
|
||||
),
|
||||
})
|
||||
);
|
||||
message.success('草稿已保存');
|
||||
setDirty(false);
|
||||
await load();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') {
|
||||
message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存');
|
||||
@@ -817,10 +831,234 @@ const ProductEditPage = () => {
|
||||
message.warning('商品已在其他位置更新,已重新加载最新内容');
|
||||
await load();
|
||||
} else message.error(apiErrorMessage(error));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const upsertTranslation = (
|
||||
previous: ProductCopyTranslation[],
|
||||
variantId: string | null,
|
||||
field: ProductCopyField,
|
||||
russian: string | string[],
|
||||
chinese: string | string[],
|
||||
taskId: string
|
||||
) => [
|
||||
...previous.filter((item) => !(item.variant_id === variantId && item.field === field)),
|
||||
{
|
||||
variant_id: variantId,
|
||||
field,
|
||||
russian_value: russian,
|
||||
chinese_value: chinese,
|
||||
source_task_id: taskId,
|
||||
},
|
||||
];
|
||||
const addCopyTranslations = (variantId: string | null, copy: AiCopyOutput, taskId: string) => {
|
||||
setCopyTranslations((current) => {
|
||||
let next = current;
|
||||
if (copy.title)
|
||||
next = upsertTranslation(
|
||||
next,
|
||||
variantId,
|
||||
'title',
|
||||
copy.title.ru,
|
||||
copy.title.zh,
|
||||
taskId
|
||||
);
|
||||
if (copy.tags?.length)
|
||||
next = upsertTranslation(
|
||||
next,
|
||||
variantId,
|
||||
'tags',
|
||||
copy.tags.map((tag) => tag.ru),
|
||||
copy.tags.map((tag) => tag.zh),
|
||||
taskId
|
||||
);
|
||||
if (copy.description)
|
||||
next = upsertTranslation(
|
||||
next,
|
||||
variantId,
|
||||
'description',
|
||||
copy.description.ru,
|
||||
copy.description.zh,
|
||||
taskId
|
||||
);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
/** 任务输出必须与发起时的版本一致,避免异步结果覆盖后续人工编辑。 */
|
||||
const waitForAiTask = async (taskId: string): Promise<ProductAiTask> => {
|
||||
if (!currentPlatform) throw new Error('平台未选择');
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (attempt) await wait(2_000);
|
||||
const task = await productEditorApi.aiTask(taskId, currentPlatform.id);
|
||||
if (['succeeded', 'partial', 'failed'].includes(task.status)) return task;
|
||||
}
|
||||
throw new Error('AI 任务仍在处理,请稍后重试');
|
||||
};
|
||||
const selectCategoryCandidate = (
|
||||
candidates: NonNullable<ProductAiAutofillResult['category_candidates']>
|
||||
) => {
|
||||
setSelectedCandidateId(null);
|
||||
setCategoryCandidates(candidates);
|
||||
};
|
||||
const confirmCategoryCandidate = () => {
|
||||
if (!selectedCandidateId) return;
|
||||
form.setFieldsValue({
|
||||
profile: {
|
||||
platform_product_category_id: selectedCandidateId,
|
||||
category_source: 'ai_suggestion',
|
||||
},
|
||||
attributes: [],
|
||||
});
|
||||
setDirty(true);
|
||||
setCategoryCandidates(null);
|
||||
setSelectedCandidateId(null);
|
||||
};
|
||||
const applyAutofill = (result: ProductAiAutofillResult, taskId: string) => {
|
||||
const values = form.getFieldsValue(true) as EditorFormValues;
|
||||
const profile = { ...values.profile };
|
||||
const common = { ...values.common };
|
||||
const variants = [...values.variants];
|
||||
if (result.category && !profile.platform_product_category_id) {
|
||||
profile.platform_product_category_id = result.category.category_id;
|
||||
profile.category_source = 'ai_suggestion';
|
||||
} else if (result.category_candidates?.length)
|
||||
selectCategoryCandidate(result.category_candidates);
|
||||
if (result.model && !common.model) common.model = result.model;
|
||||
if (result.package_spec) {
|
||||
const spec = { ...common.default_package_spec };
|
||||
let packageFilled = false;
|
||||
for (const key of [
|
||||
'length_mm',
|
||||
'width_mm',
|
||||
'height_mm',
|
||||
'shipping_weight_g',
|
||||
] as const) {
|
||||
if (!spec[key]) {
|
||||
spec[key] = result.package_spec[key];
|
||||
packageFilled = true;
|
||||
}
|
||||
}
|
||||
if (packageFilled && spec.status !== 'confirmed') spec.status = 'ambiguous';
|
||||
common.default_package_spec = spec;
|
||||
}
|
||||
for (const offer of result.offers ?? []) {
|
||||
const index = variants.findIndex((variant) => variant.id === offer.variant_id);
|
||||
if (index >= 0 && !variants[index].offer_id)
|
||||
variants[index] = { ...variants[index], offer_id: offer.offer_id };
|
||||
}
|
||||
const copy = result.spu_copy;
|
||||
if (copy?.title && !profile.title) {
|
||||
profile.title = copy.title.ru;
|
||||
addCopyTranslations(null, { title: copy.title }, taskId);
|
||||
}
|
||||
if (copy?.tags?.length && !profile.tags.length) {
|
||||
profile.tags = copy.tags.map((tag) => tag.ru);
|
||||
addCopyTranslations(null, { tags: copy.tags }, taskId);
|
||||
}
|
||||
if (copy?.description && !profile.description) {
|
||||
profile.description = copy.description.ru;
|
||||
addCopyTranslations(null, { description: copy.description }, taskId);
|
||||
}
|
||||
form.setFieldsValue({ profile, common, variants });
|
||||
setDirty(true);
|
||||
if (result.warnings.length) message.warning(result.warnings.join(';'));
|
||||
else message.success('AI 结果已填入表单,请检查后保存');
|
||||
};
|
||||
const startAutofill = async () => {
|
||||
if (!snapshot || !currentPlatform || aiAutofillLoading) return;
|
||||
setAiAutofillLoading(true);
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
const formAtStart = JSON.stringify(form.getFieldsValue(true));
|
||||
setAiAutofillStatus('正在分析采集信息…');
|
||||
const task = await productEditorApi.aiAutofill(snapshot.profile.id, currentPlatform.id);
|
||||
const completed = await waitForAiTask(task.ai_task_id);
|
||||
if (completed.status === 'failed' || !completed.result)
|
||||
throw new Error(completed.error_message ?? 'AI 生成失败');
|
||||
const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
if (
|
||||
latest.product.revision !== completed.input_revision.product ||
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
latest.source_material.source_snapshot_revision !==
|
||||
completed.input_revision.source_snapshot ||
|
||||
JSON.stringify(form.getFieldsValue(true)) !== formAtStart
|
||||
) {
|
||||
message.warning(
|
||||
'商品或采集信息在 AI 处理期间已变化,结果未自动回填,请重新点击 AI 填报'
|
||||
);
|
||||
return;
|
||||
}
|
||||
applyAutofill(completed.result as ProductAiAutofillResult, completed.id);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : apiErrorMessage(error));
|
||||
} finally {
|
||||
setAiAutofillLoading(false);
|
||||
setAiAutofillStatus('');
|
||||
}
|
||||
};
|
||||
const generateSkuCopy = async () => {
|
||||
if (!snapshot || !currentPlatform || !skuCopyVariantId || skuCopyLoading) return;
|
||||
setSkuCopyLoading(true);
|
||||
setSkuCopyError(null);
|
||||
setSkuCopyResult(null);
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
const formAtStart = JSON.stringify(form.getFieldsValue(true));
|
||||
const task = await productEditorApi.aiSkuCopy(
|
||||
snapshot.profile.id,
|
||||
skuCopyVariantId,
|
||||
currentPlatform.id
|
||||
);
|
||||
const completed = await waitForAiTask(task.ai_task_id);
|
||||
if (completed.status === 'failed' || !completed.result)
|
||||
throw new Error(completed.error_message ?? 'SKU 文案生成失败');
|
||||
const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
if (
|
||||
latest.product.revision !== completed.input_revision.product ||
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
JSON.stringify(form.getFieldsValue(true)) !== formAtStart
|
||||
)
|
||||
throw new Error('商品已被其他操作更新,请重新生成');
|
||||
skuCopyTaskId.current = completed.id;
|
||||
setSkuCopyResult((completed.result as ProductAiSkuCopyResult).copy);
|
||||
} catch (error) {
|
||||
setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error));
|
||||
} finally {
|
||||
setSkuCopyLoading(false);
|
||||
}
|
||||
};
|
||||
const applySkuCopy = () => {
|
||||
if (!skuCopyVariantId || !skuCopyResult) return;
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
|
||||
if (index < 0) return;
|
||||
const copy = skuCopyResult;
|
||||
form.setFieldValue(
|
||||
'variants',
|
||||
variants.map((variant, current) =>
|
||||
current !== index
|
||||
? variant
|
||||
: {
|
||||
...variant,
|
||||
title: copy.title?.ru ?? variant.title,
|
||||
tags: copy.tags?.map((tag) => tag.ru) ?? variant.tags,
|
||||
description: copy.description?.ru ?? variant.description,
|
||||
}
|
||||
)
|
||||
);
|
||||
// SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。
|
||||
if (skuCopyTaskId.current)
|
||||
addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current);
|
||||
setUsesVariantTitle(true);
|
||||
setUsesVariantTags(true);
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
setSkuCopyVariantId(null);
|
||||
message.success('已应用到当前 SKU,保存草稿后生效');
|
||||
};
|
||||
const skuCopyTaskId = useRef<string | null>(null);
|
||||
const showFutureFeature = (name: string) =>
|
||||
modal.info({
|
||||
title: `${name}将在下一阶段接入`,
|
||||
@@ -861,8 +1099,8 @@ const ProductEditPage = () => {
|
||||
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
|
||||
{
|
||||
description,
|
||||
source_snapshot_revision:
|
||||
(sourceDraft ?? snapshot.source_material).source_snapshot_revision,
|
||||
source_snapshot_revision: (sourceDraft ?? snapshot.source_material)
|
||||
.source_snapshot_revision,
|
||||
}
|
||||
);
|
||||
setSnapshot((current) =>
|
||||
@@ -891,8 +1129,7 @@ const ProductEditPage = () => {
|
||||
? {
|
||||
...current,
|
||||
description: saved.source_material.description,
|
||||
source_snapshot_revision:
|
||||
saved.source_material.source_snapshot_revision,
|
||||
source_snapshot_revision: saved.source_material.source_snapshot_revision,
|
||||
}
|
||||
: saved.source_material
|
||||
);
|
||||
@@ -1072,8 +1309,45 @@ const ProductEditPage = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const hasSourceCategory = snapshot.profile.category.source === 'source_capture';
|
||||
const summary = collectedSummary(collected ?? snapshot.source_material);
|
||||
const currentValues = form.getFieldsValue(true) as EditorFormValues;
|
||||
const activeSku = skuCopyVariantId
|
||||
? currentValues.variants?.find((variant) => variant.id === skuCopyVariantId)
|
||||
: null;
|
||||
const comparisonVariant = comparison?.variantId
|
||||
? currentValues.variants?.find((variant) => variant.id === comparison.variantId)
|
||||
: null;
|
||||
const comparisonRows: ComparisonRow[] = (['title', 'tags', 'description'] as const).map(
|
||||
(field) => {
|
||||
const ownValue = comparisonVariant?.[field];
|
||||
const inherited =
|
||||
!!comparisonVariant &&
|
||||
(field === 'tags' ? !Array.isArray(ownValue) || !ownValue.length : !ownValue);
|
||||
const russianValue =
|
||||
inherited || !comparisonVariant ? currentValues.profile?.[field] : ownValue;
|
||||
const russian = Array.isArray(russianValue)
|
||||
? russianValue.join('、')
|
||||
: (russianValue ?? '');
|
||||
const translation = copyTranslations.find(
|
||||
(item) =>
|
||||
item.variant_id === (inherited ? null : (comparison?.variantId ?? null)) &&
|
||||
item.field === field
|
||||
);
|
||||
const savedRussian = Array.isArray(translation?.russian_value)
|
||||
? translation.russian_value.join('、')
|
||||
: (translation?.russian_value ?? '');
|
||||
const chinese = Array.isArray(translation?.chinese_value)
|
||||
? translation.chinese_value.join('、')
|
||||
: (translation?.chinese_value ?? '');
|
||||
return {
|
||||
field,
|
||||
russian,
|
||||
chinese: savedRussian === russian ? chinese : '',
|
||||
inherited,
|
||||
stale: !!translation && savedRussian !== russian,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
@@ -1139,6 +1413,9 @@ const ProductEditPage = () => {
|
||||
}}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseVariantTitle={useVariantTitle}
|
||||
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
|
||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={useVariantPackageDimensions}
|
||||
/>
|
||||
@@ -1165,6 +1442,7 @@ const ProductEditPage = () => {
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
onUseVariantTags={useVariantTags}
|
||||
onUseVariantDescription={useVariantDescription}
|
||||
onCompareCopy={(field) => setComparison({ variantId: null, field })}
|
||||
/>
|
||||
|
||||
<ProductVariantSection
|
||||
@@ -1187,6 +1465,14 @@ const ProductEditPage = () => {
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onAiCopy={(variantId) => {
|
||||
setSkuCopyVariantId(variantId);
|
||||
setSkuCopyResult(null);
|
||||
setSkuCopyError(null);
|
||||
skuCopyTaskId.current = null;
|
||||
}}
|
||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||
/>
|
||||
|
||||
<ProductOtherSection
|
||||
@@ -1213,15 +1499,10 @@ const ProductEditPage = () => {
|
||||
<Space className="footer-ai-actions" wrap>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => showFutureFeature('AI 类目推荐')}
|
||||
loading={aiAutofillLoading}
|
||||
onClick={() => void startAutofill()}
|
||||
>
|
||||
{hasSourceCategory ? '重新推荐类目' : '推荐类目'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
onClick={() => showFutureFeature('AI 文案生成')}
|
||||
>
|
||||
生成标题、标签和简介
|
||||
{aiAutofillStatus || 'AI 填报'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
@@ -1243,6 +1524,68 @@ const ProductEditPage = () => {
|
||||
<Button disabled>选择 SKU 并首次上架</Button>
|
||||
</Space>
|
||||
</footer>
|
||||
<ProductCopyComparisonModal
|
||||
open={!!comparison}
|
||||
scopeLabel={
|
||||
comparison?.variantId
|
||||
? `SKU · ${comparisonVariant?.display_name || '未命名'}`
|
||||
: 'SPU'
|
||||
}
|
||||
rows={comparisonRows}
|
||||
focus={comparison?.field ?? 'title'}
|
||||
onClose={() => setComparison(null)}
|
||||
/>
|
||||
<SkuCopyAiModal
|
||||
open={!!skuCopyVariantId}
|
||||
variantName={activeSku?.display_name || '未命名 SKU'}
|
||||
sourceTitle={(sourceDraft ?? snapshot.source_material).title || ''}
|
||||
sourceDescription={(sourceDraft ?? snapshot.source_material).description || ''}
|
||||
specifications={
|
||||
activeSku?.specification_values
|
||||
.map((item) => `${item.axis_key}: ${item.value_name}`)
|
||||
.join(';') ?? ''
|
||||
}
|
||||
loading={skuCopyLoading}
|
||||
result={skuCopyResult}
|
||||
error={skuCopyError}
|
||||
onGenerate={() => void generateSkuCopy()}
|
||||
onApply={applySkuCopy}
|
||||
onClose={() => setSkuCopyVariantId(null)}
|
||||
/>
|
||||
<Modal
|
||||
className="ai-category-modal"
|
||||
title="确认 AI 推荐类目"
|
||||
open={!!categoryCandidates}
|
||||
centered
|
||||
width={560}
|
||||
okText="使用此类目"
|
||||
okButtonProps={{ disabled: !selectedCandidateId }}
|
||||
cancelText="暂不选择"
|
||||
onOk={confirmCategoryCandidate}
|
||||
onCancel={() => {
|
||||
setCategoryCandidates(null);
|
||||
setSelectedCandidateId(null);
|
||||
}}
|
||||
>
|
||||
<p className="ai-category-modal-intro">
|
||||
根据采集信息找到以下可能的类目。请选择最符合商品的一个,确认后会填入表单。
|
||||
</p>
|
||||
<Radio.Group
|
||||
className="ai-category-candidates"
|
||||
value={selectedCandidateId}
|
||||
onChange={(event) => setSelectedCandidateId(event.target.value)}
|
||||
>
|
||||
{categoryCandidates?.map((candidate) => (
|
||||
<Radio
|
||||
key={candidate.category_id}
|
||||
className="ai-category-candidate"
|
||||
value={candidate.category_id}
|
||||
>
|
||||
{candidate.path}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons';
|
||||
import { EditOutlined, PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
usesVariantDescription: boolean;
|
||||
onUseVariantTags: () => void;
|
||||
onUseVariantDescription: () => void;
|
||||
onCompareCopy: (field: 'tags' | 'description') => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,6 +71,7 @@ export function ProductAttributeSection({
|
||||
usesVariantDescription,
|
||||
onUseVariantTags,
|
||||
onUseVariantDescription,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||
const [richContent, setRichContent] = useState('');
|
||||
@@ -95,7 +97,6 @@ export function ProductAttributeSection({
|
||||
title="产品属性"
|
||||
description="用于当前平台发布与 AI 文案生成。"
|
||||
/>
|
||||
{!usesVariantTags && (
|
||||
<Form.Item label="主题标签">
|
||||
<div className="profile-override-control">
|
||||
<Form.Item name={['profile', 'tags']} noStyle>
|
||||
@@ -106,30 +107,43 @@ export function ProductAttributeSection({
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
{!usesVariantTags && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写主题标签"
|
||||
onClick={onUseVariantTags}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看主题标签中文对照"
|
||||
onClick={() => onCompareCopy('tags')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!usesVariantDescription && (
|
||||
<Form.Item label="产品描述" rules={[{ max: 100000 }]}>
|
||||
<div className="profile-override-control profile-override-control--description">
|
||||
<Form.Item name={['profile', 'description']} noStyle>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
{!usesVariantDescription && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写产品描述"
|
||||
onClick={onUseVariantDescription}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看产品描述中文对照"
|
||||
onClick={() => onCompareCopy('description')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name={['common', 'barcode']} label="条形码">
|
||||
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
|
||||
</Form.Item>
|
||||
@@ -156,7 +170,9 @@ export function ProductAttributeSection({
|
||||
onClick={() => {
|
||||
onToggleExpanded();
|
||||
window.requestAnimationFrame(() =>
|
||||
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||
document
|
||||
.getElementById('dynamic-attributes')
|
||||
?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.copy-comparison-head,
|
||||
.copy-comparison-row,
|
||||
.sku-copy-ai-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.copy-comparison-head {
|
||||
font-weight: 600;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.copy-comparison-row {
|
||||
border-top: 1px solid #e9e9e9;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.copy-comparison-row--focus {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
.copy-comparison-text,
|
||||
.sku-copy-ai-source,
|
||||
.sku-copy-ai-result p {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sku-copy-ai-source {
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.sku-copy-ai-zh {
|
||||
color: #666;
|
||||
}
|
||||
.sku-copy-ai-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sku-copy-ai-loading {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.copy-comparison-head,
|
||||
.copy-comparison-row,
|
||||
.sku-copy-ai-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.copy-comparison-head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Alert, Button, Modal, Space, Spin, Tag, Typography } from 'antd';
|
||||
import type { AiCopyOutput, ProductCopyField } from '@atlas/contracts';
|
||||
import './ProductCopyAiDialogs.css';
|
||||
|
||||
type ComparisonRow = {
|
||||
field: ProductCopyField;
|
||||
russian: string;
|
||||
chinese: string;
|
||||
inherited: boolean;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
const labels: Record<ProductCopyField, string> = {
|
||||
title: '标题',
|
||||
tags: '主题标签',
|
||||
description: '产品描述',
|
||||
};
|
||||
|
||||
/** SPU 与 SKU 共用一个中俄对照弹窗,继承及过期状态逐字段展示。 */
|
||||
export function ProductCopyComparisonModal({
|
||||
open,
|
||||
scopeLabel,
|
||||
rows,
|
||||
focus,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
scopeLabel: string;
|
||||
rows: ComparisonRow[];
|
||||
focus: ProductCopyField;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`${scopeLabel} · 中文对照`}
|
||||
onCancel={onClose}
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
width={880}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="copy-comparison-head">
|
||||
<span>俄文</span>
|
||||
<span>中文对照</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.field}
|
||||
className={`copy-comparison-row ${row.field === focus ? 'copy-comparison-row--focus' : ''}`}
|
||||
>
|
||||
<div>
|
||||
<Typography.Text strong>{labels[row.field]}</Typography.Text>
|
||||
{row.inherited && <Tag color="blue">继承 SPU</Tag>}
|
||||
<div className="copy-comparison-text">{row.russian || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{labels[row.field]}</Typography.Text>
|
||||
{row.stale && <Tag color="orange">对照待更新</Tag>}
|
||||
<div className="copy-comparison-text">{row.chinese || '暂无中文对照'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 当前 SKU 生成弹窗只预览结果;点击应用后才写入该 SKU 的未保存表单。 */
|
||||
export function SkuCopyAiModal({
|
||||
open,
|
||||
variantName,
|
||||
sourceTitle,
|
||||
sourceDescription,
|
||||
specifications,
|
||||
loading,
|
||||
result,
|
||||
error,
|
||||
onGenerate,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
variantName: string;
|
||||
sourceTitle: string;
|
||||
sourceDescription: string;
|
||||
specifications: string;
|
||||
loading: boolean;
|
||||
result: AiCopyOutput | null;
|
||||
error: string | null;
|
||||
onGenerate: () => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`AI 生成当前 SKU 文案 · ${variantName}`}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button onClick={onGenerate} loading={loading}>
|
||||
AI 生成
|
||||
</Button>
|
||||
<Button type="primary" disabled={!result || loading} onClick={onApply}>
|
||||
应用到当前 SKU
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert type="warning" showIcon message={error} className="sku-copy-ai-error" />
|
||||
)}
|
||||
<div className="sku-copy-ai-layout">
|
||||
<div>
|
||||
<Typography.Title level={5}>中文来源与 SKU 信息</Typography.Title>
|
||||
<Typography.Text strong>SKU 名称</Typography.Text>
|
||||
<p>{variantName}</p>
|
||||
<Typography.Text strong>来源标题</Typography.Text>
|
||||
<p>{sourceTitle || '未采集'}</p>
|
||||
<Typography.Text strong>来源描述</Typography.Text>
|
||||
<p className="sku-copy-ai-source">{sourceDescription || '未采集'}</p>
|
||||
<Typography.Text strong>SKU 规格</Typography.Text>
|
||||
<p>{specifications || '未采集'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>当前 SKU 的生成结果</Typography.Title>
|
||||
{loading && (
|
||||
<Spin tip="正在生成">
|
||||
<div className="sku-copy-ai-loading" />
|
||||
</Spin>
|
||||
)}
|
||||
{!loading && !result && (
|
||||
<Typography.Text type="secondary">
|
||||
点击下方「AI 生成」后在此预览。
|
||||
</Typography.Text>
|
||||
)}
|
||||
{result && (
|
||||
<div className="sku-copy-ai-result">
|
||||
<Typography.Text strong>标题</Typography.Text>
|
||||
<p>{result.title?.ru || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">{result.title?.zh || ''}</p>
|
||||
<Typography.Text strong>主题标签</Typography.Text>
|
||||
<p>{result.tags?.map((tag) => tag.ru).join('、') || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">
|
||||
{result.tags?.map((tag) => tag.zh).join('、') || ''}
|
||||
</p>
|
||||
<Typography.Text strong>产品描述</Typography.Text>
|
||||
<p>{result.description?.ru || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">{result.description?.zh || ''}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ComparisonRow };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusCircleOutlined } from '@ant-design/icons';
|
||||
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductMainInfoSection.css';
|
||||
@@ -93,6 +93,9 @@ type Props = {
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseVariantPackageWeight: () => void;
|
||||
onUseVariantPackageDimensions: () => void;
|
||||
usesVariantTitle: boolean;
|
||||
onUseVariantTitle: () => void;
|
||||
onCompareTitle: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +117,9 @@ export function ProductMainInfoSection({
|
||||
usesVariantPackageDimensions,
|
||||
onUseVariantPackageWeight,
|
||||
onUseVariantPackageDimensions,
|
||||
usesVariantTitle,
|
||||
onUseVariantTitle,
|
||||
onCompareTitle,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
||||
@@ -170,7 +176,10 @@ export function ProductMainInfoSection({
|
||||
getValueProps={(categoryId: string | null) => ({
|
||||
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
|
||||
})}
|
||||
getValueFromEvent={(value: string[]) => value.at(-1) ?? null}
|
||||
// Cascader 清除时会传入 undefined,统一折算为空类目避免读取空值。
|
||||
getValueFromEvent={(value: unknown) =>
|
||||
Array.isArray(value) ? (value.at(-1) ?? null) : null
|
||||
}
|
||||
>
|
||||
<Cascader
|
||||
className="editor-standard-cascader"
|
||||
@@ -182,7 +191,7 @@ export function ProductMainInfoSection({
|
||||
notFoundContent={categoryNotFoundContent}
|
||||
getPopupContainer={(node) => node.parentElement ?? document.body}
|
||||
onChange={(value) => {
|
||||
const categoryId = value.at(-1) ?? null;
|
||||
const categoryId = Array.isArray(value) ? (value.at(-1) ?? null) : null;
|
||||
if (categoryId !== selectedCategoryId)
|
||||
form.setFieldValue('attributes', []);
|
||||
form.setFieldValue(
|
||||
@@ -194,11 +203,47 @@ export function ProductMainInfoSection({
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'source_title']}
|
||||
label="标题"
|
||||
label="中文商品名"
|
||||
className="span-full"
|
||||
rules={[{ required: true, message: '请填写标题' }, { max: 1000 }]}
|
||||
rules={[{ required: true, message: '请填写中文商品名' }, { max: 1000 }]}
|
||||
>
|
||||
<Input placeholder="整理后的商品标题" />
|
||||
<Input placeholder="填写中文商品名" />
|
||||
</Form.Item>
|
||||
<Form.Item label="俄文发布标题" className="span-full" required>
|
||||
<div className="profile-override-control">
|
||||
<Form.Item
|
||||
name={['profile', 'title']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
whitespace: true,
|
||||
message: '请填写俄文发布标题',
|
||||
},
|
||||
{ max: 1000 },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
|
||||
</Form.Item>
|
||||
{!usesVariantTitle && (
|
||||
<Tooltip title="在 SKU 中分别编辑标题">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="在 SKU 中分别编辑标题"
|
||||
onClick={onUseVariantTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="查看标题中文对照">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看标题中文对照"
|
||||
onClick={onCompareTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'model']}
|
||||
|
||||
@@ -157,26 +157,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ozon 变体属性与 SKU 摘要同属卡片主体;必填项在折叠状态下也始终可见。 */
|
||||
.sku-attribute-panel {
|
||||
margin-top: 12px;
|
||||
padding: 12px 0 0 87px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-attribute-panel h5.ant-typography {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item-label {
|
||||
flex: 0 0 120px !important;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item-label > label {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
min-height: 32px;
|
||||
padding-top: 5px;
|
||||
@@ -338,7 +318,7 @@
|
||||
.sku-info-variant-description {
|
||||
grid-column: span 2;
|
||||
}
|
||||
/* 动态 SKU 属性也属于同一信息网格,不能再形成独立的横向属性区。 */
|
||||
/* 所有动态 SKU 属性与包装、文案字段共用“SKU 信息”网格。 */
|
||||
.sku-info-panel .dynamic-attribute-list {
|
||||
display: contents;
|
||||
}
|
||||
@@ -384,9 +364,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sku-attribute-panel {
|
||||
padding-left: 0;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
TranslationOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
ProductAttributeDictionaryValue,
|
||||
@@ -124,6 +131,9 @@ type Props = {
|
||||
usesVariantPackageDimensions: boolean;
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -151,23 +161,19 @@ export function ProductVariantSection({
|
||||
usesVariantPackageDimensions,
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||
const requiredAspectDefinitions = activeAttributeSchema.filter(
|
||||
(definition) =>
|
||||
definition.is_aspect &&
|
||||
definition.is_required &&
|
||||
definition.name.trim() !== '商品颜色' &&
|
||||
!definition.editor_field
|
||||
);
|
||||
// SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。
|
||||
const skuInfoAspectDefinitions = activeAttributeSchema
|
||||
.filter(
|
||||
(definition) =>
|
||||
definition.is_aspect &&
|
||||
(!definition.is_required || definition.name.trim() === '商品颜色') &&
|
||||
!definition.editor_field
|
||||
)
|
||||
.sort((left, right) => skuInfoAttributeOrder(left) - skuInfoAttributeOrder(right));
|
||||
@@ -196,6 +202,7 @@ export function ProductVariantSection({
|
||||
source_sku: null,
|
||||
specification_values: [],
|
||||
purchase_cost_cny: null,
|
||||
title: null,
|
||||
tags: [],
|
||||
description: null,
|
||||
physical_spec: {
|
||||
@@ -269,6 +276,9 @@ export function ProductVariantSection({
|
||||
display_name: variantValue.display_name
|
||||
? `${variantValue.display_name} 副本`
|
||||
: 'SKU 副本',
|
||||
title: null,
|
||||
tags: [],
|
||||
description: null,
|
||||
offer_id: null,
|
||||
media_asset_ids: [],
|
||||
});
|
||||
@@ -425,39 +435,6 @@ export function ProductVariantSection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{requiredAspectDefinitions.length > 0 && (
|
||||
<div className="sku-attribute-panel">
|
||||
<Typography.Title level={5}>
|
||||
Ozon 变体属性
|
||||
</Typography.Title>
|
||||
{sourceId ? (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={requiredAspectDefinitions}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={
|
||||
onLoadDictionaryValues
|
||||
}
|
||||
onSyncDictionaryValues={
|
||||
onSyncDictionaryValues
|
||||
}
|
||||
onOpenDictionaryValues={
|
||||
onOpenDictionaryValues
|
||||
}
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
保存新 SKU 后即可填写其 Ozon 变体属性。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="sku-card-expanded">
|
||||
<div className="sku-media-panel">
|
||||
@@ -569,6 +546,7 @@ export function ProductVariantSection({
|
||||
usesVariantPackageDimensions ||
|
||||
usesVariantTags ||
|
||||
usesVariantDescription ||
|
||||
usesVariantTitle ||
|
||||
(skuInfoAspectDefinitions.length > 0 &&
|
||||
sourceId)) && (
|
||||
<div className="sku-info-panel">
|
||||
@@ -615,32 +593,147 @@ export function ProductVariantSection({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{usesVariantTitle && (
|
||||
<Form.Item
|
||||
label="俄文发布标题"
|
||||
layout="vertical"
|
||||
>
|
||||
<Space.Compact
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[field.name, 'title']}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="留空则继承 SPU 标题" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 文案"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onAiCopy(
|
||||
sourceId,
|
||||
'title'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<TranslationOutlined />
|
||||
}
|
||||
aria-label="查看当前 SKU 标题中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onCompareCopy(
|
||||
sourceId,
|
||||
'title'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantTags && (
|
||||
<Form.Item
|
||||
name={[field.name, 'tags']}
|
||||
label="主题标签"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-tags"
|
||||
>
|
||||
<Space.Compact
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[field.name, 'tags']}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
placeholder="留空则继承 SPU 标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 标签"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onAiCopy(
|
||||
sourceId,
|
||||
'tags'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<TranslationOutlined />
|
||||
}
|
||||
aria-label="查看当前 SKU 标签中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onCompareCopy(
|
||||
sourceId,
|
||||
'tags'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantDescription && (
|
||||
<Form.Item
|
||||
name={[field.name, 'description']}
|
||||
label="产品描述"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-description"
|
||||
>
|
||||
<Space.Compact
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'description',
|
||||
]}
|
||||
noStyle
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="填写该 SKU 的产品描述"
|
||||
placeholder="留空则继承 SPU 描述"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 描述"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onAiCopy(
|
||||
sourceId,
|
||||
'description'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<TranslationOutlined />
|
||||
}
|
||||
aria-label="查看当前 SKU 描述中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onCompareCopy(
|
||||
sourceId,
|
||||
'description'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 &&
|
||||
sourceId && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ProductEditorCategoryOption,
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
ProductAiTask,
|
||||
ProductEditorValidation,
|
||||
ProductPublishPreflight,
|
||||
Operation,
|
||||
@@ -31,6 +32,32 @@ export const productEditorApi = {
|
||||
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}),
|
||||
aiAutofill: (profileId: string, platformId: string) =>
|
||||
api.post<{ ai_task_id: string; status: 'queued' }>(
|
||||
`/product-platform-profiles/${profileId}/ai/autofills`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}
|
||||
),
|
||||
aiSkuCopy: (profileId: string, variantId: string, platformId: string) =>
|
||||
api.post<{ ai_task_id: string; status: 'queued' }>(
|
||||
`/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}
|
||||
),
|
||||
aiTask: (taskId: string, platformId: string) =>
|
||||
api.get<ProductAiTask>(`/product-ai-tasks/${taskId}`, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}),
|
||||
save: (
|
||||
productId: string,
|
||||
platformId: string,
|
||||
|
||||
+92
-24
@@ -19,23 +19,34 @@ import {
|
||||
type CatalogSyncScope,
|
||||
} from './ozon-catalog-sync.js';
|
||||
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.js';
|
||||
import { PlatformCatalogDispatcher, type PlatformCatalogJob } from './platform-catalog-dispatcher.js';
|
||||
import {
|
||||
PlatformCatalogDispatcher,
|
||||
type PlatformCatalogJob,
|
||||
} from './platform-catalog-dispatcher.js';
|
||||
import { PlatformCatalogScheduler } from './platform-catalog-scheduler.js';
|
||||
import { markPlatformCatalogUnavailable, syncPlatformCatalog } from './platform-catalog-sync.js';
|
||||
import {
|
||||
markPlatformCatalogUnavailable,
|
||||
syncPlatformCatalog,
|
||||
} from './platform-catalog-sync.js';
|
||||
import { runProductAiTask } from './product-ai.js';
|
||||
|
||||
const connection = new Redis(workerConfig.redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
const database = createApplicationDatabase(workerConfig.databaseUrl);
|
||||
const queue = new Queue<OutboxJob>('atlas-dispatch', { connection });
|
||||
const platformQueue = new Queue<PlatformCatalogJob>('atlas-platform-catalog', { connection });
|
||||
const platformQueue = new Queue<PlatformCatalogJob>('atlas-platform-catalog', {
|
||||
connection,
|
||||
});
|
||||
const dispatcher = new OutboxDispatcher(
|
||||
database,
|
||||
queue,
|
||||
`worker_${randomUUID()}`,
|
||||
);
|
||||
const platformDispatcher = new PlatformCatalogDispatcher(
|
||||
database, platformQueue, `platform_worker_${randomUUID()}`,
|
||||
database,
|
||||
platformQueue,
|
||||
`platform_worker_${randomUUID()}`,
|
||||
);
|
||||
const platformScheduler = new PlatformCatalogScheduler(database);
|
||||
const objectStorage = new S3Client({
|
||||
@@ -48,7 +59,9 @@ const objectStorage = new S3Client({
|
||||
},
|
||||
});
|
||||
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
|
||||
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
|
||||
type StoreVerificationJob = OutboxJob & {
|
||||
payload: { store_id: string; operation_id: string; secret_id?: string };
|
||||
};
|
||||
type CatalogSyncJob = OutboxJob & {
|
||||
payload: {
|
||||
store_id: string;
|
||||
@@ -67,12 +80,39 @@ const worker = new Worker<OutboxJob | PlatformCatalogJob>(
|
||||
async (job) => {
|
||||
if (job.name === 'platform_catalog_sync.requested') {
|
||||
await syncPlatformCatalog(database, job.data as PlatformCatalogJob);
|
||||
} else if (job.name === 'product_ai_task.requested') {
|
||||
await runProductAiTask(database, job.data as OutboxJob);
|
||||
} else if (job.name === 'store_credential_verification.requested') {
|
||||
await verifyStoreCredentials(database, job.data as OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } });
|
||||
await verifyStoreCredentials(
|
||||
database,
|
||||
job.data as OutboxJob & {
|
||||
payload: {
|
||||
store_id: string;
|
||||
operation_id: string;
|
||||
secret_id?: string;
|
||||
};
|
||||
},
|
||||
);
|
||||
} else if (job.name === 'collection.cleanup.requested') {
|
||||
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
|
||||
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
|
||||
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data as OutboxJob);
|
||||
await cleanupCollection(
|
||||
database,
|
||||
job.data as OutboxJob & {
|
||||
payload: {
|
||||
operation_id: string;
|
||||
collection_item_ids: string[];
|
||||
};
|
||||
},
|
||||
);
|
||||
} else if (
|
||||
job.name === 'collection.assets.materialize.requested' ||
|
||||
job.name === 'collection.assets.retry.requested'
|
||||
) {
|
||||
await materializeCollectionAssets(
|
||||
database,
|
||||
objectStorage,
|
||||
workerConfig.objectStorageBucket,
|
||||
job.data as OutboxJob,
|
||||
);
|
||||
} else if (
|
||||
job.name === 'store_category_tree_sync.requested' ||
|
||||
job.name === 'store_category_attribute_sync.requested' ||
|
||||
@@ -91,18 +131,35 @@ const platformWorker = new Worker<PlatformCatalogJob>(
|
||||
{ connection, concurrency: 5 },
|
||||
);
|
||||
|
||||
async function cleanupCollection(database: Database, job: OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } }) {
|
||||
async function cleanupCollection(
|
||||
database: Database,
|
||||
job: OutboxJob & {
|
||||
payload: { operation_id: string; collection_item_ids: string[] };
|
||||
},
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
await tx.query(`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`, [job.payload.operation_id]);
|
||||
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', updated_at = now(), completed_at = now() WHERE collection_item_id = ANY($1::uuid[]) AND status IN ('queued', 'running')`, [job.payload.collection_item_ids]);
|
||||
await tx.query(`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), result = jsonb_build_object('cancelled_transfer_count', 0) WHERE id = $1 AND status IN ('queued', 'running')`, [job.payload.operation_id]);
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()), attempt_count = attempt_count + 1 WHERE id = $1 AND status = 'queued'`,
|
||||
[job.payload.operation_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', updated_at = now(), completed_at = now() WHERE collection_item_id = ANY($1::uuid[]) AND status IN ('queued', 'running')`,
|
||||
[job.payload.collection_item_ids],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(), result = jsonb_build_object('cancelled_transfer_count', 0) WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
|
||||
if (job.name === 'platform_catalog_sync.requested') {
|
||||
void markPlatformCatalogUnavailable(database, job.data as PlatformCatalogJob);
|
||||
void markPlatformCatalogUnavailable(
|
||||
database,
|
||||
job.data as PlatformCatalogJob,
|
||||
);
|
||||
} else if (job.name === 'store_credential_verification.requested') {
|
||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||
} else if (
|
||||
@@ -110,7 +167,11 @@ worker.on('failed', (job, error) => {
|
||||
job.name === 'store_category_attribute_sync.requested' ||
|
||||
job.name === 'store_attribute_dictionary_sync.requested'
|
||||
) {
|
||||
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
||||
void markCatalogSyncUnavailable(
|
||||
database,
|
||||
job.data as CatalogSyncJob,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
platformWorker.on('failed', (job) => {
|
||||
@@ -118,7 +179,10 @@ platformWorker.on('failed', (job) => {
|
||||
void markPlatformCatalogUnavailable(database, job.data);
|
||||
});
|
||||
|
||||
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
||||
async function verifyStoreCredentials(
|
||||
database: Database,
|
||||
job: StoreVerificationJob,
|
||||
) {
|
||||
const source = await database.workspaceTransaction(
|
||||
job.workspaceId,
|
||||
async (tx) => {
|
||||
@@ -167,7 +231,10 @@ async function verifyStoreCredentials(database: Database, job: StoreVerification
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelStaleOperation(database: Database, job: StoreVerificationJob) {
|
||||
async function cancelStaleOperation(
|
||||
database: Database,
|
||||
job: StoreVerificationJob,
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||||
@@ -317,8 +384,7 @@ async function bootstrap() {
|
||||
await dispatcher.dispatchBatch();
|
||||
await platformDispatcher.dispatchBatch();
|
||||
await platformScheduler.run();
|
||||
dispatcherTimer = setInterval(
|
||||
() => {
|
||||
dispatcherTimer = setInterval(() => {
|
||||
void dispatcher.dispatchBatch().catch((error: unknown) => {
|
||||
console.error(
|
||||
'outbox_dispatch_failed',
|
||||
@@ -331,12 +397,14 @@ async function bootstrap() {
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
});
|
||||
},
|
||||
2_000,
|
||||
);
|
||||
}, 2_000);
|
||||
platformCatalogTimer = setInterval(
|
||||
() => void platformScheduler.run().catch((error: unknown) => {
|
||||
console.error('platform_catalog_schedule_failed', error instanceof Error ? error.message : error);
|
||||
() =>
|
||||
void platformScheduler.run().catch((error: unknown) => {
|
||||
console.error(
|
||||
'platform_catalog_schedule_failed',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}),
|
||||
60_000,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
import type { Database } from '@atlas/database';
|
||||
import type {
|
||||
AiCopyOutput,
|
||||
ProductAiAutofillResult,
|
||||
ProductAiSkuCopyResult,
|
||||
ProductAiSourceMaterial,
|
||||
} from '@atlas/contracts';
|
||||
import type { OutboxJob } from './outbox-dispatcher.js';
|
||||
|
||||
type VariantInput = {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
source_sku: string | null;
|
||||
specification_values: unknown[];
|
||||
listing: { status: string; offer_id: string | null };
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
description: string | null;
|
||||
package_spec: unknown;
|
||||
};
|
||||
type AiInput = {
|
||||
revision: { product: number; profile: number; source_snapshot: number };
|
||||
platform_id: string;
|
||||
product_id: string;
|
||||
profile_id: string;
|
||||
variant_id: string | null;
|
||||
hint: string | null;
|
||||
source_material: ProductAiSourceMaterial;
|
||||
common: { source_title: string | null; model: string | null };
|
||||
profile: {
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
description: string | null;
|
||||
category: { id: string | null; path: string | null };
|
||||
target_store_id: string;
|
||||
};
|
||||
variants: VariantInput[];
|
||||
};
|
||||
type Category = {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
external_type_id: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
type TaskRow = {
|
||||
kind: 'autofill' | 'sku_copy' | string;
|
||||
input_snapshot: AiInput;
|
||||
};
|
||||
type ModelResponse = { data: Record<string, unknown>; tokens: number };
|
||||
|
||||
const russianCopyRule =
|
||||
'所有 title.ru、tags[].ru、description.ru 均为面向发布的俄文,严禁出现任何汉字,包括中文品牌名、系列名、颜色、尺码及括号内注释。将来源中的中文商品事实译成自然俄文;品牌、型号和货号可保留有来源依据的拉丁字母及阿拉伯数字,无法可靠翻译的中文专有名词应省略,绝不可原样复制到 ru 字段。title.zh、tags[].zh、description.zh 仍须提供中文对照。';
|
||||
|
||||
/** 一个 Outbox 事件只认领自己的任务;重复投递不会覆盖完成结果。 */
|
||||
export async function runProductAiTask(database: Database, job: OutboxJob) {
|
||||
const taskId = String(job.payload.task_id ?? '');
|
||||
if (!taskId) return;
|
||||
const task = await database.workspaceTransaction(
|
||||
job.workspaceId,
|
||||
async (tx) => {
|
||||
const selected = await tx.query<TaskRow & { status: string }>(
|
||||
`SELECT kind, status, input_snapshot FROM atlas.product_ai_tasks
|
||||
WHERE id = $1 FOR UPDATE`,
|
||||
[taskId],
|
||||
);
|
||||
const row = selected.rows[0];
|
||||
if (!row || row.status !== 'queued') return null;
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_ai_tasks
|
||||
SET status = 'running', started_at = coalesce(started_at, now())
|
||||
WHERE id = $1`,
|
||||
[taskId],
|
||||
);
|
||||
return row;
|
||||
},
|
||||
);
|
||||
if (!task) return;
|
||||
try {
|
||||
let generated: Awaited<ReturnType<typeof generateAutofill>>;
|
||||
if (task.kind === 'sku_copy') {
|
||||
generated = await generateSkuCopy(task.input_snapshot);
|
||||
} else if (task.kind === 'autofill') {
|
||||
generated = await generateAutofill(
|
||||
database,
|
||||
job.workspaceId,
|
||||
task.input_snapshot,
|
||||
);
|
||||
} else {
|
||||
throw new AiProviderError('AI_TASK_RETIRED');
|
||||
}
|
||||
const status = generated.warnings.length ? 'partial' : 'succeeded';
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_ai_tasks
|
||||
SET status = $2, result = $3::jsonb, model_name = $4,
|
||||
completed_at = now(), error_code = NULL, error_message = NULL
|
||||
WHERE id = $1 AND status = 'running'`,
|
||||
[
|
||||
taskId,
|
||||
status,
|
||||
JSON.stringify(generated.result),
|
||||
process.env.DEEPSEEK_MODEL_NAME || 'deepseek-chat',
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof AiProviderError
|
||||
? error.code
|
||||
: 'AI_GENERATION_FAILED';
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_ai_tasks
|
||||
SET status = 'failed', error_code = $2, error_message = $3,
|
||||
completed_at = now()
|
||||
WHERE id = $1 AND status = 'running'`,
|
||||
[taskId, code, aiFailureMessage(code)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAutofill(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
input: AiInput,
|
||||
) {
|
||||
const warnings: string[] = [];
|
||||
const result: ProductAiAutofillResult = { warnings };
|
||||
let categoryError: AiProviderError | null = null;
|
||||
let copyError: AiProviderError | null = null;
|
||||
let category: Category | null = null;
|
||||
if (input.profile.category.id) {
|
||||
category = await readCategory(
|
||||
database,
|
||||
workspaceId,
|
||||
input.platform_id,
|
||||
input.profile.category.id,
|
||||
);
|
||||
if (!category)
|
||||
warnings.push('当前类目在平台目录中不可用,已跳过编号生成');
|
||||
} else {
|
||||
try {
|
||||
const chosen = await chooseCategory(database, workspaceId, input);
|
||||
category = chosen.category;
|
||||
if (category)
|
||||
result.category = {
|
||||
category_id: category.id,
|
||||
path: category.path,
|
||||
};
|
||||
else if (chosen.candidates.length) {
|
||||
result.category_candidates = chosen.candidates.map((item) => ({
|
||||
category_id: item.id,
|
||||
path: item.path,
|
||||
}));
|
||||
warnings.push('类目有多个可能选项,请在页面确认');
|
||||
} else warnings.push('类目资料不足或目录不可用,已跳过类目和编号');
|
||||
} catch (error) {
|
||||
categoryError = toAiProviderError(error);
|
||||
warnings.push('类目推荐未完成,其他字段仍可使用');
|
||||
}
|
||||
}
|
||||
if (category) {
|
||||
const model =
|
||||
input.common.model?.trim() || makeModel(category, input.product_id);
|
||||
if (!input.common.model) result.model = model;
|
||||
const offers = await makeOffers(database, workspaceId, input, model);
|
||||
if (offers.length) result.offers = offers;
|
||||
const pendingOfferCount = input.variants.filter(
|
||||
(variant) =>
|
||||
variant.listing.status !== 'imported' &&
|
||||
!variant.listing.offer_id,
|
||||
).length;
|
||||
if (offers.length < pendingOfferCount)
|
||||
warnings.push('部分 SKU 货号因长度或店铺冲突未生成');
|
||||
}
|
||||
try {
|
||||
const modelResult = await deepSeekJson(
|
||||
`你是跨境商品编辑助手。只用输入中的商品事实,输出 JSON;不要编造作品、角色、材料、认证、尺寸或包装。SPU 文案必须对全部 SKU 成立。${russianCopyRule}产品描述必须是易读的多行文本:首段写简短概述,空一行后用“Характеристики:”与“•”列出有证据的事实;中文使用相同段落和要点结构。不得使用 HTML 或 Markdown 表格。`,
|
||||
`根据以下资料输出 JSON:{ "title": {"ru":"","zh":""}, "tags":[{"ru":"","zh":""}], "description":{"ru":"","zh":""}, "package":{"length_mm":"","width_mm":"","height_mm":"","shipping_weight_g":"","evidence":""} }。资料不足的包装请返回 null。若包装尺寸和重量来自不同来源字段,evidence 用换行连接每段逐字复制的原文;否则复制单段原文。不得翻译、改写或拼接单段原文。\n${promptFacts(input)}`,
|
||||
);
|
||||
const copy = validateCopy(modelResult.data);
|
||||
if (Object.keys(copy).length) result.spu_copy = copy;
|
||||
else warnings.push('俄文文案未通过校验');
|
||||
const packageSpec = validatePackage(
|
||||
modelResult.data.package,
|
||||
input.source_material,
|
||||
);
|
||||
if (packageSpec) result.package_spec = packageSpec;
|
||||
else if (hasPackageEvidence(input.source_material))
|
||||
warnings.push('包装数据未通过来源核对,已跳过包装填报');
|
||||
else
|
||||
warnings.push(
|
||||
'采集信息未包含可核对的包装尺寸和重量,已跳过包装填报',
|
||||
);
|
||||
} catch (error) {
|
||||
copyError = toAiProviderError(error);
|
||||
warnings.push('DeepSeek 文案生成失败,已保留确定性类目和编号结果');
|
||||
}
|
||||
if (
|
||||
!result.category &&
|
||||
!result.category_candidates?.length &&
|
||||
!result.model &&
|
||||
!result.offers?.length &&
|
||||
!result.spu_copy &&
|
||||
!result.package_spec
|
||||
)
|
||||
// 不用“没有结果”掩盖上游真实故障,页面才能给出可执行的提示。
|
||||
throw (
|
||||
copyError ??
|
||||
categoryError ??
|
||||
new AiProviderError('AI_NO_VALID_RESULT')
|
||||
);
|
||||
return { result, warnings };
|
||||
}
|
||||
|
||||
async function generateSkuCopy(input: AiInput) {
|
||||
const variant = input.variants.find((item) => item.id === input.variant_id);
|
||||
if (!variant) throw new AiProviderError('AI_VARIANT_CHANGED');
|
||||
const response = await deepSeekJson(
|
||||
`你只给当前一个 SKU 生成俄文发布文案,并逐项提供中文对照。只使用来源事实和该 SKU 专属事实;不能把其他 SKU 的作品、角色、规格写到当前 SKU。输出 JSON。${russianCopyRule}产品描述必须分段:首段简短概述,空一行后以“Характеристики:”和“•”列出有证据的事实;中文采用对应的段落和要点结构。不得使用 HTML 或 Markdown 表格。`,
|
||||
`输出 JSON:{ "title": {"ru":"","zh":""}, "tags":[{"ru":"","zh":""}], "description":{"ru":"","zh":""} }。\n${promptFacts(input)}\n当前 SKU:${JSON.stringify(variant)}`,
|
||||
);
|
||||
const copy = validateCopy(response.data);
|
||||
if (!Object.keys(copy).length) throw new AiProviderError('AI_COPY_INVALID');
|
||||
const result: ProductAiSkuCopyResult = {
|
||||
variant_id: variant.id,
|
||||
copy,
|
||||
warnings: [],
|
||||
};
|
||||
return { result, warnings: result.warnings };
|
||||
}
|
||||
|
||||
function promptFacts(input: AiInput) {
|
||||
return JSON.stringify({
|
||||
source: {
|
||||
title: input.source_material.title?.slice(0, 1000),
|
||||
description: input.source_material.description?.slice(0, 12000),
|
||||
brand: input.source_material.brand,
|
||||
categories: input.source_material.categories.slice(0, 20),
|
||||
attributes: input.source_material.attributes.slice(0, 80),
|
||||
},
|
||||
sku_summary: input.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
name: variant.display_name,
|
||||
specs: variant.specification_values,
|
||||
})),
|
||||
existing_spu_copy: input.variant_id
|
||||
? {
|
||||
title: input.profile.title,
|
||||
tags: input.profile.tags,
|
||||
description: input.profile.description?.slice(0, 4000),
|
||||
}
|
||||
: undefined,
|
||||
hint: input.hint,
|
||||
});
|
||||
}
|
||||
|
||||
/** 模型只可从当前层给出的真实 Atlas 节点中选择 ID。 */
|
||||
async function chooseCategory(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
input: AiInput,
|
||||
) {
|
||||
const categories = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query<Category>(
|
||||
`SELECT id, parent_id, name, path, external_type_id, is_active
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1 ORDER BY path, id`,
|
||||
[input.platform_id],
|
||||
),
|
||||
);
|
||||
const all = categories.rows;
|
||||
let parent: string | null = null;
|
||||
const empty = {
|
||||
category: null as Category | null,
|
||||
candidates: [] as Category[],
|
||||
};
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
const children = all.filter((item) => item.parent_id === parent);
|
||||
if (!children.length) return empty;
|
||||
const selection = await chooseCategoryAtLevel(children, input);
|
||||
const picked = selection.picked;
|
||||
if (!picked) return empty;
|
||||
const hasChildren = all.some((item) => item.parent_id === picked.id);
|
||||
if (!hasChildren) {
|
||||
const alternatives = children.filter(
|
||||
(item) =>
|
||||
selection.alternativeIds.includes(item.id) &&
|
||||
item.id !== picked.id &&
|
||||
item.is_active &&
|
||||
item.external_type_id &&
|
||||
item.external_type_id !== '0',
|
||||
);
|
||||
if (alternatives.length)
|
||||
return {
|
||||
category: null,
|
||||
candidates: [picked, ...alternatives].slice(0, 5),
|
||||
};
|
||||
return picked.is_active &&
|
||||
picked.external_type_id &&
|
||||
picked.external_type_id !== '0'
|
||||
? { category: picked, candidates: [] }
|
||||
: empty;
|
||||
}
|
||||
parent = picked.id;
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
/** 大目录分段初选后再决选,避免一次把完整平台目录塞进模型上下文。 */
|
||||
async function chooseCategoryAtLevel(children: Category[], input: AiInput) {
|
||||
if (children.length <= 250)
|
||||
return chooseCategoryCandidates(children, input);
|
||||
const finalists: Category[] = [];
|
||||
for (let offset = 0; offset < children.length; offset += 150) {
|
||||
const selection = await chooseCategoryCandidates(
|
||||
children.slice(offset, offset + 150),
|
||||
input,
|
||||
);
|
||||
if (selection.picked) finalists.push(selection.picked);
|
||||
}
|
||||
if (!finalists.length) return { picked: null, alternativeIds: [] };
|
||||
return chooseCategoryCandidates(finalists, input);
|
||||
}
|
||||
|
||||
async function chooseCategoryCandidates(
|
||||
candidates: Category[],
|
||||
input: AiInput,
|
||||
) {
|
||||
if (!hasCategoryFacts(input))
|
||||
return { picked: null, alternativeIds: [] as string[] };
|
||||
// 让模型返回短编号而非 UUID,避免长 UUID 在 JSON 输出中被截断、改写或遗漏。
|
||||
const options = candidates.map((item, index) => ({
|
||||
code: index + 1,
|
||||
item,
|
||||
}));
|
||||
const selection = await deepSeekJson(
|
||||
'从给定选项中选择最合适的目标 Ozon 类目。只输出 JSON {"choice":候选编号,"alternative_choices":[候选编号]}。choice 和 alternative_choices 只能使用候选中的 code。来源资料已包含可识别的商品事实,因此 choice 必须选择一个候选编号,禁止返回 null、0、字符串或候选外编号;即使无法精确到最终叶子,也要在当前层选择最合理的上级类目。先依据商品实际形态、用途、材质和规格判断;来源站点类目仅作辅助,不能凭空猜测。',
|
||||
`来源:${JSON.stringify({
|
||||
title: input.source_material.title,
|
||||
description: input.source_material.description?.slice(0, 4_000),
|
||||
brand: input.source_material.brand,
|
||||
categories: input.source_material.categories,
|
||||
attributes: input.source_material.attributes.slice(0, 80),
|
||||
sku_summary: input.variants.slice(0, 30).map((variant) => ({
|
||||
name: variant.display_name,
|
||||
specs: variant.specification_values,
|
||||
})),
|
||||
})}\n候选:${JSON.stringify(options.map(({ code, item }) => ({ code, name: item.name, path: item.path })))}`,
|
||||
);
|
||||
const choice = Number(selection.data.choice);
|
||||
const picked =
|
||||
options.find((option) => option.code === choice)?.item ?? null;
|
||||
const alternativeIds = Array.isArray(selection.data.alternative_choices)
|
||||
? selection.data.alternative_choices
|
||||
.map((choice) => Number(choice))
|
||||
.map(
|
||||
(choice) =>
|
||||
options.find((option) => option.code === choice)?.item.id,
|
||||
)
|
||||
.filter((id): id is string => !!id)
|
||||
: [];
|
||||
return { picked, alternativeIds };
|
||||
}
|
||||
|
||||
/** 没有任何可辨别商品事实时不强迫模型在平台目录中猜测。 */
|
||||
function hasCategoryFacts(input: AiInput) {
|
||||
return Boolean(
|
||||
input.source_material.title?.trim() ||
|
||||
input.source_material.description?.trim() ||
|
||||
input.source_material.categories.length ||
|
||||
input.source_material.attributes.length ||
|
||||
input.variants.some(
|
||||
(variant) =>
|
||||
variant.display_name?.trim() ||
|
||||
variant.source_sku?.trim() ||
|
||||
variant.specification_values.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function readCategory(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const row = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query<Category>(
|
||||
`SELECT id, parent_id, name, path, external_type_id, is_active
|
||||
FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||
[categoryId, platformId],
|
||||
),
|
||||
);
|
||||
return row.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export function makeModel(
|
||||
category: Pick<Category, 'name' | 'path'>,
|
||||
productId: string,
|
||||
) {
|
||||
const name = `${category.path} ${category.name}`.toLowerCase();
|
||||
const prefix = /手办|雕像|фигур|statue|figure/.test(name)
|
||||
? 'FIG'
|
||||
: /玩具|игруш|toy/.test(name)
|
||||
? 'TOY'
|
||||
: /服装|одеж|cloth|apparel/.test(name)
|
||||
? 'APP'
|
||||
: /首饰|珠宝|украшен|jewel/.test(name)
|
||||
? 'JWL'
|
||||
: 'PRD';
|
||||
return `${prefix}-MIX-${productId.replaceAll('-', '').slice(0, 5).toUpperCase()}`;
|
||||
}
|
||||
|
||||
async function makeOffers(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
input: AiInput,
|
||||
model: string,
|
||||
) {
|
||||
const existing = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query<{ offer_id: string }>(
|
||||
`SELECT offer_id FROM atlas.product_variant_platform_listings
|
||||
WHERE store_id = $1 AND platform_id = $2 AND offer_id IS NOT NULL`,
|
||||
[input.profile.target_store_id, input.platform_id],
|
||||
),
|
||||
);
|
||||
const occupied = new Set(existing.rows.map((row) => row.offer_id));
|
||||
const offers: Array<{ variant_id: string; offer_id: string }> = [];
|
||||
for (const variant of input.variants) {
|
||||
if (variant.listing.status === 'imported' || variant.listing.offer_id)
|
||||
continue;
|
||||
const stem = `${model}-V${variant.id.replaceAll('-', '').slice(0, 6).toUpperCase()}`;
|
||||
let offerId = stem;
|
||||
for (
|
||||
let attempt = 1;
|
||||
occupied.has(offerId) && attempt < 10;
|
||||
attempt += 1
|
||||
)
|
||||
offerId = `${stem}${attempt}`;
|
||||
if (
|
||||
occupied.has(offerId) ||
|
||||
offerId.length > 48 ||
|
||||
(offerId.match(/-/g) ?? []).length > 4
|
||||
)
|
||||
continue;
|
||||
occupied.add(offerId);
|
||||
offers.push({ variant_id: variant.id, offer_id: offerId });
|
||||
}
|
||||
return offers;
|
||||
}
|
||||
|
||||
export function validateCopy(value: Record<string, unknown>): AiCopyOutput {
|
||||
const output: AiCopyOutput = {};
|
||||
const title = pair(value.title, 1000);
|
||||
if (title) output.title = title;
|
||||
const description = pair(value.description, 100000);
|
||||
if (description) output.description = formatDescription(description);
|
||||
if (Array.isArray(value.tags)) {
|
||||
const tags = value.tags
|
||||
.slice(0, 30)
|
||||
.map((tag) => pair(tag, 100))
|
||||
.filter((tag) => tag !== null);
|
||||
if (tags.length) output.tags = tags;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** 保留模型的段落和项目符号;对遗留的单行多句结果做最小安全分段。 */
|
||||
function formatDescription(value: { ru: string; zh: string }) {
|
||||
return {
|
||||
ru: normalizeDescriptionLayout(value.ru, 'ru'),
|
||||
zh: normalizeDescriptionLayout(value.zh, 'zh'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDescriptionLayout(value: string, language: 'ru' | 'zh') {
|
||||
let normalized = value
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
// 模型偶尔把另一种语言的固定段落标题带入中文/俄文对照,
|
||||
// 仅替换无业务语义的标题,不改动用户可见的商品事实。
|
||||
normalized =
|
||||
language === 'zh'
|
||||
? normalized.replace(
|
||||
/^(?:Характеристики|Characteristics|Features)\s*:/gim,
|
||||
'产品信息:',
|
||||
)
|
||||
: normalized.replace(
|
||||
/^(?:产品信息|商品信息|产品特点)\s*[::]/gim,
|
||||
'Характеристики:',
|
||||
);
|
||||
if (normalized.includes('\n')) return normalized;
|
||||
const sentences = normalized.match(/^(.+?[.!?。!?])\s*(.+)$/su);
|
||||
return sentences ? `${sentences[1]}\n\n${sentences[2].trim()}` : normalized;
|
||||
}
|
||||
|
||||
function pair(value: unknown, max: number): { ru: string; zh: string } | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const ru = typeof record.ru === 'string' ? record.ru.trim() : '';
|
||||
const zh = typeof record.zh === 'string' ? record.zh.trim() : '';
|
||||
if (
|
||||
!ru ||
|
||||
!zh ||
|
||||
ru.length > max ||
|
||||
zh.length > max ||
|
||||
!/[\u0400-\u04ff]/.test(ru) ||
|
||||
/\p{Script=Han}/u.test(ru)
|
||||
)
|
||||
return null;
|
||||
return { ru, zh };
|
||||
}
|
||||
|
||||
export function validatePackage(
|
||||
value: unknown,
|
||||
source: ProductAiSourceMaterial,
|
||||
) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const evidence =
|
||||
typeof record.evidence === 'string' ? record.evidence.trim() : '';
|
||||
const original = packageEvidenceText(source);
|
||||
const evidenceParts = evidence
|
||||
.split(/\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (
|
||||
!evidenceParts.length ||
|
||||
evidenceParts.some(
|
||||
(part) => part.length < 4 || !original.includes(part),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!hasPackageEvidence(source)) return null;
|
||||
const keys = [
|
||||
'length_mm',
|
||||
'width_mm',
|
||||
'height_mm',
|
||||
'shipping_weight_g',
|
||||
] as const;
|
||||
const numbers = keys.map((key) => Number(record[key]));
|
||||
if (
|
||||
numbers.some(
|
||||
(number) =>
|
||||
!Number.isFinite(number) || number <= 0 || number > 100000,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
length_mm: String(numbers[0]),
|
||||
width_mm: String(numbers[1]),
|
||||
height_mm: String(numbers[2]),
|
||||
shipping_weight_g: String(numbers[3]),
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
/** 1688 常把“包装尺寸”放在属性名称而非属性值;核对时两者都必须保留。 */
|
||||
function packageEvidenceText(source: ProductAiSourceMaterial) {
|
||||
return [
|
||||
source.description ?? '',
|
||||
...source.attributes.map((item) => `${item.name}: ${item.value}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function hasPackageEvidence(source: ProductAiSourceMaterial) {
|
||||
const packaging = /包装|包裹|外箱|package|packing|упаков/i;
|
||||
return (
|
||||
packaging.test(source.description ?? '') ||
|
||||
source.attributes.some((item) =>
|
||||
packaging.test(`${item.key} ${item.name}`),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
class AiProviderError extends Error {
|
||||
public constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将未知异常转换为对用户安全、可归因的失败类型。 */
|
||||
function toAiProviderError(error: unknown) {
|
||||
return error instanceof AiProviderError
|
||||
? error
|
||||
: new AiProviderError('DEEPSEEK_UNAVAILABLE');
|
||||
}
|
||||
|
||||
/** 不暴露上游响应正文,但让用户知道下一步应检查什么。 */
|
||||
function aiFailureMessage(code: string) {
|
||||
switch (code) {
|
||||
case 'DEEPSEEK_NOT_CONFIGURED':
|
||||
return 'DeepSeek 未配置,请检查 Worker 的 API Key 和模型名称';
|
||||
case 'DEEPSEEK_REJECTED':
|
||||
return 'DeepSeek 拒绝了请求,请检查 API Key、模型名称和账户状态';
|
||||
case 'DEEPSEEK_RETRYABLE':
|
||||
return 'DeepSeek 当前繁忙或限流,请稍后重试';
|
||||
case 'DEEPSEEK_EMPTY_RESULT':
|
||||
return 'DeepSeek 未返回生成内容,请稍后重试';
|
||||
case 'DEEPSEEK_INVALID_JSON':
|
||||
return 'DeepSeek 返回格式异常,请稍后重试';
|
||||
case 'DEEPSEEK_UNAVAILABLE':
|
||||
return '暂时无法连接 DeepSeek,请稍后重试';
|
||||
case 'AI_COPY_INVALID':
|
||||
return 'AI 生成的文案未通过格式校验,请稍后重试';
|
||||
case 'AI_TASK_RETIRED':
|
||||
return '该 AI 任务类型已停用,请重新使用当前可用功能';
|
||||
case 'AI_VARIANT_CHANGED':
|
||||
return 'SKU 已变化,请刷新页面后重新生成';
|
||||
case 'AI_NO_VALID_RESULT':
|
||||
return '采集资料不足,未生成可安全回填的内容;可补充商品资料后重试';
|
||||
default:
|
||||
return 'AI 生成暂时失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
/** 官方 JSON 模式可能返回空内容,因此解析与网络调用分别有限次重试。 */
|
||||
async function deepSeekJson(
|
||||
system: string,
|
||||
user: string,
|
||||
): Promise<ModelResponse> {
|
||||
const key = process.env.DEEPSEEK_API_KEY;
|
||||
if (!key) throw new AiProviderError('DEEPSEEK_NOT_CONFIGURED');
|
||||
const model = process.env.DEEPSEEK_MODEL_NAME || 'deepseek-chat';
|
||||
let last: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.deepseek.com/chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `${system}\n输出必须是 JSON。`,
|
||||
},
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
max_tokens: 3500,
|
||||
temperature: 0.2,
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
if (response.status === 429 || response.status >= 500)
|
||||
throw new AiProviderError('DEEPSEEK_RETRYABLE');
|
||||
throw new AiProviderError('DEEPSEEK_REJECTED');
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { total_tokens?: number };
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (!content) throw new AiProviderError('DEEPSEEK_EMPTY_RESULT');
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new AiProviderError('DEEPSEEK_INVALID_JSON');
|
||||
return {
|
||||
data: parsed as Record<string, unknown>,
|
||||
tokens: body.usage?.total_tokens ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
last = error;
|
||||
if (
|
||||
error instanceof AiProviderError &&
|
||||
error.code === 'DEEPSEEK_REJECTED'
|
||||
)
|
||||
break;
|
||||
if (attempt < 2)
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 500 * (attempt + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
throw last instanceof AiProviderError
|
||||
? last
|
||||
: new AiProviderError('DEEPSEEK_UNAVAILABLE');
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { makeModel, validateCopy, validatePackage } from '../src/product-ai.ts';
|
||||
|
||||
test('同一商品模型只由类目和稳定商品 ID 确定', () => {
|
||||
const category = { name: '手办', path: '玩具 / 手办' };
|
||||
const first = makeModel(category, 'abcdef12-0000-4000-8000-000000000000');
|
||||
const second = makeModel(category, 'abcdef12-0000-4000-8000-000000000000');
|
||||
assert.equal(first, second);
|
||||
assert.match(first, /^FIG-MIX-[A-F0-9]{5}$/);
|
||||
assert.ok(first.length <= 32);
|
||||
assert.equal((first.match(/-/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('文案只保留成对且含俄文字符的字段', () => {
|
||||
assert.deepEqual(
|
||||
validateCopy({
|
||||
title: { ru: 'Фигурка героя', zh: '角色手办' },
|
||||
tags: [
|
||||
{ ru: 'Коллекционная', zh: '收藏' },
|
||||
{ ru: 'invalid', zh: '无效' },
|
||||
],
|
||||
description: { ru: 'Сделано из золота', zh: '' },
|
||||
}),
|
||||
{
|
||||
title: { ru: 'Фигурка героя', zh: '角色手办' },
|
||||
tags: [{ ru: 'Коллекционная', zh: '收藏' }],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('俄文标题、标签和描述中的汉字不会进入回填结果', () => {
|
||||
assert.deepEqual(
|
||||
validateCopy({
|
||||
title: { ru: 'Бейсболка ANTA/安踏', zh: '安踏棒球帽' },
|
||||
tags: [
|
||||
{ ru: 'Спортивная кепка', zh: '运动帽' },
|
||||
{ ru: 'Цвет 运动红-1', zh: '运动红-1' },
|
||||
],
|
||||
description: {
|
||||
ru: 'Бейсболка унисекс. Размер: 均码.',
|
||||
zh: '中性棒球帽。尺码:均码。',
|
||||
},
|
||||
}),
|
||||
{ tags: [{ ru: 'Спортивная кепка', zh: '运动帽' }] },
|
||||
);
|
||||
});
|
||||
|
||||
test('单行多句产品描述会保留为易读的段落', () => {
|
||||
const copy = validateCopy({
|
||||
description: {
|
||||
ru: 'Коллекционная фигурка из ПВХ. Размер упаковки: 180 × 120 × 250 мм.',
|
||||
zh: 'PVC 收藏手办。包装尺寸为 180 × 120 × 250 mm。',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
copy.description?.ru,
|
||||
'Коллекционная фигурка из ПВХ.\n\nРазмер упаковки: 180 × 120 × 250 мм.',
|
||||
);
|
||||
assert.equal(
|
||||
copy.description?.zh,
|
||||
'PVC 收藏手办。\n\n包装尺寸为 180 × 120 × 250 mm。',
|
||||
);
|
||||
});
|
||||
|
||||
test('中文对照中的通用段落标题会规范为中文', () => {
|
||||
const copy = validateCopy({
|
||||
description: {
|
||||
ru: 'Фигурка.\n\nХарактеристики:\n• Материал: ПВХ',
|
||||
zh: '收藏手办。\n\nХарактеристики:\n• 材质:PVC',
|
||||
},
|
||||
});
|
||||
assert.match(copy.description?.zh ?? '', /\n\n产品信息:\n• 材质:PVC$/);
|
||||
});
|
||||
|
||||
test('包装建议必须有采集证据,且尺寸重量为有效正数', () => {
|
||||
const source = {
|
||||
description: '包装尺寸 10 × 20 × 30 mm,包装重量 400 g',
|
||||
attributes: [],
|
||||
};
|
||||
const proposal = {
|
||||
length_mm: '10',
|
||||
width_mm: '20',
|
||||
height_mm: '30',
|
||||
shipping_weight_g: '400',
|
||||
evidence: '包装尺寸 10 × 20 × 30 mm,包装重量 400 g',
|
||||
};
|
||||
assert.equal(validatePackage(proposal, source)?.shipping_weight_g, '400');
|
||||
assert.equal(
|
||||
validatePackage({ ...proposal, evidence: '模型凭空猜测' }, source),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
validatePackage({ ...proposal, shipping_weight_g: '0' }, source),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('包装字段名称可作为 1688 分离尺寸和重量证据', () => {
|
||||
const source = {
|
||||
description: null,
|
||||
attributes: [
|
||||
{ key: 'size', name: '包装尺寸', value: '10 × 20 × 30 mm' },
|
||||
{ key: 'weight', name: '包装重量', value: '400 g' },
|
||||
],
|
||||
};
|
||||
const proposal = {
|
||||
length_mm: '10',
|
||||
width_mm: '20',
|
||||
height_mm: '30',
|
||||
shipping_weight_g: '400',
|
||||
evidence: '10 × 20 × 30 mm\n400 g',
|
||||
};
|
||||
assert.equal(validatePackage(proposal, source)?.height_mm, '30');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
# 商品编辑与首次上架 API
|
||||
|
||||
> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。素材选择已实现;定价确认、AI 任务、真正调用 Ozon 的首次发布 job 仍是冻结契约,尚未实现。
|
||||
> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。AI 填报任务已实现;定价确认和真正调用 Ozon 的首次发布 job 仍未实现。
|
||||
|
||||
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||
|
||||
@@ -74,7 +74,7 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
||||
} }
|
||||
```
|
||||
|
||||
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)、SKU 覆盖的 `tags` / `description` 及已选媒体。SKU 的 `tags: []`、`description: null` 分别表示继承 SPU 主题标签、产品描述;动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
|
||||
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)、SKU 覆盖的 `tags` / `description` 及已选媒体。AI 填报落地时新增 SKU 发布标题 `title`;`title: null`、`tags: []`、`description: null` 分别表示继承 SPU 当前平台的标题、主题标签、产品描述。读取模型还须返回逐 SKU 三项有效文案及中俄对照来源(`inherit_spu` / `override`),以支持编辑页和发布适配器使用同一继承结果;`display_name` 不能作为发布标题。动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
|
||||
|
||||
`default_package_spec` 是 SPU 的存储事实,`variants[].package_spec` 仅保存该 SKU 的覆盖值;`effective_package_spec` 按“SKU 覆盖值优先、SPU 默认值回退”解析。Ozon 发布适配器在创建 job 时才将每个目标 SKU 的有效值物化为独立 `items[]`:`length_mm → depth`、`width_mm → width`、`height_mm → height`、`shipping_weight_g → weight`,并固定带上 `dimension_unit: "mm"` 与 `weight_unit: "g"`。不得为了 payload 预先将 SPU 默认值复制到所有 SKU 草稿。商品净重/尺寸不是这组固定物流字段;它们的 SPU/SKU 展示归属由当前 Ozon schema 的 `is_aspect` 决定。
|
||||
|
||||
@@ -82,7 +82,7 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
||||
|
||||
`captured_category_match` 仅对 Ozon 来源返回自动类目判定结果。服务端只使用公开详情页的**一级**面包屑和结构化 `Type_0` 商品类型,在当前平台目录的活动可发布叶子中精确匹配;公开页后二、三级导航不参与判定。`matched` 时服务端已自动写入 `source_capture`;`ambiguous` 时前端展示 `candidates` 的完整 Atlas 路径,用户确认后按普通手选类目保存为 `manual`;`unmatched`、`unavailable` 均不得自动选择。
|
||||
|
||||
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。
|
||||
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 的稳定 ID、来源/展示名称、合并继承后的有效规格与规格轴组成。统一 AI 填报还需读取已保存的 SPU 与逐 SKU 文案及其覆盖状态,避免覆盖手填值;来源 SKU 仅作证据,不直接作为目标货号。
|
||||
|
||||
## 2. 原子保存草稿与 SKU 规则
|
||||
|
||||
@@ -128,7 +128,8 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
||||
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
|
||||
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
|
||||
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。
|
||||
- `common.model` 是用户填写的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户填写的目标平台卖家货号;采集时不得从 `source_sku` 自动生成或填入。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。
|
||||
- `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。
|
||||
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
|
||||
|
||||
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
|
||||
|
||||
@@ -174,7 +175,7 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
||||
|
||||
`GET /api/product-platform-profiles/{profile_id}/validation`
|
||||
|
||||
返回 profile 与每个未删除 SKU 的本地首次上架校验:标题、型号、品牌、类目/属性、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
|
||||
返回 profile 与每个未删除 SKU 的本地首次上架校验:每个待发布 SKU 的有效标题(SKU 覆盖优先、SPU 默认回退)、型号、品牌、类目/属性、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。SKU 覆盖标题或 SPU 回退标题均为空时,该 SKU 不可发布;已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。SKU 标题生效前此处仍按现有 SPU 标题校验。
|
||||
|
||||
响应 `data`:
|
||||
|
||||
@@ -262,26 +263,19 @@ Headers:`Idempotency-Key` 必填。Body:
|
||||
|
||||
`GET /api/media-derivatives/{derivative_id}` 返回任务状态、脱敏错误及成功时的输出素材短时预览;不能泄露 `object_key`。
|
||||
|
||||
## 6. AI 任务与人工应用
|
||||
## 6. AI 填报任务与页面回填
|
||||
|
||||
所有 AI 接口都只创建任务并返回 `202` + operation/`ai_task_id`,绝不直接改写草稿或发布数据。
|
||||
AI 填报接口只创建异步任务并返回 `202` + `ai_task_id`,不直接改写已保存草稿或发布数据。任务完成后,编辑页自动把校验合格的字段回填到**未保存表单**,再由用户使用现有 `editor-draft` 保存。详细字段规则、冲突策略与中文对照存储见[商品 AI 填报方案](../product/ai-autofill-plan.md)。
|
||||
|
||||
创建 AI 任务前,前端必须先保存当前编辑草稿。服务端从已持久化的 `ai_input` 组装输入,不接收客户端自行拼装的规格:文案和类目任务可读取全部 SKU 的规格摘要;套图和尺寸图任务只读取目标 SKU 的 `effective_physical_spec` / `effective_package_spec`。因此 AI 使用的是用户在编辑页保存后的包装规格与商品规格,而不是采集载荷中的尺寸猜测值。
|
||||
创建 AI 任务前,前端必须先保存当前编辑草稿。服务端从已持久化的 `ai_input` 组装输入,不接收客户端自行拼装的规格。填报任务读取全部 SKU 的规格摘要,并固化商品、profile、采集和类目目录版本;套图和尺寸图任务只读取目标 SKU 的 `effective_physical_spec` / `effective_package_spec`。因此 AI 使用的是用户在编辑页保存后的资料,而不是浏览器临时拼接的采集载荷。
|
||||
|
||||
| 接口 | body 核心字段 | 成功结果 |
|
||||
|---|---|---|
|
||||
| `POST /api/product-platform-profiles/{profile_id}/ai/category-suggestions` | 可选 `hint` | 读取采集证据和当前平台类目树,创建类目候选任务。Ozon 来源默认不自动调用,但用户可主动调用。 |
|
||||
| `POST /api/product-platform-profiles/{profile_id}/ai/copy` | `fields: [title,tags,description]`、可选语言 | 创建当前平台文案候选。首期不生成富内容。 |
|
||||
| `POST /api/product-platform-profiles/{profile_id}/ai/autofills` | 可选 `hint` 与策略开关;Header 必填 `Idempotency-Key` | 一次创建类目、型号、未导入 SKU 货号、可证包装规格及中俄文案的填报任务。Ozon 来源先运行确定性类目匹配;其他来源使用受限类目树候选。 |
|
||||
| `POST /api/product-platform-profiles/{profile_id}/ai/suite-plans` | `variant_ids` 必填、`prompt` 可选 | 每个目标 SKU 明确绑定,创建套图规划/生成任务。 |
|
||||
| `POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/ai/dimension-images` | 可选样式 | 固化该 SKU 合并后的**实物**规格快照;实物规格缺失/待确认返回 `422`。 |
|
||||
|
||||
`GET /api/product-ai-tasks/{ai_task_id}` 返回状态、模型/输入摘要、候选输出或脱敏错误。
|
||||
|
||||
`GET /api/product-ai-tasks/{ai_task_id}/category-suggestions` 返回候选类目、置信度、依据、rank。
|
||||
|
||||
`POST /api/product-ai-tasks/{ai_task_id}/category-suggestions/{suggestion_id}/apply`
|
||||
|
||||
Headers:`If-Match`、`Idempotency-Key` 必填。用户确认后才将候选写入 profile 的类目/类型并标记 `category_source=ai_suggestion`;随后要求重新读取 schema、审查未映射属性。文案/图片 AI 输出同样由用户在下一次 `editor-draft` 中明确选择并保存。
|
||||
`GET /api/product-ai-tasks/{ai_task_id}` 返回任务状态、输入版本、脱敏结果或错误;全局填报成功时返回类目或候选、型号、逐 SKU 货号、可证包装建议及 SPU 中俄文案,不生成 SKU 文案。单 SKU 文案由 `POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/ai/copy` 单独创建任务,在弹窗预览并由用户应用。Ozon 动态类目属性不属于当前 AI 填报范围,仍通过类目确认后加载的 schema 由用户填写。歧义类目仍需用户选择。中文对照与俄文在下一次 `editor-draft` 中一同保存,读取编辑上下文时一同返回。
|
||||
|
||||
## 7. 部分 SKU 首次上架
|
||||
|
||||
@@ -297,7 +291,7 @@ Headers:`Idempotency-Key` 必填。Body:
|
||||
|
||||
服务端在创建任务前重新运行第 3 节校验。只允许当前 profile 中状态为 `not_listed` 或 `failed` 的未删除 SKU;`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不得重新提交或把编辑草稿当平台更新。
|
||||
|
||||
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID、该 SKU 的有效包装规格和平台适配请求,再投递 Worker。适配请求中的每个 Ozon `items[]` 必须包含该 SKU 独立解析出的 `depth`、`width`、`height`、`dimension_unit`、`weight`、`weight_unit`,不得省略或传 0。返回 `202`:
|
||||
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID、该 SKU 的有效标题/标签/描述、有效包装规格和平台适配请求,再投递 Worker。三个文案字段都按「SKU 覆盖优先,SPU 默认回退」逐字段解析,中文对照不进入 Ozon 请求。适配请求中的每个 Ozon `items[]` 必须包含该 SKU 独立解析出的 `depth`、`width`、`height`、`dimension_unit`、`weight`、`weight_unit`,不得省略或传 0。返回 `202`:
|
||||
|
||||
```json
|
||||
{ "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 商品 AI 编辑页
|
||||
|
||||
> 状态:商品编辑主流程和 Ozon 动态属性 P0 已实施,平台类目/属性/字典读取及首次发布预检已迁移完成;定价确认、AI 任务和真正调用 Ozon 的发布 job 仍有未实施部分。准确接口状态以 [`../api/product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 和 [`../project/HANDOFF.md`](../project/HANDOFF.md) 为准。
|
||||
> 状态(2026-09-23):商品编辑主流程、Ozon 动态属性 P0、平台级目录读取、首次发布预检及商品 AI 填报/单 SKU 文案任务已有实现;Ozon 动态属性由用户填写,当前不由 AI 自动填报。定价确认、AI 生图、真正调用 Ozon 的发布 job 与在线商品同步尚未完成。实际验收边界以 [`../api/product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 和 [`../project/HANDOFF.md`](../project/HANDOFF.md) 为准。
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
## 分册
|
||||
|
||||
- [页面与交互](./product-ai-editor.md)
|
||||
- [AI 一键填报开发与设计方案](./ai-autofill-plan.md)
|
||||
- [Ozon 动态属性与 SKU 填报改造方案](./ozon-dynamic-attributes-and-sku-form-plan.md)
|
||||
- [数据库领域模型](./data-model.md)
|
||||
- [服务端 API 契约](./api.md)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# 商品 AI 填报:设计与开发方案
|
||||
|
||||
> 状态:2026-09-23 已实现。目标平台首期为 Ozon;采集来源覆盖 Ozon、1688、淘宝、天猫。全局 AI 填报和 SKU 文案通过异步任务回填未保存表单;动态类目属性不在当前范围。
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
用户采集商品、进入编辑页并点击底部唯一的「AI 填报」按钮后,系统依据已经保存的采集资料、当前草稿和 Atlas 平台参考目录,生成类目、型号、每个未导入 SKU 的货号、证据充分的包装规格,以及 **SPU 的** Ozon 俄文标题、主题标签和产品描述。任务完成后,页面**直接将合格结果写入当前表单**,标记为未保存修改;用户可以继续改动,并通过现有「保存」操作统一持久化。生成任务本身不修改商品草稿、店铺或 Ozon 商品。SKU 三项文案默认继承 SPU;用户展开 SKU 字段后可针对当前 SKU 单独触发 AI 生成。
|
||||
|
||||
AI 不生成售价、采购价、品牌、条形码、图片、富内容、动态类目属性或已导入 SKU 的货号。类目改变后仍由现有机制加载属性 schema,并由用户按既有控件填写。输入资料不足时允许部分填报,不能为了填满表单编造重量、尺寸或商品事实。
|
||||
|
||||
该能力的内部入口是「已规范化商品资料 + 目标平台 + 可售 SKU + 草稿版本」,而不是 React 页面。未来从商品详情链接直接采集后,先进入现有采集规范化链路,再调用同一 AI 填报服务。
|
||||
|
||||
## 2. 现状与可行性
|
||||
|
||||
- Atlas 已有 `source_material`、`source_material_state.latest`、`ai_input`、平台级类目树、商品/SKU 草稿、版本号及 `editor-draft` 原子保存接口。底部现有类目和文案两个占位按钮,可合并为一个入口。
|
||||
- Atlas 现有 AI API 是尚未实施的任务契约:先保存草稿,再创建异步任务,结果由用户应用。此次保持「先保存」和「任务不写草稿」,但把结果应用动作改为任务完成后直接写入浏览器表单;页面现有保存按钮仍是数据库写入边界。
|
||||
- 旧版 `ozon-seller-kit` 的 `server/services/prompts/copy_ru.py` 已定义中俄成对的标题、描述和标签及事实边界;`server/services/deepseek.py` 实现 JSON 请求、解析和一次纠错重试。可复用规则和测试样本,不直接复制旧版 FastAPI、无工作空间隔离的调用路径或试算页「生成后立即保存简介」行为。
|
||||
- DeepSeek 官方 JSON 输出文档支持 `response_format: {"type":"json_object"}`,同时提示空内容和输出截断的可能性。因此 JSON 模式只保证可解析性的目标,**不替代 Atlas 的字段 schema、事实引用、类目 ID、长度和唯一性校验**。具体模型名和参数应作为服务端配置,经上线时再次确认。
|
||||
- 当前迁移尚无 `product_ai_tasks` 实表;`docs/architecture/sql/002_product-domain.sql` 仅有设计草案。落地任务状态、结果和中文对照需要新迁移。
|
||||
|
||||
## 3. 一次点击的页面流程
|
||||
|
||||
1. 用户点击「AI 填报」。若表单有未保存修改,页面先调用现有保存流程;保存失败时不创建任务。当前 `source_snapshot_revision`、商品/profile revision、SKU ID、目标店铺和平台由服务端固化为任务输入快照。
|
||||
2. 页面将底部按钮改为「填报中…」,显示步骤:分析采集资料 → 推荐类目 → 生成文案与编号 → 校验结果。用户可继续浏览页面。重复点击沿用同一输入版本的进行中任务,避免重复计费。任务以字段组为独立产出单元:类目、编号、包装和文案可以分别成功或失败;只有完全没有合格结果才记为整体失败。
|
||||
3. Worker 完成后,服务端返回已校验结果、警告及中俄文案。页面核对任务快照与当前编辑版本;若复采或草稿有新版本,提示结果已过期,不自动回填。
|
||||
4. 在同一版本下,页面直接写入符合规则的表单字段并显示「已填报 N 项,M 项待处理/证据不足」。任务部分成功时,合格字段照常回填,失败字段保留原值并按原因列出。类目写入表单后触发已有动态 schema 加载;旧类目属性草稿按现有切换规则处理。本期不让 AI 接续填写新 schema。
|
||||
5. 已导入 SKU 的货号不变;全局结果只填空字段,原本由用户填写的非空字段保留。异步等待期间若草稿版本变化,本次结果不回填。
|
||||
6. 回填只改变浏览器表单并触发页面 `dirty` 状态。关闭页面前继续使用现有未保存提示;保存后与其他草稿字段一同持久化。
|
||||
|
||||
不增加逐字段确认弹窗。字段建议、被跳过项目和原因可在一个简短的填报结果面板查看;用户最终通过编辑页原有的保存动作提交。生成失败或部分字段失败时,已校验合格的其他字段仍可应用。
|
||||
|
||||
## 4. 字段规则
|
||||
|
||||
### 4.1 类目
|
||||
|
||||
- Ozon 来源先使用现有「一级面包屑 + `Type_0` 商品类型」匹配;已经有用户选择的类目不因 AI 填报改变。确定性匹配唯一时沿用它,不再为该字段消耗模型调用。
|
||||
- 1688、淘宝、天猫来源无 Ozon 类目 ID。服务端向模型分层提供 Atlas **活动、可发布目录**:先给顶层选项,再按选定分支给子节点,直到叶子;每一步只允许返回给定 UUID,并记录候选路径和理由。不得让模型自造类目名或把来源平台类目 ID 当成 Ozon ID。
|
||||
- 最终叶子由服务端复核所属平台、活动状态、可发布 `description_category_id + type_id` 以及祖先链。证据明确时把 Atlas 叶子 UUID 填入未保存表单,`category_source=ai_suggestion`;歧义或证据不足时不填,并显示至多三个候选供用户通过现有类目控件选择。
|
||||
- 不用模型自报的置信度单独决定是否回填;自动回填阈值应基于四来源样本评测校准。已保存的用户类目优先级高于新建议。
|
||||
- 类目目录若缺失、未同步完整、已失效或目标叶子缺少有效 `description_category_id + type_id`,先复用现有目录同步机制;在有界等待后仍不可用时,类目结果标为 `catalog_unavailable`,不向 AI 提供不完整候选,也不自造类目 ID。已有已确认类目可继续作为其他字段的上下文;否则依赖类目码的型号与货号暂不生成。文案和有独立证据的包装建议照常处理。
|
||||
- 这里的类目目录与 Ozon **动态属性字典**不同。类目确认并保存后,页面只加载 schema,动态属性由用户通过既有下拉搜索和输入控件填写;当前 AI 填报不处理属性及字典值。
|
||||
|
||||
### 4.2 型号与 SKU 货号
|
||||
|
||||
- **同一 SPU 只有一个 `model`**,不随 SKU 的作品、角色、颜色变化。AI 先归纳所有 SKU 真实共有的商品类型与形态;只有全部 SKU 确实属于同一系列时,系列语义才允许进入型号。死亡笔记、海贼王、火影忍者等作品混在一个手办 SPU 时,公共型号使用中性的类别/形态码,如 `FIG-MIX-8K4F`,绝不取首个 SKU 的作品名当整组型号。若 SKU 连基本商品形态都不同,提示先拆分 SPU,不强行生成共同型号。
|
||||
- 格式由服务端固定:`model = 类目短码-共通形态或 MIX-商品稳定码`,例如 `FIG-MIX-8K4F`;只用大写英文字母、数字和 `-`,最多 **32 个 ASCII 字符、2 个连接符**。短码来自 Atlas 维护的跨平台商品形态码表,由目标类目映射而来,不直接使用 Ozon 专有 ID;AI 只建议共有语义,不能直接输出最终编号。模型不可用但类目明确时,可保守回退 `MIX`;无可用类目时不生成编号。类目名称或 SKU 排序变化后,不自动重编已经保存的型号。
|
||||
- 每个未导入 SKU 的 `offer_id = model-变体识别码`,识别码可由作品、角色和必要规格组成。例如 `FIG-MIX-8K4F-DN-L`、`FIG-MIX-8K4F-OP-LUFFY`、`FIG-MIX-8K4F-NAR-SASUKE`。整串最多 **48 个 ASCII 字符、4 个连接符**,通常为 3~4 个连接符;额外的颜色/尺寸须压入已有变体段,不能不断追加 `-`。只有来源明确提供作品/角色时才使用其缩写,不能凭图片猜测 IP;语义不可靠时改用基于稳定 SKU 身份的 `V` 码。变体识别码在本 SPU 内必须唯一,不依据显示顺序生成 `V01/V02`。
|
||||
- 服务端维护短码、缩写、稳定商品码及 SKU 身份的映射;AI 不负责计数或唯一性。生成后对本次所有 SKU、目标店铺已有草稿和在线镜像检查冲突;冲突时仅调整尚未保存的新码并保持上述长度/连接符上限,仍冲突则跳过该货号并报告。保存时仍由数据库唯一索引和现有服务端校验兜底。
|
||||
- 当前通用保存契约仅有较宽的 `model ≤ 300`、`offer_id ≤ 500` 字符上限。上述 32/48 及连接符规则是**AI 生成值的强校验**,不应悄悄拒绝已有或手填的较长编号;如要统一收紧所有编号,需要先盘点历史值并单独迁移、通知用户。
|
||||
- `model` 是跨平台商品字段,也会映射到 Ozon「型号名称」属性。本期按用户希望使用可识别编码;后续若需要单独的内部编码与对外型号,应新增独立字段,不能在生成逻辑里偷偷分叉。
|
||||
- 已导入 SKU 的货号是历史事实;AI 结果绝不填入或改动。手填非空型号/货号按页面覆盖规则保留。
|
||||
|
||||
### 4.3 包装重量与尺寸
|
||||
|
||||
- 只从明确的「包装/外箱/包裹」事实提出建议,区分商品本体的尺寸、净重和物流包装规格。模型为每个建议返回原文证据片段、来源字段、原单位和换算后的 `g/mm` 数值。
|
||||
- 服务端检查证据存在、单位可识别、值为正数且数量级合理。若只有商品尺寸或重量、只看图片推测、三维缺项或存在多种包装版本,相关字段保持空值或现有值,不生成“确切数值”。
|
||||
- 商品级包装建议填 SPU 默认值;仅当某 SKU 有专属包装证据时填该 SKU 覆盖值。有明确证据的结果字段状态为 `suggested`,允许直接回填;回填后的包装**确认状态**先标为 `ambiguous`,用户核对后改为 `confirmed`。证据本身有歧义的结果字段状态为 `ambiguous`,不自动回填。因此 AI 可减少录入,但不能绕过定价和发布所需的包装确认。
|
||||
|
||||
### 4.4 SPU 默认文案、SKU 按需文案与中文对照
|
||||
|
||||
- 三项指**发布标题、主题标签、产品描述**。底部「AI 填报」只生成 SPU 三项及逐项中文对照,不生成、不写入 SKU 三项覆盖值。SPU 文案只能陈述所有 SKU 都成立的共有事实;不同作品混在一组手办 SKU 时,不把其中一个作品写成 SPU 通用事实。
|
||||
- SPU 各字段旁的 `+` 只开启对应 SKU 字段编辑入口,**不批量复制 SPU 文案、不隐藏 SPU 字段**。各 SKU 的标题、标签和描述逐字段默认继承 SPU;表单显示有效值和「继承 SPU」状态,只有用户手动输入或主动应用该 SKU 的生成结果才保存覆盖。
|
||||
- SKU 三项字段旁的 AI 图标打开同一个当前 SKU 生成弹窗。左侧展示中文来源描述、SKU 名称、规格和可证的作品/角色资料;点击「AI 生成」仅为该 SKU 生成俄文标题、标签、描述及中文配对,右侧预览,点击「应用到当前 SKU」才把合格结果写入该 SKU 的未保存表单。已有手工覆盖不得被静默替换。某项证据不足时可单独保持继承,其他合格项仍可应用。
|
||||
- 参考旧版提示词,每次文案生成可返回两组标题候选及逐项中文,默认采用第一组;标签逐项中俄配对,描述按段落对应。俄文是发布表单值,中文只是对照资料,不进入 Ozon 发布 payload。不得编造材料、认证、配件、安全性、产地、适用年龄、包装或功效;也不得把 A SKU 的角色、颜色或规格写到 B SKU。
|
||||
- SPU 和每个 SKU 的三个字段旁另有「中文对照」图标。统一弹窗左列展示当前有效俄文,右列展示对应中文,标题标明 SPU 或 SKU;继承项显示 SPU 对照与「继承 SPU」。中文对照按 `profile_id + scope(SPU/SKU) + variant_id + field` 保存配对版本;继承项只引用 SPU 对照,不复制记录。手工改俄文后若不再与中文配对,标记「中文对照待更新」,不得把 SPU 中文错配给 SKU 俄文覆盖。
|
||||
|
||||
## 5. 可复用服务与当前接口
|
||||
|
||||
全局入口使用 `POST /api/product-platform-profiles/{profile_id}/ai/autofills`;当前 SKU 的独立文案入口使用 `POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/ai/copy`。两者均要求 `Idempotency-Key` 和平台头,body 可带 `hint`;返回 `202 { ai_task_id, status }`。`GET /api/product-ai-tasks/{ai_task_id}` 返回任务状态、输入版本与已校验结果,不返回提示词、密钥或原始模型响应。Worker 从服务端 `.env` 读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_MODEL_NAME`。
|
||||
|
||||
全局结果包含 `category` 或 `category_candidates`、`model`、`offers`、可选 `package_spec`、`spu_copy`、`warnings`。单 SKU 结果只包含 `variant_id`、`copy`、`warnings`。任务输入固化商品、profile、来源快照版本和 SKU ID;前端在回填前再次比对版本。
|
||||
|
||||
接口结构示意(实际契约见 `@atlas/contracts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-uuid",
|
||||
"kind": "autofill",
|
||||
"status": "partial",
|
||||
"input_revision": { "product": 12, "profile": 7, "source_snapshot": 4 },
|
||||
"result": {
|
||||
"category": { "category_id": "atlas-category-uuid", "path": "目录路径" },
|
||||
"model": "FIG-MIX-8K4F",
|
||||
"offers": [{ "variant_id": "variant-uuid", "offer_id": "FIG-MIX-8K4F-V123456" }],
|
||||
"spu_copy": {
|
||||
"title": { "ru": "…", "zh": "…" },
|
||||
"tags": [{ "ru": "…", "zh": "…" }],
|
||||
"description": { "ru": "…", "zh": "…" }
|
||||
},
|
||||
"warnings": ["未找到可核对的包装重量和尺寸"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
任务状态区分 `succeeded`、`partial`、`failed`;部分失败通过 `warnings` 表达。候选类目不自动回填,由用户点击候选确认。包装缺少可核对证据时不返回包装建议。
|
||||
|
||||
内部拆为四个与页面无关的组件:
|
||||
|
||||
1. `SourceFactAssembler`:把采集快照和已保存的用户整理稿转为带证据的、长度受限的规范化商品事实;未来 URL 入口复用此层。
|
||||
2. `CategoryResolver`:已有类目优先沿用;否则分层查询平台目录并向模型提供封闭候选,返回经数据库验证的叶子 UUID 或需用户确认的多个候选。
|
||||
3. `AutofillGenerator`:Worker 调用 DeepSeek 生成中俄文案与有证据的包装建议;稳定型号/货号由服务端规则生成。API Key 只在 Worker 环境读取,不进入浏览器、任务输入、outbox 或日志。
|
||||
4. `AutofillValidator`:校验中俄配对、包装证据与正数、类目 UUID、编号长度和货号冲突,丢弃不合格字段。
|
||||
|
||||
NestJS 负责鉴权、工作空间事务、快照和任务创建;任务与 outbox 在同一事务入库;Worker 调用模型和写回结果。调用超时、限流、空响应、截断和非法 JSON 使用有界重试,重复任务以任务 ID 幂等。失败只更新任务状态,不触碰草稿。记录模型、提示词版本、耗时和 token 用量以便成本分析;日志及对外错误只输出脱敏摘要。
|
||||
|
||||
迁移 `029_product_ai_autofill.sql` 建立任务和中俄对照表,并给 SKU listing 增加可空 `title`;`null` 表示继承 SPU 发布标题。SKU `tags=[]`、`description=null` 继续表示继承。中文对照随草稿事务保存、随编辑快照读取;表单俄文若与对照时的俄文不一致,页面显示「待更新」。
|
||||
|
||||
页面只回填空白的全局字段和 SPU 文案,不覆盖已有手填值;SKU 文案必须由当前 SKU 弹窗单独生成,并经用户点「应用到当前 SKU」才覆盖其标题、标签、描述。中文只进入对应作用域的对照记录。回填只改变未保存的表单状态,不独立写入草稿。
|
||||
|
||||
## 6. 版本、冲突与失败处理
|
||||
|
||||
| 情况 | 页面行为 |
|
||||
|---|---|
|
||||
| 任务期间用户改了部分字段 | 页面保存前先固化草稿;任务完成时若版本已变化,拒绝整次自动回填并提示重新生成。 |
|
||||
| 任务期间商品被复采、平台或 SKU 集合改变 | 结果标为过期,不自动回填;用户可重新点击生成。 |
|
||||
| AI 找不到可确定的目标类目 | 类目留空,展示候选;文案和有独立证据的包装仍可回填。若已有已确认类目,可继续生成型号与货号;否则编号留空。 |
|
||||
| 类目目录不完整或同步失败 | 无已确认类目时,型号与货号跳过;其他独立字段仍可回填。 |
|
||||
| Ozon 动态属性字典缺失 | 本期不由 AI 填报该属性,不影响其他字段;页面显示字典加载状态,保存草稿允许缺失,发布预检继续阻止无效字典值。 |
|
||||
| 包装数据没有明确证据 | 包装项留空,说明缺失,不将商品尺寸当包装尺寸。 |
|
||||
| 目标店铺货号冲突 | 服务端重算稳定后缀;仍冲突则仅跳过该 SKU 货号并提示。 |
|
||||
| DeepSeek 超时、限流、空响应或截断 | 按错误类别执行有界退避重试;仍失败则模型依赖组标为失败,确定性类目/编号组若已合格可形成 `partial`,否则任务为 `failed`;已填值不回滚,未填值保留原样。 |
|
||||
| 结构不合法或事实/字段规则不合格 | JSON 请求有限次重试;仍失败则拒绝模型依赖组。可解析时逐字段校验,中俄不成对或数值无证据的字段单独丢弃。 |
|
||||
| 当前 SKU 文案失败 | 弹窗显示错误;原 SKU 文案不变,不影响已生成的 SPU 文案或其他 SKU。 |
|
||||
| 用户修改俄文后查看中文对照 | 标记「对照待更新」,不把旧中文当作当前俄文的翻译。 |
|
||||
|
||||
## 7. 实施顺序与验收
|
||||
|
||||
1. **契约与迁移**:修订 `docs/api/`、`@atlas/contracts`;落地 AI 任务、SKU 发布标题和分作用域中文对照表、工作空间 RLS、outbox 事件与幂等键。测试跨工作空间不可读取任务/对照及 SKU 标题继承。
|
||||
2. **服务端核心**:事实组装、平台目录分层候选、DeepSeek JSON 输出校验、稳定型号/货号规则、包装证据校验。全局只生成 SPU 文案;当前 SKU 文案由独立任务按需生成。不使用真实密钥跑自动化测试。
|
||||
3. **编辑页**:合并底部按钮、任务轮询、表单直接回填和保存前版本保护;类目回填触发现有 schema 请求,AI 不碰动态属性。SKU 展开区补独立标题编辑,标签/描述改为逐字段继承 SPU,留空即继承。
|
||||
4. **中文对照**:SPU 和每个 SKU 的三个字段旁入口、统一双栏弹窗、继承来源标记、随草稿保存与重新加载、各作用域俄文编辑后的过期提示。
|
||||
5. **端到端验收**:维护者启动 API/Worker 并完成迁移后,用 Ozon、1688、淘宝、天猫样本回归;覆盖跨作品 SKU、单 SKU 按需生成、中文对照刷新后读取、已导入 SKU 货号保护、无包装证据、歧义类目、模型异常及复采并发。
|
||||
|
||||
完成标准:点击一次「AI 填报」后,页面出现合格的类目、型号、未导入 SKU 货号及 SPU 俄文文案;证据不足的包装项保持空白。用户可以再对指定 SKU 单独生成并确认文案;中文对照保存后仍可查看。动态 Ozon 类目属性由后续单独设计。
|
||||
|
||||
## 8. 已知取舍
|
||||
|
||||
- 一键入口可以在内部多次调用模型,特别是非 Ozon 来源的分层类目选择;页面应显示异步进度,不能假设一个同步 HTTP 请求必定快速完成。
|
||||
- 直接回填提高速度,也要求可撤销、保留手填值、版本检查和完整任务记录。当前页面不增加逐项确认步骤。
|
||||
- 目前 `model` 是跨平台商品字段并会成为 Ozon 型号名称。若将来需要「内部编码」与「消费者可见型号」分离,应再增字段和迁移;本期不为此增加隐藏的第二套型号。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 商品编辑 API 契约
|
||||
|
||||
> 本文是早期产品级接口草案,部分路径和状态已经被实现契约取代,不应直接作为联调依据。当前端点、平台目录缓存和发布预检以 [`../api/product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 为准;所有请求仍须按当前用户、工作空间和平台/门店上下文授权,客户端不能跨租户指定 ID。浏览器插件采集入口以 [`../requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md) 为准。
|
||||
> 本文是早期产品级接口草案,部分路径和状态已经被实现契约取代,不应直接作为联调依据。尤其下文的独立 AI 类目/文案接口与逐项人工应用流程已由[统一 AI 填报方案](./ai-autofill-plan.md)取代。当前端点、平台目录缓存和发布预检以 [`../api/product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 为准;所有请求仍须按当前用户、工作空间和平台/门店上下文授权,客户端不能跨租户指定 ID。浏览器插件采集入口以 [`../requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md) 为准。
|
||||
|
||||
## 1. 采集与读取
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ workspaces 1 ── N collection_items ── 0..1 products (SPU) 1 ── N pro
|
||||
(平台发布配置) (平台 SKU 首次发布映射)
|
||||
```
|
||||
|
||||
`collection_items` 是工作空间级采集箱记录,保存来源平台、来源商品键、原始证据、规范化结果、素材任务和可空的草稿商品关联;它在采集阶段没有 `target_platform_id` 或 `target_store_id`。每个父商品至少有一条 SKU。`products` 仅是**采集箱商品**的跨平台可复用资料:来源标题、用户填写的型号、条形码、采买地址、货源备注、默认规格和来源证据;采集阶段型号允许为空,不能从来源数据猜测生成。首次上架前型号必须已由用户确认填写;不把任何一个平台的类目、价格、属性或上架状态直接写到该表。
|
||||
`collection_items` 是工作空间级采集箱记录,保存来源平台、来源商品键、原始证据、规范化结果、素材任务和可空的草稿商品关联;它在采集阶段没有 `target_platform_id` 或 `target_store_id`。每个父商品至少有一条 SKU。`products` 仅是**采集箱商品**的跨平台可复用资料:来源标题、用户手填或主动 AI 填报的型号、条形码、采买地址、货源备注、默认规格和来源证据;采集入箱时型号允许为空,不直接从来源数据猜测填入。首次上架前型号必须非空并通过现有校验;不把任何一个平台的类目、价格、属性或上架状态直接写到该表。
|
||||
|
||||
`product_platform_profiles` 是 `product_id + platform_id` 唯一的平台发布配置。用户在页头选中平台并进入编辑时才创建或读取它;它保存当前目标门店、该平台的标题/简介/标签、类目和类型、平台通用属性及本平台草稿版本。A 平台与 B 平台独立配置、独立状态;A 已上架不会让 B 显示已上架。门店只是配置中可改的当前发布目标,实际每次发布仍会在 SKU 映射中固化当时的 `store_id`。
|
||||
|
||||
`product_variants` 保存:稳定来源键、展示名、删除状态、排序、采购价、SKU 规格、包装物流规格、实物规格和时间戳。`product_variant_platform_listings` 保存平台 SKU 维度的数据:`profile_id`、`variant_id`、用户填写的本平台 `offer_id`(货号)、售价、划线价、币种、平台变体属性、首次导入状态/失败原因、首次导入任务和当时 `store_id`、Ozon `product_id`。采集时不得根据 `source_sku` 自动填入 `offer_id`;用户填写后,它对同一门店/平台连接必须唯一,并且是首次上架的必填项。成功导入的 SKU 映射不可再次创建导入任务;删除 Atlas SKU 只软删 Atlas 关联,不删除平台商品和审计记录。
|
||||
`product_variants` 保存:稳定来源键、展示名、删除状态、排序、采购价、SKU 规格、包装物流规格、实物规格和时间戳。`product_variant_platform_listings` 保存平台 SKU 维度的数据:`profile_id`、`variant_id`、用户填写或主动 AI 填报生成的本平台 `offer_id`(货号)、售价、划线价、币种、平台变体属性、首次导入状态/失败原因、首次导入任务和当时 `store_id`、Ozon `product_id`。AI 填报落地时新增可空的 SKU 发布标题 `title`;`title=null`、`description=null`、`tags=[]` 分别继承 SPU 当前平台发布配置的标题、描述、标签,非空/非空数组则按 SKU 覆盖。`product_variants.display_name` 只是内部展示名,不能充当发布标题。中俄文案对照按 SPU/SKU 作用域保存,继承时读取 SPU 对照而不复制。采集入箱时不填入 `offer_id`;编辑页主动生成或手填后,它对同一门店/平台连接必须唯一,并且是首次上架的必填项。成功导入的 SKU 映射不可再次创建导入任务;删除 Atlas SKU 只软删 Atlas 关联,不删除平台商品和审计记录。
|
||||
|
||||
`platform_listings`(实际建表名待定)保存店铺级在线商品 SKU 镜像,一条记录对应 Ozon 的一个 `product_id` / `offer_id`,而非采集箱 SPU:`store_id`、平台 `product_id`、`offer_id`、平台 SKU、名称、价格、可见性、审核/错误、库存、图片摘要、完整同步快照、最近同步时间和来源(`atlas_published` / `external_sync`)。它是**平台 + 门店独有**的外部事实记录,绝不跨平台、跨门店复用,也不因关联了采集箱 SKU 就变成采集箱商品。它由异步同步任务更新,包含非 Atlas 发布的商品;通过 `store_id + offer_id/product_id` 可选关联 Atlas SKU。采集箱不因成功上架而删除,其当前平台状态由首次导入映射汇总;在线商品的 `visible` 只用于在线商品实际可见性展示。
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的
|
||||
|
||||
### 2.3 SKU 设置
|
||||
|
||||
Ozon schema 中 `is_aspect=true` 的属性放入每个 SKU 卡片的「Ozon 变体属性」区域,不再按 SKU 名称集中堆放在产品属性区。每张 SKU 卡片包含:
|
||||
Ozon schema 中 `is_aspect=true` 的属性放入每个 SKU 卡片展开后的「SKU 信息」区域,与包装和文案字段共用布局,不单独显示「Ozon 变体属性」标题。每张 SKU 卡片包含:
|
||||
|
||||
- SKU 名称与当前平台上架状态;
|
||||
- 卖家货号 `offer_id`;
|
||||
@@ -195,7 +195,7 @@ POST /api/product-platform-profiles/{profile_id}/publish-preflights
|
||||
- 保存草稿允许不完整,发布校验阻断缺失项。
|
||||
- 服务端补齐通用和逐 SKU 必填属性检查。
|
||||
|
||||
实现状态:已完成。编辑器会在选定类目后立即加载 schema,商品级必填属性始终显示在「产品属性」,SKU 级必填属性始终显示在对应 SKU 卡片;保存草稿不因缺少必填值失败,`validation` 接口按商品与 SKU 返回结构化缺失项。
|
||||
实现状态:已完成。编辑器会在选定类目后立即加载 schema,商品级必填属性始终显示在「产品属性」,SKU 级变体属性在对应 SKU 展开后的「SKU 信息」中显示;保存草稿不因缺少必填值失败,`validation` 接口按商品与 SKU 返回结构化缺失项。
|
||||
|
||||
类目 schema 中由编辑器已有控件承载的字段通过 `editor_field` 显式绑定,不在动态属性区重复展示,也不重复加载其字典。绑定只允许按 Ozon 属性 ID:品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254`、简介/产品描述 `4191`;禁止使用名称或翻译文本猜测。后续发布适配器必须据此生成 Ozon 所需字段,不能要求用户重复填写。
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
```text
|
||||
页头:返回 · 商品标题 · 当前平台 · 目标门店 · 当前平台上架状态 · 来源
|
||||
├─ 主要信息
|
||||
│ ├─ 类目、标题、型号、品牌
|
||||
│ ├─ 类目、中文商品名、俄文发布标题、型号、品牌
|
||||
│ └─ 默认包装重量与默认包装尺寸
|
||||
├─ 产品属性
|
||||
│ ├─ 主题标签、简介、采买地址与货源备注
|
||||
│ └─ 当前平台/市场的商品级动态属性
|
||||
├─ SKU 商品:卡片(默认)/ 紧凑列表
|
||||
│ └─ N 个 SKU;维护货号、条形码、图片、价格、变体属性及可选包装覆盖
|
||||
│ └─ N 个 SKU;维护货号、条形码、图片、价格、变体属性、三项 SKU 文案及可选包装覆盖
|
||||
└─ 固定操作栏
|
||||
├─ 左:AI 类目 · AI 标题/标签/描述 · AI 套图生成
|
||||
├─ 左:AI 填报 · AI 套图生成
|
||||
└─ 右:取消并关闭 · 保存草稿 · 选择未上架 SKU 并首次上架
|
||||
```
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
|
||||
页面以编辑草稿工作,不采用字段失焦即写库。保存草稿需原子提交父商品、SKU、属性和媒体顺序;存在未保存修改时关闭页面须二次确认。SKU 定价确认是例外:它会即时持久化该 SKU 的价格与定价快照。
|
||||
|
||||
采集进入采集箱时,商品**型号**和各 SKU 的目标平台**货号(Ozon `offer_id`)**默认都为空,不从来源标题、来源 SKU 或规格名称猜测生成。用户在编辑页填写:型号属于商品通用资料;货号属于当前平台下的每个 SKU。两者允许随草稿保存而暂时为空,但首次上架校验必须全部通过:商品型号非空,且每个待上架 SKU 的货号非空并在目标店铺内唯一。
|
||||
主要信息中的“中文商品名”用于 Atlas 内部识别;“俄文发布标题”是 SPU 共用发布标题,页面保存时必须填写。AI 填报仍可从俄文标题为空的采集草稿启动;发布前服务端会再次检查该字段。
|
||||
|
||||
采集进入采集箱时,商品**型号**和各 SKU 的目标平台**货号(Ozon `offer_id`)**默认都为空。进入编辑页后,用户可手动填写或点击“AI 填报”生成并直接填入表单;生成货号不直接照搬来源 SKU。型号属于商品通用资料,货号属于当前平台下的每个 SKU。两者允许随草稿保存而暂时为空,但首次上架校验必须全部通过:商品型号非空,且每个待上架 SKU 的货号非空并在目标店铺内唯一。详见[AI 填报方案](./ai-autofill-plan.md)。
|
||||
|
||||
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
|
||||
|
||||
@@ -39,9 +41,9 @@
|
||||
|
||||
## 3. SKU 卡片和列表
|
||||
|
||||
卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、条形码、采购价、售价、划线价、定价按钮、按 schema 展示的商品规格与 Ozon 变体属性。列表仅是紧凑视图,字段和数据完全相同。货号由用户填写,不自动复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。SKU 包装规格为空时继承主要信息中的默认包装规格;只有该 SKU 包装不同才填写覆盖值。商品净重和商品尺寸不是 Ozon 固定物流字段,只在当前 schema 的对应属性为 SKU 级(`is_aspect=true`)时展示;商品级属性继续在 SPU 展示。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。
|
||||
卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、条形码、采购价、售价、划线价、定价按钮、按 schema 展示的商品规格与 Ozon 变体属性。列表仅是紧凑视图,字段和数据完全相同。货号可由用户填写或“AI 填报”生成,不直接复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。SKU 包装规格为空时继承主要信息中的默认包装规格;只有该 SKU 包装不同才填写覆盖值。商品净重和商品尺寸不是 Ozon 固定物流字段,只在当前 schema 的对应属性为 SKU 级(`is_aspect=true`)时展示;商品级属性继续在 SPU 展示。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。
|
||||
|
||||
SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。
|
||||
SKU 名称只用于展示,不是发布标题;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。SKU 展开区提供发布标题、主题标签、产品描述;留空逐字段继承 SPU,手工输入或应用当前 SKU 的 AI 结果才覆盖该项。SPU 三项旁的加号只显示对应 SKU 输入框,不复制共用文案。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。
|
||||
|
||||
无论 SPU 或 SKU 在当前平台是否已上架,用户都可以新增 SKU 变体、复制任一既有 SKU 作为新变体、删除 SKU。复制出的 SKU 没有平台发布映射,始终从 `未上架` 开始。删除已上架 SKU 只从 Atlas 商品结构和当前平台的可发布 SKU 集合移除,**不会删除、下架或修改 Ozon 已创建的商品**;删除未上架 SKU 后若当前平台只剩已上架 SKU,SPU 状态变为 `已上架`。
|
||||
|
||||
@@ -65,6 +67,6 @@ SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右
|
||||
|
||||
首期素材规则:发布图片仅接受 JPG、PNG、WebP;单文件最大 20 MB,最短边低于 700 px 给出警告且不得作为主图,超过 10,000 px 的边长在上传时拒绝;建议 1:1 或 3:4,允许其他比例但在预览中提示裁切风险。采集候选素材可保留原始文件供编辑选择,实际发布时始终按目标平台的图片数、格式和可访问性校验。Ozon 发布图片必须是可由 Ozon 拉取的 HTTPS 地址。
|
||||
|
||||
AI 文案针对当前平台的商品信息生成候选标题、标签与简介,用户选择后写入草稿;首期不生成或编辑富内容。AI 套图由左侧抽屉承载,必须显式选择目标 SKU;尺寸图一 SKU 一任务,并保存实物规格快照。
|
||||
“AI 填报”针对当前平台一次生成类目、型号、SKU 货号、证据充分的包装规格以及 **SPU 共用的**标题、标签与简介,并在任务完成后直接填入当前页面表单,等待用户保存。SKU 文案默认继承 SPU;用户可从当前 SKU 的 AI 图标单独生成并在弹窗明确应用。SPU 和每个 SKU 的三项字段旁均有「中文对照」图标,打开同一个弹窗查看当前作用域左俄右中的三项对照;继承字段标明来源,俄文被手改后该字段对照标为待更新。首期不生成或编辑富内容。AI 套图由左侧抽屉承载,必须显式选择目标 SKU;尺寸图一 SKU 一任务,并保存实物规格快照。
|
||||
|
||||
AI 类目由用户主动触发:系统将采集标题、规格、参数、图片摘要等证据与当前平台可发布的类目树交给 AI,返回候选类目、置信度和简短依据,用户必须选择确认后才写入当前平台发布配置。来源为 Ozon 的商品优先保留采集到的 Ozon 类目,默认不自动调用 AI,但始终允许用户主动重新推荐;1688、淘宝、天猫等其他来源默认提供“AI 推荐类目”以提升上品速度。AI 不得直接自动落类目或跳过类目属性校验。
|
||||
类目推荐由“AI 填报”主动触发:系统将采集证据与当前平台可发布的类目候选交给 AI。Ozon 来源优先沿用现有确定性匹配;1688、淘宝、天猫等来源由 AI 评估目标类目。服务端验证候选 UUID 后可将明确结果填入**未保存的页面表单**;用户点击保存后才写入平台发布配置。歧义结果留给用户选择。AI 不得跳过类目属性校验;本期 AI 填报不处理动态类目属性。
|
||||
|
||||
+32
-13
@@ -1,24 +1,24 @@
|
||||
# Atlas Seller Kit 交接说明
|
||||
|
||||
> 更新:2026-09-10。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md)。
|
||||
> 更新:2026-09-23。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及 [1.39](#139-开发进度与交接2026-09-23商品-ai-填报与编辑页收口) 为准。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||
|
||||
当前已完成 pnpm monorepo 初始化:包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享 API 契约、数据库包、本地基础设施 Compose 与依赖锁文件。API 已实现健康/readiness、集中配置与安全基础、认证、个人工作空间上下文、平台目录/上下文、门店、水印/素材、采集箱与扩展采集入库;Worker 已具备 Ozon 凭据验证、采集记录清理和按需来源素材转存消费链路,仓库同步尚未接入。Web 已具备完整前端框架(布局、路由、主题)、登录/注册页、页头平台选择器、店铺管理、水印管理、真实采集箱页面和采集数据测试页;浏览器扩展已提供毛子 ERP 风格的网页登录态同步、悬浮窗及 Ozon/1688/淘宝/天猫首版页面状态解析。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。
|
||||
pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;真正的 Ozon 首次发布、在线商品同步和定价确认仍未形成完整闭环。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。
|
||||
|
||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||
|
||||
## 0. 交接结论(先看这里)
|
||||
|
||||
当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。
|
||||
已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。
|
||||
|
||||
当前没有完成的是“可直接用于发布”的闭环:候选素材已经可以选择、转存并保存为 SKU 的平台素材,但定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
||||
尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口。
|
||||
|
||||
当前最高优先级是以真实来源样本回归素材转存与选择,然后进入定价确认能力。类目树改造已落地:Worker 使用显式指定的 Ozon 缓存源预热共享树,编辑器直接读取 Cascader 树,并会在空类目时按 Ozon 来源层级回填;详见 [1.28](#128-开发进度2026-09-10类目树预热与来源回填)。
|
||||
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与候选素材;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
||||
|
||||
接手前请先阅读 `git status --short`;当前 M3 商品编辑和 M3.4/M3.5 缓存改动均已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
||||
接手前请先阅读 `git status --short`;截至本次更新,AI 填报涉及 API、Worker、Web、contracts、迁移和文档的改动仍在工作区,其中若干文件未跟踪,不能当作已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。
|
||||
|
||||
### 1.1 开发进度(2026-09-02)
|
||||
|
||||
@@ -427,6 +427,25 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
||||
- 管理页面的缓存新鲜度设置由按钮打开弹窗;最近任务手动刷新有 loading 及成功/失败 message,5 秒自动轮询静默执行。
|
||||
- 旧门店目录章节保留为带日期的历史实现记录,不代表当前读写路径。当前未完成重点是真正的 Ozon 发布 job、树级发布新鲜度边界、大字典续传以及平台目录 PostgreSQL 集成测试。
|
||||
|
||||
### 1.39 开发进度与交接(2026-09-23):商品 AI 填报与编辑页收口
|
||||
|
||||
**已实现的链路**
|
||||
|
||||
- `029_product_ai_autofill.sql` 定义 AI 任务、中俄对照与 SKU 可空发布标题;API 提供全局填报、单 SKU 文案任务和任务状态读取。API 在工作空间事务内固化输入快照、写任务与 Outbox;Worker 从 `DEEPSEEK_API_KEY`、`DEEPSEEK_MODEL_NAME` 读取配置调用 DeepSeek。任务不直接写商品草稿,页面在完成后核对商品、profile 和来源快照版本,再把合格结果填入未保存表单。API、Worker、Web 与共享类型均有对应代码;迁移是否已应用到当前目标数据库,本次文档更新未核验。
|
||||
- 全局填报读取 Atlas 平台级活动类目树,按层向模型提供有限候选;对大分支分段初选、再决选,只接受给定选项。明确结果可回填类目,有多个命中则在「确认 AI 推荐类目」弹窗中由用户选择。已有类目不被覆盖;未确认类目时不能生成依赖它的型号和货号。型号是 SPU 共用稳定码,未导入 SKU 的货号分别生成并检查长度、连接符及冲突。
|
||||
- 全局文案只生成 SPU 的俄文标题、主题标签、产品描述及逐项中文对照;描述要求概述与「Характеристики:」分段、项目符号。SKU 默认继承 SPU 三项文案;用户可打开当前 SKU 的 AI 弹窗,预览后明确点击应用,才写入该 SKU 的未保存表单。SPU/SKU 都有中俄对照查看入口,俄文被手工修改后旧中文对照标为待更新。
|
||||
- 包装重量和尺寸仅在来源有可逐字核对的证据时建议;缺证据则留空。原计划的「AI 填写 Ozon 类目属性」已撤回:当前只在类目确定后按 schema 动态加载商品/SKU 属性,用户自行填写。`030_product_ai_attribute_fill.sql` 与历史 `attribute_fill` 类型保留兼容,不代表当前仍会创建或执行该任务。
|
||||
- 编辑页已把「内部商品名」文案改为「中文商品名」,「俄文发布标题」在表单与发布就绪校验中必填;SPU 标题有加号入口开启 SKU 标题覆盖。SKU 的 Ozon 变体字段已并入「SKU 信息」,不另设「Ozon 变体属性」标题。底部 `editor-footer` 固定在视口底部;AI 类目候选弹窗已调整为说明、单选候选和明确确认操作。类目输入框清除时的空值处理已修正。
|
||||
- 最新俄文文案规则同时作用于 SPU 与 SKU Prompt:`title.ru`、`tags[].ru`、`description.ru` 不得出现汉字;来源中文事实应翻译为自然俄文,只有有依据的拉丁品牌/型号及阿拉伯数字可保留。Worker 结果校验会丢弃仍含汉字的对应字段,中文对照与包装原文证据不受此限制。旧已保存文案不会自动改写,需重新生成或手工修订。
|
||||
|
||||
**验证状态与下一步**
|
||||
|
||||
- 最近一轮俄文禁汉字改动已通过 Worker `product-ai.test.mjs` 7 项测试、Worker TypeScript 检查和 `git diff --check`;本次交接文档更新未再发起真实 DeepSeek 请求。其他页面/API 变更有此前的构建及集成测试记录,但不能视为本次对整个脏工作区的重新验收。
|
||||
- 下一步先核对目标环境的 `029`/`030` 迁移与 API/Worker 当前进程版本,再使用 Ozon、1688、淘宝、天猫各类样本回归:类目候选准确性与确认、中文来源到纯俄文输出、无包装证据、多个 SKU 的共用型号与独立货号、SPU/SKU 文案继承和保存后中文对照。重点复查此前 1688 类目不足提示与 DeepSeek 失败场景;不能仅以单元测试认定质量已稳定。
|
||||
- 之后继续定价确认、素材按需物化回归、平台目录大字典续传与管理员回归,并将真正的首次发布 job 接到现有发布预检。发布预检 `ready` 不是已上架;在线商品同步也尚未实现。
|
||||
|
||||
详细字段规则与任务接口见 [`ai-autofill-plan.md`](../product/ai-autofill-plan.md) 和 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)。
|
||||
|
||||
## 2. 已确认产品决策
|
||||
|
||||
| 主题 | 决策 |
|
||||
@@ -435,7 +454,7 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
||||
| 平台可售单元 | 一个 SKU 对应一个独立平台 offer;Ozon 首个平台中对应 `/v3/product/import.items[]` 的一项和独立 `offer_id`。 |
|
||||
| 通用属性 | 标题、品牌、型号、主题标签、简介、条形码、采买地址、货源备注及未来商品级属性;类目和平台属性属于独立的平台发布配置。首期不做富内容。 |
|
||||
| SKU 属性 | SKU 名称、图片、货号、采购价、售价、划线价、包装重量和尺寸、商品重量和尺寸、商品颜色、颜色名称等。首期不支持视频。 |
|
||||
| 型号/货号 | 型号(`model`)是父商品通用字段;`offer_id` 是当前平台下的每个 SKU 字段。采集时两者可为空;首次上架前必须由用户填写,系统不从标题、规格或来源 SKU 自动生成。被成功导入的 `offer_id` 作为历史事实锁定。 |
|
||||
| 型号/货号 | 型号(`model`)是父商品通用字段;`offer_id` 是当前平台下的每个 SKU 字段。采集入箱时两者可为空;编辑页可手填,也可在用户主动点击“AI 填报”后生成并填入表单,保存和发布时仍校验唯一性。被成功导入的 `offer_id` 作为历史事实锁定。详见[AI 填报方案](../product/ai-autofill-plan.md)。 |
|
||||
| 规格 | 实物规格与包装物流规格严格分离;尺寸图只读取实物规格,计价/物流/发布只读取包装规格。 |
|
||||
| 继承 | 可继承字段的 `null` 表示该字段没有覆盖父商品默认值,不表示 SKU 不存在、名称为空或商品为单规格。 |
|
||||
| 尺寸图 | 必须逐 SKU 生成,任务保存 SKU ID 和实物规格快照。 |
|
||||
@@ -474,13 +493,13 @@ User → Workspace → Membership / Role
|
||||
|
||||
## 5. 已冻结的第一阶段 API 契约
|
||||
|
||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、部分业务接口已实现**。认证、平台目录/上下文、创建/查询/编辑/默认/删除门店、异步操作读取及仓库摘要读取已有实现与集成测试;门店凭据验证、仓库同步的实际平台调用仍待 Worker 消费 Outbox 后完成。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||
第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、分模块实施**。认证、门店、采集、商品编辑、平台参考目录、发布预检与 AI 任务已有接口和实现;定价、真正的商品发布及在线商品同步仍未形成完整接口/Worker 闭环。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。
|
||||
|
||||
- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。
|
||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
||||
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||
- DDL 已通过本地隔离 PostgreSQL 执行验证;迁移执行器、认证/工作空间基础、幂等与 Outbox 写入闭环、Outbox Dispatcher、Ozon 凭据验证 Worker、上传意图清理 Worker、采集记录清理 Worker 与来源素材转存 Worker 均已实现。仓库同步、发布和在线商品同步 Worker 仍待实现。
|
||||
- 早期 DDL 已通过本地隔离 PostgreSQL 验证;迁移执行器、认证/工作空间基础、幂等与 Outbox 写入闭环、Outbox Dispatcher、Ozon 凭据验证、采集清理、来源素材转存及商品 AI Worker 已有实现。新近 `029`/`030` 迁移在目标环境中的应用状态需单独核对;商品发布和在线商品同步 Worker 仍待实现。
|
||||
|
||||
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
||||
|
||||
@@ -490,11 +509,11 @@ User → Workspace → Membership / Role
|
||||
|
||||
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台、同步基础链路和商品编辑器迁移已经实现;旧门店目录只作为兼容/回滚数据保留。
|
||||
|
||||
当前执行顺序:
|
||||
当前执行顺序(2026-09-23):
|
||||
|
||||
1. 将真正的 `publish-jobs` 创建事务接入已经完成的发布预检服务,固化发布快照后再实现 Ozon `/v3/product/import` 与结果轮询;不得重新实现另一套目录判断。
|
||||
2. 完成平台目录首次预热并修正大字典续传;补系统管理员浏览器回归。旧门店目录继续保留,暂不清理。
|
||||
3. 在真实采集来源中继续回归候选媒体按需物化,然后完成定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
|
||||
1. 核对 AI 任务迁移、运行进程版本和四类来源的真实样本;回归类目选择、中俄文案(俄文字段不含汉字)、包装证据、SKU 文案继承及候选媒体按需物化。
|
||||
2. 完成定价确认、平台目录大字典续传与管理员浏览器回归;旧门店目录继续保留,暂不清理。
|
||||
3. 将真正的 `publish-jobs` 创建事务接入已完成的发布预检,固化发布快照后实现 Ozon `/v3/product/import` 与结果轮询,再推进在线商品同步;不得重新实现另一套目录判断。
|
||||
|
||||
### 历史计划(仅供追溯)
|
||||
|
||||
|
||||
@@ -23,15 +23,15 @@
|
||||
|
||||
### 2. `offer_id`(Ozon 货号)策略
|
||||
|
||||
**状态:已决策,待接口和数据校验落地。**
|
||||
**状态:2026-09-22 修订决策;AI 填报尚未实现。**
|
||||
|
||||
- 采集进入采集箱时,型号(`model`)和 `offer_id` 都允许为空。
|
||||
- 用户在商品编辑页填写:`model` 是商品通用字段;`offer_id` 是当前平台下、每个 SKU 的字段。
|
||||
- 系统**不**从采集标题、规格或来源 SKU 自动生成 `offer_id`。
|
||||
- 首次上架前,`model` 必填;每个被选中 SKU 的 `offer_id` 必填,且在目标门店内唯一。
|
||||
- `model` 是商品通用字段;`offer_id` 是当前平台下、每个 SKU 的字段。用户可手动填写,也可在进入编辑页后点击“AI 填报”,由规则与 AI 生成并直接填入表单。
|
||||
- 采集入箱本身不填写货号;AI 生成以 Atlas 稳定商品标识、已确认类目和实际 SKU 规格为依据,可使用来源信息提炼语义片段,但不得直接复用来源 SKU 作为目标平台货号。
|
||||
- 首次上架前,`model` 必填;每个被选中 SKU 的 `offer_id` 必填,且在目标门店内唯一。生成值和手填值接受相同的保存及发布校验。
|
||||
- 导入成功后,该次平台 SKU 发布记录使用的 `offer_id` 作为历史事实,不允许通过“重新上架”改变;后续编辑在线商品属于另一套平台商品更新能力。
|
||||
|
||||
`docs/api/product-editor-and-publishing.md`、商品域 DDL 和发布前校验必须以本节为准,不再保留“自动生成可编辑货号”的备选逻辑。
|
||||
详细的主动生成、表单回填及冲突处理见 [`../product/ai-autofill-plan.md`](../product/ai-autofill-plan.md)。
|
||||
|
||||
## P1:实现对应模块前修正
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './stores';
|
||||
export * from './watermarks';
|
||||
export * from './media';
|
||||
export * from './product-editor';
|
||||
export * from './product-ai';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ProductCopyField = 'title' | 'tags' | 'description';
|
||||
export type ProductCopyTranslation = {
|
||||
variant_id: string | null;
|
||||
field: ProductCopyField;
|
||||
russian_value: string | string[];
|
||||
chinese_value: string | string[];
|
||||
source_task_id: string | null;
|
||||
};
|
||||
|
||||
export type AiCopyOutput = {
|
||||
title?: { ru: string; zh: string };
|
||||
tags?: Array<{ ru: string; zh: string }>;
|
||||
description?: { ru: string; zh: string };
|
||||
};
|
||||
|
||||
export type ProductAiAutofillResult = {
|
||||
category?: { category_id: string; path: string };
|
||||
category_candidates?: Array<{ category_id: string; path: string }>;
|
||||
model?: string;
|
||||
offers?: Array<{ variant_id: string; offer_id: string }>;
|
||||
package_spec?: {
|
||||
length_mm: string;
|
||||
width_mm: string;
|
||||
height_mm: string;
|
||||
shipping_weight_g: string;
|
||||
evidence: string;
|
||||
};
|
||||
spu_copy?: AiCopyOutput;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ProductAiSkuCopyResult = {
|
||||
variant_id: string;
|
||||
copy: AiCopyOutput;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅兼容已完成的历史属性填报任务;应用不再创建或执行此类任务。
|
||||
*/
|
||||
export type ProductAiAttributeSuggestion = {
|
||||
scope: 'common' | 'variant';
|
||||
variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: string[];
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export type ProductAiAttributeFillResult = {
|
||||
attributes: ProductAiAttributeSuggestion[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ProductAiTask = {
|
||||
id: string;
|
||||
/** `attribute_fill` 仅用于读取历史任务记录。 */
|
||||
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
|
||||
status: 'queued' | 'running' | 'succeeded' | 'partial' | 'failed';
|
||||
input_revision: {
|
||||
product: number;
|
||||
profile: number;
|
||||
source_snapshot: number;
|
||||
};
|
||||
variant_id: string | null;
|
||||
result:
|
||||
| ProductAiAutofillResult
|
||||
| ProductAiSkuCopyResult
|
||||
| ProductAiAttributeFillResult
|
||||
| null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
export const requestProductAiTaskSchema = z
|
||||
.object({
|
||||
hint: z.string().trim().max(1000).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const productCopyTranslationSchema = z
|
||||
.object({
|
||||
variant_id: z.uuid().nullable(),
|
||||
field: z.enum(['title', 'tags', 'description']),
|
||||
russian_value: z.union([
|
||||
z.string().max(100_000),
|
||||
z.array(z.string().max(100)).max(30),
|
||||
]),
|
||||
chinese_value: z.union([
|
||||
z.string().max(100_000),
|
||||
z.array(z.string().max(100)).max(30),
|
||||
]),
|
||||
source_task_id: z.uuid().nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (
|
||||
typeof value.russian_value !== typeof value.chinese_value ||
|
||||
Array.isArray(value.russian_value) !==
|
||||
Array.isArray(value.chinese_value)
|
||||
) {
|
||||
context.addIssue({ code: 'custom', message: '中俄对照结构不一致' });
|
||||
}
|
||||
if (
|
||||
Array.isArray(value.russian_value) &&
|
||||
Array.isArray(value.chinese_value) &&
|
||||
value.russian_value.length !== value.chinese_value.length
|
||||
) {
|
||||
context.addIssue({ code: 'custom', message: '中俄标签数量不一致' });
|
||||
}
|
||||
if ((value.field === 'tags') !== Array.isArray(value.russian_value)) {
|
||||
context.addIssue({ code: 'custom', message: '对照字段类型不正确' });
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productCopyTranslationSchema,
|
||||
type ProductCopyTranslation,
|
||||
} from './product-ai';
|
||||
|
||||
export type ProductAiSourceAttribute = {
|
||||
key: string;
|
||||
@@ -227,6 +231,8 @@ export type ProductEditorSnapshot = {
|
||||
display_name: string | null;
|
||||
sort_order: number;
|
||||
purchase_cost_cny: string | null;
|
||||
/** SKU 发布标题覆盖;空值继承当前平台 SPU 标题。 */
|
||||
title: string | null;
|
||||
/** 当前平台下该 SKU 对 SPU 主题标签的覆盖;空数组表示继承 SPU。 */
|
||||
tags: string[];
|
||||
/** 当前平台下该 SKU 对 SPU 产品描述的覆盖;null 表示继承 SPU。 */
|
||||
@@ -282,6 +288,7 @@ export type ProductEditorSnapshot = {
|
||||
source_material_state: ProductAiSourceMaterialState;
|
||||
captured_category_match: CapturedCategoryMatch;
|
||||
ai_input: ProductAiInput;
|
||||
copy_translations: ProductCopyTranslation[];
|
||||
warnings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
|
||||
@@ -510,6 +517,10 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
})
|
||||
.strict(),
|
||||
attributes: z.array(productAttributeDraft).max(1_000).optional(),
|
||||
copy_translations: z
|
||||
.array(productCopyTranslationSchema)
|
||||
.max(1_000)
|
||||
.optional(),
|
||||
variants: z
|
||||
.array(
|
||||
z
|
||||
@@ -531,6 +542,7 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,4})?$/)
|
||||
.nullable(),
|
||||
title: nullableText(1_000).optional(),
|
||||
tags: z
|
||||
.array(z.string().trim().min(1).max(100))
|
||||
.max(30),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- SKU 发布标题为空时继承当前平台 SPU 标题;内部展示名不是发布标题。
|
||||
ALTER TABLE product_variant_platform_listings ADD COLUMN title TEXT;
|
||||
ALTER TABLE product_variants ADD CONSTRAINT uq_product_variants_workspace_id_product
|
||||
UNIQUE (workspace_id, id, product_id);
|
||||
|
||||
-- 模型任务只保存服务端固化的资料快照;Outbox 仅携带任务 ID。
|
||||
CREATE TABLE product_ai_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
variant_id UUID,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('autofill', 'sku_copy')),
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued', 'running', 'succeeded', 'partial', 'failed')),
|
||||
input_snapshot JSONB NOT NULL,
|
||||
result JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
model_name TEXT,
|
||||
prompt_version TEXT NOT NULL DEFAULT 'v1',
|
||||
token_usage JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, profile_id, product_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, variant_id, product_id)
|
||||
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE RESTRICT,
|
||||
CHECK ((kind = 'autofill' AND variant_id IS NULL)
|
||||
OR (kind = 'sku_copy' AND variant_id IS NOT NULL)),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_product_ai_tasks_profile_recent
|
||||
ON product_ai_tasks (workspace_id, profile_id, created_at DESC);
|
||||
|
||||
-- 中文只作编辑对照;发布适配器只读取俄文草稿。
|
||||
CREATE TABLE product_copy_translations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
variant_id UUID,
|
||||
field TEXT NOT NULL CHECK (field IN ('title', 'tags', 'description')),
|
||||
russian_value JSONB NOT NULL,
|
||||
chinese_value JSONB NOT NULL,
|
||||
source_task_id UUID,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id, product_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, variant_id, product_id)
|
||||
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_task_id)
|
||||
REFERENCES product_ai_tasks(workspace_id, id) ON DELETE SET NULL (source_task_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_product_copy_translations_spu
|
||||
ON product_copy_translations (workspace_id, profile_id, field)
|
||||
WHERE variant_id IS NULL;
|
||||
CREATE UNIQUE INDEX ux_product_copy_translations_sku
|
||||
ON product_copy_translations (workspace_id, profile_id, variant_id, field)
|
||||
WHERE variant_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE product_ai_tasks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_ai_tasks FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON product_ai_tasks
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
ALTER TABLE product_copy_translations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_copy_translations FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON product_copy_translations
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
@@ -0,0 +1,16 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 类目属性 AI 任务与 SPU/SKU 文案任务共用不可变输入快照和 Outbox,
|
||||
-- 但它只能在已确认的 SPU 类目下运行,因此不关联单个 SKU。
|
||||
ALTER TABLE product_ai_tasks
|
||||
DROP CONSTRAINT product_ai_tasks_kind_check,
|
||||
DROP CONSTRAINT product_ai_tasks_check;
|
||||
|
||||
ALTER TABLE product_ai_tasks
|
||||
ADD CONSTRAINT product_ai_tasks_kind_check
|
||||
CHECK (kind IN ('autofill', 'sku_copy', 'attribute_fill')),
|
||||
ADD CONSTRAINT product_ai_tasks_check
|
||||
CHECK (
|
||||
(kind = 'sku_copy' AND variant_id IS NOT NULL)
|
||||
OR (kind IN ('autofill', 'attribute_fill') AND variant_id IS NULL)
|
||||
);
|
||||
Reference in New Issue
Block a user