feat(edit): 编辑页上报ozon 开发

This commit is contained in:
R524809
2026-09-30 15:57:54 +08:00
parent 0a303414d8
commit 45871584b3
48 changed files with 3700 additions and 887 deletions
+2
View File
@@ -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'),
+6 -4
View File
@@ -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,101 +80,117 @@ export class ProductEditorValidationOperations {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
return this.validateTransaction(
transaction,
profileId,
platformId,
);
const product = await this.access.requireProduct(
transaction,
profile.product_id,
);
const variants = await this.readVariants(
transaction,
profile.id,
profile.product_id,
variantIds,
);
if (variantIds && variants.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
const attributes = await this.readAttributes(
transaction,
profile.id,
variantIds,
);
const schemaUsable = profile.platform_product_category_id
? await this.schemaUsable(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: false;
const schema =
profile.platform_product_category_id && schemaUsable
? await this.access.attributeSchemaForCategory(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: [];
const dictionaryIssues = schema.length
? await this.dictionaryIssues(
transaction,
profile.platform_id,
schema,
attributes,
)
: [];
const profileIssues = this.profileIssues(
product,
profile,
schema,
attributes,
);
profileIssues.push(
...dictionaryIssues
.filter((item) => item.scope === 'common')
.map((item) => item.issue),
);
const variantResults = variants.map((variant) =>
this.variantResult(
variant,
schema,
attributes,
dictionaryIssues
.filter(
(item) =>
item.scope === 'variant' &&
item.variant_id === variant.id,
)
.map((item) => item.issue),
),
);
const publishableVariants = variantResults.filter(
(variant) => variant.status !== 'not_publishable_again',
);
return {
ready:
profileIssues.length === 0 &&
publishableVariants.length > 0 &&
publishableVariants.every((variant) => variant.ready),
profile: {
ready: profileIssues.length === 0,
issues: profileIssues,
},
variants: variantResults,
};
},
);
}
/** 创建发布任务必须在持有商品锁的同一事务内重算,防止校验与快照读到不同版本。 */
async validateTransaction(
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
variantIds?: string[],
): Promise<ProductEditorValidation> {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
const product = await this.access.requireProduct(
transaction,
profile.product_id,
);
const variants = await this.readVariants(
transaction,
profile.id,
profile.product_id,
variantIds,
);
if (variantIds && variants.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
const attributes = await this.readAttributes(
transaction,
profile.id,
variantIds,
);
const schemaUsable = profile.platform_product_category_id
? await this.schemaUsable(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: false;
const schema =
profile.platform_product_category_id && schemaUsable
? await this.access.attributeSchemaForCategory(
transaction,
profile.platform_id,
profile.platform_product_category_id,
)
: [];
const dictionaryIssues = schema.length
? await this.dictionaryIssues(
transaction,
profile.platform_id,
schema,
attributes,
)
: [];
const profileIssues = this.profileIssues(
product,
profile,
schema,
attributes,
);
profileIssues.push(
...dictionaryIssues
.filter((item) => item.scope === 'common')
.map((item) => item.issue),
);
const variantResults = variants.map((variant) =>
this.variantResult(
variant,
profile,
schema,
attributes,
dictionaryIssues
.filter(
(item) =>
item.scope === 'variant' &&
item.variant_id === variant.id,
)
.map((item) => item.issue),
),
);
const publishableVariants = variantResults.filter(
(variant) => variant.status !== 'not_publishable_again',
);
return {
ready:
profileIssues.length === 0 &&
publishableVariants.length > 0 &&
publishableVariants.every((variant) => variant.ready),
profile: {
ready: profileIssues.length === 0,
issues: profileIssues,
},
variants: variantResults,
};
}
/** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */
private async schemaUsable(
transaction: DatabaseTransaction,
@@ -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
+115 -2
View File
@@ -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 () => {
+467
View File
@@ -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
View File
@@ -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('');
+13 -5
View File
@@ -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;
}
+150 -48
View File
@@ -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,30 +863,63 @@ 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;
const draft = normalizedValues({
...values,
ai_source_material: sourceDraft ?? snapshot.source_material,
ai_source_overridden_fields: sourceOverriddenFields,
copy_translations: copyTranslations.filter(
(item) =>
!item.variant_id ||
values.variants.some((variant) => variant.id === item.variant_id)
),
});
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 },
normalizedValues({
...values,
ai_source_material: sourceDraft ?? snapshot.source_material,
ai_source_overridden_fields: sourceOverriddenFields,
copy_translations: copyTranslations.filter(
(item) =>
!item.variant_id ||
values.variants.some((variant) => variant.id === item.variant_id)
),
})
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} />
<Input
placeholder="AI 填报或手动填写俄文标题"
maxLength={500}
disabled={usesVariantTitle}
/>
</Form.Item>
{!usesVariantTitle && (
<Tooltip title="在 SKU 中分别编辑标题">
<Button
type="text"
icon={<PlusCircleOutlined />}
aria-label="在 SKU 中分别编辑标题"
onClick={onUseVariantTitle}
/>
</Tooltip>
)}
<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,30 +148,39 @@ export function SkuInfoPanel({
</>
)}
<div className="sku-info-group-title">发布文案与类目属性</div>
<Form.Item
label="俄文发布标题"
layout="vertical"
className="sku-info-variant-title"
required={usesVariantTitle}
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name={[fieldName, 'title']} noStyle>
<Input placeholder="留空则继承 SPU 标题" />
</Form.Item>
<Button
icon={<RobotOutlined />}
aria-label="AI 生成当前 SKU 文案"
disabled={!sourceId}
onClick={() => sourceId && onAiCopy(sourceId, 'title')}
/>
<Button
icon={<TranslationOutlined />}
aria-label="查看当前 SKU 标题中文对照"
disabled={!sourceId}
onClick={() => sourceId && onCompareCopy(sourceId, 'title')}
/>
</Space.Compact>
</Form.Item>
{usesVariantTitle && (
<Form.Item
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
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name={[fieldName, 'title']} noStyle rules={[{ max: 500 }]}>
<Input placeholder="填写当前 SKU 俄文标题" maxLength={500} />
</Form.Item>
<Button
icon={<RobotOutlined />}
aria-label="AI 生成当前 SKU 文案"
disabled={!sourceId}
onClick={() => sourceId && onAiCopy(sourceId, 'title')}
/>
<Button
icon={<TranslationOutlined />}
aria-label="查看当前 SKU 标题中文对照"
disabled={!sourceId}
onClick={() => sourceId && onCompareCopy(sourceId, 'title')}
/>
</Space.Compact>
</Form.Item>
)}
{usesVariantTags && (
<Form.Item 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 && (
+5
View File
@@ -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`,
+2 -1
View File
@@ -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) {
+5
View File
@@ -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',
+3
View File
@@ -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') {
+2 -1
View File
@@ -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 -1
View File
@@ -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);
+10 -12
View File
@@ -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();
}
+132
View File
@@ -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;
}
+26 -8
View File
@@ -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))
+576
View File
@@ -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);
});
+68 -1
View File
@@ -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;
}
});