feat(ai-edit): 开发 AI 填报功能

This commit is contained in:
R524809
2026-09-23 17:18:09 +08:00
parent 95a2a37f1b
commit b3958166dd
35 changed files with 2832 additions and 278 deletions
@@ -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,11 +1965,12 @@ export class ProductEditorService {
LIMIT 20`,
[platformId, signals.typeName, signals.rootCategoryName],
);
const status = candidates.rows.length === 1
? 'matched'
: candidates.rows.length
? 'ambiguous'
: 'unmatched';
const status =
candidates.rows.length === 1
? 'matched'
: candidates.rows.length
? 'ambiguous'
: 'unmatched';
return {
status,
root_category_name: signals.rootCategoryName,
@@ -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({
+74
View File
@@ -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
View File
@@ -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;
+385 -42
View File
@@ -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,41 +97,53 @@ export function ProductAttributeSection({
title="产品属性"
description="用于当前平台发布与 AI 文案生成。"
/>
{!usesVariantTags && (
<Form.Item label="主题标签">
<div className="profile-override-control">
<Form.Item name={['profile', 'tags']} noStyle>
<Select
className="editor-standard-select"
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加标签"
/>
</Form.Item>
<Form.Item label="主题标签">
<div className="profile-override-control">
<Form.Item name={['profile', 'tags']} noStyle>
<Select
className="editor-standard-select"
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加标签"
/>
</Form.Item>
{!usesVariantTags && (
<Button
type="text"
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写主题标签"
onClick={onUseVariantTags}
/>
</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>
)}
<Button
type="text"
icon={<TranslationOutlined />}
aria-label="查看主题标签中文对照"
onClick={() => onCompareCopy('tags')}
/>
</div>
</Form.Item>
<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}
/>
</div>
</Form.Item>
)}
)}
<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,10 +170,12 @@ export function ProductAttributeSection({
onClick={() => {
onToggleExpanded();
window.requestAnimationFrame(() =>
document.getElementById('dynamic-attributes')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
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,31 +593,146 @@ 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"
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="输入后回车添加标签"
/>
<Space.Compact
style={{ width: '100%' }}
>
<Form.Item
name={[field.name, 'tags']}
noStyle
>
<Select
style={{ flex: 1 }}
mode="tags"
tokenSeparators={[',']}
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"
>
<Input.TextArea
rows={3}
placeholder="填写该 SKU 的产品描述"
/>
<Space.Compact
style={{ width: '100%' }}
>
<Form.Item
name={[
field.name,
'description',
]}
noStyle
>
<Input.TextArea
rows={3}
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 &&
+27
View File
@@ -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,
+105 -37
View File
@@ -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,27 +384,28 @@ async function bootstrap() {
await dispatcher.dispatchBatch();
await platformDispatcher.dispatchBatch();
await platformScheduler.run();
dispatcherTimer = setInterval(
() => {
void dispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
void platformDispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'platform_outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
},
2_000,
);
dispatcherTimer = setInterval(() => {
void dispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
void platformDispatcher.dispatchBatch().catch((error: unknown) => {
console.error(
'platform_outbox_dispatch_failed',
error instanceof Error ? error.message : error,
);
});
}, 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,
);
void mediaUploadCleanup.run().catch((error: unknown) => {
+694
View File
@@ -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');
}
+115
View File
@@ -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');
});