feat(edit): 编辑页上报ozon 开发
This commit is contained in:
@@ -93,3 +93,15 @@ DEEPSEEK_MODEL_NAME=deepseek-flash
|
||||
# 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。
|
||||
PCORE_BASE_URL=https://pcore.ai/v1
|
||||
PCORE_API_KEY=
|
||||
|
||||
# 首次上架:开发/测试 auto 允许,生产 auto 关闭;完成 VAT 与七牛联调后显式 true。
|
||||
OZON_PUBLISH_ENABLED=auto
|
||||
# 与素材实际入库环境一致,生产设置 qiniu_kodo。
|
||||
OBJECT_STORAGE_PROVIDER=local_minio
|
||||
# 七牛绑定的公网 HTTPS 下载域名,与 S3 服务 endpoint 分离。
|
||||
OZON_IMAGE_DOWNLOAD_ORIGIN=
|
||||
# 图片签名初始有效期 7 天;生产值须通过异步/延迟抓图验收。
|
||||
OZON_IMAGE_URL_TTL_SECONDS=604800
|
||||
# 可复用对象存储凭据;分离授权时填写专用签名凭据。
|
||||
QINIU_ACCESS_KEY=
|
||||
QINIU_SECRET_KEY=
|
||||
|
||||
@@ -25,6 +25,8 @@ const environmentSchema = z.object({
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
PRICING_ALLOW_DRAFT_RATES: z.enum(['true', 'false', 'auto']).default('auto'),
|
||||
OZON_PUBLISH_ENABLED: z.enum(['true', 'false', 'auto']).default('auto'),
|
||||
OBJECT_STORAGE_PROVIDER: z.enum(['local_minio', 'qiniu_kodo']).default('local_minio'),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
|
||||
API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
|
||||
@@ -91,7 +91,7 @@ export class MediaService {
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, 'local_minio',
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10,
|
||||
$3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
id,
|
||||
@@ -103,6 +103,7 @@ export class MediaService {
|
||||
input.checksum_sha256,
|
||||
expiresAt,
|
||||
userId,
|
||||
this.config.OBJECT_STORAGE_PROVIDER,
|
||||
],
|
||||
);
|
||||
const url = await getSignedUrl(
|
||||
@@ -219,7 +220,7 @@ export class MediaService {
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size,
|
||||
checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`,
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
assetId,
|
||||
intent.bucket,
|
||||
@@ -229,6 +230,7 @@ export class MediaService {
|
||||
inspected.checksum,
|
||||
inspected.width,
|
||||
inspected.height,
|
||||
intent.storage_provider,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -364,12 +366,12 @@ export class MediaService {
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio',
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $9,
|
||||
$2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING
|
||||
RETURNING id`,
|
||||
[assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType,
|
||||
image.bytes.length, checksum, image.width, image.height],
|
||||
image.bytes.length, checksum, image.width, image.height, this.config.OBJECT_STORAGE_PROVIDER],
|
||||
);
|
||||
assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productPublishPreflightRequestSchema,
|
||||
createProductPublishJobRequestSchema,
|
||||
requestProductAiTaskSchema,
|
||||
saveProductEditorDraftRequestSchema,
|
||||
saveProductSourceDescriptionRequestSchema,
|
||||
@@ -242,6 +243,30 @@ export class ProductEditorController {
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 同一事务重新校验并创建首次发布批次。 */
|
||||
@Post('product-platform-profiles/:profileId/publish-jobs')
|
||||
async createPublishJob(@Param('profileId') profileId: string, @Body() body: unknown,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('idempotency-key') key: string | undefined,
|
||||
@Headers('if-match') revision: string | undefined, @Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const parsed = createProductPublishJobRequestSchema.safeParse(body);
|
||||
if (!parsed.success) throw new ApiError(422, 'VALIDATION_FAILED', '请选择 1–50 个不同的待上架 SKU');
|
||||
const result = await this.editor.createPublishJob(workspace.id, request.auth.userId, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('Idempotency-Key', key), parseEditorRevision(revision), parsed.data.variant_ids);
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
/** 恢复编辑页最近发布任务,刷新页面后也能查看进度。 */
|
||||
@Get('product-platform-profiles/:profileId/publish-jobs')
|
||||
async publishJobs(@Param('profileId') profileId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
||||
return {data: await this.editor.listPublishJobs(workspace.id, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId))};
|
||||
}
|
||||
|
||||
/** 查询当前租户及平台下的逐 SKU 发布结果。 */
|
||||
@Get('product-publish-jobs/:jobId')
|
||||
async publishJob(@Param('jobId') jobId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
||||
return {data: await this.editor.readPublishJob(workspace.id, requiredUuid('publish_job_id', jobId), requiredUuid('X-Atlas-Platform-Id', platformId))};
|
||||
}
|
||||
|
||||
/** 幂等地创建所选类目属性定义的刷新任务。 */
|
||||
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||
async attributeSchemaSync(
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ProfileRow = {
|
||||
market_code: string | null;
|
||||
fulfillment: string;
|
||||
title: string | null;
|
||||
title_mode: 'common' | 'variant';
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
platform_product_category_id: string | null;
|
||||
@@ -62,7 +63,7 @@ export type OperationRow = {
|
||||
|
||||
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
|
||||
export const profileSelect = `SELECT p.id, p.product_id, p.revision, p.platform_id, p.market_code,
|
||||
p.fulfillment, p.title, p.description, p.tags, p.platform_product_category_id,
|
||||
p.fulfillment, p.title, p.title_mode, p.description, p.tags, p.platform_product_category_id,
|
||||
p.store_product_category_id,
|
||||
p.platform_type_id, p.category_source, category.name AS category_name,
|
||||
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||
|
||||
@@ -382,6 +382,10 @@ export class ProductEditorPublishPreflightOperations {
|
||||
[profileId, variantIds],
|
||||
);
|
||||
const used = new Map<string, UsedDictionary>();
|
||||
// 内建品牌等字典字段也需要平台候选值,不能绕过发布目录有效期检查。
|
||||
for (const definition of schema.filter(item => item.editor_field && item.value_type === 'dictionary' && item.dictionary_id)) {
|
||||
used.set(definition.attribute_id, {attribute_id: definition.attribute_id, platform_attribute_id: definition.platform_attribute_id, name: definition.name});
|
||||
}
|
||||
for (const attribute of attributes.rows) {
|
||||
if (!hasStringValue(attribute.values)) continue;
|
||||
const definition = definitions.get(
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { Decimal } from 'decimal.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
OzonPublishItemSnapshot,
|
||||
ProductAttributeSchema,
|
||||
ProductPublishJob,
|
||||
ProductEditorValidation,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import type { ProfileRow, ProductRow } from './product-editor.models.js';
|
||||
|
||||
type Access = {
|
||||
profile: (
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
platformId: string,
|
||||
) => Promise<ProfileRow>;
|
||||
product: (
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
lock?: boolean,
|
||||
) => Promise<ProductRow>;
|
||||
schema: (
|
||||
tx: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<ProductAttributeSchema[]>;
|
||||
validate: (
|
||||
tx: DatabaseTransaction,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
ids: string[],
|
||||
) => Promise<ProductEditorValidation>;
|
||||
};
|
||||
type Listing = {
|
||||
id: string;
|
||||
variant_id: string;
|
||||
display_name: string | null;
|
||||
offer_id: string;
|
||||
store_id: string;
|
||||
status: string;
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
description: string | null;
|
||||
selling_price: string;
|
||||
list_price: string | null;
|
||||
currency_code: string | null;
|
||||
depth: string;
|
||||
width: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
};
|
||||
type Attribute = {
|
||||
scope: string;
|
||||
product_variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
/** 发布创建只读取本地事实,所有远端访问由 Worker 执行。 */
|
||||
export class ProductEditorPublishOperations {
|
||||
constructor(
|
||||
private readonly database: Database,
|
||||
private readonly idempotency: IdempotencyService,
|
||||
private readonly access: Access,
|
||||
private readonly enabled: boolean,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
key: string,
|
||||
expected: { product: number; profile: number },
|
||||
ids: string[],
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/product-platform-profiles/${profileId}/publish-jobs`,
|
||||
key,
|
||||
request: { platformId, expected, variant_ids: ids },
|
||||
},
|
||||
async (tx) => {
|
||||
if (!this.enabled)
|
||||
throw new ApiError(
|
||||
503,
|
||||
'OZON_PUBLISH_DISABLED',
|
||||
'生产上架尚未开启,请先完成店铺 VAT 与图片交付验收',
|
||||
);
|
||||
if (
|
||||
!ids.length ||
|
||||
ids.length > 50 ||
|
||||
new Set(ids).size !== ids.length
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'本次最多上架 50 个 SKU,请使用部分上架分批提交',
|
||||
);
|
||||
const initial = await this.access.profile(
|
||||
tx,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const product = await this.access.product(
|
||||
tx,
|
||||
initial.product_id,
|
||||
true,
|
||||
);
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.product_platform_profiles WHERE id = $1 FOR UPDATE',
|
||||
[profileId],
|
||||
);
|
||||
const profile = await this.access.profile(
|
||||
tx,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
if (
|
||||
Number(product.revision) !== expected.product ||
|
||||
Number(profile.revision) !== expected.profile
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'VERSION_CONFLICT',
|
||||
'商品资料已更新,请重新加载后上架',
|
||||
);
|
||||
const store = await tx.query<{ code: string }>(
|
||||
`SELECT p.code FROM atlas.stores s JOIN atlas.platforms p ON p.id=s.platform_id WHERE s.id=$1 AND s.platform_id=$2 AND s.deleted_at IS NULL AND s.status='active' AND s.credential_status='valid' FOR SHARE OF s`,
|
||||
[profile.target_store_id, platformId],
|
||||
);
|
||||
if (store.rows[0]?.code.toUpperCase() !== 'OZON')
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_UNAVAILABLE',
|
||||
'当前 Ozon 店铺不可用,请检查授权',
|
||||
);
|
||||
const category = await tx.query<{
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
}>(
|
||||
`SELECT external_category_id, external_type_id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active FOR SHARE`,
|
||||
[profile.platform_product_category_id, platformId],
|
||||
);
|
||||
if (!category.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请选择当前有效的 Ozon 产品类目',
|
||||
);
|
||||
const listings = await tx.query<Listing>(
|
||||
`SELECT l.id, v.id AS variant_id, v.display_name, l.offer_id, l.store_id, l.status, l.title, l.tags, l.description, l.selling_price::text, l.list_price::text, l.currency_code,
|
||||
coalesce(v.package_length_mm,p.default_package_length_mm)::text AS depth,
|
||||
coalesce(v.package_width_mm,p.default_package_width_mm)::text AS width,
|
||||
coalesce(v.package_height_mm,p.default_package_height_mm)::text AS height,
|
||||
coalesce(v.shipping_weight_g,p.default_shipping_weight_g)::text AS weight
|
||||
FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id JOIN atlas.products p ON p.id=v.product_id
|
||||
WHERE l.profile_id=$1 AND v.product_id=$2 AND v.deleted_at IS NULL AND v.id=ANY($3::uuid[]) ORDER BY v.sort_order,v.id FOR UPDATE OF l,v`,
|
||||
[profileId, product.id, ids],
|
||||
);
|
||||
if (listings.rows.length !== ids.length)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'所选 SKU 不属于当前商品或已经删除',
|
||||
);
|
||||
if (listings.rows.some((l) => l.status === 'imported'))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SKU_ALREADY_IMPORTED',
|
||||
'所选 SKU 已在当前店铺导入,请前往在线商品查看',
|
||||
);
|
||||
if (listings.rows.some((l) => l.status === 'submitting'))
|
||||
throw new ApiError(
|
||||
409,
|
||||
'SKU_IMPORT_IN_PROGRESS',
|
||||
'所选 SKU 正在提交或等待平台对账,请等待任务完成',
|
||||
);
|
||||
if (
|
||||
listings.rows.some(
|
||||
(l) => l.store_id !== profile.target_store_id,
|
||||
)
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'PUBLISH_STORE_CHANGED',
|
||||
'SKU 店铺与目标店铺不一致,请保存草稿后重试',
|
||||
);
|
||||
const validation = await this.access.validate(
|
||||
tx,
|
||||
profileId,
|
||||
platformId,
|
||||
ids,
|
||||
);
|
||||
if (!validation.ready)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
'请完善待上架商品资料',
|
||||
[
|
||||
...validation.profile.issues,
|
||||
...validation.variants.flatMap((v) => v.issues),
|
||||
],
|
||||
);
|
||||
const schema = await this.access.schema(
|
||||
tx,
|
||||
platformId,
|
||||
profile.platform_product_category_id!,
|
||||
);
|
||||
const attributes = await tx.query<Attribute>(
|
||||
`SELECT scope, product_variant_id, platform_attribute_id, complex_id, values FROM atlas.product_attributes WHERE profile_id=$1 AND confirmation_status='confirmed' AND (scope='common' OR product_variant_id=ANY($2::uuid[]))`,
|
||||
[profileId, ids],
|
||||
);
|
||||
const dictionaries = await tx.query<{
|
||||
attribute_id: string;
|
||||
external_value_id: string;
|
||||
value: string;
|
||||
}>(
|
||||
`SELECT attribute_id, external_value_id, value FROM atlas.platform_catalog_dictionary_values WHERE attribute_id=ANY($1::uuid[]) AND is_active`,
|
||||
[
|
||||
schema
|
||||
.filter((d) => d.dictionary_id)
|
||||
.map((d) => d.attribute_id),
|
||||
],
|
||||
);
|
||||
const builtinDictionaryIds = schema
|
||||
.filter(
|
||||
(d) =>
|
||||
d.editor_field &&
|
||||
d.value_type === 'dictionary' &&
|
||||
d.dictionary_id,
|
||||
)
|
||||
.map((d) => d.attribute_id);
|
||||
if (builtinDictionaryIds.length) {
|
||||
const states = await tx.query<{ usable: boolean }>(
|
||||
`SELECT state.last_success_at IS NOT NULL AND state.last_success_at >= now()-make_interval(days=>policy.dictionary_expire_days) AS usable FROM unnest($2::uuid[]) used(id) JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id=$1 LEFT JOIN atlas.platform_catalog_resource_states state ON state.platform_id=$1 AND state.scope_key='dictionary:' || used.id::text`,
|
||||
[platformId, builtinDictionaryIds],
|
||||
);
|
||||
if (
|
||||
states.rows.length !== builtinDictionaryIds.length ||
|
||||
states.rows.some((s) => !s.usable)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
'品牌等内建字段的 Ozon 候选值需要更新,请重新预检',
|
||||
);
|
||||
}
|
||||
const snapshots: OzonPublishItemSnapshot[] = [];
|
||||
for (const listing of listings.rows) {
|
||||
const media = await tx.query<
|
||||
OzonPublishItemSnapshot['media'][number]
|
||||
>(
|
||||
`SELECT a.id AS asset_id,a.checksum_sha256,a.bucket,a.object_key,a.storage_provider,a.content_type FROM atlas.product_variant_listing_media m JOIN atlas.media_assets a ON a.id=m.asset_id WHERE m.listing_id=$1 ORDER BY m.sort_order`,
|
||||
[listing.id],
|
||||
);
|
||||
if (!['not_listed', 'failed'].includes(listing.status))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SKU_NOT_PUBLISHABLE',
|
||||
'所选 SKU 当前状态不允许首次上架',
|
||||
);
|
||||
if (
|
||||
!media.rows.length ||
|
||||
media.rows.some(
|
||||
(a) =>
|
||||
![
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
].includes(a.content_type),
|
||||
)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_IMAGE_INVALID',
|
||||
'SKU 图片必须为 JPG、PNG 或 WebP',
|
||||
);
|
||||
const mapped: OzonPublishItemSnapshot['attributes'] = [];
|
||||
for (const definition of schema) {
|
||||
if (
|
||||
definition.editor_field === 'category' ||
|
||||
definition.editor_field === 'rich_content'
|
||||
)
|
||||
continue;
|
||||
let values: string[];
|
||||
if (definition.editor_field) {
|
||||
const fields = {
|
||||
brand: product.brand,
|
||||
model: product.model,
|
||||
tags: (listing.tags?.length
|
||||
? listing.tags
|
||||
: profile.tags) as string[],
|
||||
description:
|
||||
listing.description ?? profile.description,
|
||||
};
|
||||
const field = fields[definition.editor_field];
|
||||
values = Array.isArray(field)
|
||||
? field
|
||||
: field
|
||||
? [field]
|
||||
: [];
|
||||
} else
|
||||
values =
|
||||
attributes.rows.find(
|
||||
(a) =>
|
||||
a.platform_attribute_id ===
|
||||
definition.platform_attribute_id &&
|
||||
a.complex_id ===
|
||||
definition.complex_id &&
|
||||
(definition.is_aspect
|
||||
? a.product_variant_id ===
|
||||
listing.variant_id
|
||||
: a.scope === 'common'),
|
||||
)?.values ?? [];
|
||||
values = values
|
||||
.filter((v) => typeof v === 'string' && v.trim())
|
||||
.map((v) => v.trim());
|
||||
if (values.length > definition.max_value_count)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
`${definition.name}最多 ${definition.max_value_count} 个值`,
|
||||
[
|
||||
{
|
||||
field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
|
||||
code: 'ATTRIBUTE_VALUE_LIMIT',
|
||||
message: `${definition.name}值数量超限`,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (!values.length) continue;
|
||||
mapped.push({
|
||||
id: numberId(definition.platform_attribute_id),
|
||||
complex_id: Number(definition.complex_id),
|
||||
values: values.map((value) => {
|
||||
if (definition.value_type !== 'dictionary')
|
||||
return { value };
|
||||
const entry = dictionaries.rows.find(
|
||||
(d) =>
|
||||
d.attribute_id ===
|
||||
definition.attribute_id &&
|
||||
(definition.editor_field
|
||||
? d.value === value ||
|
||||
d.external_value_id === value
|
||||
: d.external_value_id === value),
|
||||
);
|
||||
if (!entry)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
`${definition.name}未匹配有效 Ozon 候选值`,
|
||||
[
|
||||
{
|
||||
field: definition.editor_field
|
||||
? `common.${definition.editor_field}`
|
||||
: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
|
||||
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
|
||||
message: `${definition.name}未匹配有效 Ozon 候选值`,
|
||||
},
|
||||
],
|
||||
);
|
||||
return {
|
||||
dictionary_value_id: numberId(
|
||||
entry.external_value_id,
|
||||
),
|
||||
value: entry.value,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
snapshots.push({
|
||||
offer_id: listing.offer_id,
|
||||
name: (profile.title_mode === 'variant'
|
||||
? listing.title
|
||||
: profile.title)!.trim(),
|
||||
description_category_id: numberId(
|
||||
category.rows[0].external_category_id,
|
||||
),
|
||||
type_id: numberId(category.rows[0].external_type_id),
|
||||
price: listing.selling_price,
|
||||
...(listing.list_price &&
|
||||
new Decimal(listing.list_price).greaterThan(
|
||||
listing.selling_price,
|
||||
)
|
||||
? { old_price: listing.list_price! }
|
||||
: {}),
|
||||
currency_code: profile.currency_code,
|
||||
depth: Number(listing.depth),
|
||||
width: Number(listing.width),
|
||||
height: Number(listing.height),
|
||||
weight: Number(listing.weight),
|
||||
dimension_unit: 'mm',
|
||||
weight_unit: 'g',
|
||||
attributes: mapped,
|
||||
media: media.rows,
|
||||
});
|
||||
}
|
||||
const jobId = randomUUID();
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_publish_jobs(id,workspace_id,product_id,profile_id,platform_id,store_id,product_revision,profile_revision,item_count) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[
|
||||
jobId,
|
||||
workspaceId,
|
||||
product.id,
|
||||
profileId,
|
||||
platformId,
|
||||
profile.target_store_id,
|
||||
expected.product,
|
||||
expected.profile,
|
||||
ids.length,
|
||||
],
|
||||
);
|
||||
for (let i = 0; i < listings.rows.length; i++) {
|
||||
const listing = listings.rows[i];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_publish_job_items(workspace_id,job_id,product_id,profile_id,platform_id,store_id,listing_id,variant_id,display_name,offer_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
|
||||
[
|
||||
workspaceId,
|
||||
jobId,
|
||||
product.id,
|
||||
profileId,
|
||||
platformId,
|
||||
profile.target_store_id,
|
||||
listing.id,
|
||||
listing.variant_id,
|
||||
listing.display_name,
|
||||
listing.offer_id,
|
||||
JSON.stringify(snapshots[i]),
|
||||
],
|
||||
);
|
||||
}
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET status='submitting',last_error=NULL,revision=revision+1,updated_at=now() WHERE id=ANY($1::uuid[])`,
|
||||
[listings.rows.map((l) => l.id)],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.outbox_events(workspace_id,aggregate_type,aggregate_id,event_type,event_payload) VALUES($1,'product_publish_job',$2,'product_publish.requested',$3::jsonb)`,
|
||||
[
|
||||
workspaceId,
|
||||
jobId,
|
||||
JSON.stringify({ publish_job_id: jobId }),
|
||||
],
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: {
|
||||
publish_job: await readJob(tx, jobId, platformId),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
async read(workspaceId: string, id: string, platformId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, (tx) =>
|
||||
readJob(tx, id, platformId),
|
||||
);
|
||||
}
|
||||
async list(workspaceId: string, profileId: string, platformId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await this.access.profile(tx, profileId, platformId);
|
||||
const rows = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.product_publish_jobs WHERE profile_id=$1 AND platform_id=$2 ORDER BY created_at DESC,id DESC LIMIT 10',
|
||||
[profileId, platformId],
|
||||
);
|
||||
const jobs = [];
|
||||
for (const row of rows.rows)
|
||||
jobs.push(await readJob(tx, row.id, platformId));
|
||||
return jobs;
|
||||
});
|
||||
}
|
||||
}
|
||||
async function readJob(
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
platformId: string,
|
||||
): Promise<ProductPublishJob> {
|
||||
const rows = await tx.query<ProductPublishJob>(
|
||||
`SELECT id,profile_id,store_id,status,item_count,created_at,completed_at FROM atlas.product_publish_jobs WHERE id=$1 AND platform_id=$2`,
|
||||
[id, platformId],
|
||||
);
|
||||
const job = rows.rows[0];
|
||||
if (!job)
|
||||
throw new ApiError(404, 'PUBLISH_JOB_NOT_FOUND', '发布任务不存在');
|
||||
const items = await tx.query<ProductPublishJob['items'][number]>(
|
||||
`SELECT id,variant_id,display_name,offer_id,status,platform_product_id,errors FROM atlas.product_publish_job_items WHERE job_id=$1 ORDER BY created_at,id`,
|
||||
[id],
|
||||
);
|
||||
return {
|
||||
...job,
|
||||
created_at: new Date(job.created_at).toISOString(),
|
||||
completed_at: job.completed_at
|
||||
? new Date(job.completed_at).toISOString()
|
||||
: null,
|
||||
items: items.rows,
|
||||
};
|
||||
}
|
||||
function numberId(value: string) {
|
||||
const n = Number(value);
|
||||
if (!Number.isSafeInteger(n) || n <= 0)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'OZON_ID_INVALID',
|
||||
'平台目录标识无效,请刷新目录',
|
||||
);
|
||||
return n;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
||||
import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
||||
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||
import { ProductEditorPublishOperations } from './product-editor.publish.js';
|
||||
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
||||
import {
|
||||
capturedOzonCategorySignals,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class ProductEditorService {
|
||||
private readonly publishing: ProductEditorPublishOperations;
|
||||
private readonly pricing: PricingService;
|
||||
private readonly catalog: ProductEditorCatalogOperations;
|
||||
private readonly validationOperations: ProductEditorValidationOperations;
|
||||
@@ -71,6 +73,12 @@ export class ProductEditorService {
|
||||
this.attributeSchemaForCategory(...args),
|
||||
},
|
||||
);
|
||||
this.publishing = new ProductEditorPublishOperations(this.database, this.idempotency, {
|
||||
profile: (...args) => this.requireProfileForPlatform(...args),
|
||||
product: (...args) => this.requireProduct(...args),
|
||||
schema: (...args) => this.attributeSchemaForCategory(...args),
|
||||
validate: (...args) => this.validationOperations.validateTransaction(...args),
|
||||
}, config.OZON_PUBLISH_ENABLED === 'true' || ((config.OZON_PUBLISH_ENABLED ?? 'auto') === 'auto' && config.NODE_ENV !== 'production'));
|
||||
this.publishPreflightOperations =
|
||||
new ProductEditorPublishPreflightOperations(this.database, {
|
||||
requireProfileForPlatform: (...args) =>
|
||||
@@ -451,6 +459,7 @@ export class ProductEditorService {
|
||||
},
|
||||
available_target_stores: stores.rows,
|
||||
title: profile.title,
|
||||
title_mode: profile.title_mode,
|
||||
description: profile.description,
|
||||
tags: Array.isArray(profile.tags)
|
||||
? profile.tags.filter(
|
||||
@@ -586,6 +595,12 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
createPublishJob(workspaceId: string, userId: string, profileId: string, platformId: string, key: string, expected: { product: number; profile: number }, ids: string[]) {
|
||||
return this.publishing.create(workspaceId, userId, profileId, platformId, key, expected, ids);
|
||||
}
|
||||
readPublishJob(workspaceId: string, id: string, platformId: string) { return this.publishing.read(workspaceId, id, platformId); }
|
||||
listPublishJobs(workspaceId: string, profileId: string, platformId: string) { return this.publishing.list(workspaceId, profileId, platformId); }
|
||||
|
||||
/** 从平台级缓存查询当前类型属性的字典值。 */
|
||||
async attributeDictionary(
|
||||
workspaceId: string,
|
||||
@@ -757,6 +772,12 @@ export class ProductEditorService {
|
||||
],
|
||||
);
|
||||
}
|
||||
const lockedListings = await transaction.query<{ product_variant_id: string; status: string }>(
|
||||
`SELECT product_variant_id,status FROM atlas.product_variant_platform_listings WHERE product_id=$1 AND status IN ('imported','submitting')`, [productId]);
|
||||
if (lockedListings.rows.length && input.common.model !== product.model) throw new ApiError(409, 'PRODUCT_MODEL_LOCKED', '商品已有 SKU 导入或正在导入,型号不可修改');
|
||||
const profileLocked = await transaction.query(`SELECT id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND status='submitting' LIMIT 1`, [profile.id]);
|
||||
if (profileLocked.rows.length && input.profile.target_store_id !== profile.target_store_id) throw new ApiError(409, 'PUBLISH_STORE_LOCKED', '当前商品有 SKU 正在导入或等待对账,暂不能更换目标店铺');
|
||||
if (lockedListings.rows.some(row => !input.variants.some(v => v.id === row.product_variant_id))) throw new ApiError(409, 'PUBLISH_VARIANT_LOCKED', '已导入或正在提交的 SKU 不可删除');
|
||||
this.assertConfirmedSpecsComplete(input);
|
||||
await this.ensureVariantIdsBelongToProduct(
|
||||
transaction,
|
||||
@@ -788,14 +809,14 @@ export class ProductEditorService {
|
||||
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
||||
tags = $5::jsonb, platform_product_category_id = $6,
|
||||
store_product_category_id = $7, platform_type_id = $8,
|
||||
category_source = $9,
|
||||
category_source = $9, title_mode = $14,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $10 AND product_id = $11 AND platform_id = $12 AND revision = $13
|
||||
RETURNING revision`,
|
||||
[
|
||||
input.profile.target_store_id,
|
||||
input.profile.fulfillment,
|
||||
input.profile.title,
|
||||
input.profile.title_mode === 'variant' ? null : input.profile.title,
|
||||
input.profile.description,
|
||||
JSON.stringify(input.profile.tags),
|
||||
selectedCategory?.id ?? null,
|
||||
@@ -806,6 +827,7 @@ export class ProductEditorService {
|
||||
productId,
|
||||
platformId,
|
||||
expected.profile,
|
||||
input.profile.title_mode ?? profile.title_mode,
|
||||
],
|
||||
);
|
||||
if (!updatedProfile.rows[0])
|
||||
@@ -919,14 +941,14 @@ export class ProductEditorService {
|
||||
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 = $7 AND product_variant_id = $8 AND status <> 'imported'`,
|
||||
WHERE profile_id = $7 AND product_variant_id = $8 AND status NOT IN ('imported','submitting')`,
|
||||
[
|
||||
variant.offer_id,
|
||||
input.profile.target_store_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
variant.title !== undefined,
|
||||
variant.title ?? null,
|
||||
variant.title !== undefined || input.profile.title_mode === 'common',
|
||||
input.profile.title_mode === 'common' ? null : (variant.title ?? null),
|
||||
profile.id,
|
||||
variant.id,
|
||||
],
|
||||
@@ -1024,7 +1046,7 @@ export class ProductEditorService {
|
||||
variant.offer_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
variant.title ?? null,
|
||||
input.profile.title_mode === 'common' ? null : (variant.title ?? null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type ValidationAccess = {
|
||||
type VariantValidationRow = {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
title: string | null;
|
||||
listing_status: string;
|
||||
offer_id: string | null;
|
||||
selling_price: string | null;
|
||||
@@ -79,6 +80,23 @@ export class ProductEditorValidationOperations {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
return this.validateTransaction(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
variantIds,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建发布任务必须在持有商品锁的同一事务内重算,防止校验与快照读到不同版本。 */
|
||||
async validateTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantIds?: string[],
|
||||
): Promise<ProductEditorValidation> {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
@@ -144,6 +162,7 @@ export class ProductEditorValidationOperations {
|
||||
const variantResults = variants.map((variant) =>
|
||||
this.variantResult(
|
||||
variant,
|
||||
profile,
|
||||
schema,
|
||||
attributes,
|
||||
dictionaryIssues
|
||||
@@ -170,8 +189,6 @@ export class ProductEditorValidationOperations {
|
||||
},
|
||||
variants: variantResults,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */
|
||||
@@ -181,7 +198,7 @@ export class ProductEditorValidationOperations {
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{ usable: boolean }>(
|
||||
`SELECT state.last_success_at IS NOT NULL
|
||||
`SELECT state.last_success_at IS NOT NULL AND state.is_complete
|
||||
AND state.last_success_at >= now() -
|
||||
make_interval(days => policy.attribute_expire_days) AS usable
|
||||
FROM atlas.platform_catalog_refresh_policies policy
|
||||
@@ -208,11 +225,23 @@ export class ProductEditorValidationOperations {
|
||||
field: 'common.source_title',
|
||||
message: '请填写中文商品名',
|
||||
});
|
||||
if (!profile.title?.trim())
|
||||
if (profile.title_mode === 'common' && !profile.title?.trim())
|
||||
issues.push({
|
||||
code: 'PUBLISH_TITLE_REQUIRED',
|
||||
field: 'profile.title',
|
||||
message: '请填写俄文发布标题',
|
||||
message: '请填写俄文标题',
|
||||
});
|
||||
if (profile.title && [...profile.title].length > 500)
|
||||
issues.push({
|
||||
code: 'TITLE_TOO_LONG',
|
||||
field: 'profile.title',
|
||||
message: '俄文标题最多 500 字符',
|
||||
});
|
||||
if (profile.fulfillment !== 'rfbs')
|
||||
issues.push({
|
||||
code: 'FULFILLMENT_UNSUPPORTED',
|
||||
field: 'profile.fulfillment',
|
||||
message: '首次上架当前仅支持跨境 rFBS',
|
||||
});
|
||||
if (!product.model?.trim())
|
||||
issues.push({
|
||||
@@ -256,6 +285,7 @@ export class ProductEditorValidationOperations {
|
||||
/** 计算单个 SKU 的发布资格与缺失项。 */
|
||||
private variantResult(
|
||||
variant: VariantValidationRow,
|
||||
profile: ProfileRow,
|
||||
schema: ProductAttributeSchema[],
|
||||
attributes: AttributeValidationRow[],
|
||||
dictionaryIssues: ProductEditorValidation['profile']['issues'] = [],
|
||||
@@ -272,12 +302,36 @@ export class ProductEditorValidationOperations {
|
||||
const issues: ProductEditorValidation['variants'][number]['issues'] = [
|
||||
...dictionaryIssues,
|
||||
];
|
||||
if (profile.title_mode === 'variant' && !variant.title?.trim())
|
||||
issues.push({
|
||||
code: 'PUBLISH_TITLE_REQUIRED',
|
||||
field: `variants.${variant.id}.title`,
|
||||
message: '请填写该 SKU 的俄文标题',
|
||||
});
|
||||
if (variant.listing_status === 'submitting')
|
||||
issues.push({
|
||||
code: 'SKU_IMPORT_IN_PROGRESS',
|
||||
field: `variants.${variant.id}.listing`,
|
||||
message: '该 SKU 正在首次上架,请等待当前任务完成',
|
||||
});
|
||||
if (variant.title && [...variant.title].length > 500)
|
||||
issues.push({
|
||||
code: 'TITLE_TOO_LONG',
|
||||
field: `variants.${variant.id}.title`,
|
||||
message: '俄文标题最多 500 字符',
|
||||
});
|
||||
if (variant.offer_id && [...variant.offer_id].length > 50)
|
||||
issues.push({
|
||||
code: 'OFFER_ID_TOO_LONG',
|
||||
field: `variants.${variant.id}.offer_id`,
|
||||
message: '卖家货号最多 50 字符',
|
||||
});
|
||||
if (Number(variant.media_count) > 30)
|
||||
issues.push({
|
||||
code: 'IMAGE_LIMIT_EXCEEDED',
|
||||
field: `variants.${variant.id}.media_asset_ids`,
|
||||
message: '每个 SKU 最多 30 张图片',
|
||||
});
|
||||
if (!variant.offer_id?.trim())
|
||||
issues.push({
|
||||
code: 'OFFER_ID_REQUIRED',
|
||||
@@ -290,7 +344,7 @@ export class ProductEditorValidationOperations {
|
||||
field: `variants.${variant.id}.pricing_selection`,
|
||||
message: '请按当前采购价和包装确认报价',
|
||||
});
|
||||
if (!variant.selling_price)
|
||||
if (!variant.selling_price || Number(variant.selling_price) <= 0)
|
||||
issues.push({
|
||||
code: 'PRICE_REQUIRED',
|
||||
field: `variants.${variant.id}.selling_price`,
|
||||
@@ -301,7 +355,15 @@ export class ProductEditorValidationOperations {
|
||||
!variant.package_length_mm ||
|
||||
!variant.package_width_mm ||
|
||||
!variant.package_height_mm ||
|
||||
!variant.shipping_weight_g
|
||||
!variant.shipping_weight_g ||
|
||||
![
|
||||
variant.package_length_mm,
|
||||
variant.package_width_mm,
|
||||
variant.package_height_mm,
|
||||
variant.shipping_weight_g,
|
||||
].every(
|
||||
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
|
||||
)
|
||||
)
|
||||
issues.push({
|
||||
code: 'PACKAGE_SPEC_REQUIRED',
|
||||
@@ -354,7 +416,7 @@ export class ProductEditorValidationOperations {
|
||||
variantIds?: string[],
|
||||
) {
|
||||
const variants = await transaction.query<VariantValidationRow>(
|
||||
`SELECT v.id, v.display_name, listing.status AS listing_status,
|
||||
`SELECT v.id, v.display_name, listing.title, listing.status AS listing_status,
|
||||
listing.offer_id, listing.selling_price::text, listing.pricing_valid,
|
||||
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
|
||||
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
|
||||
@@ -369,7 +431,7 @@ export class ProductEditorValidationOperations {
|
||||
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
|
||||
WHERE v.product_id = $2 AND v.deleted_at IS NULL
|
||||
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
|
||||
GROUP BY v.id, v.display_name, listing.status, listing.offer_id,
|
||||
GROUP BY v.id, v.display_name, listing.title, listing.status, listing.offer_id,
|
||||
listing.selling_price, listing.pricing_valid, product.default_package_length_mm,
|
||||
product.default_package_width_mm, product.default_package_height_mm,
|
||||
product.default_shipping_weight_g, product.package_spec_status
|
||||
|
||||
@@ -2129,7 +2129,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('俄文发布标题为空时发布就绪校验提示必填,填写后移除提示', async () => {
|
||||
it('俄文标题按通用或逐 SKU 模式校验,并持久保存切换结果', async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const store = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
@@ -2148,7 +2148,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[platformId, `editor-title-${randomUUID()}`],
|
||||
);
|
||||
});
|
||||
const created = await createCollectionItem('俄文发布标题校验商品');
|
||||
const created = await createCollectionItem('俄文标题校验商品');
|
||||
const entered = await request(
|
||||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||||
{
|
||||
@@ -2189,6 +2189,119 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
|
||||
const beforeModeSwitch = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const snapshot = beforeModeSwitch.body.data;
|
||||
assert.equal(snapshot.profile.title_mode, 'common');
|
||||
const draft = {
|
||||
ai_source_material: snapshot.source_material,
|
||||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||||
common: {
|
||||
...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec,
|
||||
},
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: snapshot.profile.fulfillment,
|
||||
title: null as string | null,
|
||||
title_mode: 'variant',
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
category_source: snapshot.profile.category.source,
|
||||
},
|
||||
variants: snapshot.variants.map((variant: any) => ({
|
||||
id: variant.id,
|
||||
client_key: null,
|
||||
display_name: variant.display_name,
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
title: null,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec,
|
||||
offer_id: variant.listing.offer_id,
|
||||
media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id),
|
||||
})),
|
||||
};
|
||||
const saveTitleMode = async (body: typeof draft, productRevision: number, profileRevision: number) =>
|
||||
request(`/api/products/${created.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${productRevision}:${profileRevision}"`,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const variantMode = await saveTitleMode(
|
||||
draft,
|
||||
snapshot.product.revision,
|
||||
snapshot.profile.revision,
|
||||
);
|
||||
assert.equal(variantMode.response.status, 200, JSON.stringify(variantMode.body));
|
||||
const blankVariant = await request(
|
||||
`/api/product-platform-profiles/${profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
!blankVariant.body.data.profile.issues.some(
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
blankVariant.body.data.variants[0].issues.some(
|
||||
(issue: { code: string; field: string }) =>
|
||||
issue.code === 'PUBLISH_TITLE_REQUIRED' &&
|
||||
issue.field === `variants.${snapshot.variants[0].id}.title`,
|
||||
),
|
||||
);
|
||||
const afterVariantMode = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(afterVariantMode.body.data.profile.title_mode, 'variant');
|
||||
assert.equal(afterVariantMode.body.data.profile.title, null);
|
||||
const filledVariant = await saveTitleMode(
|
||||
{
|
||||
...draft,
|
||||
variants: [{ ...draft.variants[0], title: 'Название SKU' }],
|
||||
},
|
||||
afterVariantMode.body.data.product.revision,
|
||||
afterVariantMode.body.data.profile.revision,
|
||||
);
|
||||
assert.equal(filledVariant.response.status, 200, JSON.stringify(filledVariant.body));
|
||||
const readyTitle = await request(
|
||||
`/api/product-platform-profiles/${profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
!readyTitle.body.data.variants[0].issues.some(
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
const afterFilledVariant = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const commonMode = await saveTitleMode(
|
||||
{
|
||||
...draft,
|
||||
profile: { ...draft.profile, title: '通用俄文标题', title_mode: 'common' },
|
||||
variants: [{ ...draft.variants[0], title: null }],
|
||||
},
|
||||
afterFilledVariant.body.data.product.revision,
|
||||
afterFilledVariant.body.data.profile.revision,
|
||||
);
|
||||
assert.equal(commonMode.response.status, 200, JSON.stringify(commonMode.body));
|
||||
const afterCommonMode = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(afterCommonMode.body.data.profile.title_mode, 'common');
|
||||
assert.equal(afterCommonMode.body.data.variants[0].title, null);
|
||||
});
|
||||
|
||||
it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type { ProductPublishJob } from '@atlas/contracts';
|
||||
import type { S3Client } from '@aws-sdk/client-s3';
|
||||
import { beforeAll, afterAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
import {
|
||||
runProductPublish,
|
||||
type PublishDependencies,
|
||||
} from '../../worker/src/product-publish.js';
|
||||
import {
|
||||
OzonRateLimitError,
|
||||
OzonRetryableError,
|
||||
} from '../../worker/src/ozon-client.js';
|
||||
|
||||
let app: NestExpressApplication,
|
||||
db: Database,
|
||||
base = '',
|
||||
workspace = '',
|
||||
token = '',
|
||||
platform = '',
|
||||
store = '',
|
||||
category = '';
|
||||
const storage = {} as S3Client;
|
||||
async function request(
|
||||
path: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
extra: Record<string, string> = {},
|
||||
) {
|
||||
const res = await fetch(`${base}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Atlas-Platform-Id': platform,
|
||||
...extra,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as any };
|
||||
}
|
||||
async function fixture(count = 2) {
|
||||
const created = await request(
|
||||
'/collection-items',
|
||||
'POST',
|
||||
{ title: '发布测试商品' },
|
||||
{ 'Idempotency-Key': randomUUID() },
|
||||
);
|
||||
assert.equal(created.status, 201, JSON.stringify(created));
|
||||
const product = created.body.data.product_id;
|
||||
const entered = await request(
|
||||
`/collection-items/${created.body.data.collection_item.id}/editor-contexts`,
|
||||
'POST',
|
||||
{},
|
||||
{ 'Idempotency-Key': randomUUID() },
|
||||
);
|
||||
assert.equal(entered.status, 201, JSON.stringify(entered));
|
||||
const profile = entered.body.data.profile_id;
|
||||
const ids: string[] = [];
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET brand='Без бренда',model='TEST-MODEL',default_package_length_mm=240,default_package_width_mm=120,default_package_height_mm=60,default_shipping_weight_g=350,package_spec_status='confirmed' WHERE id=$1`,
|
||||
[product],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_platform_profiles SET title='Тестовый товар',fulfillment='rfbs',platform_product_category_id=$2,platform_type_id='1002' WHERE id=$1`,
|
||||
[profile, category],
|
||||
);
|
||||
const first = (
|
||||
await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.product_variants WHERE product_id=$1',
|
||||
[product],
|
||||
)
|
||||
).rows[0].id;
|
||||
ids.push(first);
|
||||
for (let i = 1; i < count; i++)
|
||||
ids.push(
|
||||
(
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants(workspace_id,product_id,display_name,sort_order) VALUES($1,$2,$3,$4) RETURNING id`,
|
||||
[workspace, product, `SKU ${i}`, i],
|
||||
)
|
||||
).rows[0].id,
|
||||
);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const variant = ids[i];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variant_platform_listings(workspace_id,product_id,profile_id,platform_id,product_variant_id,store_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
|
||||
[workspace, product, profile, platform, variant, store],
|
||||
);
|
||||
const quote = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.pricing_quotes(workspace_id,store_id,platform_id,product_id,profile_id,product_variant_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,input_snapshot,status,confirmed_at) VALUES($1,$2,$3,$4,$5,$6,'rfbs',20,0.350,24,12,6,12,'{}','confirmed',now()) RETURNING id`,
|
||||
[workspace, store, platform, product, profile, variant],
|
||||
)
|
||||
).rows[0].id;
|
||||
const listing = (
|
||||
await tx.query<{ id: string }>(
|
||||
`UPDATE atlas.product_variant_platform_listings SET offer_id=$2,selling_price=1999.1234,list_price=2999.1234,currency_code='RUB',pricing_quote_id=$3,pricing_valid=true WHERE profile_id=$1 AND product_variant_id=$4 RETURNING id`,
|
||||
[profile, `TEST-${randomUUID()}`, quote, variant],
|
||||
)
|
||||
).rows[0].id;
|
||||
const asset = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`,
|
||||
[workspace, randomUUID(), randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,0)`,
|
||||
[workspace, product, profile, listing, variant, asset],
|
||||
);
|
||||
}
|
||||
});
|
||||
return { profile, product, ids };
|
||||
}
|
||||
async function publish(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
ids = f.ids,
|
||||
key = randomUUID(),
|
||||
) {
|
||||
return request(
|
||||
`/product-platform-profiles/${f.profile}/publish-jobs`,
|
||||
'POST',
|
||||
{ variant_ids: ids },
|
||||
{ 'Idempotency-Key': key, 'If-Match': '"1:1"' },
|
||||
);
|
||||
}
|
||||
async function run(
|
||||
job: ProductPublishJob,
|
||||
requestFn: PublishDependencies['request'],
|
||||
images: PublishDependencies['images'] = async () => [
|
||||
'https://images.example.test/fixture.png',
|
||||
],
|
||||
) {
|
||||
await runProductPublish(
|
||||
db,
|
||||
storage,
|
||||
{
|
||||
eventId: randomUUID(),
|
||||
workspaceId: workspace,
|
||||
eventType: 'product_publish.requested',
|
||||
payload: { publish_job_id: job.id },
|
||||
},
|
||||
{
|
||||
request: requestFn,
|
||||
images,
|
||||
decrypt: () => 'fixture-key',
|
||||
},
|
||||
);
|
||||
const response = await request(`/product-publish-jobs/${job.id}`);
|
||||
assert.equal(response.status, 200);
|
||||
return response.body.data as ProductPublishJob;
|
||||
}
|
||||
|
||||
describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
LOG_LEVEL: 'error',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
OZON_PUBLISH_ENABLED: 'true',
|
||||
});
|
||||
({ app } = await createApplication());
|
||||
db = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const registration = await fetch(`${base}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: `publish-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
const data = (await registration.json()) as any;
|
||||
assert.equal(registration.status, 201, JSON.stringify(data));
|
||||
workspace = data.data.workspace.id;
|
||||
token = data.data.access_token;
|
||||
platform = (await request('/platforms')).body.data[0].id;
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
store = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'发布测试店铺',$3,'RUB',true,'active','valid') RETURNING id`,
|
||||
[workspace, platform, randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_secrets(workspace_id,store_id,kind,secret_ciphertext,secret_nonce,encryption_key_version,fingerprint,status) VALUES($1,$2,'seller_api_key',$3,$4,'v1','fixture','valid')`,
|
||||
[workspace, store, Buffer.from('fixture'), Buffer.alloc(12)],
|
||||
);
|
||||
category = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'1002','发布测试类目','发布测试类目') RETURNING id`,
|
||||
[platform, `${Date.now()}`],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1)`,
|
||||
[platform, category],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope_key,scope,category_id,last_success_at,is_complete) VALUES($1,$2,'attributes',$3,now(),true)`,
|
||||
[platform, `attributes:${category}`, category],
|
||||
);
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => {
|
||||
const f = await fixture();
|
||||
const key = randomUUID();
|
||||
const [a, b] = await Promise.all([
|
||||
publish(f, undefined, key),
|
||||
publish(f, undefined, key),
|
||||
]);
|
||||
assert.equal(a.status, 202, JSON.stringify(a));
|
||||
assert.deepEqual(a.body, b.body);
|
||||
const job = a.body.data.publish_job as ProductPublishJob;
|
||||
assert.equal(job.items.length, 2);
|
||||
const facts = await db.workspaceTransaction(workspace, async (tx) => ({
|
||||
items: (
|
||||
await tx.query(
|
||||
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
|
||||
[job.id],
|
||||
)
|
||||
).rows,
|
||||
events: (
|
||||
await tx.query(
|
||||
'SELECT event_payload FROM atlas.outbox_events WHERE aggregate_id=$1',
|
||||
[job.id],
|
||||
)
|
||||
).rows,
|
||||
}));
|
||||
assert.equal(facts.events.length, 1);
|
||||
assert.deepEqual(facts.events[0].event_payload, {
|
||||
publish_job_id: job.id,
|
||||
});
|
||||
assert.equal(facts.items[0].snapshot.price, '1999.1234');
|
||||
assert.equal(facts.items[0].snapshot.depth, 240);
|
||||
assert.ok(!('vat' in facts.items[0].snapshot));
|
||||
assert.ok(!('images' in facts.items[0].snapshot));
|
||||
const other = randomUUID();
|
||||
assert.equal(
|
||||
(
|
||||
await db.workspaceTransaction(other, (tx) =>
|
||||
tx.query(
|
||||
'SELECT id FROM atlas.product_publish_jobs WHERE id=$1',
|
||||
[job.id],
|
||||
),
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/product-publish-jobs/${job.id}`,
|
||||
'GET',
|
||||
undefined,
|
||||
{ 'X-Atlas-Platform-Id': randomUUID() },
|
||||
)
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await publish(f)).body.error.code,
|
||||
'SKU_IMPORT_IN_PROGRESS',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await publish(f, [
|
||||
...Array.from({ length: 51 }, () => randomUUID()),
|
||||
])
|
||||
).status,
|
||||
422,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/product-platform-profiles/${f.profile}/publish-jobs`,
|
||||
'POST',
|
||||
{ variant_ids: f.ids },
|
||||
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"2:1"' },
|
||||
)
|
||||
).body.error.code,
|
||||
'VERSION_CONFLICT',
|
||||
);
|
||||
});
|
||||
it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => {
|
||||
const f = await fixture();
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET offer_id=NULL WHERE profile_id=$1 AND product_variant_id=$2`,
|
||||
[f.profile, f.ids[1]],
|
||||
),
|
||||
);
|
||||
const all = await publish(f);
|
||||
assert.equal(all.status, 422);
|
||||
assert.equal(all.body.error.code, 'PUBLISH_VALIDATION_FAILED');
|
||||
const partial = await publish(f, [f.ids[0]]);
|
||||
assert.equal(partial.status, 202, JSON.stringify(partial));
|
||||
assert.equal(partial.body.data.publish_job.item_count, 1);
|
||||
});
|
||||
it('混合结果持久化,成功锁定型号/货号,重复消息不重复上报', async () => {
|
||||
const f = await fixture();
|
||||
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
|
||||
let imports = 0;
|
||||
job = await run(job, async (_client, _key, path, body) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
assert.equal(path, '/v3/product/import');
|
||||
imports++;
|
||||
const items = body.items as any[];
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].price, '1999.1234');
|
||||
assert.ok(!('vat' in items[0]));
|
||||
assert.ok(!('barcode' in items[0]));
|
||||
assert.ok(!('description' in items[0]));
|
||||
return { result: { task_id: 123456 } };
|
||||
});
|
||||
assert.equal(job.items[0].status, 'polling');
|
||||
const offers = job.items.map((i) => i.offer_id);
|
||||
job = await run(job, async () => ({
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
offer_id: offers[0],
|
||||
product_id: 101,
|
||||
status: 'imported',
|
||||
errors: [],
|
||||
},
|
||||
{
|
||||
offer_id: offers[1],
|
||||
status: 'failed',
|
||||
errors: [
|
||||
{
|
||||
code: 'BAD_IMAGE',
|
||||
message:
|
||||
'失败 https://private.example.test/a?token=hidden',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
assert.equal(job.status, 'partially_succeeded');
|
||||
assert.equal(
|
||||
job.items.find((i) => i.status === 'imported')?.platform_product_id,
|
||||
'101',
|
||||
);
|
||||
assert.ok(!JSON.stringify(job).includes('token='));
|
||||
await assert.rejects(
|
||||
db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.products SET model='CHANGED' WHERE id=$1`,
|
||||
[f.product],
|
||||
),
|
||||
),
|
||||
/PRODUCT_MODEL_LOCKED/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET offer_id='CHANGED' WHERE profile_id=$1 AND status='imported'`,
|
||||
[f.profile],
|
||||
),
|
||||
),
|
||||
/SKU_ALREADY_IMPORTED/,
|
||||
);
|
||||
await run(job, async () => {
|
||||
imports++;
|
||||
throw new Error('终态不应再访问平台');
|
||||
});
|
||||
assert.equal(imports, 1);
|
||||
});
|
||||
it('响应丢失后只按货号对账,绝不重复导入', async () => {
|
||||
const f = await fixture(1);
|
||||
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
|
||||
let imports = 0;
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
imports++;
|
||||
throw new OzonRetryableError('response lost');
|
||||
});
|
||||
assert.equal(job.status, 'reconciling');
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
assert.equal(path, '/v3/product/info/list');
|
||||
return { items: [] };
|
||||
});
|
||||
assert.equal(job.status, 'reconciling');
|
||||
assert.equal(imports, 1);
|
||||
job = await run(job, async () => ({
|
||||
items: [{ offer_id: job.items[0].offer_id, id: 999 }],
|
||||
}));
|
||||
assert.equal(job.status, 'succeeded');
|
||||
assert.equal(imports, 1);
|
||||
});
|
||||
it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => {
|
||||
const f = await fixture(2);
|
||||
const created = (await publish(f)).body.data
|
||||
.publish_job as ProductPublishJob;
|
||||
let prepared = 0;
|
||||
const job = await run(
|
||||
created,
|
||||
async (_c, _k, path) => {
|
||||
assert.equal(path, '/v3/product/info/list');
|
||||
return { items: [] };
|
||||
},
|
||||
async () => {
|
||||
if (++prepared === 2)
|
||||
throw new Error('AccessDenied secret/object/key');
|
||||
return ['https://images.example.test/fixture.png'];
|
||||
},
|
||||
);
|
||||
assert.equal(job.status, 'failed');
|
||||
assert.ok(job.items.every((i) => i.status === 'failed'));
|
||||
assert.ok(!JSON.stringify(job).includes('secret/object'));
|
||||
const source = await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
|
||||
[job.id],
|
||||
),
|
||||
);
|
||||
assert.equal(source.rows[0].submit_started_at, null);
|
||||
});
|
||||
it('远端已有同货号时跳过,不调用更新接口;429 延迟后允许提交', async () => {
|
||||
const existing = await fixture(1);
|
||||
let job = (await publish(existing)).body.data
|
||||
.publish_job as ProductPublishJob;
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
assert.equal(path, '/v3/product/info/list');
|
||||
return { items: [{ offer_id: job.items[0].offer_id, id: 88 }] };
|
||||
});
|
||||
assert.equal(job.items[0].status, 'skipped');
|
||||
assert.equal(job.items[0].errors[0].code, 'OZON_OFFER_ALREADY_EXISTS');
|
||||
const f = await fixture(1);
|
||||
job = (await publish(f)).body.data.publish_job as ProductPublishJob;
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
throw new OzonRateLimitError(120);
|
||||
});
|
||||
assert.equal(job.items[0].status, 'queued');
|
||||
const source = await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
|
||||
[job.id],
|
||||
),
|
||||
);
|
||||
assert.equal(source.rows[0].submit_started_at, null);
|
||||
job = await run(job, async (_c, _k, path) =>
|
||||
path === '/v3/product/info/list'
|
||||
? { items: [] }
|
||||
: { result: { task_id: 987 } },
|
||||
);
|
||||
assert.equal(job.items[0].status, 'polling');
|
||||
});
|
||||
});
|
||||
+12
-4
@@ -1,19 +1,27 @@
|
||||
# Web 前端交接:商品编辑器与 AI 填报
|
||||
|
||||
更新日期:2026-09-23
|
||||
更新日期:2026-09-30
|
||||
|
||||
本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。
|
||||
|
||||
## 当前状态(2026-09-23)
|
||||
## 当前状态(2026-09-30)
|
||||
|
||||
- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 生图、首次上架仍是占位/不可用入口。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。
|
||||
- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 套图生成仍是占位入口;首次上架已接入,见下方记录。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。
|
||||
- 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。
|
||||
- SPU 的「中文商品名」和「俄文发布标题」在页面中均标为必填;发布就绪校验也要求俄文标题。标题、标签、描述各有中文对照入口;标题旁有加号,可开启 SKU 标题覆盖。SKU 默认继承 SPU 文案,用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。
|
||||
- SPU 的「中文商品名」和「俄文标题」标示首次上架必填,草稿保存允许留空;俄文标题可在通用与按 SKU 设置间切换并保存模式,按 SKU 设置时清空、禁用 SPU 标题并在各 SKU 填写。标题、标签、描述各有中文对照入口;用户可在当前 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-30 首次上架
|
||||
|
||||
- `ProductPublishActions.tsx` 提供「上架至 OZON」「部分上架」,沿用父页面草稿保存与版本锁,按指定 SKU 调用预检;超过 50 个需分批选择,任一待提交项失败不创建任务。
|
||||
- 选择表禁选已导入/进行中 SKU,并显示原因;确认摘要列出店铺、货号、售价和图片数量。任务列表恢复最近任务,轮询后仅合并 listing 状态,保留未保存表单。
|
||||
- 发布标题最多 500、货号最多 50、SKU 图片最多 30;中文来源商品名仍为 1000。已导入或进行中锁定型号,进行中锁定换店;其他草稿资料可以编辑。
|
||||
- Web 类型检查及构建通过。隔离数据库页面验证了禁选、全部/部分预检、缺货号阻断、确认提交和刷新恢复;没有向真实 Ozon 店铺导入。
|
||||
- VAT 首期省略且生产发布默认关闭;真实店铺与七牛图片交付待验收。后端契约见 `docs/api/product-editor-and-publishing.md` 第 7 节。
|
||||
|
||||
## 2026-09-11 历史前端记录(以下内容仅供追溯)
|
||||
|
||||
### 商品编辑器
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { InputProps } from 'antd';
|
||||
|
||||
const digitsOnly = /^\d+$/;
|
||||
|
||||
/** 仅去掉数值本身为整数的历史小数尾零;非整数原值留给用户核对。 */
|
||||
export function normalizePackageInteger(value: string | null | undefined): string {
|
||||
const text = value ?? '';
|
||||
if (!/^\d+(?:\.0+)?$/.test(text)) return text;
|
||||
return text.replace(/\.0+$/, '').replace(/^0+(?=\d)/, '');
|
||||
}
|
||||
|
||||
export function isPositivePackageInteger(value: unknown): value is string {
|
||||
return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import { pricingPreviewRequestSchema } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { PackageIntegerInput } from '@/components/PackageIntegerInput';
|
||||
import { PackageIntegerInput, normalizePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import { percentToRate, rateToPercent } from './pricingPercentage';
|
||||
import { pricingInputProblem } from './pricingInputValidation';
|
||||
|
||||
@@ -326,7 +326,16 @@ export function PricingCalculator({
|
||||
const [policyName, setPolicyName] = useState<string | null>(null);
|
||||
const [policySaving, setPolicySaving] = useState(false);
|
||||
const [localCatalog, setLocalCatalog] = useState(catalog);
|
||||
const [input, setInput] = useState(() => structuredClone(initial));
|
||||
const [input, setInput] = useState(() => ({
|
||||
...structuredClone(initial),
|
||||
package: {
|
||||
...initial.package,
|
||||
shipping_weight_g: normalizePackageInteger(initial.package.shipping_weight_g),
|
||||
length_mm: normalizePackageInteger(initial.package.length_mm),
|
||||
width_mm: normalizePackageInteger(initial.package.width_mm),
|
||||
height_mm: normalizePackageInteger(initial.package.height_mm),
|
||||
},
|
||||
}));
|
||||
const [preview, setPreview] = useState<PricingPreview | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { normalizePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import { defaultPricing, PricingCalculator } from './PricingCalculator';
|
||||
import { pricingInputProblem } from './pricingInputValidation';
|
||||
type Values = SaveProductEditorDraftRequest;
|
||||
@@ -27,10 +28,10 @@ export function skuPricingInput(
|
||||
base = values.common.default_package_spec,
|
||||
pkg = v.package_spec;
|
||||
const effectivePackage = {
|
||||
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
|
||||
length_mm: pkg.length_mm ?? base.length_mm ?? '',
|
||||
width_mm: pkg.width_mm ?? base.width_mm ?? '',
|
||||
height_mm: pkg.height_mm ?? base.height_mm ?? '',
|
||||
shipping_weight_g: normalizePackageInteger(pkg.shipping_weight_g ?? base.shipping_weight_g),
|
||||
length_mm: normalizePackageInteger(pkg.length_mm ?? base.length_mm),
|
||||
width_mm: normalizePackageInteger(pkg.width_mm ?? base.width_mm),
|
||||
height_mm: normalizePackageInteger(pkg.height_mm ?? base.height_mm),
|
||||
};
|
||||
return {
|
||||
store_id: values.profile.target_store_id,
|
||||
@@ -538,7 +539,14 @@ export function SkuPricing({
|
||||
]);
|
||||
setModal({
|
||||
...modal,
|
||||
package: { ...base, status: base.status },
|
||||
package: {
|
||||
...base,
|
||||
shipping_weight_g: normalizePackageInteger(base.shipping_weight_g),
|
||||
length_mm: normalizePackageInteger(base.length_mm),
|
||||
width_mm: normalizePackageInteger(base.width_mm),
|
||||
height_mm: normalizePackageInteger(base.height_mm),
|
||||
status: base.status,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -47,6 +47,88 @@
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-publish-status-slot:not(:empty) {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.editor-publish-status {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--publish-status-border);
|
||||
border-radius: 10px;
|
||||
background: var(--publish-status-background);
|
||||
color: #344054;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.editor-publish-status:hover {
|
||||
border-color: var(--publish-status-accent);
|
||||
box-shadow: 0 2px 8px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
.editor-publish-status:focus-visible {
|
||||
outline: 2px solid var(--publish-status-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.editor-publish-status--progress {
|
||||
--publish-status-border: #d9d6f6;
|
||||
--publish-status-background: #f7f6ff;
|
||||
--publish-status-accent: #7c52e8;
|
||||
}
|
||||
.editor-publish-status--success {
|
||||
--publish-status-border: #cdebd5;
|
||||
--publish-status-background: #f5fbf6;
|
||||
--publish-status-accent: #389e55;
|
||||
}
|
||||
.editor-publish-status--warning {
|
||||
--publish-status-border: #f5e2b9;
|
||||
--publish-status-background: #fffaf0;
|
||||
--publish-status-accent: #ad6800;
|
||||
}
|
||||
.editor-publish-status--error {
|
||||
--publish-status-border: #f2cccc;
|
||||
--publish-status-background: #fff7f7;
|
||||
--publish-status-accent: #cf3d3d;
|
||||
}
|
||||
.editor-publish-status-icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--publish-status-accent);
|
||||
font-size: 16px;
|
||||
}
|
||||
.editor-publish-status-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.editor-publish-status-title {
|
||||
color: #252339;
|
||||
font-weight: 600;
|
||||
}
|
||||
.editor-publish-status-count {
|
||||
color: #73718a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.editor-publish-status-link {
|
||||
flex: 0 0 auto;
|
||||
color: var(--publish-status-accent);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.editor-publish-status-link .anticon {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 工作区:左侧主编辑列 + 右侧采集信息面板(见 CollectedProductInfoPanel.css) */
|
||||
.editor-workspace {
|
||||
@@ -299,6 +381,10 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.editor-publish-status-content {
|
||||
flex-wrap: wrap;
|
||||
gap: 0 8px;
|
||||
}
|
||||
.product-editor {
|
||||
padding-bottom: 230px;
|
||||
}
|
||||
|
||||
@@ -27,16 +27,18 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { productEditorApi } from '@/services/productEditor';
|
||||
import { usePlatform } from '@/services/platform';
|
||||
import { collectionApi } from '@/services/collection';
|
||||
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import { isPositivePackageInteger, normalizePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import {
|
||||
CollectedProductInfoPanel,
|
||||
type CollectedProductSummary,
|
||||
} from './components/CollectedProductInfoPanel';
|
||||
import { ProductAttributeSection } from './components/ProductAttributeSection';
|
||||
import { ProductPublishActions } from './components/ProductPublishActions';
|
||||
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
|
||||
import { ProductOtherSection } from './components/ProductOtherSection';
|
||||
import { ProductVariantSection } from './components/ProductVariantSection';
|
||||
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
@@ -97,7 +99,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
|
||||
common: {
|
||||
...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec,
|
||||
default_package_spec: normalizedPackageSpec(snapshot.product.default_package_spec),
|
||||
package_weight_mode: snapshot.product.package_weight_mode,
|
||||
package_dimensions_mode: snapshot.product.package_dimensions_mode,
|
||||
},
|
||||
@@ -107,7 +109,8 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
|
||||
platformCode.toUpperCase() === 'OZON' && snapshot.profile.fulfillment === 'unknown'
|
||||
? 'rfbs'
|
||||
: (snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment']),
|
||||
title: snapshot.profile.title,
|
||||
title: snapshot.profile.title_mode === 'variant' ? null : snapshot.profile.title,
|
||||
title_mode: snapshot.profile.title_mode,
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
@@ -141,7 +144,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec,
|
||||
package_spec: normalizedPackageSpec(variant.package_spec),
|
||||
offer_id: variant.listing.offer_id,
|
||||
media_asset_ids: variant.media.map((media) => media.asset_id),
|
||||
})),
|
||||
@@ -153,6 +156,55 @@ function nullable(value: string | null | undefined) {
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizedPackageSpec<
|
||||
T extends {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
},
|
||||
>(spec: T): T {
|
||||
const integerOrOriginal = (value: string | null) =>
|
||||
nullable(normalizePackageInteger(value?.trim()));
|
||||
return {
|
||||
...spec,
|
||||
length_mm: integerOrOriginal(spec.length_mm),
|
||||
width_mm: integerOrOriginal(spec.width_mm),
|
||||
height_mm: integerOrOriginal(spec.height_mm),
|
||||
shipping_weight_g: integerOrOriginal(spec.shipping_weight_g),
|
||||
};
|
||||
}
|
||||
|
||||
/** 草稿可缺少包装规格,但已填写的重量和尺寸必须是正整数。 */
|
||||
function packageIntegerProblem(values: EditorFormValues): string | null {
|
||||
const check = (
|
||||
spec: Pick<
|
||||
EditorFormValues['common']['default_package_spec'],
|
||||
'shipping_weight_g' | 'length_mm' | 'width_mm' | 'height_mm'
|
||||
>,
|
||||
owner: string
|
||||
) => {
|
||||
for (const [key, label] of [
|
||||
['shipping_weight_g', '包装重量'],
|
||||
['length_mm', '包装长度'],
|
||||
['width_mm', '包装宽度'],
|
||||
['height_mm', '包装高度'],
|
||||
] as const) {
|
||||
const value = spec[key];
|
||||
if (value !== null && value !== '' && !isPositivePackageInteger(value))
|
||||
return `${owner}的${label}请输入大于 0 的整数`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const commonProblem = check(values.common.default_package_spec, '商品');
|
||||
if (commonProblem) return commonProblem;
|
||||
for (const [index, variant] of values.variants.entries()) {
|
||||
const problem = check(variant.package_spec, variant.display_name || `SKU ${index + 1}`);
|
||||
if (problem) return problem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const wait = (milliseconds: number) =>
|
||||
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
|
||||
@@ -229,23 +281,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
height_mm: nullable(spec.height_mm),
|
||||
net_weight_g: nullable(spec.net_weight_g),
|
||||
});
|
||||
const packageSpec = <
|
||||
T extends {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
},
|
||||
>(
|
||||
spec: T
|
||||
): T => ({
|
||||
...spec,
|
||||
length_mm: nullable(spec.length_mm),
|
||||
width_mm: nullable(spec.width_mm),
|
||||
height_mm: nullable(spec.height_mm),
|
||||
shipping_weight_g: nullable(spec.shipping_weight_g),
|
||||
});
|
||||
const commonPackage = packageSpec(values.common.default_package_spec);
|
||||
const commonPackage = normalizedPackageSpec(values.common.default_package_spec);
|
||||
const weightByVariant = values.common.package_weight_mode === 'variant';
|
||||
const dimensionsByVariant = values.common.package_dimensions_mode === 'variant';
|
||||
const packageComplete = (
|
||||
@@ -278,6 +314,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
profile: {
|
||||
...values.profile,
|
||||
title: nullable(values.profile.title),
|
||||
title_mode: values.profile.title_mode ?? 'common',
|
||||
description: nullable(values.profile.description),
|
||||
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
|
||||
},
|
||||
@@ -288,7 +325,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
|
||||
}),
|
||||
variants: values.variants.map((variant) => {
|
||||
const normalizedPackage = packageSpec(variant.package_spec);
|
||||
const normalizedPackage = normalizedPackageSpec(variant.package_spec);
|
||||
if (weightByVariant || dimensionsByVariant) {
|
||||
const effectivePackage = {
|
||||
...normalizedPackage,
|
||||
@@ -359,7 +396,7 @@ function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSumma
|
||||
};
|
||||
return {
|
||||
price: facts.price ? `${facts.price.amount} ${facts.price.currency_code}` : '—',
|
||||
brand: facts.brand ?? find((label) => /brand|品牌/.test(label)),
|
||||
brand: displayBrandName(facts.brand ?? find((label) => /brand|品牌/.test(label))),
|
||||
physicalDimensions: dimensions(dimension(false)),
|
||||
physicalWeight: grams(weight(false)),
|
||||
packageDimensions: dimensions(dimension(true)),
|
||||
@@ -458,6 +495,7 @@ const ProductEditPage = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingSourceDescription, setSavingSourceDescription] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [publishStatusContainer, setPublishStatusContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
|
||||
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
|
||||
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
|
||||
@@ -497,10 +535,9 @@ const ProductEditPage = () => {
|
||||
: ([] as ProductAttributeSchema[]),
|
||||
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
|
||||
);
|
||||
// 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。
|
||||
const editableAttributeSchema = useMemo(
|
||||
() => activeAttributeSchema.filter((attribute) => !attribute.editor_field),
|
||||
[activeAttributeSchema]
|
||||
// 内建品牌字段同样使用当前类目的 Ozon 字典,其余内建字段无需加载字典。
|
||||
const brandDefinition = activeAttributeSchema.find(
|
||||
(attribute) => attribute.editor_field === 'brand'
|
||||
);
|
||||
|
||||
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
|
||||
@@ -519,7 +556,7 @@ const ProductEditPage = () => {
|
||||
setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
|
||||
setUsesVariantTitle(next.profile.title_mode === 'variant');
|
||||
setCopyTranslations(next.copy_translations);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
@@ -637,13 +674,16 @@ const ProductEditPage = () => {
|
||||
selectedCategoryId,
|
||||
snapshot,
|
||||
]);
|
||||
// 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。
|
||||
// 预取类目属性字典;品牌初始只查询 Ozon 的无品牌候选,其他品牌按关键词加载。
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
const attributeIds = [
|
||||
...new Set(
|
||||
editableAttributeSchema
|
||||
.filter((attribute) => !!attribute.dictionary_id)
|
||||
activeAttributeSchema
|
||||
.filter((attribute) =>
|
||||
!!attribute.dictionary_id &&
|
||||
(!attribute.editor_field || attribute.editor_field === 'brand')
|
||||
)
|
||||
.map((attribute) => attribute.attribute_id)
|
||||
),
|
||||
];
|
||||
@@ -662,7 +702,9 @@ const ProductEditPage = () => {
|
||||
snapshot.profile.id,
|
||||
currentPlatform.id,
|
||||
attributeId,
|
||||
undefined,
|
||||
attributeId === brandDefinition?.attribute_id
|
||||
? OZON_NO_BRAND_VALUE
|
||||
: undefined,
|
||||
selectedCategoryId
|
||||
),
|
||||
] as const
|
||||
@@ -698,7 +740,7 @@ const ProductEditPage = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [catalogRefresh, currentPlatform, editableAttributeSchema, selectedCategoryId, snapshot]);
|
||||
}, [catalogRefresh, currentPlatform, activeAttributeSchema, selectedCategoryId, snapshot]);
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform) return;
|
||||
let active = true;
|
||||
@@ -821,21 +863,42 @@ const ProductEditPage = () => {
|
||||
const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant');
|
||||
const useCommonPackageWeight = () => confirmCommonPackageMode('weight');
|
||||
const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions');
|
||||
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
|
||||
/** SKU 标签和描述继续按覆盖值继承商品通用文案。 */
|
||||
const useVariantTags = () => setUsesVariantTags(true);
|
||||
const useVariantDescription = () => setUsesVariantDescription(true);
|
||||
const useVariantTitle = () => setUsesVariantTitle(true);
|
||||
/** 俄文标题模式切换时清空退出模式的值,避免隐藏文案继续参与发布。 */
|
||||
const changeTitleMode = (mode: 'common' | 'variant') => {
|
||||
if (mode === 'variant') {
|
||||
form.setFieldValue(['profile', 'title'], null);
|
||||
setCopyTranslations((current) =>
|
||||
current.filter((item) => item.field !== 'title' || item.variant_id !== null)
|
||||
);
|
||||
} else {
|
||||
const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants'];
|
||||
variants.forEach((_, index) => form.setFieldValue(['variants', index, 'title'], null));
|
||||
setCopyTranslations((current) =>
|
||||
current.filter((item) => item.field !== 'title' || item.variant_id === null)
|
||||
);
|
||||
}
|
||||
form.setFieldValue(['profile', 'title_mode'], mode);
|
||||
setUsesVariantTitle(mode === 'variant');
|
||||
setDirty(true);
|
||||
};
|
||||
const useVariantTitle = () => changeTitleMode('variant');
|
||||
const useCommonTitle = () =>
|
||||
modal.confirm({
|
||||
title: '将俄文标题设为通用?',
|
||||
content: '确认后将清空所有 SKU 的俄文标题,并恢复商品通用俄文标题的编辑。',
|
||||
okText: '确认调整',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => changeTitleMode('common'),
|
||||
});
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
values = { ...values, ...form.getFieldsValue(true) };
|
||||
if (!productId || !currentPlatform || !snapshot) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
productId,
|
||||
currentPlatform.id,
|
||||
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
|
||||
normalizedValues({
|
||||
const draft = normalizedValues({
|
||||
...values,
|
||||
ai_source_material: sourceDraft ?? snapshot.source_material,
|
||||
ai_source_overridden_fields: sourceOverriddenFields,
|
||||
@@ -844,7 +907,19 @@ const ProductEditPage = () => {
|
||||
!item.variant_id ||
|
||||
values.variants.some((variant) => variant.id === item.variant_id)
|
||||
),
|
||||
})
|
||||
});
|
||||
const invalidPackage = packageIntegerProblem(draft);
|
||||
if (invalidPackage) {
|
||||
message.error(invalidPackage);
|
||||
return false;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
productId,
|
||||
currentPlatform.id,
|
||||
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
|
||||
draft
|
||||
);
|
||||
message.success('草稿已保存');
|
||||
setDirty(false);
|
||||
@@ -996,7 +1071,7 @@ const ProductEditPage = () => {
|
||||
variants[index] = { ...variants[index], offer_id: offer.offer_id };
|
||||
}
|
||||
const copy = result.spu_copy;
|
||||
if (copy?.title && !profile.title) {
|
||||
if (copy?.title && profile.title_mode !== 'variant' && !profile.title) {
|
||||
profile.title = copy.title.ru;
|
||||
addCopyTranslations(null, { title: copy.title }, taskId);
|
||||
}
|
||||
@@ -1082,6 +1157,7 @@ const ProductEditPage = () => {
|
||||
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
|
||||
if (index < 0) return;
|
||||
const copy = skuCopyResult;
|
||||
if (copy.title && !usesVariantTitle) changeTitleMode('variant');
|
||||
form.setFieldValue(
|
||||
'variants',
|
||||
variants.map((variant, current) =>
|
||||
@@ -1098,7 +1174,6 @@ const ProductEditPage = () => {
|
||||
// SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。
|
||||
if (skuCopyTaskId.current)
|
||||
addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current);
|
||||
setUsesVariantTitle(true);
|
||||
setUsesVariantTags(true);
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
@@ -1406,6 +1481,7 @@ const ProductEditPage = () => {
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setPublishStatusContainer} className="editor-publish-status-slot" />
|
||||
</header>
|
||||
{snapshot.warnings.map((warning) => (
|
||||
<Alert key={warning.code} type="warning" message={warning.message} showIcon />
|
||||
@@ -1436,11 +1512,21 @@ const ProductEditPage = () => {
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseVariantTitle={useVariantTitle}
|
||||
onUseCommonTitle={useCommonTitle}
|
||||
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
|
||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={useVariantPackageDimensions}
|
||||
onUseCommonPackageWeight={useCommonPackageWeight}
|
||||
onUseCommonPackageDimensions={useCommonPackageDimensions}
|
||||
brandDefinition={brandDefinition}
|
||||
brandOptions={brandDefinition ? dictionaryValues[brandDefinition.attribute_id] ?? [] : []}
|
||||
onOpenBrandOptions={(attributeId) =>
|
||||
void loadDictionaryValues(attributeId, OZON_NO_BRAND_VALUE)
|
||||
}
|
||||
onSearchBrandOptions={(attributeId, query) =>
|
||||
void loadDictionaryValues(attributeId, query)
|
||||
}
|
||||
onLoadMoreBrandOptions={loadMoreDictionaryValues}
|
||||
/>
|
||||
|
||||
<ProductAttributeSection
|
||||
@@ -1488,6 +1574,7 @@ const ProductEditPage = () => {
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseCommonTitle={useCommonTitle}
|
||||
onAiCopy={(variantId) => {
|
||||
setSkuCopyVariantId(variantId);
|
||||
setSkuCopyResult(null);
|
||||
@@ -1495,6 +1582,7 @@ const ProductEditPage = () => {
|
||||
skuCopyTaskId.current = null;
|
||||
}}
|
||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||
onAttributeChanged={() => setDirty(true)}
|
||||
onImagesChanged={() => setDirty(true)}
|
||||
/>
|
||||
|
||||
@@ -1539,13 +1627,27 @@ const ProductEditPage = () => {
|
||||
<Button onClick={leave}>取消并关闭</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
icon={<SaveOutlined />}
|
||||
>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button disabled>选择 SKU 并首次上架</Button>
|
||||
<ProductPublishActions
|
||||
snapshot={snapshot}
|
||||
platformId={currentPlatform.id}
|
||||
disabled={saving || aiAutofillLoading}
|
||||
statusContainer={publishStatusContainer}
|
||||
ensureSaved={async () => {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return null;
|
||||
return productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
}}
|
||||
onStatusChange={async () => {
|
||||
const next = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
setSnapshot(current => current ? {...current, profile: {...current.profile, listing_summary: next.profile.listing_summary}, variants: current.variants.map(v => ({...v, listing: next.variants.find(n => n.id === v.id)?.listing ?? v.listing}))} : current);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</footer>
|
||||
<ProductCopyComparisonModal
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Ozon 品牌字典的无品牌值只在界面翻译,草稿与发布继续使用原始候选值。 */
|
||||
export const OZON_NO_BRAND_VALUE = 'Нет бренда';
|
||||
|
||||
export function displayBrandName(value: string): string {
|
||||
return value === OZON_NO_BRAND_VALUE ? '无品牌' : value;
|
||||
}
|
||||
@@ -212,8 +212,12 @@
|
||||
padding: 0 6px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
font-size: 11px;
|
||||
gap: 3px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.collected-image-edit .anticon-down {
|
||||
font-size: 10px;
|
||||
}
|
||||
@container (max-width: 1099px) {
|
||||
/* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */
|
||||
.editor-aside {
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
type NamePath = Array<string | number>;
|
||||
const productColorValueLimit = 100;
|
||||
|
||||
/**
|
||||
@@ -118,6 +117,7 @@ type Props = {
|
||||
itemLayout?: 'horizontal' | 'vertical';
|
||||
forceRequiredNames?: string[];
|
||||
colorNameDefinition?: ProductAttributeSchema;
|
||||
onAttributeChanged?: () => void;
|
||||
};
|
||||
|
||||
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
|
||||
@@ -152,6 +152,7 @@ function DynamicAttributeField({
|
||||
scope,
|
||||
variantId,
|
||||
colorNameDefinition,
|
||||
onAttributeChanged,
|
||||
}: {
|
||||
definition: ProductAttributeSchema;
|
||||
index: number;
|
||||
@@ -167,6 +168,7 @@ function DynamicAttributeField({
|
||||
scope: Props['scope'];
|
||||
variantId: Props['variantId'];
|
||||
colorNameDefinition: Props['colorNameDefinition'];
|
||||
onAttributeChanged: Props['onAttributeChanged'];
|
||||
}) {
|
||||
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
||||
const onSyncRef = useRef(onSyncDictionaryValues);
|
||||
@@ -181,12 +183,25 @@ function DynamicAttributeField({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [definition.attribute_id, definition.dictionary_id, dictionaryQuery]);
|
||||
|
||||
const valueName = ['attributes', index, 'values'] as NamePath;
|
||||
const scalarValueName = [...valueName, 0] as NamePath;
|
||||
const selectedProductColorValue = Form.useWatch(
|
||||
['attributes', index, 'values'],
|
||||
form
|
||||
) as unknown;
|
||||
const valueName: ['attributes', number, 'values'] = ['attributes', index, 'values'];
|
||||
const scalarValueName: ['attributes', number, 'values', 0] = [
|
||||
'attributes',
|
||||
index,
|
||||
'values',
|
||||
0,
|
||||
];
|
||||
// SKU 卡片位于 Form.List('variants') 内,命名 Form.Item 会自动附加 variants 前缀。
|
||||
const directVariantBinding = scope === 'variant';
|
||||
const watchedValues = Form.useWatch(valueName, { form, preserve: true }) as unknown;
|
||||
const attributeValues = Array.isArray(watchedValues)
|
||||
? watchedValues.filter((value): value is string => typeof value === 'string')
|
||||
: typeof watchedValues === 'string'
|
||||
? [watchedValues]
|
||||
: [];
|
||||
const setVariantValues = (values: string[]) => {
|
||||
form.setFieldValue(valueName, values);
|
||||
onAttributeChanged?.();
|
||||
};
|
||||
const lastColorSelection = useRef<string | null>(null);
|
||||
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
|
||||
const isRequired =
|
||||
@@ -204,11 +219,7 @@ function DynamicAttributeField({
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
const selectedColorValueIds = Array.isArray(selectedProductColorValue)
|
||||
? selectedProductColorValue.filter((value): value is string => typeof value === 'string')
|
||||
: typeof selectedProductColorValue === 'string'
|
||||
? [selectedProductColorValue]
|
||||
: [];
|
||||
const selectedColorValueIds = attributeValues;
|
||||
const colorSelectionKey = selectedColorValueIds.join('\u0000');
|
||||
/** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */
|
||||
const syncColorName = (selectedValue: unknown) => {
|
||||
@@ -254,16 +265,32 @@ function DynamicAttributeField({
|
||||
|
||||
const input =
|
||||
definition.value_type === 'dictionary' ? (
|
||||
<Form.Item name={allowsMultipleValues ? valueName : scalarValueName} noStyle>
|
||||
<Form.Item
|
||||
name={directVariantBinding ? undefined : allowsMultipleValues ? valueName : scalarValueName}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
className={isProductColor(definition) ? 'product-color-select' : undefined}
|
||||
mode={allowsMultipleValues ? 'multiple' : undefined}
|
||||
maxCount={allowsMultipleValues ? maxValueCount : undefined}
|
||||
options={dictionaryOptions}
|
||||
value={
|
||||
directVariantBinding
|
||||
? allowsMultipleValues
|
||||
? attributeValues
|
||||
: attributeValues[0]
|
||||
: undefined
|
||||
}
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onChange={(value) => syncColorName(value)}
|
||||
onChange={(value) => {
|
||||
if (directVariantBinding)
|
||||
setVariantValues(
|
||||
Array.isArray(value) ? value : value ? [value] : []
|
||||
);
|
||||
syncColorName(value);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open || !definition.dictionary_id) return;
|
||||
setDictionaryQuery('');
|
||||
@@ -313,8 +340,14 @@ function DynamicAttributeField({
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'boolean' ? (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
|
||||
<Select
|
||||
value={directVariantBinding ? attributeValues[0] : undefined}
|
||||
onChange={
|
||||
directVariantBinding
|
||||
? (value) => setVariantValues(value ? [value] : [])
|
||||
: undefined
|
||||
}
|
||||
options={[
|
||||
{ value: 'true', label: '是' },
|
||||
{ value: 'false', label: '否' },
|
||||
@@ -323,25 +356,42 @@ function DynamicAttributeField({
|
||||
/>
|
||||
</Form.Item>
|
||||
) : allowsMultipleValues ? (
|
||||
<Form.Item name={valueName} noStyle>
|
||||
<Form.Item name={directVariantBinding ? undefined : valueName} noStyle>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={directVariantBinding ? attributeValues : undefined}
|
||||
onChange={directVariantBinding ? setVariantValues : undefined}
|
||||
maxCount={maxValueCount}
|
||||
tokenSeparators={[',']}
|
||||
placeholder={`输入后回车添加,最多 ${maxValueCount} 项`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
|
||||
<InputNumber<string>
|
||||
value={directVariantBinding ? attributeValues[0] ?? null : undefined}
|
||||
onChange={
|
||||
directVariantBinding
|
||||
? (value) => setVariantValues(value === null ? [] : [value])
|
||||
: undefined
|
||||
}
|
||||
step={definition.value_type === 'integer' ? '1' : '0.001'}
|
||||
stringMode
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Input placeholder={`填写${definition.name}`} />
|
||||
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
|
||||
<Input
|
||||
value={directVariantBinding ? attributeValues[0] ?? '' : undefined}
|
||||
onChange={
|
||||
directVariantBinding
|
||||
? (event) =>
|
||||
setVariantValues(event.target.value ? [event.target.value] : [])
|
||||
: undefined
|
||||
}
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
@@ -384,6 +434,7 @@ export function DynamicAttributeFields({
|
||||
itemLayout,
|
||||
forceRequiredNames,
|
||||
colorNameDefinition,
|
||||
onAttributeChanged,
|
||||
}: Props) {
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
const fields = definitions.flatMap((definition) => {
|
||||
@@ -413,6 +464,7 @@ export function DynamicAttributeFields({
|
||||
scope={scope}
|
||||
variantId={variantId}
|
||||
colorNameDefinition={colorNameDefinition}
|
||||
onAttributeChanged={onAttributeChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Cascader, Form, Input, Radio, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductEditorSnapshot,
|
||||
} from '@atlas/contracts';
|
||||
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from '../brandDisplay';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductMainInfoSection.css';
|
||||
|
||||
@@ -110,7 +116,13 @@ type Props = {
|
||||
onUseCommonPackageDimensions: () => void;
|
||||
usesVariantTitle: boolean;
|
||||
onUseVariantTitle: () => void;
|
||||
onUseCommonTitle: () => void;
|
||||
onCompareTitle: () => void;
|
||||
brandDefinition: ProductAttributeSchema | undefined;
|
||||
brandOptions: ProductAttributeDictionaryValue[];
|
||||
onOpenBrandOptions: (attributeId: string) => void;
|
||||
onSearchBrandOptions: (attributeId: string, query: string) => void;
|
||||
onLoadMoreBrandOptions: (attributeId: string, query: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,8 +148,36 @@ export function ProductMainInfoSection({
|
||||
onUseCommonPackageDimensions,
|
||||
usesVariantTitle,
|
||||
onUseVariantTitle,
|
||||
onUseCommonTitle,
|
||||
onCompareTitle,
|
||||
brandDefinition,
|
||||
brandOptions,
|
||||
onOpenBrandOptions,
|
||||
onSearchBrandOptions,
|
||||
onLoadMoreBrandOptions,
|
||||
}: Props) {
|
||||
const publishingLocked = snapshot.variants.some(v => ['imported', 'submitting'].includes(v.listing.status));
|
||||
const [brandQuery, setBrandQuery] = useState('');
|
||||
const onSearchBrandOptionsRef = useRef(onSearchBrandOptions);
|
||||
onSearchBrandOptionsRef.current = onSearchBrandOptions;
|
||||
// 中文“无品牌”仅用于展示与搜索,表单始终保存用户选中的 Ozon 候选名称。
|
||||
const brandSearchQuery =
|
||||
brandQuery.trim() === '无品牌' ? OZON_NO_BRAND_VALUE : brandQuery.trim();
|
||||
const visibleBrandOptions = brandOptions.filter((option) =>
|
||||
brandSearchQuery
|
||||
? option.value.toLocaleLowerCase().includes(brandSearchQuery.toLocaleLowerCase())
|
||||
: option.value === OZON_NO_BRAND_VALUE
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!brandDefinition?.dictionary_id || !brandSearchQuery) return;
|
||||
const timer = window.setTimeout(
|
||||
() => onSearchBrandOptionsRef.current(brandDefinition.attribute_id, brandSearchQuery),
|
||||
400
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [brandDefinition?.attribute_id, brandDefinition?.dictionary_id, brandSearchQuery]);
|
||||
const usesBrandDictionary =
|
||||
brandDefinition?.value_type === 'dictionary' && !!brandDefinition.dictionary_id;
|
||||
return (
|
||||
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
||||
<SectionTitle
|
||||
@@ -154,6 +194,7 @@ export function ProductMainInfoSection({
|
||||
>
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
disabled={snapshot.variants.some(v => v.listing.status === 'submitting')}
|
||||
options={snapshot.profile.available_target_stores.map((store) => ({
|
||||
value: store.id,
|
||||
label: `${store.name}(${store.currency_code})`,
|
||||
@@ -227,30 +268,33 @@ export function ProductMainInfoSection({
|
||||
>
|
||||
<Input placeholder="填写中文商品名" />
|
||||
</Form.Item>
|
||||
<Form.Item label="俄文发布标题" className="span-full" required>
|
||||
<Form.Item label="俄文标题" className="span-full" required={!usesVariantTitle}>
|
||||
<div className="profile-override-control">
|
||||
<Form.Item
|
||||
name={['profile', 'title']}
|
||||
noStyle
|
||||
rules={[{ max: 1000 }]}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
|
||||
</Form.Item>
|
||||
{!usesVariantTitle && (
|
||||
<Tooltip title="在 SKU 中分别编辑标题">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="在 SKU 中分别编辑标题"
|
||||
onClick={onUseVariantTitle}
|
||||
<Input
|
||||
placeholder="AI 填报或手动填写俄文标题"
|
||||
maxLength={500}
|
||||
disabled={usesVariantTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="link"
|
||||
className="package-mode-button"
|
||||
aria-label={`${usesVariantTitle ? '设为通用' : '按 SKU 设置'}俄文标题`}
|
||||
onClick={usesVariantTitle ? onUseCommonTitle : onUseVariantTitle}
|
||||
>
|
||||
{usesVariantTitle ? '设为通用' : '按 SKU 设置'}
|
||||
</Button>
|
||||
<Tooltip title="查看标题中文对照">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看标题中文对照"
|
||||
disabled={usesVariantTitle}
|
||||
onClick={onCompareTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -260,15 +304,62 @@ export function ProductMainInfoSection({
|
||||
name={['common', 'model']}
|
||||
label="型号"
|
||||
required
|
||||
extra={publishingLocked ? "已有 SKU 导入或正在导入,型号已锁定" : undefined}
|
||||
>
|
||||
<Input className="editor-short-input" placeholder="首次上架前需填写" />
|
||||
<Input className="editor-short-input" disabled={publishingLocked} placeholder="首次上架前需填写" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'brand']}
|
||||
label="品牌"
|
||||
required
|
||||
extra={
|
||||
usesBrandDictionary
|
||||
? '默认显示“无品牌”;输入关键词搜索其他品牌。'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
|
||||
{usesBrandDictionary ? (
|
||||
<Select
|
||||
className="editor-standard-select editor-short-input"
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
labelRender={({ value, label }) =>
|
||||
value === OZON_NO_BRAND_VALUE ? '无品牌' : label
|
||||
}
|
||||
options={visibleBrandOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: displayBrandName(option.value),
|
||||
key: option.value_id,
|
||||
}))}
|
||||
placeholder="选择 Ozon 品牌,输入名称搜索"
|
||||
notFoundContent={
|
||||
brandQuery.trim()
|
||||
? '暂无匹配的 Ozon 品牌候选'
|
||||
: '暂无无品牌候选,输入关键词搜索其他品牌'
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
setBrandQuery('');
|
||||
onOpenBrandOptions(brandDefinition.attribute_id);
|
||||
}}
|
||||
onSearch={(query) => {
|
||||
setBrandQuery(query);
|
||||
if (!query.trim())
|
||||
onOpenBrandOptions(brandDefinition.attribute_id);
|
||||
}}
|
||||
onPopupScroll={(event) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
|
||||
onLoadMoreBrandOptions(
|
||||
brandDefinition.attribute_id,
|
||||
brandSearchQuery
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input className="editor-short-input" placeholder="填写品牌" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
<PackageFields
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
ProductEditorSnapshot,
|
||||
ProductPublishJob,
|
||||
ProductPublishPreflight,
|
||||
} from '@atlas/contracts';
|
||||
import { productEditorApi } from '@/services/productEditor';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
type Props = {
|
||||
snapshot: ProductEditorSnapshot;
|
||||
platformId: string;
|
||||
disabled: boolean;
|
||||
statusContainer: HTMLElement | null;
|
||||
ensureSaved: () => Promise<ProductEditorSnapshot | null>;
|
||||
onStatusChange: () => Promise<void>;
|
||||
};
|
||||
const terminal = (job: ProductPublishJob) =>
|
||||
['succeeded', 'partially_succeeded', 'failed'].includes(job.status);
|
||||
const itemLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
submitting: '提交中',
|
||||
polling: '等待 Ozon',
|
||||
imported: '已导入',
|
||||
failed: '失败',
|
||||
skipped: '已跳过',
|
||||
reconciling: '等待对账',
|
||||
};
|
||||
const jobLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
running: '处理中',
|
||||
succeeded: '已导入',
|
||||
partially_succeeded: '部分已导入',
|
||||
failed: '未导入',
|
||||
reconciling: '等待对账',
|
||||
};
|
||||
|
||||
/** 两个发布入口共用保存、指定 SKU 预检、确认与可恢复任务状态。 */
|
||||
export function ProductPublishActions({
|
||||
snapshot,
|
||||
platformId,
|
||||
disabled,
|
||||
statusContainer,
|
||||
ensureSaved,
|
||||
onStatusChange,
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stage, setStage] = useState<'select' | 'check' | 'confirm' | 'result'>('select');
|
||||
const [saved, setSaved] = useState(snapshot);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preflight, setPreflight] = useState<ProductPublishPreflight | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [job, setJob] = useState<ProductPublishJob | null>(null);
|
||||
const requestKey = useRef<string | null>(null);
|
||||
const run = useRef(0);
|
||||
const statusChanged = useRef(onStatusChange);
|
||||
statusChanged.current = onStatusChange;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setJob(null);
|
||||
productEditorApi
|
||||
.publishJobs(snapshot.profile.id, platformId)
|
||||
.then((jobs) => {
|
||||
if (!cancelled) setJob(jobs.find((j) => !terminal(j)) ?? jobs[0] ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
run.current++;
|
||||
};
|
||||
}, [snapshot.profile.id, platformId]);
|
||||
useEffect(() => {
|
||||
if (!job || terminal(job)) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await productEditorApi.publishJob(job.id, platformId);
|
||||
if (cancelled) return;
|
||||
setJob(next);
|
||||
await statusChanged.current();
|
||||
if (terminal(next)) return;
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(apiErrorMessage(e));
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(() => void poll(), 5000);
|
||||
};
|
||||
timer = setTimeout(() => void poll(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [job?.id, job?.status, platformId]);
|
||||
const eligible = (data: ProductEditorSnapshot) =>
|
||||
data.variants.filter((v) => ['not_listed', 'failed'].includes(v.listing.status));
|
||||
const skipReason = (status: string) =>
|
||||
status === 'imported'
|
||||
? '已有成功导入记录,不能重复提交'
|
||||
: '正在提交或等待对账,不能重复排队';
|
||||
const check = async (data: ProductEditorSnapshot, selected: string[]) => {
|
||||
if (!selected.length) {
|
||||
message.warning('请至少选择 1 个待上架 SKU');
|
||||
return;
|
||||
}
|
||||
if (selected.length > 50) {
|
||||
message.warning('本次最多上架 50 个 SKU,请使用部分上架分批提交');
|
||||
return;
|
||||
}
|
||||
const thisRun = ++run.current;
|
||||
setStage('check');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setPreflight(null);
|
||||
requestKey.current = null;
|
||||
try {
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const result = await productEditorApi.publishPreflight(
|
||||
data.profile.id,
|
||||
platformId,
|
||||
selected
|
||||
);
|
||||
if (thisRun !== run.current) return;
|
||||
setPreflight(result);
|
||||
if (result.status !== 'refreshing') {
|
||||
setStage(result.status === 'ready' ? 'confirm' : 'check');
|
||||
return;
|
||||
}
|
||||
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
} catch (e) {
|
||||
if (thisRun === run.current) setError(apiErrorMessage(e));
|
||||
} finally {
|
||||
if (thisRun === run.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
const start = async (partial: boolean) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await ensureSaved();
|
||||
if (!data) return;
|
||||
setSaved(data);
|
||||
setPreflight(null);
|
||||
requestKey.current = null;
|
||||
const all = eligible(data).map((v) => v.id);
|
||||
setIds(partial ? [] : all);
|
||||
setOpen(true);
|
||||
setStage('select');
|
||||
if (!partial) {
|
||||
if (all.length > 50) {
|
||||
setError('待上架 SKU 超过 50 个,请选择本次需要上架的 SKU,分批提交。');
|
||||
} else if (!all.length) {
|
||||
setError('没有可首次上架的 SKU。已导入的 SKU 请前往在线商品查看。');
|
||||
} else await check(data, all);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
requestKey.current ??= crypto.randomUUID();
|
||||
try {
|
||||
const result = await productEditorApi.createPublishJob(
|
||||
saved.profile.id,
|
||||
platformId,
|
||||
ids,
|
||||
{ product: saved.product.revision, profile: saved.profile.revision },
|
||||
requestKey.current
|
||||
);
|
||||
setJob(result.publish_job);
|
||||
setStage('result');
|
||||
await onStatusChange();
|
||||
} catch (e) {
|
||||
setError(apiErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const chosen = saved.variants.filter((v) => ids.includes(v.id));
|
||||
const issues = preflight
|
||||
? [
|
||||
...preflight.validation.profile.issues.map((i) => ({ ...i, scope: '商品资料' })),
|
||||
...preflight.validation.variants.flatMap((v) =>
|
||||
v.issues.map((i) => ({
|
||||
...i,
|
||||
scope:
|
||||
v.display_name ||
|
||||
saved.variants.find((s) => s.id === v.variant_id)?.listing.offer_id ||
|
||||
'SKU',
|
||||
}))
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const statusTone = job?.status === 'succeeded'
|
||||
? 'success'
|
||||
: job?.status === 'failed'
|
||||
? 'error'
|
||||
: job?.status === 'partially_succeeded' || job?.status === 'reconciling'
|
||||
? 'warning'
|
||||
: 'progress';
|
||||
const statusIcon = statusTone === 'success'
|
||||
? <CheckCircleOutlined />
|
||||
: statusTone === 'progress'
|
||||
? <ClockCircleOutlined />
|
||||
: <ExclamationCircleOutlined />;
|
||||
return (
|
||||
<>
|
||||
{/* 发布状态与结果弹窗共用任务数据,状态入口展示在页头。 */}
|
||||
{job && statusContainer && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-publish-status editor-publish-status--${statusTone}`}
|
||||
onClick={() => {
|
||||
setStage('result');
|
||||
setOpen(true);
|
||||
setError('');
|
||||
}}
|
||||
aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`}
|
||||
>
|
||||
<span className="editor-publish-status-icon" aria-hidden="true">
|
||||
{statusIcon}
|
||||
</span>
|
||||
<span className="editor-publish-status-content">
|
||||
<span className="editor-publish-status-title">
|
||||
发布任务 · {jobLabels[job.status]}
|
||||
</span>
|
||||
<span className="editor-publish-status-count">{job.item_count} 个 SKU</span>
|
||||
</span>
|
||||
<span className="editor-publish-status-link">
|
||||
查看详情 <RightOutlined />
|
||||
</span>
|
||||
</button>,
|
||||
statusContainer
|
||||
)}
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={disabled || busy}
|
||||
loading={busy && !open}
|
||||
onClick={() => void start(false)}
|
||||
>
|
||||
上架至 OZON
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void start(true)}
|
||||
>
|
||||
部分上架
|
||||
</Button>
|
||||
</Space>
|
||||
<Modal
|
||||
title={
|
||||
stage === 'result'
|
||||
? 'Ozon 导入结果'
|
||||
: stage === 'confirm'
|
||||
? '确认上架至 OZON'
|
||||
: '选择与校验上架 SKU'
|
||||
}
|
||||
open={open}
|
||||
width={900}
|
||||
onCancel={() => {
|
||||
run.current++;
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
}}
|
||||
footer={
|
||||
stage === 'result' ? (
|
||||
<Button onClick={() => setOpen(false)}>关闭</Button>
|
||||
) : (
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
run.current++;
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{stage === 'confirm' ? (
|
||||
<Button type="primary" loading={busy} onClick={() => void submit()}>
|
||||
提交 {ids.length} 个 SKU
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy}
|
||||
disabled={!ids.length || ids.length > 50}
|
||||
onClick={() => void check(saved, ids)}
|
||||
>
|
||||
校验所选 SKU
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert type="error" showIcon title={error} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
{stage === 'result' && job ? (
|
||||
<>
|
||||
<Alert
|
||||
type={
|
||||
job.status === 'failed'
|
||||
? 'error'
|
||||
: job.status === 'reconciling'
|
||||
? 'warning'
|
||||
: terminal(job)
|
||||
? 'success'
|
||||
: 'info'
|
||||
}
|
||||
showIcon
|
||||
title={`${jobLabels[job.status]} · ${job.item_count} 个 SKU`}
|
||||
description={
|
||||
job.status === 'reconciling'
|
||||
? '平台受理结果尚未确认,系统将继续对账,请勿重复导入。'
|
||||
: '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={job.items}
|
||||
columns={[
|
||||
{ title: 'SKU', render: (_, i) => i.display_name || '未命名 SKU' },
|
||||
{ title: '货号', dataIndex: 'offer_id' },
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, i) => (
|
||||
<Tag
|
||||
color={
|
||||
i.status === 'imported'
|
||||
? 'green'
|
||||
: i.status === 'failed'
|
||||
? 'red'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{itemLabels[i.status]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果说明',
|
||||
render: (_, i) =>
|
||||
i.errors.map((e) => (
|
||||
<div key={`${e.code}:${e.message}`}>{e.message}</div>
|
||||
)),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Paragraph>
|
||||
目标店铺:{saved.profile.target_store.name}。本次最多 50 个
|
||||
SKU,仅首次导入商品资料;库存需在 Ozon 后台设置。
|
||||
</Typography.Paragraph>
|
||||
{saved.variants.some(
|
||||
(v) => !['not_listed', 'failed'].includes(v.listing.status)
|
||||
) && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title="以下 SKU 本次不重复提交"
|
||||
description={saved.variants
|
||||
.filter(
|
||||
(v) => !['not_listed', 'failed'].includes(v.listing.status)
|
||||
)
|
||||
.map((v) => (
|
||||
<div key={v.id}>
|
||||
{v.display_name || v.listing.offer_id || '未命名 SKU'}:
|
||||
{skipReason(v.listing.status)}
|
||||
</div>
|
||||
))}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
{stage === 'select' ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
dataSource={saved.variants}
|
||||
rowSelection={{
|
||||
selectedRowKeys: ids,
|
||||
onChange: (keys) => {
|
||||
setIds(keys as string[]);
|
||||
setPreflight(null);
|
||||
},
|
||||
getCheckboxProps: (v) => ({
|
||||
disabled: !['not_listed', 'failed'].includes(
|
||||
v.listing.status
|
||||
),
|
||||
}),
|
||||
}}
|
||||
columns={[
|
||||
{ title: 'SKU', dataIndex: 'display_name' },
|
||||
{
|
||||
title: '货号',
|
||||
render: (_, v) => v.listing.offer_id || '未填写',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, v) =>
|
||||
v.listing.status === 'imported'
|
||||
? '已导入'
|
||||
: v.listing.status === 'submitting'
|
||||
? '提交中'
|
||||
: v.listing.status === 'failed'
|
||||
? '可修正后重试'
|
||||
: '待首次上架',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={chosen}
|
||||
columns={[
|
||||
{ title: 'SKU', dataIndex: 'display_name' },
|
||||
{ title: '货号', render: (_, v) => v.listing.offer_id },
|
||||
{
|
||||
title: '售价',
|
||||
render: (_, v) =>
|
||||
`${v.listing.selling_price} ${saved.profile.target_store.currency_code}`,
|
||||
},
|
||||
{ title: '图片', render: (_, v) => `${v.media.length} 张` },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{preflight?.status === 'refreshing' && (
|
||||
<Alert
|
||||
type="info"
|
||||
title="正在更新 Ozon 类目与属性目录,请稍候或重新校验"
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
{!!issues.length && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="请完善以下资料后重新上架"
|
||||
description={issues.map((i, index) => (
|
||||
<div key={`${i.field}:${index}`}>
|
||||
<strong>{i.scope}</strong>:{i.message}
|
||||
</div>
|
||||
))}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
{stage === 'confirm' && (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={`预检通过,将提交 ${ids.length} 个 SKU`}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,16 @@
|
||||
color: #90889c;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sku-main-thumbnail:hover {
|
||||
border-color: #8956f5;
|
||||
}
|
||||
.sku-main-thumbnail:focus-visible {
|
||||
outline: 2px solid #8956f5;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sku-main-thumbnail img {
|
||||
width: 100%;
|
||||
@@ -212,6 +222,9 @@
|
||||
color: #292433;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-expand-button .anticon-down {
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-card-expanded {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
@@ -393,6 +406,9 @@
|
||||
.sku-info-panel .sku-info-package-row .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-info-panel .sku-info-variant-title .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-package-label {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -101,8 +101,10 @@ type Props = {
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
onUseCommonTitle: () => void;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onAttributeChanged: () => void;
|
||||
onImagesChanged: () => void;
|
||||
};
|
||||
|
||||
@@ -130,8 +132,10 @@ export function ProductVariantSection({
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
onUseCommonTitle,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
onAttributeChanged,
|
||||
onImagesChanged,
|
||||
}: Props) {
|
||||
const [pricingAuto, setPricingAuto] = useState(
|
||||
@@ -432,6 +436,7 @@ export function ProductVariantSection({
|
||||
imageUrl={displayImages[0]?.url}
|
||||
imageCount={selectedAssetIds.length}
|
||||
sourcePreview={!!sourceMainCandidate}
|
||||
onPick={pickImages}
|
||||
/>
|
||||
<div className="sku-card-main">
|
||||
<div className="sku-card-content">
|
||||
@@ -541,6 +546,7 @@ export function ProductVariantSection({
|
||||
usesVariantDescription
|
||||
}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseCommonTitle={onUseCommonTitle}
|
||||
skuInfoAspectDefinitions={
|
||||
skuInfoAspectDefinitions
|
||||
}
|
||||
@@ -561,6 +567,7 @@ export function ProductVariantSection({
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
onAttributeChanged={onAttributeChanged}
|
||||
onAiCopy={onAiCopy}
|
||||
onCompareCopy={onCompareCopy}
|
||||
/>
|
||||
|
||||
@@ -4,18 +4,21 @@ type Props = {
|
||||
imageUrl?: string;
|
||||
imageCount: number;
|
||||
sourcePreview: boolean;
|
||||
onPick: () => void;
|
||||
};
|
||||
|
||||
/** 收起态主图沿用 SKU 图片顺序,来源候选图不计入已选图片数量。 */
|
||||
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
|
||||
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview, onPick }: Props) {
|
||||
const imageLabel = sourcePreview ? '来源图' : '主图';
|
||||
|
||||
return (
|
||||
<div className="sku-main-preview">
|
||||
<div
|
||||
<button
|
||||
className="sku-main-thumbnail"
|
||||
role="img"
|
||||
aria-label={`SKU ${sourcePreview ? '来源图片' : '主图'},已选 ${imageCount} 张图片`}
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
aria-label={`选择 SKU 图片,当前已选 ${imageCount} 张`}
|
||||
title="选择 SKU 图片"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
@@ -28,7 +31,7 @@ export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
|
||||
<span className="sku-main-count" aria-hidden="true">
|
||||
<PictureOutlined /> {sourcePreview ? '已选 0 张' : `${imageCount} 张图`}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
onUseCommonTitle: () => void;
|
||||
skuInfoAspectDefinitions: ProductAttributeSchema[];
|
||||
colorNameDefinition: ProductAttributeSchema | undefined;
|
||||
catalogSyncing: string | null;
|
||||
@@ -30,6 +31,7 @@ type Props = {
|
||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onAttributeChanged: () => void;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
};
|
||||
@@ -66,6 +68,7 @@ export function SkuInfoPanel({
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
onUseCommonTitle,
|
||||
skuInfoAspectDefinitions,
|
||||
colorNameDefinition,
|
||||
catalogSyncing,
|
||||
@@ -74,6 +77,7 @@ export function SkuInfoPanel({
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
onAttributeChanged,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
@@ -86,7 +90,7 @@ export function SkuInfoPanel({
|
||||
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
|
||||
<Input placeholder="填写 SKU 名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical">
|
||||
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical" rules={[{max: 50, message: '货号最多 50 字符'}]}>
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
|
||||
@@ -144,15 +148,23 @@ export function SkuInfoPanel({
|
||||
</>
|
||||
)}
|
||||
<div className="sku-info-group-title">发布文案与类目属性</div>
|
||||
{usesVariantTitle && (
|
||||
<Form.Item
|
||||
label="俄文发布标题"
|
||||
label={
|
||||
<span className="sku-package-label">
|
||||
<span>俄文标题</span>
|
||||
<Button type="link" size="small" onClick={onUseCommonTitle}>
|
||||
设为通用
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
layout="vertical"
|
||||
className="sku-info-variant-title"
|
||||
required={usesVariantTitle}
|
||||
required
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name={[fieldName, 'title']} noStyle>
|
||||
<Input placeholder="留空则继承 SPU 标题" />
|
||||
<Form.Item name={[fieldName, 'title']} noStyle rules={[{ max: 500 }]}>
|
||||
<Input placeholder="填写当前 SKU 俄文标题" maxLength={500} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
@@ -168,6 +180,7 @@ export function SkuInfoPanel({
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantTags && (
|
||||
<Form.Item label="主题标签" layout="vertical" className="sku-info-variant-tags">
|
||||
<Space.Compact
|
||||
@@ -244,6 +257,7 @@ export function SkuInfoPanel({
|
||||
itemLayout="vertical"
|
||||
forceRequiredNames={['商品颜色']}
|
||||
colorNameDefinition={colorNameDefinition}
|
||||
onAttributeChanged={onAttributeChanged}
|
||||
/>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 && !sourceId && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ProductAiTask,
|
||||
ProductEditorValidation,
|
||||
ProductPublishPreflight,
|
||||
ProductPublishJob,
|
||||
Operation,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductEditorDraftResponse,
|
||||
@@ -118,6 +119,10 @@ export const productEditorApi = {
|
||||
{ variant_ids: variantIds },
|
||||
{ headers: { 'X-Atlas-Platform-Id': platformId } }
|
||||
),
|
||||
createPublishJob: (profileId: string, platformId: string, variantIds: string[], revisions: {product: number; profile: number}, key: string) =>
|
||||
api.post<{publish_job: ProductPublishJob}>(`/product-platform-profiles/${profileId}/publish-jobs`, {variant_ids: variantIds}, {headers: {'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': key, 'If-Match': `"${revisions.product}:${revisions.profile}"`}}),
|
||||
publishJob: (id: string, platformId: string) => api.get<ProductPublishJob>(`/product-publish-jobs/${id}`, {headers: {'X-Atlas-Platform-Id': platformId}}),
|
||||
publishJobs: (profileId: string, platformId: string) => api.get<ProductPublishJob[]>(`/product-platform-profiles/${profileId}/publish-jobs`, {headers: {'X-Atlas-Platform-Id': platformId}}),
|
||||
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||
api.post<{ run: ProductCatalogRefreshRun }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { CreateBucketCommand, HeadBucketCommand, PutObjectCommand, type S3Client } from '@aws-sdk/client-s3';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { workerConfig } from './config.js';
|
||||
import type { OutboxJob } from './outbox-dispatcher.js';
|
||||
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
@@ -45,7 +46,7 @@ async function materializeOne(database: Database, storage: S3Client, bucket: str
|
||||
return;
|
||||
}
|
||||
const asset = await tx.query<{ id: string }>(`INSERT INTO atlas.media_assets (workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'local_minio', $1, $2, $3, $4, $5, $6, $7) RETURNING id`, [bucket, objectKey, downloaded.contentType, downloaded.bytes.byteLength, checksum, downloaded.width, downloaded.height]);
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $8, $1, $2, $3, $4, $5, $6, $7) RETURNING id`, [bucket, objectKey, downloaded.contentType, downloaded.bytes.byteLength, checksum, downloaded.width, downloaded.height, workerConfig.objectStorageProvider]);
|
||||
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'succeeded', asset_id = $2, completed_at = now(), updated_at = now(), error_payload = NULL WHERE id = $1 AND status = 'running' AND source_snapshot_revision = $3`, [transfer.id, asset.rows[0].id, snapshotRevision]);
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -31,6 +31,11 @@ export const workerConfig = {
|
||||
databaseUrl: required('DATABASE_URL'),
|
||||
redisUrl: required('REDIS_URL'),
|
||||
storeSecretEncryptionKey: required('STORE_SECRET_ENCRYPTION_KEY'),
|
||||
objectStorageProvider: process.env.OBJECT_STORAGE_PROVIDER ?? 'local_minio',
|
||||
ozonImageDownloadOrigin: process.env.OZON_IMAGE_DOWNLOAD_ORIGIN ?? '',
|
||||
ozonImageUrlTtlSeconds: Number(process.env.OZON_IMAGE_URL_TTL_SECONDS ?? 604800),
|
||||
qiniuAccessKey: process.env.QINIU_ACCESS_KEY ?? process.env.OBJECT_STORAGE_ACCESS_KEY ?? '',
|
||||
qiniuSecretKey: process.env.QINIU_SECRET_KEY ?? process.env.OBJECT_STORAGE_SECRET_KEY ?? '',
|
||||
objectStorageEndpoint:
|
||||
process.env.OBJECT_STORAGE_ENDPOINT ?? 'http://localhost:9000',
|
||||
objectStorageBucket: process.env.OBJECT_STORAGE_BUCKET ?? 'atlas-assets',
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
markPlatformCatalogUnavailable,
|
||||
syncPlatformCatalog,
|
||||
} from './platform-catalog-sync.js';
|
||||
import { runProductPublish } from './product-publish.js';
|
||||
import { runProductAiTask } from './product-ai.js';
|
||||
import { markAspectRatioDerivativeFailed, runAspectRatioDerivative } from './media-aspect-ratio.js';
|
||||
import { markWatermarkDerivativeFailed, runWatermarkDerivative } from './media-watermark.js';
|
||||
@@ -85,6 +86,8 @@ 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_publish.requested') {
|
||||
await runProductPublish(database, objectStorage, job.data as OutboxJob);
|
||||
} else if (job.name === 'product_ai_task.requested') {
|
||||
await runProductAiTask(database, job.data as OutboxJob);
|
||||
} else if (job.name === 'media.aspect_ratio_derivative.requested') {
|
||||
|
||||
@@ -50,7 +50,8 @@ export function parseOzonListPage(value: unknown): {
|
||||
}
|
||||
|
||||
export function parseOzonInfoItems(value: unknown): OzonProductDetail[] {
|
||||
const result = record(record(value)?.result);
|
||||
const root = record(value);
|
||||
const result = Array.isArray(root?.items) ? root : record(root?.result);
|
||||
if (!result || !Array.isArray(result.items))
|
||||
throw new Error('Ozon 商品详情响应格式异常');
|
||||
return result.items.filter((item): item is OzonProductDetail =>
|
||||
|
||||
@@ -11,6 +11,10 @@ export class OzonRequestError extends Error {
|
||||
}
|
||||
}
|
||||
export class OzonRetryableError extends Error {}
|
||||
/** 429 明确未受理写请求,可按平台要求延迟重试。 */
|
||||
export class OzonRateLimitError extends OzonRetryableError {
|
||||
constructor(public readonly retryAfterSeconds: number) { super('Ozon request rate limited'); }
|
||||
}
|
||||
|
||||
export type OzonCategoryNode = {
|
||||
/** 类型叶子会省略这两个字段,继承最近的父类目。 */
|
||||
@@ -193,7 +197,13 @@ export async function requestOzon(
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OzonCredentialError(response.status);
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (response.status === 429) {
|
||||
const header = response.headers.get('Item-Retry-After') ?? response.headers.get('Retry-After');
|
||||
const parsed = Number(header);
|
||||
const date = header ? (Date.parse(header)-Date.now())/1000 : NaN;
|
||||
throw new OzonRateLimitError(Number.isFinite(parsed) && parsed > 0 ? Math.min(86400,Math.ceil(parsed)) : Number.isFinite(date) && date > 0 ? Math.min(86400,Math.ceil(date)) : 60);
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new OzonRetryableError(`Ozon temporarily unavailable (${response.status})`);
|
||||
}
|
||||
throw new OzonRequestError(response.status);
|
||||
|
||||
@@ -16,10 +16,8 @@ export type OzonProductImportItem = {
|
||||
name: string;
|
||||
description_category_id: number;
|
||||
type_id: number;
|
||||
price: number;
|
||||
price: string;
|
||||
currency_code?: string;
|
||||
description?: string;
|
||||
barcode?: string;
|
||||
images?: string[];
|
||||
attributes: OzonProductImportAttribute[];
|
||||
depth: number;
|
||||
@@ -78,6 +76,7 @@ export function buildOzonProductImportPayload(
|
||||
);
|
||||
const typeId = positiveInteger(source.category.type_id, 'category.type_id');
|
||||
const name = requiredText(source.title, 'title');
|
||||
if ([...name].length > 500 || source.variants.length > 50) throw new OzonProductImportPayloadError('profile', 'title', '标题最多 500 字符,每批最多 50 个 SKU');
|
||||
|
||||
return {
|
||||
items: source.variants.map((variant) => {
|
||||
@@ -86,8 +85,7 @@ export function buildOzonProductImportPayload(
|
||||
variant.package_spec,
|
||||
);
|
||||
const currencyCode = optionalText(variant.currency_code);
|
||||
const description = optionalText(source.description);
|
||||
const barcode = optionalText(source.barcode);
|
||||
if ([...(variant.offer_id ?? '')].length > 50 || variant.images.length > 30) throw new OzonProductImportPayloadError(variant.variant_id, 'offer_id', '货号最多 50 字符,每 SKU 最多 30 张图片');
|
||||
return {
|
||||
offer_id: requiredText(
|
||||
variant.offer_id,
|
||||
@@ -97,14 +95,8 @@ export function buildOzonProductImportPayload(
|
||||
name,
|
||||
description_category_id: categoryId,
|
||||
type_id: typeId,
|
||||
price: positiveDecimal(
|
||||
variant.price,
|
||||
'price',
|
||||
variant.variant_id,
|
||||
),
|
||||
price: priceString(variant.price, variant.variant_id),
|
||||
...(currencyCode ? { currency_code: currencyCode } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(barcode ? { barcode } : {}),
|
||||
...(variant.images.length ? { images: variant.images } : {}),
|
||||
// 每个 Ozon items[] 同时携带商品级属性和本 SKU 的 aspect 属性。
|
||||
attributes: [
|
||||
@@ -195,3 +187,9 @@ function positiveDecimal(
|
||||
`${field} 必须大于 0`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 售价保留十进制字符串,不用浮点数重新格式化报价快照。 */
|
||||
function priceString(value: string | null, variantId: string) {
|
||||
positiveDecimal(value, 'price', variantId);
|
||||
return value!.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import sharp from 'sharp';
|
||||
import type { OzonPublishItemSnapshot } from '@atlas/contracts';
|
||||
|
||||
export type PublishMediaConfig = {
|
||||
provider: string;
|
||||
bucket: string;
|
||||
downloadOrigin: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
/** 七牛私有下载凭证签署包含过期时间的完整 URL,凭证只在提交期间保留。 */
|
||||
export function qiniuPrivateDownloadUrl(
|
||||
origin: string,
|
||||
key: string,
|
||||
deadline: number,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
): string {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
base.protocol !== 'https:' ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash ||
|
||||
base.pathname !== '/' ||
|
||||
!accessKey ||
|
||||
!secretKey ||
|
||||
!Number.isSafeInteger(deadline) ||
|
||||
deadline <= 0
|
||||
)
|
||||
throw new Error('请配置七牛公网 HTTPS 下载域名及签名凭据');
|
||||
const url = `${base.origin}/${key.split('/').map(encodeURIComponent).join('/')}?e=${deadline}`;
|
||||
const signature = createHmac('sha1', secretKey)
|
||||
.update(url)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
return `${url}&token=${accessKey}:${signature}`;
|
||||
}
|
||||
|
||||
/** 为本次发布复制不可变图片,源 checksum 不符时禁止交付;WebP 转为 JPEG。 */
|
||||
export async function prepareOzonImages(
|
||||
storage: S3Client,
|
||||
snapshot: OzonPublishItemSnapshot,
|
||||
workspaceId: string,
|
||||
jobId: string,
|
||||
config: PublishMediaConfig,
|
||||
): Promise<string[]> {
|
||||
if (
|
||||
config.provider !== 'qiniu_kodo' ||
|
||||
!config.downloadOrigin ||
|
||||
!Number.isSafeInteger(config.ttlSeconds) ||
|
||||
config.ttlSeconds < 3600
|
||||
)
|
||||
throw new Error(
|
||||
'上架图片交付尚未配置,请配置七牛 HTTPS 下载域名和有效期',
|
||||
);
|
||||
const urls: string[] = [];
|
||||
for (const media of snapshot.media) {
|
||||
if (
|
||||
media.storage_provider !== config.provider ||
|
||||
media.bucket !== config.bucket
|
||||
)
|
||||
throw new Error('图片存储环境与当前七牛配置不一致,请先迁移素材');
|
||||
const object = await storage.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: media.bucket,
|
||||
Key: media.object_key,
|
||||
}),
|
||||
{ abortSignal: AbortSignal.timeout(60_000) },
|
||||
);
|
||||
if (!object.Body) throw new Error('发布源图片不存在');
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||
size += chunk.byteLength;
|
||||
if (size > 20 * 1024 * 1024) throw new Error('发布图片超过 20 MB');
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const source = Buffer.concat(chunks);
|
||||
if (
|
||||
createHash('sha256').update(source).digest('hex') !==
|
||||
media.checksum_sha256
|
||||
)
|
||||
throw new Error('图片内容已变更,请重新保存草稿后上架');
|
||||
const image = sharp(source, {
|
||||
limitInputPixels: 100_000_000,
|
||||
failOn: 'error',
|
||||
});
|
||||
const meta = await image.metadata();
|
||||
if (!['jpeg', 'png', 'webp'].includes(meta.format ?? ''))
|
||||
throw new Error('发布图片实际格式不受支持');
|
||||
const webp = meta.format === 'webp';
|
||||
const bytes = webp
|
||||
? await image
|
||||
.rotate()
|
||||
.flatten({ background: '#ffffff' })
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer()
|
||||
: source;
|
||||
const ext = meta.format === 'png' ? 'png' : 'jpg';
|
||||
const key = `publishing/${workspaceId}/${jobId}/${media.asset_id}-${media.checksum_sha256}.${ext}`;
|
||||
await storage.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: media.bucket,
|
||||
Key: key,
|
||||
Body: bytes,
|
||||
ContentType: ext === 'png' ? 'image/png' : 'image/jpeg',
|
||||
}),
|
||||
{ abortSignal: AbortSignal.timeout(60_000) },
|
||||
);
|
||||
urls.push(
|
||||
qiniuPrivateDownloadUrl(
|
||||
config.downloadOrigin,
|
||||
key,
|
||||
Math.floor(Date.now() / 1000) + config.ttlSeconds,
|
||||
config.accessKey,
|
||||
config.secretKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export async function runProductAiTask(database: Database, job: OutboxJob) {
|
||||
taskId,
|
||||
status,
|
||||
JSON.stringify(generated.result),
|
||||
process.env.DEEPSEEK_MODEL_NAME || 'deepseek-chat',
|
||||
process.env.DEEPSEEK_MODEL_NAME || 'deepseek-flash',
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -605,6 +605,8 @@ function aiFailureMessage(code: string) {
|
||||
return 'DeepSeek 当前繁忙或限流,请稍后重试';
|
||||
case 'DEEPSEEK_EMPTY_RESULT':
|
||||
return 'DeepSeek 未返回生成内容,请稍后重试';
|
||||
case 'DEEPSEEK_OUTPUT_TRUNCATED':
|
||||
return 'DeepSeek 生成内容超过长度限制,请精简采集资料后重试';
|
||||
case 'DEEPSEEK_INVALID_JSON':
|
||||
return 'DeepSeek 返回格式异常,请稍后重试';
|
||||
case 'DEEPSEEK_UNAVAILABLE':
|
||||
@@ -622,15 +624,16 @@ function aiFailureMessage(code: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 官方 JSON 模式可能返回空内容,因此解析与网络调用分别有限次重试。 */
|
||||
async function deepSeekJson(
|
||||
/** 结构化填报不需要思考输出;避免推理耗尽正文预算,并对空白和截断结果有限次重试。 */
|
||||
export 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';
|
||||
const model = process.env.DEEPSEEK_MODEL_NAME || 'deepseek-flash';
|
||||
let last: unknown;
|
||||
let maxTokens = 8192;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -648,10 +651,17 @@ async function deepSeekJson(
|
||||
role: 'system',
|
||||
content: `${system}\n输出必须是 JSON。`,
|
||||
},
|
||||
{ role: 'user', content: user },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
attempt === 0
|
||||
? user
|
||||
: `${user}\n请立即返回完整的 JSON 对象,不要只返回空白。`,
|
||||
},
|
||||
],
|
||||
thinking: { type: 'disabled' },
|
||||
response_format: { type: 'json_object' },
|
||||
max_tokens: 3500,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 0.2,
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
@@ -663,10 +673,18 @@ async function deepSeekJson(
|
||||
throw new AiProviderError('DEEPSEEK_REJECTED');
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
choices?: Array<{
|
||||
finish_reason?: string;
|
||||
message?: { content?: string };
|
||||
}>;
|
||||
usage?: { total_tokens?: number };
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
const choice = body.choices?.[0];
|
||||
if (choice?.finish_reason === 'length') {
|
||||
maxTokens = 16384;
|
||||
throw new AiProviderError('DEEPSEEK_OUTPUT_TRUNCATED');
|
||||
}
|
||||
const content = choice?.message?.content?.trim();
|
||||
if (!content) throw new AiProviderError('DEEPSEEK_EMPTY_RESULT');
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
OzonPublishItemSnapshot,
|
||||
ProductPublishItemStatus,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import type { S3Client } from '@aws-sdk/client-s3';
|
||||
import type { OutboxJob } from './outbox-dispatcher.js';
|
||||
import { workerConfig } from './config.js';
|
||||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||
import {
|
||||
OzonCredentialError,
|
||||
OzonRequestError,
|
||||
OzonRateLimitError,
|
||||
requestOzon,
|
||||
} from './ozon-client.js';
|
||||
import { prepareOzonImages } from './ozon-publish-media.js';
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
listing_id: string;
|
||||
variant_id: string;
|
||||
offer_id: string;
|
||||
status: ProductPublishItemStatus;
|
||||
snapshot: OzonPublishItemSnapshot;
|
||||
};
|
||||
type Job = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
profile_id: string;
|
||||
store_id: string;
|
||||
status: string;
|
||||
ozon_task_id: string | null;
|
||||
submit_started_at: string | null;
|
||||
poll_count: number;
|
||||
};
|
||||
type ErrorInfo = { code: string; field?: string; message: string };
|
||||
export type PublishDependencies = {
|
||||
request: typeof requestOzon;
|
||||
images: (
|
||||
snapshot: OzonPublishItemSnapshot,
|
||||
workspaceId: string,
|
||||
jobId: string,
|
||||
) => Promise<string[]>;
|
||||
decrypt: (cipher: Buffer, nonce: Buffer) => string;
|
||||
};
|
||||
|
||||
/** 发布输入按 SKU 冻结,VAT、条码、仓库、库存以及顶层描述均不进入首期请求。 */
|
||||
export function publishRequestItem(
|
||||
snapshot: OzonPublishItemSnapshot,
|
||||
images: string[],
|
||||
) {
|
||||
const item = {
|
||||
offer_id: snapshot.offer_id,
|
||||
name: snapshot.name,
|
||||
description_category_id: snapshot.description_category_id,
|
||||
type_id: snapshot.type_id,
|
||||
price: snapshot.price,
|
||||
...(snapshot.old_price ? { old_price: snapshot.old_price } : {}),
|
||||
currency_code: snapshot.currency_code,
|
||||
depth: snapshot.depth,
|
||||
width: snapshot.width,
|
||||
height: snapshot.height,
|
||||
weight: snapshot.weight,
|
||||
dimension_unit: snapshot.dimension_unit,
|
||||
weight_unit: snapshot.weight_unit,
|
||||
attributes: snapshot.attributes,
|
||||
};
|
||||
if (
|
||||
!images.length ||
|
||||
images.length > 30 ||
|
||||
[...item.offer_id].length > 50 ||
|
||||
[...item.name].length > 500
|
||||
)
|
||||
throw new Error('发布快照超出 Ozon 字段上限');
|
||||
if (
|
||||
![item.depth, item.width, item.height, item.weight].every(
|
||||
(n) => Number.isInteger(n) && n > 0,
|
||||
)
|
||||
)
|
||||
throw new Error('包装规格必须为正整数');
|
||||
return { ...item, images };
|
||||
}
|
||||
export function parseInfoProducts(
|
||||
body: unknown,
|
||||
): Array<{ offer_id: string; id: string }> {
|
||||
const data = body as { items?: unknown; result?: { items?: unknown } };
|
||||
const items = data?.items ?? data?.result?.items;
|
||||
if (!Array.isArray(items)) throw new Error('Ozon 商品详情响应格式异常');
|
||||
return items.map((raw) => {
|
||||
const item = raw as {
|
||||
offer_id?: unknown;
|
||||
id?: unknown;
|
||||
product_id?: unknown;
|
||||
};
|
||||
const id = String(item.id ?? item.product_id ?? '');
|
||||
if (
|
||||
typeof item.offer_id !== 'string' ||
|
||||
!/^\d+$/.test(id) ||
|
||||
id === '0'
|
||||
)
|
||||
throw new Error('Ozon 商品详情缺少标识');
|
||||
return { offer_id: item.offer_id, id };
|
||||
});
|
||||
}
|
||||
export function safePublishErrors(input: unknown): ErrorInfo[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.slice(0, 30).map((raw) => {
|
||||
const item = raw as Record<string, unknown>;
|
||||
return {
|
||||
code: clean(item.code ?? 'OZON_IMPORT_FAILED', 100),
|
||||
...(typeof item.field === 'string'
|
||||
? { field: clean(item.field, 200) }
|
||||
: {}),
|
||||
message: clean(
|
||||
item.message ??
|
||||
item.description ??
|
||||
'Ozon 拒绝该 SKU,请核对商品资料',
|
||||
800,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
function clean(value: unknown, max: number) {
|
||||
return String(value)
|
||||
.replace(/https?:\/\/[^\s"<>]+/gi, '[链接已隐藏]')
|
||||
.replace(
|
||||
/(?:api[-_ ]?key|token|signature)\s*[:=]\s*[^\s,;]+/gi,
|
||||
'[凭证已隐藏]',
|
||||
)
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/** 一次事件执行一次提交或轮询;延迟由持久化 Outbox 控制,不长期占用 Worker。 */
|
||||
export async function runProductPublish(
|
||||
database: Database,
|
||||
storage: S3Client,
|
||||
event: OutboxJob,
|
||||
overrides?: Partial<PublishDependencies>,
|
||||
) {
|
||||
const dependencies: PublishDependencies = {
|
||||
request: requestOzon,
|
||||
images: (snapshot, workspaceId, jobId) =>
|
||||
prepareOzonImages(storage, snapshot, workspaceId, jobId, {
|
||||
provider: workerConfig.objectStorageProvider,
|
||||
bucket: workerConfig.objectStorageBucket,
|
||||
downloadOrigin: workerConfig.ozonImageDownloadOrigin,
|
||||
accessKey: workerConfig.qiniuAccessKey,
|
||||
secretKey: workerConfig.qiniuSecretKey,
|
||||
ttlSeconds: workerConfig.ozonImageUrlTtlSeconds,
|
||||
}),
|
||||
decrypt: (cipher, nonce) =>
|
||||
decryptStoreSecret(
|
||||
cipher,
|
||||
nonce,
|
||||
workerConfig.storeSecretEncryptionKey,
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
const id = String(event.payload.publish_job_id ?? '');
|
||||
const token = randomUUID();
|
||||
const claimed = await database.workspaceTransaction(
|
||||
event.workspaceId,
|
||||
async (tx) => {
|
||||
const result = await tx.query<Job & { lease_until: string | null }>(
|
||||
`SELECT * FROM atlas.product_publish_jobs WHERE id=$1 FOR UPDATE`,
|
||||
[id],
|
||||
);
|
||||
const job = result.rows[0];
|
||||
if (
|
||||
!job ||
|
||||
['succeeded', 'partially_succeeded', 'failed'].includes(
|
||||
job.status,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
job.lease_until &&
|
||||
new Date(job.lease_until).getTime() > Date.now()
|
||||
) {
|
||||
await enqueue(
|
||||
tx,
|
||||
event.workspaceId,
|
||||
id,
|
||||
Math.ceil(
|
||||
(new Date(job.lease_until).getTime() - Date.now()) /
|
||||
1000,
|
||||
) + 1,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET lease_token=$2,lease_until=now()+interval '30 minutes',status=CASE WHEN status='reconciling' THEN status ELSE 'running' END,updated_at=now() WHERE id=$1`,
|
||||
[id, token],
|
||||
);
|
||||
const items = await tx.query<Item>(
|
||||
`SELECT id,listing_id,variant_id,offer_id,status,snapshot FROM atlas.product_publish_job_items WHERE job_id=$1 ORDER BY created_at,id`,
|
||||
[id],
|
||||
);
|
||||
return { job, items: items.rows };
|
||||
},
|
||||
);
|
||||
if (!claimed) return;
|
||||
const { job } = claimed;
|
||||
let items = claimed.items.filter(
|
||||
(i) => !['imported', 'failed', 'skipped'].includes(i.status),
|
||||
);
|
||||
const mutate = async <T>(work: (tx: DatabaseTransaction) => Promise<T>) =>
|
||||
database.workspaceTransaction(event.workspaceId, async (tx) => {
|
||||
// 与草稿保存采用相同锁顺序,成功写入与型号变更不能交错。
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.products WHERE id=$1 FOR UPDATE',
|
||||
[job.product_id],
|
||||
);
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.product_platform_profiles WHERE id=$1 FOR UPDATE',
|
||||
[job.profile_id],
|
||||
);
|
||||
const current = await tx.query(
|
||||
`SELECT id FROM atlas.product_publish_jobs WHERE id=$1 AND lease_token=$2 FOR UPDATE`,
|
||||
[id, token],
|
||||
);
|
||||
if (!current.rows.length) throw new Error('发布任务处理权已转移');
|
||||
return work(tx);
|
||||
});
|
||||
const release = async (delay: number, reconciling = false) =>
|
||||
mutate(async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET status=$3,lease_until=NULL,lease_token=NULL,poll_count=poll_count+1,updated_at=now() WHERE id=$1 AND lease_token=$2`,
|
||||
[id, token, reconciling ? 'reconciling' : 'running'],
|
||||
);
|
||||
if (reconciling)
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_job_items SET status='reconciling',errors=$2::jsonb,updated_at=now() WHERE job_id=$1 AND status IN ('queued','submitting','polling','reconciling')`,
|
||||
[
|
||||
id,
|
||||
JSON.stringify([
|
||||
{
|
||||
code: 'OZON_SUBMISSION_UNCERTAIN',
|
||||
message: '平台提交结果待对账,请勿重复提交',
|
||||
},
|
||||
]),
|
||||
],
|
||||
);
|
||||
await enqueue(tx, event.workspaceId, id, delay);
|
||||
});
|
||||
const finish = () =>
|
||||
mutate(async (tx) => {
|
||||
const counts = await tx.query<{
|
||||
total: string;
|
||||
imported: string;
|
||||
pending: string;
|
||||
}>(
|
||||
`SELECT count(*)::text AS total,count(*) FILTER(WHERE status='imported')::text AS imported,count(*) FILTER(WHERE status IN ('queued','submitting','polling','reconciling'))::text AS pending FROM atlas.product_publish_job_items WHERE job_id=$1`,
|
||||
[id],
|
||||
);
|
||||
const c = counts.rows[0];
|
||||
if (Number(c.pending)) return;
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET status=$2,completed_at=now(),lease_until=NULL,lease_token=NULL,updated_at=now() WHERE id=$1`,
|
||||
[
|
||||
id,
|
||||
Number(c.imported) === Number(c.total)
|
||||
? 'succeeded'
|
||||
: Number(c.imported)
|
||||
? 'partially_succeeded'
|
||||
: 'failed',
|
||||
],
|
||||
);
|
||||
});
|
||||
const complete = async (
|
||||
item: Item,
|
||||
status: 'imported' | 'failed' | 'skipped',
|
||||
productId: string | null,
|
||||
errors: ErrorInfo[],
|
||||
) =>
|
||||
mutate(async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_job_items SET status=$2,platform_product_id=$3,errors=$4::jsonb,updated_at=now() WHERE id=$1 AND status NOT IN ('imported','failed','skipped')`,
|
||||
[item.id, status, productId, JSON.stringify(errors)],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET status=$2,platform_product_id=CASE WHEN $2='imported' THEN $3 ELSE platform_product_id END,first_imported_at=CASE WHEN $2='imported' THEN coalesce(first_imported_at,now()) ELSE first_imported_at END,last_error=$4::jsonb,revision=revision+1,updated_at=now() WHERE id=$1 AND status='submitting'`,
|
||||
[
|
||||
item.listing_id,
|
||||
status === 'imported' ? 'imported' : 'failed',
|
||||
productId,
|
||||
JSON.stringify(errors),
|
||||
],
|
||||
);
|
||||
});
|
||||
try {
|
||||
const credentials = await database.workspaceTransaction(
|
||||
event.workspaceId,
|
||||
async (tx) =>
|
||||
(
|
||||
await tx.query<{
|
||||
platform_client_id: string;
|
||||
secret_ciphertext: Buffer;
|
||||
secret_nonce: Buffer;
|
||||
}>(
|
||||
`SELECT s.platform_client_id,secret.secret_ciphertext,secret.secret_nonce FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.store_id=s.id AND secret.kind='seller_api_key' AND secret.revoked_at IS NULL WHERE s.id=$1 AND s.deleted_at IS NULL AND s.status='active' AND s.credential_status='valid' ORDER BY secret.created_at DESC LIMIT 1`,
|
||||
[job.store_id],
|
||||
)
|
||||
).rows[0],
|
||||
);
|
||||
if (!credentials) throw new OzonCredentialError(401);
|
||||
const apiKey = dependencies.decrypt(
|
||||
credentials.secret_ciphertext,
|
||||
credentials.secret_nonce,
|
||||
);
|
||||
const remote = async () =>
|
||||
parseInfoProducts(
|
||||
await dependencies.request(
|
||||
credentials.platform_client_id,
|
||||
apiKey,
|
||||
'/v3/product/info/list',
|
||||
{ offer_id: items.map((i) => i.offer_id) },
|
||||
),
|
||||
);
|
||||
if (job.ozon_task_id) {
|
||||
const body = (await dependencies.request(
|
||||
credentials.platform_client_id,
|
||||
apiKey,
|
||||
'/v1/product/import/info',
|
||||
{ task_id: Number(job.ozon_task_id) },
|
||||
)) as {
|
||||
result?: {
|
||||
items?: Array<{
|
||||
offer_id: string;
|
||||
status: string;
|
||||
product_id?: number | string;
|
||||
errors?: unknown;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
if (!Array.isArray(body.result?.items))
|
||||
throw new Error('Ozon 导入任务响应格式异常');
|
||||
for (const item of items) {
|
||||
const outcome = body.result.items.find(
|
||||
(r) => r.offer_id === item.offer_id,
|
||||
);
|
||||
if (!outcome) continue;
|
||||
if (
|
||||
outcome.status === 'imported' &&
|
||||
outcome.product_id &&
|
||||
String(outcome.product_id) !== '0'
|
||||
)
|
||||
await complete(
|
||||
item,
|
||||
'imported',
|
||||
String(outcome.product_id),
|
||||
[],
|
||||
);
|
||||
else if (outcome.status === 'failed')
|
||||
await complete(
|
||||
item,
|
||||
'failed',
|
||||
null,
|
||||
safePublishErrors(outcome.errors).length
|
||||
? safePublishErrors(outcome.errors)
|
||||
: [
|
||||
{
|
||||
code: 'OZON_IMPORT_FAILED',
|
||||
message: 'Ozon 导入失败,请检查商品资料',
|
||||
},
|
||||
],
|
||||
);
|
||||
else if (outcome.status === 'skipped') {
|
||||
const found = (await remote()).find(
|
||||
(r) => r.offer_id === item.offer_id,
|
||||
);
|
||||
if (found)
|
||||
await complete(item, 'imported', found.id, [
|
||||
{
|
||||
code: 'OZON_IMPORT_RECONCILED',
|
||||
message:
|
||||
'Ozon 跳过该项,已通过商品信息对账确认货号存在',
|
||||
},
|
||||
]);
|
||||
else
|
||||
await complete(item, 'skipped', null, [
|
||||
{
|
||||
code: 'OZON_IMPORT_SKIPPED',
|
||||
message:
|
||||
'Ozon 跳过该 SKU,未确认导入成功,请检查平台记录',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else if (job.submit_started_at) {
|
||||
// 提交标记已写入但 task ID 未落库:只对账,绝不盲目重发导入。
|
||||
const found = await remote();
|
||||
for (const item of items) {
|
||||
const match = found.find((r) => r.offer_id === item.offer_id);
|
||||
if (match)
|
||||
await complete(item, 'imported', match.id, [
|
||||
{
|
||||
code: 'OZON_IMPORT_RECONCILED',
|
||||
message:
|
||||
'提交响应未保存,已通过货号对账确认商品存在',
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
const existing = await remote();
|
||||
for (const item of items)
|
||||
if (existing.some((r) => r.offer_id === item.offer_id))
|
||||
await complete(item, 'skipped', null, [
|
||||
{
|
||||
code: 'OZON_OFFER_ALREADY_EXISTS',
|
||||
message:
|
||||
'该货号已存在于当前店铺,本次未重复导入。请前往在线商品查看或在 Ozon 后台处理。',
|
||||
},
|
||||
]);
|
||||
items = items.filter(
|
||||
(i) => !existing.some((r) => r.offer_id === i.offer_id),
|
||||
);
|
||||
if (!items.length) {
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
const payload = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
payload.push(
|
||||
publishRequestItem(
|
||||
item.snapshot,
|
||||
await dependencies.images(
|
||||
item.snapshot,
|
||||
event.workspaceId,
|
||||
id,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// 本批素材全部准备成功才允许提交,存储原始错误不向用户暴露对象路径。
|
||||
for (const pending of items)
|
||||
await complete(pending, 'failed', null, [
|
||||
{
|
||||
code: 'PUBLISH_MEDIA_OR_SNAPSHOT_INVALID',
|
||||
message:
|
||||
'发布图片或商品快照准备失败,请检查七牛交付配置、素材和包装规格后重试',
|
||||
},
|
||||
]);
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
await mutate((tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET lease_until=now()+interval '30 minutes' WHERE id=$1`,
|
||||
[id],
|
||||
),
|
||||
);
|
||||
}
|
||||
await mutate(async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET submit_started_at=now(),updated_at=now() WHERE id=$1`,
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_job_items SET status='submitting',updated_at=now() WHERE job_id=$1 AND status='queued'`,
|
||||
[id],
|
||||
);
|
||||
});
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await dependencies.request(
|
||||
credentials.platform_client_id,
|
||||
apiKey,
|
||||
'/v3/product/import',
|
||||
{ items: payload },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof OzonRateLimitError) {
|
||||
await mutate(async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET submit_started_at=NULL WHERE id=$1`,
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_job_items SET status='queued' WHERE job_id=$1 AND status='submitting'`,
|
||||
[id],
|
||||
);
|
||||
});
|
||||
await release(error.retryAfterSeconds);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error instanceof OzonRequestError ||
|
||||
error instanceof OzonCredentialError
|
||||
) {
|
||||
for (const item of items)
|
||||
await complete(item, 'failed', null, [
|
||||
{
|
||||
code: 'OZON_IMPORT_REJECTED',
|
||||
message: `Ozon 拒绝导入(HTTP ${error.status}),请检查店铺授权和商品资料`,
|
||||
},
|
||||
]);
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
await release(30, true);
|
||||
return;
|
||||
}
|
||||
const taskId = String(
|
||||
(result as { result?: { task_id?: unknown } })?.result
|
||||
?.task_id ?? '',
|
||||
);
|
||||
if (!/^\d+$/.test(taskId) || taskId === '0') {
|
||||
await release(30, true);
|
||||
return;
|
||||
}
|
||||
await mutate(async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_jobs SET ozon_task_id=$2 WHERE id=$1`,
|
||||
[id, taskId],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_publish_job_items SET status='polling' WHERE job_id=$1 AND status='submitting'`,
|
||||
[id],
|
||||
);
|
||||
});
|
||||
}
|
||||
const pending = await mutate((tx) =>
|
||||
tx.query(
|
||||
`SELECT id FROM atlas.product_publish_job_items WHERE job_id=$1 AND status IN ('queued','submitting','polling','reconciling') LIMIT 1`,
|
||||
[id],
|
||||
),
|
||||
);
|
||||
if (pending.rows.length)
|
||||
await release(
|
||||
job.poll_count > 120 ? 900 : 15,
|
||||
Boolean(job.submit_started_at && !job.ozon_task_id) ||
|
||||
job.poll_count > 120,
|
||||
);
|
||||
else await finish();
|
||||
} catch (error) {
|
||||
// 读取或数据库失败仍保留平台状态,避免把未知结果当作可重发失败。
|
||||
if (
|
||||
!job.submit_started_at &&
|
||||
!job.ozon_task_id &&
|
||||
(error instanceof OzonCredentialError ||
|
||||
error instanceof OzonRequestError)
|
||||
) {
|
||||
for (const item of items)
|
||||
await complete(item, 'failed', null, [
|
||||
{
|
||||
code: 'OZON_STORE_UNAVAILABLE',
|
||||
message: '店铺授权或读取权限不可用,请重新验证店铺',
|
||||
},
|
||||
]);
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
await release(
|
||||
error instanceof OzonRateLimitError
|
||||
? error.retryAfterSeconds
|
||||
: job.poll_count > 120
|
||||
? 900
|
||||
: 60,
|
||||
Boolean(job.submit_started_at && !job.ozon_task_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
async function enqueue(
|
||||
tx: DatabaseTransaction,
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
seconds: number,
|
||||
) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.outbox_events(workspace_id,aggregate_type,aggregate_id,event_type,event_payload,next_attempt_at) VALUES($1,'product_publish_job',$2,'product_publish.requested',$3::jsonb,now()+make_interval(secs=>$4))`,
|
||||
[workspaceId, id, JSON.stringify({ publish_job_id: id }), seconds],
|
||||
);
|
||||
}
|
||||
@@ -61,3 +61,7 @@ test('不存在库存数据和非 HTTPS 图片不能伪装成有效平台数据'
|
||||
assert.equal(product.image_summary.url, null);
|
||||
assert.equal(product.selling_price, null);
|
||||
});
|
||||
|
||||
test('v3 商品详情按官方顶层 items 读取', () => {
|
||||
assert.deepEqual(parseOzonInfoItems({items:[{id:123,offer_id:'a'}]}),[{id:123,offer_id:'a'}]);
|
||||
});
|
||||
|
||||
@@ -56,10 +56,8 @@ test('按 SKU 将 SPU 默认包装规格展开为 Ozon items[]', () => {
|
||||
name: '测试商品',
|
||||
description_category_id: 17028922,
|
||||
type_id: 91234,
|
||||
price: 199.9,
|
||||
price: '199.90',
|
||||
currency_code: 'RUB',
|
||||
description: '商品描述',
|
||||
barcode: '1234567890123',
|
||||
images: ['https://cdn.example.test/a.jpg'],
|
||||
attributes: [
|
||||
{ id: 85, complex_id: 0, values: [{ value: '品牌' }] },
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { test } from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
qiniuPrivateDownloadUrl,
|
||||
prepareOzonImages,
|
||||
} from '../src/ozon-publish-media.ts';
|
||||
|
||||
const config = {
|
||||
provider: 'qiniu_kodo',
|
||||
bucket: 'test-bucket',
|
||||
downloadOrigin: 'https://images.example.test',
|
||||
accessKey: 'MY_ACCESS_KEY',
|
||||
secretKey: 'MY_SECRET_KEY',
|
||||
ttlSeconds: 86400,
|
||||
};
|
||||
test('七牛签名包含秒级有效期,HMAC-SHA1 与独立计算向量一致', () => {
|
||||
assert.equal(
|
||||
qiniuPrivateDownloadUrl(
|
||||
config.downloadOrigin,
|
||||
'resource/flower.jpg',
|
||||
1451491200,
|
||||
config.accessKey,
|
||||
config.secretKey,
|
||||
),
|
||||
'https://images.example.test/resource/flower.jpg?e=1451491200&token=MY_ACCESS_KEY:qUjF3pEnCC9cpm3MaHVikOTEevE=',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
qiniuPrivateDownloadUrl(
|
||||
'http://localhost:9000',
|
||||
'a',
|
||||
1451491200,
|
||||
'ak',
|
||||
'sk',
|
||||
),
|
||||
/HTTPS/,
|
||||
);
|
||||
});
|
||||
test('发布 WebP 按 checksum 固定源,转 JPEG 并交付独立发布路径', async () => {
|
||||
const bytes = await sharp({
|
||||
create: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
channels: 4,
|
||||
background: { r: 100, g: 80, b: 60, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.webp()
|
||||
.toBuffer();
|
||||
const checksum = createHash('sha256').update(bytes).digest('hex');
|
||||
const writes = [];
|
||||
const storage = {
|
||||
send: async (command) => {
|
||||
if (command.constructor.name === 'GetObjectCommand')
|
||||
return {
|
||||
Body: (async function* () {
|
||||
yield bytes;
|
||||
})(),
|
||||
};
|
||||
writes.push(command.input);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
const snapshot = {
|
||||
media: [
|
||||
{
|
||||
asset_id: 'asset-a',
|
||||
checksum_sha256: checksum,
|
||||
bucket: 'test-bucket',
|
||||
object_key: 'private/original',
|
||||
storage_provider: 'qiniu_kodo',
|
||||
content_type: 'image/webp',
|
||||
},
|
||||
],
|
||||
};
|
||||
const urls = await prepareOzonImages(
|
||||
storage,
|
||||
snapshot,
|
||||
'workspace-a',
|
||||
'job-a',
|
||||
config,
|
||||
);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].ContentType, 'image/jpeg');
|
||||
assert.equal((await sharp(writes[0].Body).metadata()).format, 'jpeg');
|
||||
assert.ok(writes[0].Key.startsWith('publishing/workspace-a/job-a/'));
|
||||
assert.ok(!urls[0].includes('private/original'));
|
||||
assert.ok(new URL(urls[0]).searchParams.get('token'));
|
||||
snapshot.media[0].checksum_sha256 = 'changed';
|
||||
await assert.rejects(
|
||||
prepareOzonImages(storage, snapshot, 'workspace-a', 'job-a', config),
|
||||
/图片内容已变更/,
|
||||
);
|
||||
assert.equal(writes.length, 1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { makeModel, validateCopy, validatePackage } from '../src/product-ai.ts';
|
||||
import { deepSeekJson, makeModel, validateCopy, validatePackage } from '../src/product-ai.ts';
|
||||
|
||||
test('同一商品模型只由类目和稳定商品 ID 确定', () => {
|
||||
const category = { name: '手办', path: '玩具 / 手办' };
|
||||
@@ -113,3 +113,70 @@ test('包装字段名称可作为 1688 分离尺寸和重量证据', () => {
|
||||
};
|
||||
assert.equal(validatePackage(proposal, source)?.height_mm, '30');
|
||||
});
|
||||
|
||||
test('DeepSeek 结构化请求关闭思考,截断后提高输出上限重试', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalKey = process.env.DEEPSEEK_API_KEY;
|
||||
const requests = [];
|
||||
process.env.DEEPSEEK_API_KEY = 'test-key';
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
requests.push(JSON.parse(options.body));
|
||||
const truncated = requests.length === 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: truncated ? 'length' : 'stop',
|
||||
message: { content: truncated ? '' : '{"ok":true}' },
|
||||
},
|
||||
],
|
||||
usage: { total_tokens: 25 },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(await deepSeekJson('输出 JSON', '商品事实'), {
|
||||
data: { ok: true },
|
||||
tokens: 25,
|
||||
});
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[0].thinking, { type: 'disabled' });
|
||||
assert.equal(requests[0].max_tokens, 8192);
|
||||
assert.equal(requests[1].max_tokens, 16384);
|
||||
assert.match(requests[1].messages[1].content, /不要只返回空白/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
||||
else process.env.DEEPSEEK_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test('DeepSeek JSON 模式返回空白内容时调整提示词重试', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalKey = process.env.DEEPSEEK_API_KEY;
|
||||
const requests = [];
|
||||
process.env.DEEPSEEK_API_KEY = 'test-key';
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
requests.push(JSON.parse(options.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{
|
||||
finish_reason: 'stop',
|
||||
message: { content: requests.length === 1 ? ' ' : '{"ok":true}' },
|
||||
}],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
};
|
||||
try {
|
||||
assert.deepEqual((await deepSeekJson('输出 JSON', '商品事实')).data, { ok: true });
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].max_tokens, 8192);
|
||||
assert.match(requests[1].messages[1].content, /不要只返回空白/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
||||
else process.env.DEEPSEEK_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
||||
"profile": {
|
||||
"id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs",
|
||||
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
|
||||
"title": "俄语标题", "description": null, "tags": [],
|
||||
"title": "俄语标题", "title_mode": "common", "description": null, "tags": [],
|
||||
"category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture", "name": "收纳箱", "path": "家居 › 收纳 › 收纳箱" },
|
||||
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" }
|
||||
},
|
||||
@@ -120,7 +120,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
||||
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
|
||||
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" },
|
||||
"package_weight_mode": "common", "package_dimensions_mode": "common" },
|
||||
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
|
||||
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "title_mode": "common", "description": "…", "tags": ["tag"],
|
||||
"platform_product_category_id": "uuid", "category_source": "manual" },
|
||||
"variants": [{
|
||||
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
|
||||
@@ -149,7 +149,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
||||
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
|
||||
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;当前商品成功的水印与 3:4 处理结果也可选择。采集图片仍须先转存,且来源变体须匹配。
|
||||
- `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。
|
||||
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”。编辑页保存草稿时允许中文商品名、俄文发布标题、型号、品牌、产品类目及类目必填属性留空,必填标记只提示首次上架所需资料;首次上架预检统一检查缺失项,`profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。草稿仍须携带有效的 `target_store_id` 作为店铺上下文,并遵守已填写字段的格式和长度约束。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
|
||||
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文标题”。`profile.title_mode` 为 `common` / `variant`,编辑快照必须返回当前模式,保存草稿时可传入;旧客户端省略时保留原模式,新商品默认为 `common`。切到 `variant` 时清空并禁用 SPU 俄文标题,在各 SKU 展示标题输入;切回 `common` 时经确认清空所有 SKU 俄文标题,恢复 SPU 输入。两种模式均允许空白草稿;首次上架预检在 `common` 模式检查 `profile.title`,在 `variant` 模式逐个检查待发布 SKU 的 `variants[].title`,缺失时返回 `PUBLISH_TITLE_REQUIRED`。草稿仍须携带有效的 `target_store_id` 作为店铺上下文,并遵守已填写字段的格式和长度约束。SKU 标题 `variants[].title` 为 `null` 时在旧覆盖语义下继承 `profile.title`,不回退到 `display_name`;新版逐 SKU 模式不允许发布时依赖该继承。现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
|
||||
|
||||
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
|
||||
|
||||
@@ -236,7 +236,7 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
|
||||
|
||||
`POST /api/product-platform-profiles/{profile_id}/publish-preflights`
|
||||
|
||||
Body:`{ "variant_ids": ["uuid", "uuid"] }`,最多 100 个且不能重复。该接口是创建真正发布 job 之前的可重算预检,不固化商品发布快照;平台目录任务按资源 `scope_key` 幂等合并,因此重复预检不会并发重复请求 Ozon。
|
||||
Body:`{ "variant_ids": ["uuid", "uuid"] }`,最多 50 个且不能重复。该接口是创建真正发布 job 之前的可重算预检,不固化商品发布快照;平台目录任务按资源 `scope_key` 幂等合并,因此重复预检不会并发重复请求 Ozon。
|
||||
|
||||
预检只接受当前 profile 下 `not_listed` / `failed` 的未删除 SKU。`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不属于当前商品或已删除返回 `422 PUBLISH_VARIANTS_INVALID`。
|
||||
|
||||
@@ -293,31 +293,33 @@ AI 填报接口只创建异步任务并返回 `202` + `ai_task_id`,不直接
|
||||
|
||||
`GET /api/product-ai-tasks/{ai_task_id}` 返回任务状态、输入版本、脱敏结果或错误;全局填报成功时返回类目或候选、型号、逐 SKU 货号、可证包装建议及 SPU 中俄文案,不生成 SKU 文案。单 SKU 文案由 `POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/ai/copy` 单独创建任务,在弹窗预览并由用户应用。Ozon 动态类目属性不属于当前 AI 填报范围,仍通过类目确认后加载的 schema 由用户填写。歧义类目仍需用户选择。中文对照与俄文在下一次 `editor-draft` 中一同保存,读取编辑上下文时一同返回。
|
||||
|
||||
## 7. 部分 SKU 首次上架
|
||||
## 7. SKU 首次上架(2026-09-30 实施修订)
|
||||
|
||||
> 当前实现到上述 `publish-preflights`。以下 `publish-jobs`、Ozon `/v3/product/import` 提交和结果轮询仍是冻结契约,尚未开发;不得把预检 `ready` 当成已经提交或发布成功。
|
||||
底部提供「上架至 OZON」和「部分上架」。前者选择全部 `not_listed` / `failed` 活动 SKU,列出已导入或正在提交的跳过原因;后者由用户选择相同资格的 SKU。一次 1–50 个,超过时提示分批,不自动拆批。先保存草稿,再对相同选择集合预检,用户确认后创建任务。
|
||||
|
||||
`POST /api/product-platform-profiles/{profile_id}/publish-jobs`
|
||||
|
||||
Headers:`Idempotency-Key` 必填。Body:
|
||||
Headers:`X-Atlas-Platform-Id`、`Idempotency-Key`、`If-Match: "product_revision:profile_revision"` 必填。Body:
|
||||
|
||||
```json
|
||||
{ "variant_ids": ["uuid", "uuid"] }
|
||||
```
|
||||
|
||||
服务端在创建任务前重新运行第 3 节校验。只允许当前 profile 中状态为 `not_listed` 或 `failed` 的未删除 SKU;`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不得重新提交或把编辑草稿当平台更新。
|
||||
创建事务锁定商品、profile 和 listing,重新校验版本、活动店铺、当前类目、仍可用的 schema / 实际引用字典和选中 SKU 的字段。无需强制刷新整棵目录;必须读取当前有效类目,过期 schema / 字典通过预检刷新,创建时仍过期则阻断。返回 `409 VERSION_CONFLICT`、`409 SKU_IMPORT_IN_PROGRESS`、`422 SKU_ALREADY_IMPORTED`、`422 PUBLISH_VALIDATION_FAILED`(逐字段 details)、`422 PUBLISH_VARIANTS_INVALID`。`OZON_PUBLISH_ENABLED` 在开发/测试默认允许,生产默认关闭,真实 VAT / 图片验收后部署显式开启;关闭时返回 `503 OZON_PUBLISH_DISABLED`。
|
||||
|
||||
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID、该 SKU 的有效标题/标签/描述、有效包装规格和平台适配请求,再投递 Worker。三个文案字段都按「SKU 覆盖优先,SPU 默认回退」逐字段解析,中文对照不进入 Ozon 请求。适配请求中的每个 Ozon `items[]` 必须包含该 SKU 独立解析出的 `depth`、`width`、`height`、`dimension_unit`、`weight`、`weight_unit`,不得省略或传 0。返回 `202`:
|
||||
同一事务创建 `product_publish_jobs` / `product_publish_job_items`、将 listing 置为 `submitting`,写 Outbox(只含 job ID)。冻结商品/profile 版本、店铺、货号、Ozon 类目和属性、十进制价格、逐 SKU 标题/标签/描述、有效包装规格、素材 ID / checksum / 对象身份;签名 URL 不持久化。标题按 `title_mode` 选择,标签/描述按 SKU 覆盖回退。货号最多 50 字符,发布标题最多 500 字符,每 SKU 1–30 张图。首期仅 rFBS,不传 SPU 共用条码、库存、仓库、顶层 description、视频或富内容;中国主体跨境 rFBS 首期省略 `vat`,实际导入与商品信息税率核对后再决定。
|
||||
|
||||
```json
|
||||
{ "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } }
|
||||
{ "data": { "publish_job": { "id": "uuid", "profile_id": "uuid", "store_id": "uuid", "status": "queued", "item_count": 2, "created_at": "ISO8601", "completed_at": null, "items": [] } } }
|
||||
```
|
||||
|
||||
首期 Ozon `/v3/product/import` 不传仓库或库存。Worker 按 SKU 保存提交、轮询、成功/失败和平台原因;成功 SKU 状态为 `imported` 后不能从采集箱再次上架。SPU 的未上架/已部分上架/已上架由当前平台未删除 SKU 汇总,采集箱记录始终保留。
|
||||
`GET /api/product-publish-jobs/{publish_job_id}` 返回 `{data: ProductPublishJob}`;必须传平台上下文,仅可访问当前工作空间和平台。`GET /api/product-platform-profiles/{profile_id}/publish-jobs` 返回 `{data: ProductPublishJob[]}`,最近 10 次任务,供页面刷新恢复。实际 `items` 含 id、variant_id、display_name、offer_id、status、platform_product_id、errors(code/field/message),不返回发布快照、签名 URL、密钥。job 状态为 `queued/running/succeeded/partially_succeeded/failed/reconciling`;item 状态为 `queued/submitting/polling/imported/failed/skipped/reconciling`。`reconciling` 表示提交结果不确定,listing 保持 `submitting`,禁止再次导入。202/任务 ID 仅表示受理。
|
||||
|
||||
`GET /api/product-publish-jobs/{publish_job_id}`
|
||||
Worker 提交前查询远端货号;已存在则标 `skipped` 并保留原因,不能调用导入更新它。每批最多 50 个 SKU,提交前生成七牛独立 HTTPS 私有签名图地址(有效期可配置,WebP 转 JPEG);来源与派生对象身份固定,地址仅在内存使用。导入请求的 task ID 持久化后通过延迟 Outbox 轮询,逐项处理 imported/failed/skipped。提交前预写 `submitting`:如果超时、5xx 或响应丢失,下次仅按货号对账,不盲目再次导入;无法确认则保持 reconciling。明确 429 可按 Retry-After 延迟再提交。读请求失败可以重试。平台 errors 必须脱敏,不得包含签名 URL 或请求体。
|
||||
|
||||
返回 job、每个 SKU item 的 `queued/submitting/polling/imported/failed/skipped`、Ozon task/product ID、时间和可展示的脱敏错误。发布失败不删除草稿或报价;用户修正未导入 SKU 后可以创建新的首次上架任务。在线商品镜像由另一个同步模块维护,不能以此 job 的状态替代 Ozon 当前 `visible`、库存或审核事实。
|
||||
导入成功锁定历史货号/product ID 与商品型号;其他草稿属性可修改,不隐式更新平台。存在提交中 SKU 时禁止删除该 SKU、改型号或变更目标店铺。任一 SKU 已导入后型号不可修改。沿用现有草稿换店行为:没有正在提交的 SKU 时可调整目标店铺,已导入 SKU 保留原店铺和导入身份,仍不能重复上架。失败保留草稿/报价,可修复并新建任务;任务成功不代表审核通过或有库存,在线商品事实仍由独立同步维护。
|
||||
|
||||
生产验收:同类中国主体跨境 rFBS 店铺省略 VAT 的真实导入及 `/v3/product/info/list.vat` 结果、七牛域名/证书/签名期/延迟抓取和 WebP 派生图。不能仅以模拟平台测试宣称已经通过生产验收。
|
||||
|
||||
## 8. 已替代的旧草案
|
||||
|
||||
|
||||
@@ -1,619 +1,73 @@
|
||||
# 商品导入(创建/更新)API
|
||||
# Ozon 商品导入 API(2026-09-30 核对)
|
||||
|
||||
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
|
||||
官方:[创建或更新商品](https://docs.ozon.ru/api/seller/en/#operation/ProductAPI_ImportProductsV3)、[导入状态](https://docs.ozon.ru/api/seller/en/#operation/ProductAPI_GetImportProductsInfo)、[商品信息](https://docs.ozon.ru/api/seller/en/#operation/ProductAPI_GetProductInfoList)。Atlas 的产品流程、字段来源和错误契约以 [商品编辑与首次上架](../../api/product-editor-and-publishing.md) 为准。
|
||||
|
||||
---
|
||||
## 创建或更新商品
|
||||
|
||||
## 接口信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 方法 | POST |
|
||||
| 路径 | `/v3/product/import` |
|
||||
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||
| 用途 | **创建或更新商品**(Ozon 核心接口) |
|
||||
| 异步 | ✅ 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 请求体结构
|
||||
|
||||
### 完整示例
|
||||
`POST /v3/product/import`,鉴权 Header 为 `Client-Id` / `Api-Key`。Ozon 每批最多 100 个 item,Atlas 首期收紧为最多 50 个 SKU。此接口也会更新已存在的货号,不能依赖“重复货号必定返回 409”。Atlas 在提交前查询当前店铺货号,存在时跳过;提交结果不确定时先对账,不能直接重发。
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"offer_id": "MY-THERMOS-001",
|
||||
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
|
||||
"description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.",
|
||||
"items": [{
|
||||
"offer_id": "ATLAS-THERMOS-001",
|
||||
"name": "Термокружка из нержавеющей стали 500 мл",
|
||||
"description_category_id": 17033876,
|
||||
"type_id": 97114,
|
||||
"price": "2990",
|
||||
"old_price": "3490",
|
||||
"price": "2990.00",
|
||||
"old_price": "3490.00",
|
||||
"currency_code": "RUB",
|
||||
"vat": "0",
|
||||
"depth": 80,
|
||||
"width": 80,
|
||||
"height": 200,
|
||||
"dimension_unit": "mm",
|
||||
"weight": 320,
|
||||
"weight_unit": "g",
|
||||
"images": [
|
||||
"https://cdn.example.com/thermos-main-1.jpg",
|
||||
"https://cdn.example.com/thermos-main-2.jpg"
|
||||
],
|
||||
"primary_image": "",
|
||||
"images360": [],
|
||||
"color_image": "",
|
||||
"barcode": "",
|
||||
"attributes": [
|
||||
{
|
||||
"complex_id": 0,
|
||||
"id": 85,
|
||||
"values": [
|
||||
{
|
||||
"dictionary_value_id": 971082156,
|
||||
"value": "Thermos"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"complex_id": 0,
|
||||
"id": 8505,
|
||||
"values": [
|
||||
{
|
||||
"value": "Нержавеющая сталь"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"complex_attributes": []
|
||||
}
|
||||
]
|
||||
"images": ["https://images.example.test/publishing/photo.jpg"],
|
||||
"attributes": [{"complex_id": 0, "id": 9048, "values": [{"value": "ATLAS-THERMOS"}]}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
示例类目、类型和属性必须替换为目标类目当前有效目录,不可直接作为真实商品输入。
|
||||
|
||||
## 2. 字段说明
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| **offer_id** | string | ✅ 是 | **自己的货号**(唯一标识,用于更新)。最长 255 字符 |
|
||||
| **name** | string | ✅ 是 | 商品名称。最长 500 字符 |
|
||||
| **description** | string | ✅ 是 | 商品描述。最长 5000 字符,支持 HTML 标签 |
|
||||
| **description_category_id** | integer | ✅ 是 | 类目 ID(从类目树获取) |
|
||||
| **type_id** | integer | ✅ 是 | 商品类型 ID(从类目树获取) |
|
||||
|
||||
### 价格
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| **price** | string | ✅ 是 | 销售价(字符串格式,如 `"2990"` = 2990 卢布) |
|
||||
| old_price | string | 可选 | 划线价(原价),用于展示折扣 |
|
||||
| **currency_code** | string | ✅ 是 | 币种,通常 `"RUB"`(卢布)。也可 `"CNY"` 等 |
|
||||
| **vat** | string | ✅ 是 | 增值税率:`"0"` / `"0.1"` / `"0.2"`。俄罗斯默认 `"0"` |
|
||||
|
||||
### 尺寸与重量
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| **depth** | number | ✅ 是 | 长度(包装尺寸)。**不能为 0** |
|
||||
| **width** | number | ✅ 是 | 宽度(包装尺寸)。**不能为 0** |
|
||||
| **height** | number | ✅ 是 | 高度(包装尺寸)。**不能为 0** |
|
||||
| **dimension_unit** | string | ✅ 是 | 尺寸单位:`"mm"` / `"cm"` |
|
||||
| **weight** | number | ✅ 是 | 重量(包装重量)。**不能为 0** |
|
||||
| **weight_unit** | string | ✅ 是 | 重量单位:`"g"` / `"kg"` |
|
||||
|
||||
⚠️ **硬约束**:尺寸和重量必须 **> 0**,否则 API 返回 400 错误。
|
||||
|
||||
### 图片
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| **images** | array | ✅ 是 | **主图数组**(最多 15 张)。顺序即展示顺序,第一张为主图 |
|
||||
| primary_image | string | 可选 | 主图(单独指定)。若使用则 `images` 最多 14 张 |
|
||||
| images360 | array | 可选 | 360° 图片数组 |
|
||||
| color_image | string | 可选 | 营销色图(部分类目支持) |
|
||||
|
||||
⚠️ **硬约束**:
|
||||
- 图片 URL 必须是 **https 公网直链**(http 会被拒绝)
|
||||
- 图片需可访问(Ozon 服务器会主动拉取)
|
||||
- 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+
|
||||
|
||||
### 属性
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| **attributes** | array | ✅ 是 | 商品属性数组(从类目属性获取) |
|
||||
| attributes[].complex_id | integer | ✅ 是 | 复杂属性 ID,通常填 `0` |
|
||||
| attributes[].id | integer | ✅ 是 | 属性 ID |
|
||||
| attributes[].values | array | ✅ 是 | 属性值数组 |
|
||||
| values[].dictionary_value_id | integer | 条件 | 字典值 ID(属性有字典时必填) |
|
||||
| values[].value | string | ✅ 是 | 属性值文本 |
|
||||
|
||||
### 复杂属性
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| complex_attributes | array | 可选 | 复杂属性(视频、尺码表等) |
|
||||
|
||||
### 其他
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| barcode | string | 可选 | 条形码 |
|
||||
| pdf_list | array | 可选 | PDF 文件 URL 列表 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 响应
|
||||
|
||||
### 成功响应(200)
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"task_id": 123456789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| task_id | integer | **任务 ID**(用于轮询状态,见下节) |
|
||||
|
||||
⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 4. 轮询任务状态
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项 | 值 |
|
||||
| 字段 | 当前约束 / Atlas 首期处理 |
|
||||
|---|---|
|
||||
| 方法 | POST |
|
||||
| 路径 | `/v1/product/import/info` |
|
||||
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
|
||||
| `offer_id` | 最多 50 字符;每个 SKU 独立货号,当前店铺唯一 |
|
||||
| `name` | 最多 500 字符;按已保存的标题模式选择通用或 SKU 标题 |
|
||||
| `description_category_id` / `type_id` | 当前有效类目、类型数值 ID |
|
||||
| `price` / `old_price` | 十进制字符串;售价大于零,划线价大于售价时才传 |
|
||||
| `currency_code` | 与目标店铺实际币种一致 |
|
||||
| `vat` | 官方俄/英文 OpenAPI 可选,中文版列为必填,存在差异;`0` 表示不征收 VAT,其他列出的值为 `0.05`、`0.07`、`0.1`、`0.2`、`0.22`。Atlas 中国主体跨境 rFBS 首期省略,真实测试后确认,不设置默认税率 |
|
||||
| 包装规格 | 正整数 mm/g;每个 SKU 合并通用默认值与自己的覆盖值,不传商品本体规格 |
|
||||
| `images` | JPG/PNG 公网链接,最多 30 张,第一张主图;若额外传 `primary_image`,则 images 最多 29 张。Atlas 首期不另传 `primary_image` |
|
||||
| `attributes` | 按当前类目定义拼装;字典值用官方 ID 和文本,SKU aspect 属性独立解析。品牌、型号、标签和描述仅映射当前类目有效的固定属性 |
|
||||
| 商品描述 | 不在 item 顶层传 `description`,应通过描述属性(例如当前类目可用时的 4191)填写 |
|
||||
| 条码 / 库存 / 仓库 | rFBS 首期不传;不将 SPU 共用条码复制到多个 SKU |
|
||||
| 视频、富内容、360 图 | 首期不发布 |
|
||||
|
||||
### 请求
|
||||
返回 `{"result":{"task_id":123456789}}` 只表示受理,商品导入仍需查询任务状态。
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": 123456789
|
||||
}
|
||||
```
|
||||
## 查询导入结果
|
||||
|
||||
### 响应
|
||||
`POST /v1/product/import/info` 请求 `{"task_id":123456789}`,返回 `result.items[]`,每项含 `offer_id`、`product_id`、`status`、`errors`。
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"items": [
|
||||
{
|
||||
"offer_id": "MY-THERMOS-001",
|
||||
"product_id": 987654321,
|
||||
"status": "imported",
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
| 状态 | 处理 |
|
||||
|---|---|
|
||||
| `pending` | 继续轮询,等待导入 |
|
||||
| `imported` | 保存远端 product ID,确认该 SKU 已导入;不等于审核通过、前台可见或有库存 |
|
||||
| `failed` | 保存脱敏错误,用户修正后可以新建首次发布任务 |
|
||||
| `skipped` | 对账确认是否已有商品;不能仅凭 skipped 声称导入成功 |
|
||||
|
||||
### 状态值
|
||||
审核状态应从商品信息接口读取,不能将 `processing` / `moderation` 当成此导入任务的 item 状态。
|
||||
|
||||
| status | 含义 | 处理 |
|
||||
|---|---|---|
|
||||
| **imported** | ✅ 导入成功 | 保存 `product_id`,标记商品为 `published` |
|
||||
| **pending** | ⏳ 排队中 | 继续轮询(间隔 5s) |
|
||||
| **processing** | ⏳ 处理中 | 继续轮询(间隔 5s) |
|
||||
| **moderation** | ⏳ 审核中 | 继续轮询(间隔 30s,审核可能需几小时) |
|
||||
| **failed** | ❌ 失败 | 读取 `errors` 数组,展示错误给用户 |
|
||||
## 查询商品信息与对账
|
||||
|
||||
### 错误结构
|
||||
`POST /v3/product/info/list` 可传 `offer_id` / `product_id` / `sku`。当前官方响应为**顶层 `items[]`**,不是 `result.items[]`;每个商品含 `id`、`offer_id`、`vat`、`statuses` 等字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"offer_id": "MY-THERMOS-001",
|
||||
"product_id": 0,
|
||||
"status": "failed",
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_ATTRIBUTE",
|
||||
"message": "Attribute 'Бренд' is required",
|
||||
"field": "attributes"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Atlas 用它检查远端货号、恢复提交结果和核对 VAT。任务受理后不得因 HTTP 超时就重新发 `/v3/product/import`。无法确认时保持“等待对账”,禁止当前 SKU 再次入队。
|
||||
|
||||
---
|
||||
## 图片签名与上线验收
|
||||
|
||||
## 5. 轮询策略
|
||||
素材从 Atlas 正式对象存储交付;10 分钟页面预览 URL 不用于发布。Worker 校验素材 checksum,为本次发布复制固定对象,将 WebP 转 JPEG,并从七牛 HTTPS 下载域名签发独立有效期 URL。URL 及签名不写入数据库、Outbox 或日志。
|
||||
|
||||
### 推荐策略
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def wait_for_import(task_id: int, timeout=300):
|
||||
"""轮询导入状态,最多等待 5 分钟"""
|
||||
start = time.time()
|
||||
interval = 5 # 初始间隔 5s
|
||||
|
||||
while time.time() - start < timeout:
|
||||
resp = await ozon_client.get_import_info(task_id)
|
||||
item = resp["result"]["items"][0]
|
||||
status = item["status"]
|
||||
|
||||
if status == "imported":
|
||||
return {"success": True, "product_id": item["product_id"]}
|
||||
|
||||
if status == "failed":
|
||||
return {"success": False, "errors": item["errors"]}
|
||||
|
||||
if status == "moderation":
|
||||
interval = 30 # 审核阶段降低频率
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
# 超时:不算失败,标记为"审核中"继续后台轮询
|
||||
return {"success": None, "status": "timeout"}
|
||||
```
|
||||
|
||||
### 后台轮询(推荐)
|
||||
|
||||
```python
|
||||
# 用户提交发布后立即返回,后台协程轮询
|
||||
# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新)
|
||||
|
||||
async def background_poll_task(task_id: int, product_id: str):
|
||||
"""后台协程,轮询直到完成或失败"""
|
||||
result = await wait_for_import(task_id, timeout=3600) # 最多 1 小时
|
||||
|
||||
# 更新数据库
|
||||
await db.execute(
|
||||
update(Product)
|
||||
.where(Product.id == product_id)
|
||||
.values(
|
||||
stage="published" if result["success"] else "failed",
|
||||
ozon_product_id=result.get("product_id"),
|
||||
published_at=datetime.utcnow() if result["success"] else None
|
||||
)
|
||||
)
|
||||
|
||||
# 记录任务结果
|
||||
await db.execute(
|
||||
update(PublishTask)
|
||||
.where(PublishTask.ozon_task_id == task_id)
|
||||
.values(
|
||||
status=result.get("status"),
|
||||
errors=result.get("errors"),
|
||||
completed_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 创建 vs 更新
|
||||
|
||||
### 创建新商品
|
||||
|
||||
```json
|
||||
{
|
||||
"offer_id": "NEW-PRODUCT-001", // 全新 offer_id
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
- 如果 `offer_id` 不存在 → 创建新商品
|
||||
- 如果 `offer_id` 已存在 → 返回 409 冲突
|
||||
|
||||
### 更新已有商品
|
||||
|
||||
```json
|
||||
{
|
||||
"offer_id": "EXISTING-001", // 已存在的 offer_id
|
||||
// ... 要更新的字段(可部分更新)
|
||||
}
|
||||
```
|
||||
|
||||
或使用 `product_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 987654321, // Ozon 商品 ID
|
||||
// ... 要更新的字段
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **注意**:
|
||||
- 更新时,未传的字段**保持原值**(非清空)
|
||||
- 图片数组传空 `[]` 会清空图片(需小心)
|
||||
- 建议更新前先读取当前值(`/v3/product/info/list`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 批量导入
|
||||
|
||||
单次请求最多 **100 个 item**:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "offer_id": "PROD-001", /* ... */ },
|
||||
{ "offer_id": "PROD-002", /* ... */ },
|
||||
// ... 最多 100 个
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
响应包含每个 item 的状态:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"items": [
|
||||
{ "offer_id": "PROD-001", "status": "imported", "product_id": 111 },
|
||||
{ "offer_id": "PROD-002", "status": "failed", "errors": [...] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见错误
|
||||
|
||||
### 错误码速查
|
||||
|
||||
| code | message | 原因 | 解决 |
|
||||
|---|---|---|---|
|
||||
| `INVALID_PARAMETER` | 参数错误 | 必填字段缺失 / 格式错误 | 检查字段完整性 |
|
||||
| `INVALID_ATTRIBUTE` | 属性错误 | 缺少必填属性 / 字典值不匹配 | 补全必填属性,校验字典值 |
|
||||
| `INVALID_CATEGORY` | 类目错误 | `description_category_id` 不存在或已禁用 | 重新选择类目 |
|
||||
| `INVALID_IMAGE` | 图片错误 | URL 不可访问 / 非 https / 格式不支持 | 检查图片 URL 有效性 |
|
||||
| `OFFER_ID_DUPLICATE` | offer_id 重复 | 该 offer_id 已存在 | 换一个唯一 offer_id 或走更新 |
|
||||
| `DIMENSION_REQUIRED` | 尺寸必填 | 尺寸/重量为 0 或缺失 | 填写正确尺寸重量 |
|
||||
| `PRICE_INVALID` | 价格错误 | 价格 ≤ 0 或格式错误 | 检查价格字段 |
|
||||
|
||||
### 典型错误示例
|
||||
|
||||
#### 错误 1:尺寸为 0
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "DIMENSION_REQUIRED",
|
||||
"message": "Dimensions must be greater than 0",
|
||||
"field": "weight"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**解决**:确保 `depth/width/height/weight` 都 > 0。
|
||||
|
||||
#### 错误 2:缺少必填属性
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_ATTRIBUTE",
|
||||
"message": "Required attribute 'Бренд' (id=85) is missing",
|
||||
"field": "attributes"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**解决**:补充缺失的必填属性。
|
||||
|
||||
#### 错误 3:图片 URL 不可访问
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_IMAGE",
|
||||
"message": "Image URL is not accessible: https://...",
|
||||
"field": "images[0]"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**解决**:
|
||||
1. 检查 URL 是 https(非 http)
|
||||
2. 检查 URL 公网可访问(Ozon 服务器需能拉取)
|
||||
3. 检查图片格式(支持 jpg/png/webp)
|
||||
|
||||
---
|
||||
|
||||
## 9. 发布后操作
|
||||
|
||||
### 设置库存(必须)
|
||||
|
||||
商品导入成功后**不会自动上架**,需设置库存才能开售:
|
||||
|
||||
```http
|
||||
POST /v2/products/stocks
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"stocks": [
|
||||
{
|
||||
"product_id": 987654321,
|
||||
"offer_id": "MY-THERMOS-001",
|
||||
"stock": 100,
|
||||
"warehouse_id": 12345678
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ 不设置库存 → 商品在后台但不可购买。
|
||||
|
||||
### 查询商品详情
|
||||
|
||||
```http
|
||||
POST /v3/product/info/list
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"offer_id": ["MY-THERMOS-001"]
|
||||
}
|
||||
```
|
||||
|
||||
返回商品完整信息(含审核状态、图片、属性)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 早期集成示例(当前未实现)
|
||||
|
||||
> 下方 `server/api/publish.py` 与前端组件来自早期 V2 方案,不存在于当前 NestJS/React 实现中,仅可帮助理解流程,不能作为联调契约。Atlas 目前只完成 `publish-preflights`;真正的发布 job、`/v3/product/import` 提交和结果轮询尚未实现。当前契约见[商品编辑与发布 API](../../api/product-editor-and-publishing.md)。
|
||||
|
||||
### 历史 API 示例
|
||||
|
||||
```python
|
||||
# server/api/publish.py
|
||||
|
||||
@router.post("/products/{product_id}/publish")
|
||||
async def publish_product(
|
||||
product_id: str,
|
||||
shop_id: str = Body(...),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# 1. 取商品数据
|
||||
product = await get_product(db, product_id)
|
||||
|
||||
# 2. 校验必填项
|
||||
validate_required_fields(product)
|
||||
|
||||
# 3. 组装 ImportProductsV3 请求体
|
||||
item = build_import_item(product)
|
||||
|
||||
# 4. 调用 Ozon API
|
||||
shop = await get_shop(db, shop_id)
|
||||
resp = await ozon_client.import_products(
|
||||
shop.client_id_dec,
|
||||
shop.api_key_dec,
|
||||
[item]
|
||||
)
|
||||
|
||||
task_id = resp["result"]["task_id"]
|
||||
|
||||
# 5. 记录发布任务
|
||||
task = PublishTask(
|
||||
product_id=product_id,
|
||||
shop_id=shop_id,
|
||||
ozon_task_id=task_id,
|
||||
status="pending",
|
||||
request_payload=item
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
# 6. 启动后台轮询
|
||||
asyncio.create_task(background_poll_task(task_id, product_id))
|
||||
|
||||
return {"task_id": task_id}
|
||||
|
||||
def build_import_item(product: Product) -> dict:
|
||||
"""组装 ImportProductsV3 items[0]"""
|
||||
return {
|
||||
"offer_id": product.offer_id,
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"description_category_id": product.description_category_id,
|
||||
"type_id": product.type_id,
|
||||
"price": str(product.price),
|
||||
"old_price": str(product.old_price) if product.old_price else "",
|
||||
"currency_code": product.currency_code,
|
||||
"vat": product.vat,
|
||||
"depth": product.depth,
|
||||
"width": product.width,
|
||||
"height": product.height,
|
||||
"dimension_unit": product.dimension_unit,
|
||||
"weight": product.weight,
|
||||
"weight_unit": product.weight_unit,
|
||||
"images": product.images, # 七牛 URL 数组
|
||||
"primary_image": product.primary_image or "",
|
||||
"images360": product.images360 or [],
|
||||
"color_image": product.color_image or "",
|
||||
"barcode": product.barcode or "",
|
||||
"attributes": product.attributes or [],
|
||||
"complex_attributes": product.complex_attributes or []
|
||||
}
|
||||
```
|
||||
|
||||
### 前端(待实现)
|
||||
|
||||
```tsx
|
||||
// studio/src/pages/product/components/PublishPanel.tsx
|
||||
|
||||
import { Button, Select, message } from 'antd';
|
||||
|
||||
export function PublishPanel({ productId }) {
|
||||
const [shops, setShops] = useState([]);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
|
||||
const handlePublish = async (shopId) => {
|
||||
setPublishing(true);
|
||||
try {
|
||||
const resp = await fetch(`/api/products/${productId}/publish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ shop_id: shopId })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
message.success('发布任务已提交,轮询中...');
|
||||
|
||||
// 轮询状态(或 WebSocket 推送)
|
||||
pollPublishStatus(data.task_id);
|
||||
} catch (err) {
|
||||
message.error(`发布失败: ${err.message}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
placeholder="选择目标店铺"
|
||||
options={shops.map(s => ({ label: s.name, value: s.id }))}
|
||||
onChange={handlePublish}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={publishing}
|
||||
onClick={() => /* trigger select */}
|
||||
>
|
||||
发布到 Ozon
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [02-category-tree.md](./02-category-tree.md) —— 获取类目
|
||||
- [03-category-attributes.md](./03-category-attributes.md) —— 获取属性
|
||||
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
|
||||
- [09-stocks.md](./09-stocks.md) —— 设置库存(必须)
|
||||
- [商品编辑与发布 API](../../api/product-editor-and-publishing.md) —— 当前 Atlas 预检与未来发布契约
|
||||
- [平台目录缓存刷新方案](../../architecture/platform-catalog-cache-refresh-plan.md) —— 发布前属性/字典刷新边界
|
||||
真实中国主体跨境 rFBS 店铺的 VAT 行为、七牛域名 / 证书 / 过期时间和 Ozon 延迟抓取仍须联调验收。生产默认关闭上架,验收后显式设置 `OZON_PUBLISH_ENABLED=true`。配置参见 `.env.example`。
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 编辑页 Ozon 首次上架:字段对齐、开发方案与实施记录
|
||||
|
||||
> 2026-09-30:产品决策已确认,首期代码已实现;真实店铺 VAT 与七牛图片交付验收待完成。现行接口以 `docs/api/product-editor-and-publishing.md` 为准,下文保留字段研究与实施方案供追溯。
|
||||
|
||||
## 1. 目标和首期边界
|
||||
|
||||
编辑页底部将当前禁用的「选择 SKU 并首次上架」替换为两个入口:
|
||||
|
||||
| 入口 | 建议行为 |
|
||||
|---|---|
|
||||
| **上架至 OZON** | 只校验并提交当前平台、目标店铺下状态为 `not_listed` / `failed` 的活动 SKU;已成功导入的 SKU 保留原记录、不重复提交,并在确认摘要中逐项说明已跳过的 SKU。任一待提交 SKU 或商品级字段未通过预检,本次一个也不提交。 |
|
||||
| **部分上架** | 打开 SKU 选择窗口,仅可选择当前商品下 `not_listed` / `failed` 的活动 SKU;至少选择 1 个,只校验并提交选中的 SKU。`submitting` 和 `imported` 展示状态、原因提示,但不可勾选。 |
|
||||
|
||||
这是**首次导入 Ozon 商品卡片**,不是更新已导入商品,也不包含库存设置。Ozon 的 `imported` 表示商品资料已导入;审核、前台可见性和可售库存需以在线商品同步与后续库存能力为准。按钮文案按需求使用“上架”,结果页须明确显示“已导入 / 待审核 / 不可售”等平台事实,不能把 `202` 或 Ozon `task_id` 当作上架成功。
|
||||
|
||||
首期沿用当前 Ozon `rFBS` 商品发布配置;`fbs`、`fbo`、`fbp` 的仓库、条码与库存规则另行验收。在当前首期中,不向 Ozon 提交仓库或库存。首期不发布视频、视频封面、富内容,也不借此入口修改已导入商品。
|
||||
|
||||
一次首次上架最多提交 **50 个 SKU**,包括“上架至 OZON”自动选出的待提交集合和“部分上架”手选集合;超过 50 个时先提示用户分批使用“部分上架”,不自动拆批或静默截取前 50 个。Ozon 接口本身允许的数量更高,但首期按本产品决策收紧。
|
||||
|
||||
## 2. 官方接口核对(2026-09-30)
|
||||
|
||||
本次直接核对 Ozon 官方 Seller API 页面及其 OpenAPI 2.1 定义:
|
||||
|
||||
1. [创建或更新商品 `POST /v3/product/import`](https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3):一次最多 100 个 `items`;返回 `result.task_id`,仍需查询任务。它同时是**更新**接口,因此首次上架不能仅凭 HTTP 200 推断新建成功,也不能盲目重试不确定的提交。
|
||||
2. [查询导入状态 `POST /v1/product/import/info`](https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetImportProductsInfo):按 `task_id` 返回各 `offer_id` 的 `product_id`、`status`、`errors`。官方列出的 item 状态是 `pending`、`imported`、`failed`、`skipped`;`skipped` 不能直接等同首次导入成功。
|
||||
3. [按标识符查询商品 `POST /v3/product/info/list`](https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductInfoList):可按 `offer_id` / `product_id` / `sku` 查询,单次总计最多 1000 个;返回 `statuses.moderate_status` 等审核、验证和可见状态。用于提交前货号归属检查、超时后的对账及导入后的状态同步。
|
||||
4. [类目属性 `POST /v1/description-category/attribute`](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes) 与[类目树](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree):类目 / 类型及必填、SKU 维度、字典属性以平台当前目录为准。Atlas 已有平台级目录缓存和发布预检,发布任务继续复用它们。
|
||||
5. [商品操作限额 `POST /v4/product/info/limit`](https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetUploadQuota):按门店检查创建/更新额度;导入接口的 429 还可能带 `Item-Retry-After` 和 `Item-Rate-Limit-Remaining`,Worker 应尊重重试时间。
|
||||
|
||||
评审时提供的毛子 ERP 请求体顶层是 `product_id`、`form_data`、`is_online`,`form_data` 中含 `shop_id` 和 `skus`,整个请求体未出现 `vat`。它是 ERP 接收编辑表单的请求形状,不是 Ozon `/v3/product/import` 所需的顶层 `items[]` 形状;仅凭这一份请求,无法判断 ERP 服务端是否补上 VAT、调用了另一个 Ozon 接口,或采用了其他处理。
|
||||
|
||||
**VAT 文档存在版本冲突,不能再称为已确认必传**:同一 `v3ImportProductsRequestItem` 的 Ozon 官方 [俄文 OpenAPI](https://docs.ozon.ru/api/seller/swagger.json) 和[英文 OpenAPI](https://docs.ozon.ru/api/seller/en/swagger.json)的 `required` 均只有 `description_category_id`、`price`、`type_id`、`offer_id`,`vat` 是可选属性;[中文 OpenAPI](https://docs.ozon.ru/api/seller/zh/swagger.json)则将 `vat` 列为必填。这三份文档均标为 2.1,不能凭语言版本判断真实店铺校验结果。俄文与英文说明将 `vat: "0"` 定义为“该商品不征收 VAT”,并未声明“不传 `vat`”的默认行为;也未找到明确规定“中国主体、跨境 rFBS”一律应传哪个值的 Ozon 官方说明。[Ozon Global 跨境合同](https://global-help.ozon.com/contracts-for-sellers/dogovor/)区分 Ozon 服务费 VAT 与交易适用税款,也没有给出导入字段的固定税率。中国出口环节的增值税处理不能直接推导此商品字段的值。
|
||||
|
||||
**现有整理稿需要在评审后订正**:`docs/integrations/ozon-seller-api/04-product-import.md` 写了 `offer_id` 最长 255、图片最多 15、顶层 `description`、以及“重复货号必定 409”;官方当前导入定义分别是**最多 50 字符、最多 30 张图片、描述不在 item 顶层字段、同一导入接口同时承担创建和更新**。旧整理稿列出的 `processing`、`moderation` 也不是当前 `/v1/product/import/info` 的 item 状态;审核状态另从商品信息接口读取。按本次产品决策,**编辑草稿改为货号最多 50 字符、Ozon 发布标题最多 500 字符、每 SKU 最多 30 张图片**,与 Ozon 当前导入接口一致;来源商品标题等采集证据不因发布标题上限而改写。已有超长草稿需提示用户修正,不自动截断或静默改写。
|
||||
|
||||
## 3. Atlas → Ozon 字段映射
|
||||
|
||||
| Ozon `items[]` 字段 | Atlas 来源与适配 | 发布时校验 |
|
||||
|---|---|---|
|
||||
| `offer_id` | 当前目标店铺下该 SKU 的 `listing.offer_id`,原样作为卖家货号 | 草稿和 Ozon 当前接口均最多 50 字符;现存超长值在编辑页显示并要求用户主动修正,保存 / 上架均不自动截断。本店数据库唯一,并查询 Ozon 当前店铺同货号是否已存在。 |
|
||||
| `name` | 通用模式取 SPU `profile.title`;按 SKU 设置模式取当前 SKU 标题 | 草稿与 Ozon 当前接口均最多 500 字符;俄文标题不可为空。SKU `display_name` 不作回退。 |
|
||||
| `description_category_id`, `type_id` | 当前平台活动叶子类目缓存对应的 Ozon 数值 ID | 均有效;schema 和实际使用字典通过现有 `publish-preflights` 定向刷新。 |
|
||||
| `price`, `old_price`, `currency_code` | SKU 已确认报价的售价 / 划线价;币种来自目标店铺 | 保留十进制字符串精度;`price > 0`;仅 `old_price > price` 时传 `old_price`;报价仍与当前采购、包装、店铺、类目和履约匹配,币种与店铺 Ozon 账户一致。 |
|
||||
| `vat` | 当前模型缺字段;中、俄、英文官方 schema 对是否必传有冲突。对已确认的中国主体、跨境 rFBS 店铺,首期拟**省略字段**,不擅自填 `"0"` 或俄罗斯境内税率;若该店铺确有适用税率,以卖家或 Ozon 支持确认值为准。 | `"0"` 在俄/英文定义中是“不征收 VAT”,不是“省略”;省略后 Ozon 是否补默认值尚无官方结论。测试店铺须做一次真实导入,查询任务结果与 `/v3/product/info/list` 返回的 `vat`,确认可接受且结果符合店铺税务设置后再开放生产发布;若被拒绝或结果不符,暂停该店铺发布并由卖家确认税率,不自动重试成 `"0"`。 |
|
||||
| `depth`, `width`, `height`, `dimension_unit`, `weight`, `weight_unit` | 每个 SKU 合并继承后的**包装**长宽高与物流重量;首期统一 mm/g | 均为已确认的正整数,不能拿商品本体尺寸或净重替代;每个 item 独立解析。 |
|
||||
| `images` / `primary_image` | 当前 SKU 已保存的素材 ID 及顺序;首期用 `images` 首张作为主图,不另传 `primary_image` | 草稿最多 30 张,现有迁移 `031_sku_image_limit.sql` 已把数据库顺序上限扩至 30,与 Ozon 当前数量上限一致。发布时从正式素材生成 Ozon 可访问的公网 HTTPS JPG/PNG 地址;WebP 需生成 JPG/PNG 发布派生图或给出可定位的阻断项。 |
|
||||
| `barcode` | 当前只有 SPU 通用条码 | rFBS 首期允许不传;不得把同一个通用条码复制给多个 SKU。需要按 SKU 条码或 FBP 发布时,先扩充数据模型和校验。 |
|
||||
| `attributes[]` | 类目动态属性中已确认的通用值复制到本次各 SKU,`is_aspect=true` 的值只取当前 SKU;主要信息字段依固定 Ozon ID 映射 | `id` / `complex_id` 转数值;字典值以官方 ID 及缓存文本构成 `dictionary_value_id` / `value`,文本值构成 `value`。仅提交当前类目活动属性,强制项和最大值数沿用最新 schema。 |
|
||||
| `complex_attributes` | 首期无视频或复杂富内容输入 | 无值时省略或空数组;不虚构。 |
|
||||
|
||||
现有固定属性绑定是品牌 `85`、型号 `9048`、商品类型 `8229`、标签 `23171`、富内容 `11254`、简介 `4191`。发布适配器应只在当前类目 schema 确认这些 ID 可用时映射:品牌、型号、有效的 SKU / SPU 标签与描述进入对应属性;`type_id` 已在顶层时不重复拼装 `8229`。这些 ID 目前来自 Atlas 的绑定实现;**在发布适配器编码前应以目标类目的实时属性响应验证值类型、字典约束与可用性**,不能仅靠旧示例。中文对照、中文商品名、采购价、货源备注不进入 Ozon 请求。
|
||||
|
||||
部分上架后只锁定**当前商品的型号 `common.model`**:任一 SKU 首次导入成功,后续草稿保存不得改动型号;前端禁用并解释原因,服务端在保存事务内核对已导入 SKU 后拒绝绕过页面的修改。型号对应 Ozon 属性 `9048`,后续新 SKU 沿用锁定值。品牌、描述及其他动态属性仍可修改并保存在 Atlas 草稿中;这些编辑不会自动更新已导入的 Ozon 卡片。若其他共同属性变化可能影响新 SKU 与旧卡片合组,发布预检给出差异提示,但不以“共同属性不可改”为由阻断草稿保存。
|
||||
|
||||
## 4. 页面流程与验证语义
|
||||
|
||||
1. 点击任一按钮时先检测编辑页脏数据。若有未保存修改,走现有带版本检查的保存草稿流程;保存失败或资料版本冲突时停止上架,并定位字段。之后以**服务端已保存快照**作为唯一发布输入。
|
||||
2. 「上架至 OZON」只选当前平台下未成功导入的活动 SKU;已导入 SKU 不传给预检或发布接口,在确认摘要中展示其名称 / 货号及“此前已导入,本次未重复提交”的原因。正在提交的 SKU 另提示“提交处理中”,本轮不重复排队。若没有可提交 SKU,不创建 job。「部分上架」先展示 SKU 名称、货号、状态及就绪问题供勾选;已导入 / 正在提交的 SKU 不可选择,服务端遇到过时选择仍按现有 `SKU_ALREADY_IMPORTED` / `SKU_IMPORT_IN_PROGRESS` 拒绝。
|
||||
3. 调用现有 `publish-preflights`,只针对本次提交集合检查。`refreshing` 显示目录刷新中并使用同一 SKU 集合重试;`blocked` 显示 SPU 与每个选中 SKU 的字段问题,禁止创建 job;`ready` 才展示最终确认摘要(店铺、类目、SKU 数、货号、价格、无库存说明)。
|
||||
4. 用户确认后使用新的幂等键创建 `publish-jobs`。服务端在同一事务重新校验版本、店铺、SKU 状态、目录与字段并固定请求快照;前端先前的 `ready` 不作为授权凭证。
|
||||
5. 返回 202 只表示任务已排队。页面轮询 job,展示逐 SKU `排队 / 提交 / 等待 Ozon / 已导入 / 失败`,保留 Ozon 的脱敏错误字段,允许用户修改失败 SKU 后重新发起新任务;已导入的绝不由此入口再次提交。
|
||||
6. 任一入口都不静默剔除被选中的无效 SKU。一次 Ozon 调用可能出现逐 SKU 成功和失败,UI 按 item 结果汇总,不能把整批简单标成成功或失败。
|
||||
|
||||
已导入 SKU 的提示建议为:**“以下 SKU 已在当前店铺导入,本次未重复提交:{SKU 列表}。需要修改时,请到「在线商品」查看,并在 Ozon 后台处理。”** 列表过长时显示数量、前几项和展开入口。目前 `docs/api/online-listings.md` 定义的在线商品功能只有查看和同步,不能把按钮写成可直接跳转到 Atlas 在线商品编辑。只删除 Atlas 的采集箱或本地上架记录,不会删除 Ozon 商品,也不能据此开放再次导入;后续若实现平台删除与重上架,应单独设计安全的状态转换。
|
||||
|
||||
目前 `GET validation` 的总 `ready` 是“全部未导入 SKU 就绪”的汇总;部分上架应以所选 SKU 的 `publish-preflights.validation` 为准,不复用无选择范围的页面总标记。即使现行 `publish-preflights` 允许最多 100 个,本功能也应在页面、预检及创建 job 的服务端边界统一实施 **1–50 个 SKU** 的上限;超过 50 个时提示“本次最多上架 50 个 SKU,请使用「部分上架」分批提交”,不截取、不自动拆批。
|
||||
|
||||
现行 `docs/api/product-editor-and-publishing.md` 第 3 节还明确留下“类目树过期时如何保护真正发布”的待决项。创建 job 前需确定是否强制刷新整棵类目树;无论采用哪种策略,必须再核对所选类目仍在目标平台有效,不能只依赖编辑页先前看到的类目状态。
|
||||
|
||||
## 5. 服务端任务、媒体与失败恢复
|
||||
|
||||
- `POST /api/product-platform-profiles/{profile_id}/publish-jobs` 和 `GET /api/product-publish-jobs/{job_id}` 沿用现有预留路径;请求、响应和错误码先更新 `docs/api/` 与 `@atlas/contracts` 后实现。创建接口必须使用 `Idempotency-Key`,只接受当前工作空间、平台、profile 和店铺下的 SKU。
|
||||
- 新迁移将 `product_publish_jobs`、`product_publish_job_items` 从架构草案落到真实数据库,补齐 `workspace_id`、复合外键、RLS、唯一约束、索引和可恢复的状态字段。与 listing 状态变更、Outbox 事件在同一事务提交;BullMQ 只传 job ID,不带密钥、商品全文或图片地址。
|
||||
- Worker 读取加密保存的当前目标店铺 Ozon 凭据,按固定快照组装单批最多 50 个 item 的请求;在提交前批量查询远端 `offer_id`,避免 `/v3/product/import` 将外部已有同号商品当作更新。收到 `task_id` 后持久化,再轮询 `/v1/product/import/info`;`imported` 记录 `product_id`,`failed` 保存脱敏错误,`skipped` 先对账。商品审核与库存从 `/v3/product/info/list` 及在线商品同步单独读取。
|
||||
- 网络超时或 Worker 崩溃时,先根据已存 `task_id` 或 `offer_id` 向 Ozon 对账;不确定请求是否已到达时不能直接重发导入请求。429 遵守官方重试头;5xx 有界退避;确定性 4xx 转成逐 SKU / 整批失败并保留可修复原因。重复 Outbox 消息与同幂等键请求不得重复上报。
|
||||
- 已保存的 SKU 图片不是外部图片链接:采集图会先下载并转存 Atlas 对象存储,网络导入、用户上传和图片处理结果也存为正式素材;编辑草稿只保存 `asset_id` 和顺序。API 当前签发的 `preview_url` 是指向 Atlas 对象存储的**10 分钟预览凭证**,不随草稿持久化。不能直接把它当发布图片 URL,因为 Ozon 可能在链接失效后抓取,且本地默认对象存储 endpoint 为 `http://localhost:9000`,外网无法访问。
|
||||
- 发布前需从这些 Atlas 正式素材提供**公网 HTTPS、可在 Ozon 异步抓取期间持续访问**的 JPG/PNG 图像交付链路;推荐专用不泄露桶 key 的随机访问 URL,绑定素材版本,可撤销并在任务完成后留合理抓取宽限期。发布快照存 asset ID / 版本,不持久化临时预览 URL 或密钥;WebP 转换在 Worker 中完成并验证实际公网可抓取性。生产对象存储端点及其公网可访问性尚需部署配置确认,不能从本地默认值推断生产环境一定不可访问。
|
||||
- SKU 的历史首次导入 `offer_id` 和 `product_id` 锁定;已导入 SKU 的 Atlas 草稿编辑仍不隐式更新 Ozon。在线商品镜像保留其独立同步事实。
|
||||
|
||||
### 七牛 Kodo 图片交付调研结论
|
||||
|
||||
项目既有架构已选定本地 MinIO、共享测试与生产七牛 Kodo。七牛官方[私有资源下载](https://developer.qiniu.com/kodo/1656/download-private)文档定义私有资源的 `e`(秒级 Unix 过期时间)和 `token` 下载凭证,过期返回 401;[Node.js SDK](https://developer.qiniu.com/kodo/sdk/nodejs)提供 `privateDownloadUrl(domain, key, deadline)`;[自定义源站域名](https://developer.qiniu.com/kodo/8556/set-the-custom-source-domain-name)支持绑定 HTTPS 证书。因此**七牛支持给正式素材签发指定有效期的 HTTPS 私有下载地址**。这确认能力存在,尚不等于现有生产域名、证书、跨境抓取及 Ozon 异步抓图均已验收。
|
||||
|
||||
发布适配器应从 `media_asset` 的 bucket / object key 在提交前生成**独立于 10 分钟页面预览**的七牛下载 URL;对象存储 S3 API endpoint 继续用于上传与内部读取,给 Ozon 的 URL 使用已绑定的公网 HTTPS 下载域名。七牛的[S3 兼容服务域名](https://developer.qiniu.com/kodo/4088/s3-access-domainname)也支持 HTTPS,但其文档要求 AWS Signature,不能把该服务域名当成匿名图片地址。URL 有效期设为可配置、覆盖提交排队、Ozon 抓取和重试的窗口;具体时长及过期后 Ozon 是否再次抓图,必须在测试店铺做真实导入与延迟抓图验证。若平台需要长期稳定可抓取 URL,再评审专用发布副本 / 域名方案,不能凭文档推定签名短链永久足够。
|
||||
|
||||
当前若干素材入库路径仍将 `storage_provider` 写死为 `local_minio`,与已选定的生产七牛架构不一致。实施七牛联调前,应让入库记录按环境填写实际 provider,并验证从已有 `media_asset` 到正确下载域名的映射;不得仅把对象存储 endpoint 换成七牛地址就宣称发布图片可用。
|
||||
|
||||
实施时已更新 API 第 7 节:快照固化素材 ID / checksum / 存储身份,Worker 提交时签发独立 URL;每个 job 对应一个最多 50 SKU 的 Ozon task,混合成功失败汇总为 `partially_succeeded`。
|
||||
|
||||
## 6. 实施顺序与验收
|
||||
|
||||
| 阶段 | 修改范围 | 交付与验收 |
|
||||
|---|---|---|
|
||||
| A. 契约与数据 | `docs/api/`、`packages/contracts/`、`packages/database/migrations/` | 将 VAT 标为待目标店铺验证的可选适配字段,首期不因文档分歧强加必填税率配置;修订上架契约及旧 Ozon 整理稿;草稿约束改为货号 50、标题 500、图片 30,并处理历史超长草稿;建 job/item 表、索引/RLS;迁移可在空库与现有库运行,跨工作空间和同商品归属测试通过。 |
|
||||
| B. 服务端快照与预检 | `apps/api/src/product-editor/` | 同一 SKU 集合重算目录和字段;草稿保存与发布预检均对超过 50 字符的货号给出定位提示,并校验价格、图片、型号锁定、已上架状态和每批 50 个上限;VAT 按已验证店铺策略省略或传已确认值;冻结逐 SKU 适配快照和 Outbox;并发、版本冲突、重复键、远端同货号用集成测试覆盖。 |
|
||||
| C. Ozon Worker | `apps/worker/src/`、媒体交付模块 | 组装可验证的真实请求体,单批 ≤50 提交 / 轮询 / 对账 / 超时恢复;修正素材 provider 写入与下载域名映射,使用七牛公网 HTTPS 下载域名和可配置签名有效期;用固定夹具比对导入字段,模拟混合成功失败、`skipped`、429、进程重启和重复投递;在测试店铺验证跨境抓图和延迟抓图。 |
|
||||
| D. 编辑页 | `apps/web/src/pages/products/`、`apps/web/src/services/` | 两个按钮、部分上架选择弹窗、保存→预检→确认→任务进度与逐 SKU 错误;全部上架跳过并列出已导入 SKU,待提交项有错则无 job;超过 50 个提示分批;首个 SKU 成功后型号不可改,其他草稿属性仍可编辑。 |
|
||||
| E. 联调验收 | API / Worker / Web / Ozon 测试店铺 | 使用同类中国主体跨境 rFBS 店铺验证省略 `vat` 的真实导入、任务结果及 `/v3/product/info/list` 返回值,并与卖家店铺税务设置核对;未通过前不开放生产发布;再覆盖单 SKU、全部 SKU、部分 SKU、同 SPU 后续补上架、类目字典过期、图片抓取、Ozon 拒绝、超时重试、店铺货号冲突;typecheck/build 和受影响测试全部通过。 |
|
||||
|
||||
## 7. 评审时需确认的产品决策
|
||||
|
||||
1. **全部上架与已导入 SKU(已确认)**:只提交 `not_listed` / `failed`;已导入 SKU 保留并在摘要列出跳过原因,正在提交的 SKU 也不得重复排队。
|
||||
2. **草稿长度(已确认)**:货号 50 字符、Ozon 发布标题 500 字符、每 SKU 图片 30 张,与 Ozon 当前导入限制对齐;旧超长值需人工修改。
|
||||
3. **VAT(产品决策已确认,目标店铺联调待验证)**:中国主体、跨境 rFBS 的首期在导入 item 中省略 `vat`,不自动填 `"0"`;需以同类测试店铺真实导入和商品信息返回值验收省略后的行为,再开放生产发布。如果 Ozon 校验要求传值,或返回结果与卖家店铺税务设置不符,再由卖家向 Ozon 确认适用值并补店铺级配置;此时不得用默认 `"0"` 绕过。毛子 ERP 表单省略 `vat` 仅是参考,不能证明其发往 Ozon 的内容。
|
||||
4. **多 SKU 条码与履约(已确认)**:首期仅 `rFBS`,可不传条码,不把 SPU 通用条码复制到多个 SKU;不在这次提交库存或仓库。
|
||||
5. **每批数量(已确认)**:最多 50 个待提交 SKU;超出即提示使用部分上架分批操作,不自动拆批。
|
||||
6. **部分上架后的型号(已确认)**:任一 SKU 成功导入即锁定 `common.model`;其他草稿属性可修改,但不会隐式更新 Ozon 已导入商品。
|
||||
7. **图片交付(方案已确认,联调待验收)**:使用七牛私有正式素材的独立签名下载 URL,过期时间可配置,公网 HTTPS 域名与页面预览地址分离;生产域名 / 证书、Ozon 可抓取性、签名有效期以及 WebP 转码结果要在测试店铺验证。
|
||||
|
||||
2026-09-30:维护者确认 VAT 决策并授权按本方案开始开发;生产发布仍须完成 VAT 与七牛图片真实联调验收。
|
||||
|
||||
## 8. 本轮实施与验证
|
||||
|
||||
- 已实现两个入口、指定 SKU 预检、确认摘要、原子 job/item/Outbox 创建、请求幂等、冻结快照、远端货号检查、异步导入轮询及不确定提交对账。任何待提交项校验失败都不会创建批次;任一素材准备失败则本批剩余项全部失败、不发导入请求。
|
||||
- 实际请求显式列出允许字段,省略 `vat`、条码、库存、仓库与顶层描述;金额保留十进制字符串。成功后锁定型号与历史货号,进行中禁止换店;完成后仍允许换店,已导入 SKU 保留原店铺事实。
|
||||
- 图片校验 checksum、复制独立发布对象、WebP 转 JPEG;七牛私有 URL 默认 7 天,`OZON_IMAGE_URL_TTL_SECONDS` 可配置(至少 1 小时)。原有 MinIO 素材需迁移到七牛后再交付,不能仅改 provider 标签。
|
||||
- 迁移 `055` 已在空白隔离库和本地开发库验证应用。API/编辑器/素材 26 项与 Worker 14 项测试通过,contracts/API/Worker/Web 类型检查与构建通过。浏览器使用隔离数据库验证全部选择、部分禁选、缺字段阻断、确认摘要、创建与刷新恢复;未调用真实 Ozon 导入。
|
||||
- 生产 `OZON_PUBLISH_ENABLED=auto` 默认关闭。仍需配置七牛 S3 存储、HTTPS 下载域名与签名凭据,并在同类测试店铺验收 VAT 省略行为及延迟抓图,再显式启用生产上架。配置见根目录 `.env.example`。
|
||||
@@ -9,7 +9,7 @@
|
||||
```text
|
||||
页头:返回 · 商品标题 · 当前平台 · 目标门店 · 当前平台上架状态 · 来源
|
||||
├─ 主要信息
|
||||
│ ├─ 类目、中文商品名、俄文发布标题、型号、品牌
|
||||
│ ├─ 类目、中文商品名、俄文标题、型号、品牌
|
||||
│ └─ 默认包装重量与默认包装尺寸
|
||||
├─ 产品属性
|
||||
│ ├─ 主题标签、简介、采买地址与货源备注
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
页面以编辑草稿工作,不采用字段失焦即写库。保存草稿需原子提交父商品、SKU、属性和媒体顺序;存在未保存修改时关闭页面须二次确认。SKU 定价确认是例外:它会即时持久化该 SKU 的价格与定价快照。
|
||||
|
||||
主要信息中的“中文商品名”用于 Atlas 内部识别;“俄文发布标题”是 SPU 共用发布标题,页面保存时必须填写。AI 填报仍可从俄文标题为空的采集草稿启动;发布前服务端会再次检查该字段。
|
||||
主要信息中的“中文商品名”用于 Atlas 内部识别;“俄文标题”默认在 SPU 通用,也可切换为按 SKU 设置。草稿保存允许标题留空;首次上架前按当前标题模式校验所需标题。
|
||||
|
||||
采集进入采集箱时,商品**型号**和各 SKU 的目标平台**货号(Ozon `offer_id`)**默认都为空。进入编辑页后,用户可手动填写或点击“AI 填报”生成并直接填入表单;生成货号不直接照搬来源 SKU。型号属于商品通用资料,货号属于当前平台下的每个 SKU。两者允许随草稿保存而暂时为空,但首次上架校验必须全部通过:商品型号非空,且每个待上架 SKU 的货号非空并在目标店铺内唯一。详见[AI 填报方案](./ai-autofill-plan.md)。
|
||||
|
||||
|
||||
+17
-8
@@ -1,12 +1,12 @@
|
||||
# Atlas Seller Kit 交接说明
|
||||
|
||||
> 更新:2026-09-29。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节、2026-09-29 类目佣金记录及最新的 1.47 节为准。
|
||||
> 更新:2026-09-30。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节、2026-09-29 类目佣金记录及最新的 1.47 节为准。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||
|
||||
pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;SKU 定价试算与确认、在线商品同步已有实现,真正的 Ozon 首次发布仍待接入。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。
|
||||
pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;SKU 定价试算与确认、在线商品同步已有实现,Ozon 首次发布已实现,真实店铺 VAT 和七牛图片交付仍待验收。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。
|
||||
|
||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||
|
||||
@@ -14,12 +14,21 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、
|
||||
|
||||
已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。
|
||||
|
||||
SKU 定价已打通试算、表单应用与原子保存确认,在线商品同步已有 API/Worker。可直接发布的闭环仍缺真正的 Ozon 发布 job/import 及结果轮询;正式定价还需完成 CEL 费率及类目佣金映射审核。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。
|
||||
SKU 定价已打通试算、表单应用与原子保存确认,在线商品同步已有 API/Worker。首次发布 job/import 与结果轮询已接入;生产启用仍需完成 VAT 与七牛图片交付验收;正式定价还需完成 CEL 费率及类目佣金映射审核。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。
|
||||
|
||||
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;审核正式物流费率与佣金映射,并用实际结算账单核对 Ozon 平台服务费、收款费和其他扣费;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
||||
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;审核正式物流费率与佣金映射,并用实际结算账单核对 Ozon 平台服务费、收款费和其他扣费;完成首次发布在中国主体跨境 rFBS 测试店铺的 VAT 省略与七牛延迟抓图验收。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
||||
|
||||
接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。
|
||||
|
||||
### 2026-09-30:商品编辑页首次上架
|
||||
|
||||
- Web、API、Worker、contracts 与 database 同步实现,接口以 [商品编辑与首次上架 §7](../api/product-editor-and-publishing.md) 为准;研究、产品决策及验收见 [实施记录](../product/ozon-first-publish-review-plan.md)。扩展未改动。
|
||||
- 两个入口为「上架至 OZON」「部分上架」,只提交未导入或失败 SKU,显示已导入/进行中的跳过原因;每批最多 50,先保存草稿、预检和确认,再原子写入不可变快照及 Outbox。成功锁定型号和历史货号,其他资料仍能保存。任务状态可刷新恢复。
|
||||
- Worker 提交前检查远端货号,导入后轮询;受理结果不确定时仅对账,禁止盲目重发。七牛图片校验 checksum、复制发布对象、WebP 转 JPEG,再签发独立私有 HTTPS 下载地址,默认 7 天;不持久化签名或 URL。
|
||||
- VAT 按维护者确认首期省略,不默认 `0`;rFBS 首期不提交条码、库存和仓库。生产 `OZON_PUBLISH_ENABLED=auto` 默认关闭,真实店铺与图片验收后显式启用。MinIO 素材不能直接供 Ozon 抓取,需要迁移到七牛;环境项见 `.env.example`。
|
||||
- `055` 全量空库迁移和本地开发库应用通过。26 项 API/编辑器/素材与 14 项 Worker 测试通过;contracts/API/Worker/Web 类型检查与构建通过,Web 仍有既有 bundle 提示。隔离库浏览器验证了禁选、全部/部分预检、缺字段阻断、提交及刷新恢复,未发送真实 Ozon 导入。
|
||||
- 维护者原有 API8200、Web8100、Worker 保持运行,通过监听自动加载改动;验证用 API8201、Web8101 已关闭,隔离测试库已清理。
|
||||
|
||||
### 2026-09-29:类目佣金 JSON 导入与定价接入
|
||||
|
||||
- 已先编写[实施方案](../product/category-commission-import-plan.md),并完成[API 契约](../api/category-commissions.md)、迁移 `052`、导入解析器、原子启用、管理页面及试算展示。影响 Web、API、contracts、database;未涉及 Worker 和扩展。
|
||||
@@ -468,7 +477,7 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
||||
- 全局填报读取 Atlas 平台级活动类目树,按层向模型提供有限候选;对大分支分段初选、再决选,只接受给定选项。明确结果可回填类目,有多个命中则在「确认 AI 推荐类目」弹窗中由用户选择。已有类目不被覆盖;未确认类目时不能生成依赖它的型号和货号。型号是 SPU 共用稳定码,未导入 SKU 的货号分别生成并检查长度、连接符及冲突。
|
||||
- 全局文案只生成 SPU 的俄文标题、主题标签、产品描述及逐项中文对照;描述要求概述与「Характеристики:」分段、项目符号。SKU 默认继承 SPU 三项文案;用户可打开当前 SKU 的 AI 弹窗,预览后明确点击应用,才写入该 SKU 的未保存表单。SPU/SKU 都有中俄对照查看入口,俄文被手工修改后旧中文对照标为待更新。
|
||||
- 包装重量和尺寸仅在来源有可逐字核对的证据时建议;缺证据则留空。原计划的「AI 填写 Ozon 类目属性」已撤回:当前只在类目确定后按 schema 动态加载商品/SKU 属性,用户自行填写。`030_product_ai_attribute_fill.sql` 与历史 `attribute_fill` 类型保留兼容,不代表当前仍会创建或执行该任务。
|
||||
- 编辑页已把「内部商品名」文案改为「中文商品名」,「俄文发布标题」在表单与发布就绪校验中必填;SPU 标题有加号入口开启 SKU 标题覆盖。SKU 的 Ozon 变体字段已并入「SKU 信息」,不另设「Ozon 变体属性」标题。底部 `editor-footer` 固定在视口底部;AI 类目候选弹窗已调整为说明、单选候选和明确确认操作。类目输入框清除时的空值处理已修正。
|
||||
- 编辑页已把「内部商品名」文案改为「中文商品名」;「俄文标题」默认在 SPU,支持切换为按 SKU 设置并持久化模式。草稿保存允许标题留空,首次上架预检按通用或逐 SKU 模式检查。SKU 的 Ozon 变体字段已并入「SKU 信息」,不另设「Ozon 变体属性」标题。底部 `editor-footer` 固定在视口底部;AI 类目候选弹窗已调整为说明、单选候选和明确确认操作。类目输入框清除时的空值处理已修正。
|
||||
- 最新俄文文案规则同时作用于 SPU 与 SKU Prompt:`title.ru`、`tags[].ru`、`description.ru` 不得出现汉字;来源中文事实应翻译为自然俄文,只有有依据的拉丁品牌/型号及阿拉伯数字可保留。Worker 结果校验会丢弃仍含汉字的对应字段,中文对照与包装原文证据不受此限制。旧已保存文案不会自动改写,需重新生成或手工修订。
|
||||
|
||||
**验证状态与下一步**
|
||||
@@ -567,7 +576,7 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
||||
| 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 |
|
||||
| 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 |
|
||||
| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 |
|
||||
| 平台商品参考目录 | `platform_id` 下共享类目/类型树、属性定义和字典候选值;同平台门店共用,本阶段不维护门店差异层。定时同步、系统管理员页面、手动排队、商品编辑器读取和发布预检已实现;真正的 Ozon 发布 job 待开发。 |
|
||||
| 平台商品参考目录 | `platform_id` 下共享类目/类型树、属性定义和字典候选值;同平台门店共用,本阶段不维护门店差异层。定时同步、系统管理员页面、手动排队、商品编辑器读取和发布预检已实现;真正的 Ozon 发布 job 已实现,真实店铺联调待验收。 |
|
||||
|
||||
## 3. Ozon 已核对结论
|
||||
|
||||
@@ -602,7 +611,7 @@ User → Workspace → Membership / Role
|
||||
- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。
|
||||
- **响应信封是强制契约:** 成功单资源或同步动作返回 `{ "data": T }`,列表返回 `{ "data": T[], "meta": PaginationMeta }`,失败返回 `{ "error": ApiError }`,`204 No Content` 不返回响应体。项目不采用完整 JSON:API;任何调用方不得自行增加嵌套 `data` 或以 `data: null` 表示错误。完整规则见 [`api/conventions.md`](../api/conventions.md)。
|
||||
- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。
|
||||
- 早期 DDL 已通过本地隔离 PostgreSQL 验证;迁移执行器、认证/工作空间基础、幂等与 Outbox 写入闭环、Outbox Dispatcher、Ozon 凭据验证、采集清理、来源素材转存及商品 AI Worker 已有实现。新近 `029`/`030` 迁移在目标环境中的应用状态需单独核对;商品发布和在线商品同步 Worker 仍待实现。
|
||||
- 早期 DDL 已通过本地隔离 PostgreSQL 验证;迁移执行器、认证/工作空间基础、幂等与 Outbox 写入闭环、Outbox Dispatcher、Ozon 凭据验证、采集清理、来源素材转存及商品 AI Worker 已有实现。新近 `029`/`030` 迁移在目标环境中的应用状态需单独核对;商品发布和在线商品同步 Worker 已实现,真实平台联调状态见最新记录。
|
||||
|
||||
仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。
|
||||
|
||||
@@ -616,7 +625,7 @@ User → Workspace → Membership / Role
|
||||
|
||||
1. 核对 AI 任务迁移、运行进程版本和四类来源的真实样本;回归类目选择、中俄文案(俄文字段不含汉字)、包装证据、SKU 文案继承及候选媒体按需物化,并联调 SKU 图片上传、网络导入与保存。
|
||||
2. 完成定价确认、平台目录大字典续传与管理员浏览器回归;旧门店目录继续保留,暂不清理。
|
||||
3. 将真正的 `publish-jobs` 创建事务接入已完成的发布预检,固化发布快照后实现 Ozon `/v3/product/import` 与结果轮询,再推进在线商品同步;不得重新实现另一套目录判断。
|
||||
3. 首次发布 job、快照、Ozon 导入/轮询和在线同步已实现;下一步完成真实中国主体跨境 rFBS 店铺的 VAT 省略和七牛图片交付验收。
|
||||
|
||||
### 历史计划(仅供追溯)
|
||||
|
||||
|
||||
@@ -178,6 +178,45 @@ export type ProductPublishPreflight = {
|
||||
refresh_runs: ProductCatalogRefreshRun[];
|
||||
};
|
||||
|
||||
export type ProductPublishItemStatus = 'queued' | 'submitting' | 'polling' | 'imported' | 'failed' | 'skipped' | 'reconciling';
|
||||
export type ProductPublishJob = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
store_id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'partially_succeeded' | 'failed' | 'reconciling';
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
variant_id: string;
|
||||
display_name: string | null;
|
||||
offer_id: string;
|
||||
status: ProductPublishItemStatus;
|
||||
platform_product_id: string | null;
|
||||
errors: Array<{ code: string; field?: string; message: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 发布快照只保存确定的素材身份,私有下载凭证在 Worker 提交前生成。 */
|
||||
export type OzonPublishItemSnapshot = {
|
||||
offer_id: string;
|
||||
name: string;
|
||||
description_category_id: number;
|
||||
type_id: number;
|
||||
price: string;
|
||||
old_price?: string;
|
||||
currency_code: string;
|
||||
depth: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dimension_unit: 'mm';
|
||||
weight: number;
|
||||
weight_unit: 'g';
|
||||
attributes: Array<{ id: number; complex_id: number; values: Array<{ value: string; dictionary_value_id?: number }> }>;
|
||||
media: Array<{ asset_id: string; checksum_sha256: string; bucket: string; object_key: string; storage_provider: string; content_type: string }>;
|
||||
};
|
||||
|
||||
/** 商品编辑入口与完整读模型;真正的发布 job DTO 仍在后续纵向任务中定义。 */
|
||||
export type ProductEditorContext = {
|
||||
collection_item_id: string;
|
||||
@@ -218,6 +257,7 @@ export type ProductEditorSnapshot = {
|
||||
currency_code: string;
|
||||
}>;
|
||||
title: string | null;
|
||||
title_mode: 'common' | 'variant';
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
category: {
|
||||
@@ -479,7 +519,7 @@ export const productPublishPreflightRequestSchema = z
|
||||
variant_ids: z
|
||||
.array(uuid)
|
||||
.min(1)
|
||||
.max(100)
|
||||
.max(50)
|
||||
.refine((ids) => new Set(ids).size === ids.length, 'SKU 不能重复'),
|
||||
})
|
||||
.strict();
|
||||
@@ -488,6 +528,9 @@ export type ProductPublishPreflightRequest = z.infer<
|
||||
typeof productPublishPreflightRequestSchema
|
||||
>;
|
||||
|
||||
export const createProductPublishJobRequestSchema = productPublishPreflightRequestSchema;
|
||||
export type CreateProductPublishJobRequest = ProductPublishPreflightRequest;
|
||||
|
||||
/** 商品、发布配置与 SKU(含报价确认意图)原子保存。 */
|
||||
export const saveProductEditorDraftRequestSchema = z
|
||||
.object({
|
||||
@@ -517,7 +560,8 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.object({
|
||||
target_store_id: uuid,
|
||||
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
|
||||
title: nullableText(1_000),
|
||||
title: nullableText(500),
|
||||
title_mode: z.enum(['common', 'variant']).optional(),
|
||||
description: nullableText(100_000),
|
||||
tags: z.array(z.string().trim().min(1).max(100)).max(30),
|
||||
platform_product_category_id: uuid.nullable().optional(),
|
||||
@@ -557,14 +601,14 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,2})?$/)
|
||||
.nullable(),
|
||||
title: nullableText(1_000).optional(),
|
||||
title: nullableText(500).optional(),
|
||||
tags: z
|
||||
.array(z.string().trim().min(1).max(100))
|
||||
.max(30),
|
||||
description: nullableText(100_000),
|
||||
physical_spec: variantPhysicalSpec,
|
||||
package_spec: variantPackageSpec,
|
||||
offer_id: nullableText(500),
|
||||
offer_id: nullableText(50),
|
||||
media_asset_ids: z
|
||||
.array(uuid)
|
||||
.max(30)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 俄文标题可由商品通用值或逐 SKU 值填写;空草稿也需要保存模式。
|
||||
ALTER TABLE product_platform_profiles
|
||||
ADD COLUMN title_mode TEXT NOT NULL DEFAULT 'common'
|
||||
CHECK (title_mode IN ('common', 'variant'));
|
||||
|
||||
-- 既有 SKU 标题覆盖保留为逐 SKU 模式,不清除已有商品或 SKU 文案。
|
||||
UPDATE product_platform_profiles p
|
||||
SET title_mode = 'variant'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings l
|
||||
JOIN product_variants v ON v.id = l.product_variant_id
|
||||
WHERE l.profile_id = p.id AND v.deleted_at IS NULL AND nullif(btrim(l.title), '') IS NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 发布批次与每个 SKU 的不可变适配快照均沿用商品、配置和工作空间归属链。
|
||||
CREATE TABLE product_publish_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
product_revision BIGINT NOT NULL,
|
||||
profile_revision BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','running','succeeded','partially_succeeded','failed','reconciling')),
|
||||
item_count INTEGER NOT NULL CHECK (item_count BETWEEN 1 AND 50),
|
||||
ozon_task_id TEXT,
|
||||
submit_started_at TIMESTAMPTZ,
|
||||
poll_count INTEGER NOT NULL DEFAULT 0,
|
||||
lease_until TIMESTAMPTZ,
|
||||
lease_token UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id, product_id, platform_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id)
|
||||
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, id, product_id, profile_id, platform_id, store_id)
|
||||
);
|
||||
CREATE INDEX ix_product_publish_jobs_profile ON product_publish_jobs(workspace_id, profile_id, created_at DESC);
|
||||
|
||||
CREATE TABLE product_publish_job_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL,
|
||||
job_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
store_id UUID NOT NULL,
|
||||
listing_id UUID NOT NULL,
|
||||
variant_id UUID NOT NULL,
|
||||
display_name TEXT,
|
||||
offer_id TEXT NOT NULL CHECK (char_length(offer_id) BETWEEN 1 AND 50),
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','submitting','polling','imported','failed','skipped','reconciling')),
|
||||
snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
|
||||
platform_product_id TEXT,
|
||||
errors JSONB NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(errors) = 'array'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, job_id, product_id, profile_id, platform_id, store_id)
|
||||
REFERENCES product_publish_jobs(workspace_id, id, product_id, profile_id, platform_id, store_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, listing_id, product_id, profile_id, variant_id)
|
||||
REFERENCES product_variant_platform_listings(workspace_id, id, product_id, profile_id, product_variant_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, job_id, variant_id)
|
||||
);
|
||||
CREATE INDEX ix_product_publish_job_items_job ON product_publish_job_items(workspace_id, job_id);
|
||||
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY['product_publish_jobs','product_publish_job_items'] LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- 成功导入身份不可被草稿写入覆盖;进行中 SKU 的删除和型号变更也不能绕过 API。
|
||||
CREATE FUNCTION guard_first_publish_identity() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'products' THEN
|
||||
IF NEW.model IS DISTINCT FROM OLD.model AND EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings WHERE product_id = OLD.id AND status IN ('imported','submitting')
|
||||
) THEN RAISE EXCEPTION 'PRODUCT_MODEL_LOCKED' USING ERRCODE = '23514'; END IF;
|
||||
ELSIF TG_TABLE_NAME = 'product_platform_profiles' THEN
|
||||
IF NEW.target_store_id IS DISTINCT FROM OLD.target_store_id AND EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings WHERE profile_id = OLD.id AND status = 'submitting'
|
||||
) THEN RAISE EXCEPTION 'PUBLISH_STORE_LOCKED' USING ERRCODE = '23514'; END IF;
|
||||
ELSIF TG_TABLE_NAME = 'product_variants' THEN
|
||||
IF NEW.deleted_at IS DISTINCT FROM OLD.deleted_at AND EXISTS (
|
||||
SELECT 1 FROM product_variant_platform_listings WHERE product_variant_id = OLD.id AND status IN ('imported','submitting')
|
||||
) THEN RAISE EXCEPTION 'PUBLISH_VARIANT_LOCKED' USING ERRCODE = '23514'; END IF;
|
||||
ELSIF OLD.status = 'imported' AND (
|
||||
NEW.status IS DISTINCT FROM OLD.status OR NEW.offer_id IS DISTINCT FROM OLD.offer_id
|
||||
OR NEW.store_id IS DISTINCT FROM OLD.store_id OR NEW.platform_product_id IS DISTINCT FROM OLD.platform_product_id
|
||||
OR NEW.first_imported_at IS DISTINCT FROM OLD.first_imported_at
|
||||
) THEN RAISE EXCEPTION 'SKU_ALREADY_IMPORTED' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER product_model_first_publish_guard BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
CREATE TRIGGER profile_store_first_publish_guard BEFORE UPDATE ON product_platform_profiles FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
CREATE TRIGGER variant_first_publish_guard BEFORE UPDATE ON product_variants FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
CREATE TRIGGER listing_first_publish_guard BEFORE UPDATE ON product_variant_platform_listings FOR EACH ROW EXECUTE FUNCTION guard_first_publish_identity();
|
||||
|
||||
CREATE FUNCTION guard_publish_snapshot() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF NEW.snapshot IS DISTINCT FROM OLD.snapshot OR NEW.offer_id IS DISTINCT FROM OLD.offer_id
|
||||
OR NEW.job_id IS DISTINCT FROM OLD.job_id OR NEW.listing_id IS DISTINCT FROM OLD.listing_id
|
||||
OR NEW.variant_id IS DISTINCT FROM OLD.variant_id OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id
|
||||
OR NEW.product_id IS DISTINCT FROM OLD.product_id OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
|
||||
OR NEW.platform_id IS DISTINCT FROM OLD.platform_id OR NEW.store_id IS DISTINCT FROM OLD.store_id
|
||||
THEN RAISE EXCEPTION 'PUBLISH_SNAPSHOT_IMMUTABLE' USING ERRCODE = '23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER publish_snapshot_guard BEFORE UPDATE ON product_publish_job_items FOR EACH ROW EXECUTE FUNCTION guard_publish_snapshot();
|
||||
|
||||
-- 快照创建时必须与当时的店铺一致;后续失败草稿换店不应改写历史快照。
|
||||
CREATE FUNCTION guard_publish_item_creation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
DECLARE batch_size INTEGER;
|
||||
BEGIN
|
||||
SELECT item_count INTO batch_size FROM product_publish_jobs WHERE workspace_id=NEW.workspace_id AND id=NEW.job_id FOR UPDATE;
|
||||
IF NOT EXISTS(SELECT 1 FROM product_variant_platform_listings WHERE workspace_id=NEW.workspace_id AND id=NEW.listing_id AND store_id=NEW.store_id AND platform_id=NEW.platform_id)
|
||||
THEN RAISE EXCEPTION 'PUBLISH_LISTING_STORE_MISMATCH' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT count(*) FROM product_publish_job_items WHERE workspace_id=NEW.workspace_id AND job_id=NEW.job_id) >= batch_size
|
||||
THEN RAISE EXCEPTION 'PUBLISH_ITEM_LIMIT_EXCEEDED' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER publish_item_creation_guard BEFORE INSERT ON product_publish_job_items FOR EACH ROW EXECUTE FUNCTION guard_publish_item_creation();
|
||||
|
||||
|
||||
-- 批次身份与版本属于历史事实,只允许 Worker 推进执行状态与租约。
|
||||
CREATE FUNCTION guard_publish_job_identity() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF ROW(NEW.workspace_id,NEW.product_id,NEW.profile_id,NEW.platform_id,NEW.store_id,NEW.product_revision,NEW.profile_revision,NEW.item_count)
|
||||
IS DISTINCT FROM ROW(OLD.workspace_id,OLD.product_id,OLD.profile_id,OLD.platform_id,OLD.store_id,OLD.product_revision,OLD.profile_revision,OLD.item_count)
|
||||
OR (OLD.ozon_task_id IS NOT NULL AND NEW.ozon_task_id IS DISTINCT FROM OLD.ozon_task_id)
|
||||
THEN RAISE EXCEPTION 'PUBLISH_JOB_IMMUTABLE' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER publish_job_identity_guard BEFORE UPDATE ON product_publish_jobs FOR EACH ROW EXECUTE FUNCTION guard_publish_job_identity();
|
||||
Reference in New Issue
Block a user