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');
});
});