feat(ai-edit): 开发 AI 填报功能
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user