feat(edit): 商品编辑页开发
This commit is contained in:
@@ -98,6 +98,7 @@ export class ExtensionService {
|
||||
product.rows[0].id,
|
||||
1,
|
||||
payload,
|
||||
false,
|
||||
);
|
||||
return {
|
||||
status: 201,
|
||||
@@ -191,6 +192,19 @@ export class ExtensionService {
|
||||
payload: CollectedProductPayload,
|
||||
) {
|
||||
const { status, sourcePrice } = sourceState(payload);
|
||||
// Once a platform profile exists, product_variants are no longer a
|
||||
// disposable collection projection: they may carry user edits and are
|
||||
// referenced by platform listings. A re-collection refreshes the
|
||||
// source evidence/candidates, but must not overwrite that draft or
|
||||
// sever a SKU's publishing history.
|
||||
const profileCount = await tx.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM atlas.product_platform_profiles
|
||||
WHERE product_id = $1`,
|
||||
[productId],
|
||||
);
|
||||
const preserveEditorDraft =
|
||||
Number(profileCount.rows[0]?.count ?? 0) > 0;
|
||||
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
|
||||
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
|
||||
await tx.query(
|
||||
@@ -222,25 +236,14 @@ export class ExtensionService {
|
||||
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
|
||||
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
|
||||
throw new Error('采集来源快照版本更新失败');
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET source_title = $1, updated_at = now(), revision = revision + 1 WHERE id = $2`,
|
||||
[payload.common.title?.value ?? null, productId],
|
||||
);
|
||||
// The historical task rows must remain auditable after replacing the
|
||||
// source snapshot. Clear only their optional variant pointer before
|
||||
// deleting old variants. The original composite FK used ON DELETE SET
|
||||
// NULL and PostgreSQL tried to null both key columns, including the
|
||||
// required workspace_id.
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_asset_transfers
|
||||
SET source_variant_id = NULL, updated_at = now()
|
||||
WHERE product_id = $1 AND source_variant_id IS NOT NULL`,
|
||||
[productId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variants WHERE product_id = $1',
|
||||
[productId],
|
||||
);
|
||||
if (!preserveEditorDraft) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.products
|
||||
SET source_title = $1, updated_at = now(), revision = revision + 1
|
||||
WHERE id = $2`,
|
||||
[payload.common.title?.value ?? null, productId],
|
||||
);
|
||||
}
|
||||
await this.writeSourceSnapshotRows(
|
||||
tx,
|
||||
workspaceId,
|
||||
@@ -248,6 +251,7 @@ export class ExtensionService {
|
||||
productId,
|
||||
snapshotRevision,
|
||||
payload,
|
||||
preserveEditorDraft,
|
||||
);
|
||||
return snapshotRevision;
|
||||
}
|
||||
@@ -259,6 +263,7 @@ export class ExtensionService {
|
||||
productId: string,
|
||||
snapshotRevision: number,
|
||||
payload: CollectedProductPayload,
|
||||
preserveEditorDraft: boolean,
|
||||
) {
|
||||
const verifiedVariants = payload.variants.filter(
|
||||
(variant) => variant.sourceVariantKey.trim().length > 0,
|
||||
@@ -272,23 +277,99 @@ export class ExtensionService {
|
||||
displayName: '默认 SKU',
|
||||
},
|
||||
];
|
||||
const sourceKeys = variantRows
|
||||
.map((variant) => variant.sourceVariantKey)
|
||||
.filter((key): key is string => !!key);
|
||||
const existing = await tx.query<{
|
||||
id: string;
|
||||
source_variant_key: string | null;
|
||||
deleted_at: Date | string | null;
|
||||
}>(
|
||||
`SELECT id, source_variant_key, deleted_at
|
||||
FROM atlas.product_variants
|
||||
WHERE product_id = $1
|
||||
AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL)
|
||||
ORDER BY sort_order, id`,
|
||||
[productId, sourceKeys],
|
||||
);
|
||||
const existingBySourceKey = new Map(
|
||||
existing.rows
|
||||
.filter((variant) => variant.source_variant_key)
|
||||
.map((variant) => [variant.source_variant_key!, variant]),
|
||||
);
|
||||
// A capture without a stable source key has exactly one synthetic
|
||||
// default SKU. Reuse an existing unkeyed row when possible; this keeps
|
||||
// its platform mapping rather than attempting a duplicate insert.
|
||||
const existingDefault = existing.rows.find(
|
||||
(variant) => variant.source_variant_key === null,
|
||||
);
|
||||
const variants = new Map<string, string>();
|
||||
const retainedVariantIds: string[] = [];
|
||||
for (let index = 0; index < variantRows.length; index += 1) {
|
||||
const variant = variantRows[index];
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
const matched = variant.sourceVariantKey
|
||||
? existingBySourceKey.get(variant.sourceVariantKey)
|
||||
: existingDefault;
|
||||
let variantId: string;
|
||||
if (matched) {
|
||||
variantId = matched.id;
|
||||
// A user may have deliberately removed an Atlas SKU after
|
||||
// editing began. Keep that decision; its source evidence is
|
||||
// still present in normalized_payload, but it is not silently
|
||||
// revived into the editable/publishable SKU set.
|
||||
if (!preserveEditorDraft) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET source_sku = $1, display_name = $2, sort_order = $3,
|
||||
deleted_at = NULL, updated_at = now()
|
||||
WHERE id = $4`,
|
||||
[
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
variantId,
|
||||
],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const inserted = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants
|
||||
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
productId,
|
||||
variant.sourceVariantKey,
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
],
|
||||
);
|
||||
variantId = inserted.rows[0].id;
|
||||
await this.ensureProfileListingsForVariant(
|
||||
tx,
|
||||
productId,
|
||||
variant.sourceVariantKey,
|
||||
variant.sourceSku ?? null,
|
||||
variant.displayName,
|
||||
index,
|
||||
],
|
||||
);
|
||||
variantId,
|
||||
);
|
||||
}
|
||||
retainedVariantIds.push(variantId);
|
||||
if (variant.sourceVariantKey)
|
||||
variants.set(variant.sourceVariantKey, inserted.rows[0].id);
|
||||
variants.set(variant.sourceVariantKey, variantId);
|
||||
}
|
||||
// Before an editor profile exists, variants are still just the latest
|
||||
// source projection, so a missing source SKU can be safely hidden. Do
|
||||
// not physically delete it: the partial source-key unique index does
|
||||
// not exclude soft-deleted rows, and a later refresh may legitimately
|
||||
// rediscover the same stable key. After editing begins, omission from a
|
||||
// source refresh must never act as a user-requested SKU deletion.
|
||||
if (!preserveEditorDraft) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE product_id = $1 AND deleted_at IS NULL
|
||||
AND NOT (id = ANY($2::uuid[]))`,
|
||||
[productId, retainedVariantIds],
|
||||
);
|
||||
}
|
||||
for (const asset of payload.assets) {
|
||||
await tx.query(
|
||||
@@ -323,6 +404,28 @@ export class ExtensionService {
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureProfileListingsForVariant(
|
||||
tx: { query: Database['query'] },
|
||||
productId: string,
|
||||
variantId: string,
|
||||
) {
|
||||
// A source refresh may discover a new real SKU after a user has
|
||||
// already entered one or more platform editors. Every existing profile
|
||||
// needs a fresh, explicitly not-listed mapping for that SKU; imported
|
||||
// mappings for other SKU rows are untouched.
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variant_platform_listings
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, p.id, p.platform_id,
|
||||
$2, p.target_store_id, s.currency_code
|
||||
FROM atlas.product_platform_profiles p
|
||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||
WHERE p.product_id = $1
|
||||
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
|
||||
[productId, variantId],
|
||||
);
|
||||
}
|
||||
|
||||
private async materialResponse(
|
||||
tx: { query: Database['query'] },
|
||||
collectionItemId: string,
|
||||
@@ -360,7 +463,9 @@ export class ExtensionService {
|
||||
editor_entry_path: `/collection-test/${collectionItemId}`,
|
||||
materialization: {
|
||||
total: Number(count.rows[0]?.total ?? 0),
|
||||
pending_selection: Number(count.rows[0]?.pending_selection ?? 0),
|
||||
pending_selection: Number(
|
||||
count.rows[0]?.pending_selection ?? 0,
|
||||
),
|
||||
queued: Number(count.rows[0]?.queued ?? 0),
|
||||
succeeded: Number(count.rows[0]?.succeeded ?? 0),
|
||||
failed: Number(count.rows[0]?.failed ?? 0),
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { Body, Controller, Get, Headers, Inject, Param, Post, Put, Req, Res } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { saveProductEditorDraftRequestSchema, type Workspace } from '@atlas/contracts';
|
||||
import {
|
||||
saveProductEditorDraftRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
@@ -50,6 +65,137 @@ export class ProductEditorController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get('product-platform-profiles/:profileId/category-options')
|
||||
async categoryOptions(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.editor.categoryOptions(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
query,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('product-platform-profiles/:profileId/category-syncs')
|
||||
async categorySync(
|
||||
@Param('profileId') profileId: string,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.editor.requestCategoryTreeSync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||
async attributeSchema(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return {
|
||||
data: await this.editor.attributeSchema(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
optionalUuid('category_id', categoryId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||
async attributeSchemaSync(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.editor.requestAttributeSchemaSync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
requiredUuid('category_id', categoryId),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get(
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
||||
)
|
||||
async attributeDictionary(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||
return {
|
||||
data: await this.editor.attributeDictionary(
|
||||
workspace.id,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
dictionaryId,
|
||||
query,
|
||||
optionalUuid('category_id', categoryId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post(
|
||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
||||
)
|
||||
async attributeDictionarySync(
|
||||
@Param('profileId') profileId: string,
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('q') query: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!dictionaryId.trim() || dictionaryId.length > 200)
|
||||
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
|
||||
const result = await this.editor.requestAttributeDictionarySync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
requiredUuid('category_id', categoryId),
|
||||
dictionaryId,
|
||||
query ?? '',
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Put('products/:productId/editor-draft')
|
||||
async save(
|
||||
@Param('productId') productId: string,
|
||||
@@ -62,19 +208,28 @@ export class ProductEditorController {
|
||||
) {
|
||||
const parsed = saveProductEditorDraftRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message,
|
||||
})));
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return (await this.editor.save(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('product_id', productId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
parseEditorRevision(ifMatch),
|
||||
parsed.data,
|
||||
)).body;
|
||||
return (
|
||||
await this.editor.save(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('product_id', productId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
parseEditorRevision(ifMatch),
|
||||
parsed.data,
|
||||
)
|
||||
).body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +240,18 @@ function requiredUuid(field: string, value: string | undefined) {
|
||||
]);
|
||||
}
|
||||
|
||||
function optionalUuid(field: string, value: string | undefined) {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
return requiredUuid(field, value);
|
||||
}
|
||||
|
||||
function parseEditorRevision(value: string | undefined) {
|
||||
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
|
||||
if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match');
|
||||
if (!match)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IF_MATCH_REQUIRED',
|
||||
'请求缺少或格式错误的 If-Match',
|
||||
);
|
||||
return { product: Number(match[1]), profile: Number(match[2]) };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -168,6 +179,62 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
||||
assert.equal(first.body.data.materialization.pending_selection, 1);
|
||||
assert.equal(first.body.data.materialization.queued, 0);
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
const targetPlatform = platforms.body.data.find(
|
||||
(platform: { code: string }) => platform.code === 'OZON',
|
||||
);
|
||||
assert.ok(targetPlatform);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores
|
||||
(workspace_id, platform_id, name, platform_client_id, currency_code,
|
||||
is_default, status, credential_status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, '复采测试店铺',
|
||||
$2, 'RUB', true, 'active', 'valid')`,
|
||||
[targetPlatform.id, `refresh-${randomUUID()}`],
|
||||
);
|
||||
});
|
||||
const editorContext = await request(
|
||||
`/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(editorContext.response.status, 201);
|
||||
// Simulate real draft work before the source page is collected again.
|
||||
// Re-collection must neither delete this SKU nor overwrite its Atlas
|
||||
// fields / platform mapping just because a newer source snapshot arrives.
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const variant = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.product_variants
|
||||
WHERE product_id = $1 AND source_variant_key = '123456789'`,
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET source_title = '人工确认商品名'
|
||||
WHERE id = $1`,
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80'
|
||||
WHERE id = $1`,
|
||||
[variant.rows[0].id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings
|
||||
SET offer_id = 'manual-offer-001'
|
||||
WHERE product_variant_id = $1`,
|
||||
[variant.rows[0].id],
|
||||
);
|
||||
});
|
||||
// A sibling Ozon URL has a different entered SKU. It must still reuse
|
||||
// the item because the submitted real-SKU sets overlap at 123456789.
|
||||
const refreshedPayload = {
|
||||
@@ -216,7 +283,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
{
|
||||
url: 'https://videos.example.test/refreshed-product.mp4',
|
||||
mediaType: 'video',
|
||||
posterUrl: 'https://images.example.test/refreshed-video-poster.jpg',
|
||||
posterUrl:
|
||||
'https://images.example.test/refreshed-video-poster.jpg',
|
||||
role: 'source_main',
|
||||
relation: 'product_candidate',
|
||||
sortOrder: 1,
|
||||
@@ -241,6 +309,33 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
);
|
||||
assert.equal(second.body.data.materialization.pending_selection, 2);
|
||||
assert.equal(second.body.data.materialization.queued, 0);
|
||||
const editorAfterRefresh = await request(
|
||||
`/api/products/${first.body.data.product_id}/editor`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(editorAfterRefresh.response.status, 200);
|
||||
assert.equal(
|
||||
editorAfterRefresh.body.data.product.common.source_title,
|
||||
'人工确认商品名',
|
||||
);
|
||||
const originalVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
variant.source_variant_key === '123456789',
|
||||
);
|
||||
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
|
||||
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
||||
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
variant.source_variant_key === '987654321',
|
||||
);
|
||||
assert.ok(discoveredVariant);
|
||||
assert.equal(discoveredVariant.listing.status, 'not_listed');
|
||||
const inspection = await request(
|
||||
`/api/collection-items/${first.body.data.collection_item.id}`,
|
||||
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
|
||||
@@ -269,10 +364,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
'pending_selection',
|
||||
);
|
||||
const videoTransfer = inspection.body.data.asset_transfers.find(
|
||||
(transfer: { media_type: string }) => transfer.media_type === 'video',
|
||||
(transfer: { media_type: string }) =>
|
||||
transfer.media_type === 'video',
|
||||
);
|
||||
assert.equal(
|
||||
videoTransfer?.source_url,
|
||||
'https://videos.example.test/refreshed-product.mp4',
|
||||
);
|
||||
assert.equal(
|
||||
videoTransfer?.poster_url,
|
||||
'https://images.example.test/refreshed-video-poster.jpg',
|
||||
);
|
||||
assert.equal(videoTransfer?.source_url, 'https://videos.example.test/refreshed-product.mp4');
|
||||
assert.equal(videoTransfer?.poster_url, 'https://images.example.test/refreshed-video-poster.jpg');
|
||||
const eventsBeforeSelection = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-10
@@ -14,17 +14,8 @@ function App() {
|
||||
colorPrimary: '#8b5cf6',
|
||||
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
||||
borderRadius: 10,
|
||||
// 全局正文字号 14px;输入类控件经 components 单独放大
|
||||
// 全局正文字号沿用 Ant Design 默认值
|
||||
fontSize: 14,
|
||||
controlHeight: 34,
|
||||
controlHeightSM: 26,
|
||||
},
|
||||
components: {
|
||||
// 文本框:文本 16px、高度同步加高;按钮与表格/菜单等保持全局默认
|
||||
Input: { fontSize: 16, controlHeight: 40 },
|
||||
Select: { fontSize: 16, controlHeight: 40 },
|
||||
InputNumber: { fontSize: 16, controlHeight: 40 },
|
||||
DatePicker: { fontSize: 16, controlHeight: 40 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
.product-editor {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 92px;
|
||||
}
|
||||
.editor-header {
|
||||
margin: -4px 0 20px;
|
||||
}
|
||||
.editor-back {
|
||||
margin-left: -8px;
|
||||
color: #667085;
|
||||
}
|
||||
.editor-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.editor-header h2.ant-typography {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.editor-eyebrow {
|
||||
color: #7c3aed;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.editor-header-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 410px;
|
||||
align-items: start;
|
||||
gap: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.editor-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.editor-section.ant-card {
|
||||
border: 1px solid #e9e7ef;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025);
|
||||
}
|
||||
.editor-section .ant-card-body {
|
||||
padding: 25px 28px;
|
||||
}
|
||||
.editor-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.editor-section-heading h4.ant-typography {
|
||||
margin: 2px 0 3px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.editor-section-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 25px;
|
||||
height: 25px;
|
||||
margin-right: 9px;
|
||||
border-radius: 7px;
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
.attribute-item span,
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.editor-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
.editor-form-grid .span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.editor-form-grid .span-two {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.editor-section .ant-form-item-label > label {
|
||||
color: #344054;
|
||||
font-weight: 600;
|
||||
}
|
||||
.product-editor .ant-form-item-label > label {
|
||||
font-size: 16px;
|
||||
}
|
||||
.product-editor .ant-input,
|
||||
.product-editor .ant-select-selection-item,
|
||||
.product-editor .ant-select-selection-placeholder {
|
||||
font-size: 16px;
|
||||
}
|
||||
.product-editor .ant-input:not(textarea) {
|
||||
min-height: 40px;
|
||||
}
|
||||
.product-editor .ant-input-group-addon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.product-editor .ant-select .ant-select-selector {
|
||||
min-height: 40px !important;
|
||||
font-size: 16px;
|
||||
}
|
||||
.product-editor .ant-select-single .ant-select-selector {
|
||||
height: 40px !important;
|
||||
}
|
||||
.product-editor .ant-select-single .ant-select-selection-item,
|
||||
.product-editor .ant-select-single .ant-select-selection-placeholder {
|
||||
line-height: 38px !important;
|
||||
}
|
||||
.product-editor .ant-select-multiple .ant-select-selector {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.package-fields {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0eef4;
|
||||
}
|
||||
.package-fields > .ant-form-item:first-child {
|
||||
width: 150px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.package-dimensions {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.package-dimensions-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 22px 120px 22px 120px auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: max-content;
|
||||
}
|
||||
.package-dimension-separator {
|
||||
color: #98a2b3;
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.package-dimension-unit {
|
||||
color: #667085;
|
||||
font-size: 16px;
|
||||
line-height: 40px;
|
||||
}
|
||||
.category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #eae7f0;
|
||||
border-radius: 10px;
|
||||
background: #fcfbfd;
|
||||
}
|
||||
.category-row strong {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.attribute-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.attribute-item {
|
||||
min-width: 0;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #eeeaf3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.attribute-item strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-standard-select {
|
||||
min-height: 40px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.editor-standard-select .ant-select-content {
|
||||
min-height: 38px;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.editor-standard-select .ant-select-content-has-value,
|
||||
.editor-standard-select .ant-select-placeholder,
|
||||
.editor-standard-select .ant-select-input {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.category-select-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 8px 6px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
||||
}
|
||||
.editor-standard-select.ant-select-single {
|
||||
height: 40px !important;
|
||||
}
|
||||
.editor-standard-select.ant-select-single .ant-select-content {
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
}
|
||||
.editor-standard-select.ant-select-single .ant-select-input {
|
||||
height: 38px;
|
||||
line-height: 38px !important;
|
||||
}
|
||||
.editor-standard-select.ant-select-multiple .ant-select-content {
|
||||
min-height: 40px !important;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.editor-standard-select.ant-select-multiple .ant-select-content-item {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.editor-standard-select.ant-select-multiple .ant-select-placeholder,
|
||||
.editor-standard-select.ant-select-multiple .ant-select-input {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.editor-standard-select.ant-select-multiple .ant-select-placeholder,
|
||||
.editor-standard-select.ant-select-multiple .ant-select-input {
|
||||
line-height: 32px !important;
|
||||
}
|
||||
.dynamic-attributes {
|
||||
margin-top: 24px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid #eeeaf4;
|
||||
}
|
||||
.dynamic-attributes h5.ant-typography {
|
||||
margin: 0 0 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.dynamic-attribute-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dynamic-attribute-item .ant-space-compact {
|
||||
width: 100%;
|
||||
}
|
||||
.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text {
|
||||
min-width: 56px;
|
||||
}
|
||||
.copy-subsection {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #eeeaf4;
|
||||
}
|
||||
.copy-subsection .editor-section-heading {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.copy-store-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) 170px;
|
||||
gap: 16px;
|
||||
}
|
||||
.sku-section .ant-input {
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sku-section textarea.ant-input {
|
||||
min-height: 58px;
|
||||
resize: none;
|
||||
}
|
||||
.sku-section .ant-input-group-addon {
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sku-section .ant-input-group .ant-input {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
.sku-section .ant-input:not(textarea) {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
.sku-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-card.ant-card {
|
||||
border: 1px solid #e8e4ee;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.sku-card .ant-card-body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.sku-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
}
|
||||
.sku-card-index {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #eee9fe;
|
||||
color: #7151df;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sku-card-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 1.55fr) 58px minmax(132px, 1fr) 104px 112px 82px auto;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.sku-card-summary .ant-form-item {
|
||||
margin: 0;
|
||||
}
|
||||
.sku-card-field,
|
||||
.sku-card-value {
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-main-thumbnail {
|
||||
display: flex;
|
||||
width: 42px;
|
||||
aspect-ratio: 3 / 4;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ece8f0;
|
||||
border-radius: 8px;
|
||||
background: #f4f2f7;
|
||||
color: #90889c;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-main-thumbnail .ant-image,
|
||||
.sku-main-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.sku-main-thumbnail img {
|
||||
object-fit: cover;
|
||||
}
|
||||
.sku-image-field {
|
||||
justify-self: center;
|
||||
}
|
||||
.sku-card-field > span,
|
||||
.sku-card-value > span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #40394f;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
min-height: 32px;
|
||||
padding-top: 5px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.sku-expand-button {
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
margin-top: 23px;
|
||||
}
|
||||
.sku-card-expanded {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr);
|
||||
gap: 32px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-media-panel h5.ant-typography,
|
||||
.sku-spec-panel h5.ant-typography {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.sku-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.sku-media-secondary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.sku-media-item {
|
||||
min-width: 0;
|
||||
aspect-ratio: 3 / 4;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ece8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f3f7;
|
||||
}
|
||||
.sku-media-item .ant-image,
|
||||
.sku-media-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.sku-media-item img {
|
||||
object-fit: contain;
|
||||
}
|
||||
.sku-media-empty {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
min-height: 190px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #d9d4df;
|
||||
border-radius: 8px;
|
||||
color: #90889c;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sku-spec-panel {
|
||||
padding: 18px 18px 6px;
|
||||
border: 1px solid #eeeaf3;
|
||||
border-radius: 10px;
|
||||
background: #fcfbfd;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item-label {
|
||||
flex: 0 0 86px !important;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item-label > label {
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-spec-panel .ant-form-item-control {
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper {
|
||||
max-width: 220px;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.sku-dimension-separator {
|
||||
color: #98a2b3;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-dimension-unit {
|
||||
color: #77718a;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
.add-sku-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.add-sku-button {
|
||||
min-width: 180px;
|
||||
}
|
||||
.editor-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.collected-info-panel.ant-card {
|
||||
border: 1px solid #e9e7ef;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025);
|
||||
}
|
||||
.collected-info-panel .ant-card-body {
|
||||
min-height: 820px;
|
||||
padding: 20px;
|
||||
}
|
||||
.collected-info-panel h4.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
.collected-info-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
.collected-info-heading .ant-typography-secondary {
|
||||
font-size: 12px;
|
||||
}
|
||||
.source-update-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.source-update-alert .ant-alert-description {
|
||||
font-size: 12px;
|
||||
}
|
||||
.collected-info-panel .ant-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
.collected-info-panel .ant-input:not(textarea) {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
.collected-field {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.collected-field:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
.collected-field > span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.collected-field-label {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.collected-field-label .ant-btn {
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.source-diff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.source-diff-list strong,
|
||||
.source-diff-list span {
|
||||
display: block;
|
||||
}
|
||||
.source-diff-list span {
|
||||
margin-top: 2px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
.collected-field > strong {
|
||||
display: block;
|
||||
color: #352f43;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.collected-field .ant-typography {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #50485d;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.collected-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ece9f1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.collected-summary > div {
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
border-right: 1px solid #f0edf3;
|
||||
border-bottom: 1px solid #f0edf3;
|
||||
}
|
||||
.collected-summary > div:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
.collected-summary > div:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.collected-summary b {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.collected-summary strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #352f43;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.collected-attributes {
|
||||
border: 1px solid #ece9f1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.collected-attributes > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr);
|
||||
gap: 12px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #f0edf3;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.collected-attributes > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.collected-attributes b {
|
||||
color: #6b6376;
|
||||
font-weight: 600;
|
||||
}
|
||||
.collected-attributes span {
|
||||
color: #352f43;
|
||||
word-break: break-word;
|
||||
}
|
||||
.other-section .editor-section-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.editor-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #e4e0eb;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 -8px 24px rgba(38, 27, 56, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.footer-ai-actions {
|
||||
display: flex;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.editor-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editor-aside {
|
||||
position: static;
|
||||
}
|
||||
.collected-info-panel {
|
||||
max-width: 100%;
|
||||
}
|
||||
.collected-info-panel .ant-card-body {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.editor-header-main,
|
||||
.editor-section-heading,
|
||||
.editor-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.editor-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editor-form-grid .span-two {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.package-fields > .ant-form-item:first-child,
|
||||
.package-dimensions-controls {
|
||||
width: 100%;
|
||||
}
|
||||
.package-dimensions-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.package-dimension-separator {
|
||||
display: none;
|
||||
}
|
||||
.copy-store-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.attribute-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sku-card-summary {
|
||||
grid-template-columns: minmax(150px, 1fr) 54px repeat(2, minmax(115px, 1fr)) auto;
|
||||
}
|
||||
.sku-card-value {
|
||||
display: none;
|
||||
}
|
||||
.sku-card-expanded {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.editor-section .ant-card-body {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.category-row,
|
||||
.editor-footer {
|
||||
align-items: stretch;
|
||||
}
|
||||
.category-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.attribute-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-card-summary {
|
||||
grid-template-columns: minmax(0, 1fr) 48px auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.sku-card-field {
|
||||
display: none;
|
||||
}
|
||||
.sku-main-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.sku-media-grid {
|
||||
grid-auto-rows: 72px;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-inline-dimensions > span {
|
||||
display: none;
|
||||
}
|
||||
.editor-footer .ant-space {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.editor-footer .ant-space-item {
|
||||
flex: 1;
|
||||
}
|
||||
.editor-footer .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductEditorCategoryOption,
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
Operation,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductEditorDraftResponse,
|
||||
} from '@atlas/contracts';
|
||||
@@ -8,9 +12,16 @@ import { api } from './api';
|
||||
|
||||
export const productEditorApi = {
|
||||
enter: (collectionItemId: string, platformId: string) =>
|
||||
api.post<ProductEditorContext>(`/collection-items/${collectionItemId}/editor-contexts`, undefined, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
api.post<ProductEditorContext>(
|
||||
`/collection-items/${collectionItemId}/editor-contexts`,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}
|
||||
),
|
||||
read: (productId: string, platformId: string) =>
|
||||
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
@@ -19,12 +30,88 @@ export const productEditorApi = {
|
||||
productId: string,
|
||||
platformId: string,
|
||||
revisions: { product: number; profile: number },
|
||||
payload: SaveProductEditorDraftRequest,
|
||||
) => api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||
},
|
||||
}),
|
||||
payload: SaveProductEditorDraftRequest
|
||||
) =>
|
||||
api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||
},
|
||||
}),
|
||||
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
||||
api.get<ProductEditorCategoryOption[]>(
|
||||
`/product-platform-profiles/${profileId}/category-options`,
|
||||
{
|
||||
params: search ? { q: search } : undefined,
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}
|
||||
),
|
||||
syncCategoryTree: (profileId: string, platformId: string) =>
|
||||
api.post<{ operation: Operation }>(
|
||||
`/product-platform-profiles/${profileId}/category-syncs`,
|
||||
undefined,
|
||||
{
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}
|
||||
),
|
||||
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null) =>
|
||||
api.get<ProductAttributeSchema[]>(
|
||||
`/product-platform-profiles/${profileId}/attribute-schema`,
|
||||
{
|
||||
params: categoryId ? { category_id: categoryId } : undefined,
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}
|
||||
),
|
||||
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||
api.post<{ operation: Operation }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||
undefined,
|
||||
{
|
||||
params: { category_id: categoryId },
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}
|
||||
),
|
||||
attributeDictionary: (
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
search?: string,
|
||||
categoryId?: string | null
|
||||
) =>
|
||||
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
||||
{
|
||||
params: {
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
...(search ? { q: search } : {}),
|
||||
},
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}
|
||||
),
|
||||
syncAttributeDictionary: (
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
categoryId: string,
|
||||
query: string
|
||||
) =>
|
||||
api.post<{ operation: Operation }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
||||
undefined,
|
||||
{
|
||||
params: { category_id: categoryId, q: query },
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}
|
||||
),
|
||||
operation: (operationId: string) => api.get<Operation>(`/operations/${operationId}`),
|
||||
};
|
||||
|
||||
+33
-2
@@ -13,6 +13,11 @@ import {
|
||||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||
import { MediaUploadCleanup } from './media-upload-cleanup.js';
|
||||
import { materializeCollectionAssets } from './collection-assets.js';
|
||||
import {
|
||||
markCatalogSyncUnavailable,
|
||||
syncOzonCatalog,
|
||||
type CatalogSyncScope,
|
||||
} from './ozon-catalog-sync.js';
|
||||
|
||||
const connection = new Redis(workerConfig.redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
@@ -35,6 +40,18 @@ const objectStorage = new S3Client({
|
||||
});
|
||||
const mediaUploadCleanup = new MediaUploadCleanup(database, objectStorage);
|
||||
type StoreVerificationJob = OutboxJob & { payload: { store_id: string; operation_id: string; secret_id?: string } };
|
||||
type CatalogSyncJob = OutboxJob & {
|
||||
payload: {
|
||||
store_id: string;
|
||||
secret_id: string;
|
||||
operation_id: string;
|
||||
platform_id: string;
|
||||
scope: CatalogSyncScope;
|
||||
category_id?: string;
|
||||
dictionary_id?: string;
|
||||
query?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const worker = new Worker<OutboxJob>(
|
||||
'atlas-dispatch',
|
||||
@@ -45,6 +62,12 @@ const worker = new Worker<OutboxJob>(
|
||||
await cleanupCollection(database, job.data as OutboxJob & { payload: { operation_id: string; collection_item_ids: string[] } });
|
||||
} else if (job.name === 'collection.assets.materialize.requested' || job.name === 'collection.assets.retry.requested') {
|
||||
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
|
||||
} else if (
|
||||
job.name === 'platform_category_tree_sync.requested' ||
|
||||
job.name === 'platform_category_attribute_sync.requested' ||
|
||||
job.name === 'platform_attribute_dictionary_sync.requested'
|
||||
) {
|
||||
await syncOzonCatalog(database, job.data as CatalogSyncJob);
|
||||
}
|
||||
},
|
||||
{ connection, concurrency: 5 },
|
||||
@@ -59,8 +82,16 @@ async function cleanupCollection(database: Database, job: OutboxJob & { payload:
|
||||
}
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
if (!job || job.attemptsMade < (job.opts.attempts ?? 1) || job.name !== 'store_credential_verification.requested') return;
|
||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||
if (!job || job.attemptsMade < (job.opts.attempts ?? 1)) return;
|
||||
if (job.name === 'store_credential_verification.requested') {
|
||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||
} else if (
|
||||
job.name === 'platform_category_tree_sync.requested' ||
|
||||
job.name === 'platform_category_attribute_sync.requested' ||
|
||||
job.name === 'platform_attribute_dictionary_sync.requested'
|
||||
) {
|
||||
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
||||
}
|
||||
});
|
||||
|
||||
async function verifyStoreCredentials(database: Database, job: StoreVerificationJob) {
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { OutboxJob } from './outbox-dispatcher.js';
|
||||
import {
|
||||
fetchOzonCategoryAttributes,
|
||||
fetchOzonCategoryTree,
|
||||
fetchOzonDictionaryValues,
|
||||
OzonCredentialError,
|
||||
OzonRequestError,
|
||||
type OzonCategoryAttribute,
|
||||
type OzonCategoryNode,
|
||||
type OzonDictionaryValue,
|
||||
} from './ozon-client.js';
|
||||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||||
import { workerConfig } from './config.js';
|
||||
|
||||
export type CatalogSyncScope = 'tree' | 'attributes' | 'dictionary';
|
||||
type CatalogSyncJob = OutboxJob & {
|
||||
payload: {
|
||||
store_id: string;
|
||||
secret_id: string;
|
||||
operation_id: string;
|
||||
platform_id: string;
|
||||
scope: CatalogSyncScope;
|
||||
category_id?: string;
|
||||
dictionary_id?: string;
|
||||
query?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SyncSource = {
|
||||
platform_code: string;
|
||||
platform_client_id: string;
|
||||
secret_ciphertext: Buffer;
|
||||
secret_nonce: Buffer;
|
||||
};
|
||||
type CategorySource = {
|
||||
external_category_id: string;
|
||||
external_type_id: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps platform metadata global while every credential and operation lookup
|
||||
* remains inside the requesting workspace. Neither queue payloads nor cache
|
||||
* rows contain the decrypted API key.
|
||||
*/
|
||||
export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||
const source = await loadSource(database, job);
|
||||
if (!source) return cancelStaleOperation(database, job);
|
||||
if (source.platform_code.toUpperCase() !== 'OZON') {
|
||||
return failOperation(
|
||||
database,
|
||||
job,
|
||||
'PLATFORM_CATALOG_UNSUPPORTED',
|
||||
'当前平台暂不支持类目缓存同步',
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = decryptStoreSecret(
|
||||
source.secret_ciphertext,
|
||||
source.secret_nonce,
|
||||
workerConfig.storeSecretEncryptionKey,
|
||||
);
|
||||
try {
|
||||
let result: Record<string, unknown>;
|
||||
if (job.payload.scope === 'tree') {
|
||||
const nodes = await fetchOzonCategoryTree(source.platform_client_id, apiKey);
|
||||
result = await persistCategoryTree(database, job.payload.platform_id, nodes);
|
||||
} else {
|
||||
const category = await requireCategory(
|
||||
database,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id,
|
||||
);
|
||||
if (!category.external_type_id) {
|
||||
return failOperation(
|
||||
database,
|
||||
job,
|
||||
'CATEGORY_TYPE_UNAVAILABLE',
|
||||
'该类目缺少 Ozon 商品类型,无法同步属性',
|
||||
);
|
||||
}
|
||||
if (job.payload.scope === 'attributes') {
|
||||
const attributes = await fetchOzonCategoryAttributes(
|
||||
source.platform_client_id,
|
||||
apiKey,
|
||||
category.external_category_id,
|
||||
category.external_type_id,
|
||||
);
|
||||
result = await persistAttributes(
|
||||
database,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id ?? '',
|
||||
attributes,
|
||||
);
|
||||
} else {
|
||||
const dictionary = await requireDictionaryAttribute(
|
||||
database,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id,
|
||||
job.payload.dictionary_id,
|
||||
);
|
||||
const values = await fetchOzonDictionaryValues(
|
||||
source.platform_client_id,
|
||||
apiKey,
|
||||
{
|
||||
attributeId: dictionary.platform_attribute_id,
|
||||
categoryId: category.external_category_id,
|
||||
typeId: category.external_type_id,
|
||||
query: job.payload.query,
|
||||
},
|
||||
);
|
||||
result = await persistDictionaryValues(
|
||||
database,
|
||||
job.payload.platform_id,
|
||||
job.payload.dictionary_id ?? '',
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
await succeedOperation(database, job, result);
|
||||
} catch (error) {
|
||||
if (error instanceof OzonCredentialError) {
|
||||
await failOperation(
|
||||
database,
|
||||
job,
|
||||
error.status === 403
|
||||
? 'OZON_CATALOG_CREDENTIAL_FORBIDDEN'
|
||||
: 'OZON_CATALOG_CREDENTIAL_UNAUTHORIZED',
|
||||
error.status === 403
|
||||
? 'Ozon 拒绝读取类目:请检查 API Key 权限'
|
||||
: 'Ozon 未接受当前门店凭据',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error instanceof OzonRequestError) {
|
||||
await failOperation(
|
||||
database,
|
||||
job,
|
||||
'OZON_CATALOG_REQUEST_REJECTED',
|
||||
`Ozon 拒绝类目同步请求(HTTP ${error.status})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markCatalogSyncUnavailable(
|
||||
database: Database,
|
||||
job: CatalogSyncJob,
|
||||
_error: Error,
|
||||
) {
|
||||
await failOperation(
|
||||
database,
|
||||
job,
|
||||
'OZON_CATALOG_SYNC_UNAVAILABLE',
|
||||
'暂时无法同步 Ozon 类目数据,请稍后重试',
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSource(database: Database, job: CatalogSyncJob) {
|
||||
return database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()),
|
||||
attempt_count = attempt_count + 1
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id],
|
||||
);
|
||||
const source = await tx.query<SyncSource>(
|
||||
`SELECT platform.code AS platform_code, store.platform_client_id,
|
||||
secret.secret_ciphertext, secret.secret_nonce
|
||||
FROM atlas.operations operation
|
||||
JOIN atlas.stores store ON store.id = $2 AND store.deleted_at IS NULL
|
||||
JOIN atlas.platforms platform ON platform.id = store.platform_id AND platform.id = $3
|
||||
JOIN atlas.store_secrets secret ON secret.id = $4 AND secret.store_id = store.id
|
||||
WHERE operation.id = $1 AND operation.kind IN (
|
||||
'platform_category_tree_sync',
|
||||
'platform_category_attribute_sync',
|
||||
'platform_attribute_dictionary_sync'
|
||||
)
|
||||
AND store.credential_status = 'valid'
|
||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||
LIMIT 1`,
|
||||
[
|
||||
job.payload.operation_id,
|
||||
job.payload.store_id,
|
||||
job.payload.platform_id,
|
||||
job.payload.secret_id,
|
||||
],
|
||||
);
|
||||
return source.rows[0] ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelStaleOperation(database: Database, job: CatalogSyncJob) {
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||||
error_code = 'CATALOG_SYNC_SOURCE_REPLACED',
|
||||
error_message = '门店凭据或发布配置已变化,旧类目同步已取消'
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function succeedOperation(
|
||||
database: Database,
|
||||
job: CatalogSyncJob,
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
|
||||
result = $2::jsonb, error_code = NULL, error_message = NULL
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id, JSON.stringify({ scope: job.payload.scope, ...result })],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function failOperation(
|
||||
database: Database,
|
||||
job: CatalogSyncJob,
|
||||
code: string,
|
||||
message: string,
|
||||
) {
|
||||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||||
error_code = $2, error_message = $3
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[job.payload.operation_id, code, message],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function requireCategory(
|
||||
database: Database,
|
||||
platformId: string,
|
||||
categoryId: string | undefined,
|
||||
) {
|
||||
if (!categoryId) throw new Error('Catalog sync category is missing');
|
||||
const result = await database.query<CategorySource>(
|
||||
`SELECT external_category_id, external_type_id
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||
[categoryId, platformId],
|
||||
);
|
||||
if (!result.rows[0]) throw new Error('Catalog sync category no longer exists');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function requireDictionaryAttribute(
|
||||
database: Database,
|
||||
platformId: string,
|
||||
categoryId: string | undefined,
|
||||
dictionaryId: string | undefined,
|
||||
) {
|
||||
if (!categoryId || !dictionaryId) throw new Error('Catalog dictionary input is missing');
|
||||
const result = await database.query<{ platform_attribute_id: string }>(
|
||||
`SELECT platform_attribute_id
|
||||
FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2 AND dictionary_id = $3
|
||||
LIMIT 1`,
|
||||
[platformId, categoryId, dictionaryId],
|
||||
);
|
||||
if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
type FlatCategory = {
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
parent_key: string | null;
|
||||
name: string;
|
||||
path: string;
|
||||
is_active: boolean;
|
||||
source_payload: OzonCategoryNode;
|
||||
};
|
||||
|
||||
async function persistCategoryTree(
|
||||
database: Database,
|
||||
platformId: string,
|
||||
roots: OzonCategoryNode[],
|
||||
) {
|
||||
// Ozon 的树偶尔会在不同分支重复返回同一个类目;缓存主键是
|
||||
// (category_id, type_id),因此保留最后一个完整路径而不让批量 upsert 冲突。
|
||||
const records = [
|
||||
...new Map(
|
||||
flattenCategories(roots).map((record) => [
|
||||
categoryKey(record.external_category_id, record.external_type_id),
|
||||
record,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const syncTime = new Date();
|
||||
await database.transaction(async (tx) => {
|
||||
await tx.query(
|
||||
`WITH incoming AS (
|
||||
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||||
external_category_id text, external_type_id text, parent_key text,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
)
|
||||
INSERT INTO atlas.platform_product_categories
|
||||
(platform_id, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, synced_at)
|
||||
SELECT $1, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, $3::timestamptz
|
||||
FROM incoming
|
||||
ON CONFLICT (platform_id, external_category_id, external_type_id) DO UPDATE
|
||||
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
||||
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||||
updated_at = now()`,
|
||||
[platformId, JSON.stringify(records), syncTime],
|
||||
);
|
||||
await tx.query(
|
||||
`WITH incoming AS (
|
||||
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||||
external_category_id text, external_type_id text, parent_key text,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
), resolved AS (
|
||||
SELECT child.id AS child_id, parent.id AS parent_id
|
||||
FROM incoming
|
||||
JOIN atlas.platform_product_categories child
|
||||
ON child.platform_id = $1
|
||||
AND child.external_category_id = incoming.external_category_id
|
||||
AND child.external_type_id = incoming.external_type_id
|
||||
LEFT JOIN atlas.platform_product_categories parent
|
||||
ON parent.platform_id = $1
|
||||
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||||
)
|
||||
UPDATE atlas.platform_product_categories category
|
||||
SET parent_id = resolved.parent_id, updated_at = now()
|
||||
FROM resolved WHERE category.id = resolved.child_id`,
|
||||
[platformId, JSON.stringify(records)],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_product_categories SET is_active = false, updated_at = now()
|
||||
WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||
[platformId, syncTime],
|
||||
);
|
||||
});
|
||||
return {
|
||||
category_count: records.length,
|
||||
active_category_count: records.filter((record) => record.is_active).length,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenCategories(
|
||||
nodes: OzonCategoryNode[],
|
||||
path: string[] = [],
|
||||
parentKey: string | null = null,
|
||||
): FlatCategory[] {
|
||||
const flattened: FlatCategory[] = [];
|
||||
for (const node of nodes) {
|
||||
const categoryId = String(node.description_category_id ?? '').trim();
|
||||
if (!categoryId || !node.category_name?.trim()) continue;
|
||||
const typeId = String(node.type_id ?? 0);
|
||||
const nextPath = [...path, node.category_name.trim()];
|
||||
const key = categoryKey(categoryId, typeId);
|
||||
flattened.push({
|
||||
external_category_id: categoryId,
|
||||
external_type_id: typeId,
|
||||
parent_key: parentKey,
|
||||
name: node.category_name.trim(),
|
||||
path: nextPath.join(' › '),
|
||||
is_active: node.disabled !== true && typeId !== '0',
|
||||
source_payload: node,
|
||||
});
|
||||
flattened.push(...flattenCategories(node.children ?? [], nextPath, key));
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function categoryKey(categoryId: string, typeId: string) {
|
||||
// Ozon category/type IDs are scalar identifiers; a printable separator is
|
||||
// intentional because PostgreSQL text/jsonb forbids embedded NUL bytes.
|
||||
return `${categoryId}:${typeId}`;
|
||||
}
|
||||
|
||||
type CachedAttribute = {
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
name: string;
|
||||
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
|
||||
is_required: boolean;
|
||||
is_collection: boolean;
|
||||
max_value_count: number;
|
||||
dictionary_id: string | null;
|
||||
is_aspect: boolean;
|
||||
source_payload: OzonCategoryAttribute;
|
||||
};
|
||||
|
||||
async function persistAttributes(
|
||||
database: Database,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
attributes: OzonCategoryAttribute[],
|
||||
) {
|
||||
const records = attributes
|
||||
.map(toCachedAttribute)
|
||||
.filter((attribute): attribute is CachedAttribute => attribute !== null);
|
||||
await database.transaction(async (tx) => {
|
||||
await tx.query(
|
||||
`DELETE FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2`,
|
||||
[platformId, categoryId],
|
||||
);
|
||||
if (!records.length) return;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_product_category_id, platform_attribute_id, complex_id,
|
||||
name, value_type, is_required, is_collection, max_value_count,
|
||||
dictionary_id, is_aspect, source_payload, synced_at)
|
||||
SELECT $1, $2, platform_attribute_id, complex_id, name, value_type,
|
||||
is_required, is_collection, max_value_count, dictionary_id,
|
||||
is_aspect, source_payload, now()
|
||||
FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||
platform_attribute_id text, complex_id text, name text, value_type text,
|
||||
is_required boolean, is_collection boolean, max_value_count integer,
|
||||
dictionary_id text, is_aspect boolean, source_payload jsonb
|
||||
)`,
|
||||
[platformId, categoryId, JSON.stringify(records)],
|
||||
);
|
||||
});
|
||||
return { attribute_count: records.length };
|
||||
}
|
||||
|
||||
function toCachedAttribute(value: OzonCategoryAttribute): CachedAttribute | null {
|
||||
const id = String(value.id ?? '').trim();
|
||||
const name = value.name?.trim();
|
||||
if (!id || !name) return null;
|
||||
const dictionaryId = String(value.dictionary_id ?? '').trim();
|
||||
const hasDictionary = !!dictionaryId && dictionaryId !== '0';
|
||||
const isCollection = value.is_collection === true;
|
||||
const requestedMax = Number(value.max_value_count ?? (isCollection ? 100 : 1));
|
||||
return {
|
||||
platform_attribute_id: id,
|
||||
complex_id: String(value.complex_id ?? 0),
|
||||
name,
|
||||
value_type: hasDictionary ? 'dictionary' : mapAttributeType(value.type),
|
||||
is_required: value.is_required === true,
|
||||
is_collection: isCollection,
|
||||
max_value_count: isCollection
|
||||
? Math.max(1, Math.min(100, Number.isFinite(requestedMax) ? requestedMax : 100))
|
||||
: 1,
|
||||
dictionary_id: hasDictionary ? dictionaryId : null,
|
||||
is_aspect: value.is_aspect === true,
|
||||
source_payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAttributeType(value: string | null | undefined): CachedAttribute['value_type'] {
|
||||
switch (value?.toLowerCase()) {
|
||||
case 'integer':
|
||||
return 'integer';
|
||||
case 'decimal':
|
||||
case 'number':
|
||||
return 'decimal';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDictionaryValues(
|
||||
database: Database,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
values: OzonDictionaryValue[],
|
||||
) {
|
||||
const records = values
|
||||
.map((value) => ({
|
||||
external_value_id: String(value.id ?? '').trim(),
|
||||
value: value.value?.trim(),
|
||||
source_payload: value,
|
||||
}))
|
||||
.filter((value): value is { external_value_id: string; value: string; source_payload: OzonDictionaryValue } =>
|
||||
!!value.external_value_id && !!value.value,
|
||||
);
|
||||
if (!records.length) return { dictionary_value_count: 0 };
|
||||
await database.query(
|
||||
`INSERT INTO atlas.platform_attribute_dictionary_values
|
||||
(platform_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
||||
SELECT $1, $2, external_value_id, value, true, source_payload, now()
|
||||
FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||
external_value_id text, value text, source_payload jsonb
|
||||
)
|
||||
ON CONFLICT (platform_id, dictionary_id, external_value_id) DO UPDATE
|
||||
SET value = EXCLUDED.value, is_active = true, source_payload = EXCLUDED.source_payload,
|
||||
synced_at = EXCLUDED.synced_at, updated_at = now()`,
|
||||
[platformId, dictionaryId, JSON.stringify(records)],
|
||||
);
|
||||
return { dictionary_value_count: records.length };
|
||||
}
|
||||
@@ -12,6 +12,34 @@ export class OzonRequestError extends Error {
|
||||
}
|
||||
export class OzonRetryableError extends Error {}
|
||||
|
||||
export type OzonCategoryNode = {
|
||||
description_category_id: number | string;
|
||||
category_name: string;
|
||||
type_id?: number | string | null;
|
||||
type_name?: string | null;
|
||||
disabled?: boolean;
|
||||
children?: OzonCategoryNode[];
|
||||
};
|
||||
|
||||
export type OzonCategoryAttribute = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
type?: string | null;
|
||||
is_collection?: boolean;
|
||||
is_required?: boolean;
|
||||
is_aspect?: boolean;
|
||||
max_value_count?: number | null;
|
||||
dictionary_id?: number | string | null;
|
||||
complex_id?: number | string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type OzonDictionaryValue = {
|
||||
id: number | string;
|
||||
value: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses the category-tree read endpoint as the credential probe. It has no write side effect and,
|
||||
* unlike warehouse-list, is verified against the current Ozon account integration.
|
||||
@@ -20,22 +48,80 @@ export async function verifyOzonCredentials(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
await requestOzon(clientId, apiKey, '/v1/description-category/tree', {
|
||||
language: 'ZH_HANS',
|
||||
});
|
||||
}
|
||||
|
||||
/** 平台目录同步只在 Worker 中使用,密钥仅在此函数调用期间留在内存。 */
|
||||
export async function fetchOzonCategoryTree(clientId: string, apiKey: string) {
|
||||
const body = await requestOzon(clientId, apiKey, '/v1/description-category/tree', {
|
||||
language: 'ZH_HANS',
|
||||
});
|
||||
return readResultArray<OzonCategoryNode>(body, '类目树');
|
||||
}
|
||||
|
||||
export async function fetchOzonCategoryAttributes(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
categoryId: string,
|
||||
typeId: string,
|
||||
) {
|
||||
const body = await requestOzon(clientId, apiKey, '/v1/description-category/attribute', {
|
||||
description_category_id: numberOrText(categoryId),
|
||||
type_id: numberOrText(typeId),
|
||||
language: 'ZH_HANS',
|
||||
});
|
||||
return readResultArray<OzonCategoryAttribute>(body, '类目属性');
|
||||
}
|
||||
|
||||
export async function fetchOzonDictionaryValues(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
input: {
|
||||
attributeId: string;
|
||||
categoryId: string;
|
||||
typeId: string;
|
||||
query?: string;
|
||||
},
|
||||
) {
|
||||
const query = input.query?.trim() ?? '';
|
||||
const path = query
|
||||
? '/v1/description-category/attribute/values/search'
|
||||
: '/v1/description-category/attribute/values';
|
||||
const body = await requestOzon(clientId, apiKey, path, {
|
||||
attribute_id: numberOrText(input.attributeId),
|
||||
description_category_id: numberOrText(input.categoryId),
|
||||
type_id: numberOrText(input.typeId),
|
||||
language: 'ZH_HANS',
|
||||
limit: query ? 100 : 1_000,
|
||||
...(query ? { value: query } : { last_value_id: 0 }),
|
||||
});
|
||||
return readResultArray<OzonDictionaryValue>(body, '属性字典');
|
||||
}
|
||||
|
||||
async function requestOzon(
|
||||
clientId: string,
|
||||
apiKey: string,
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
||||
response = await fetch(`https://api-seller.ozon.ru${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': clientId,
|
||||
'Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new OzonRetryableError('Ozon request could not be completed');
|
||||
}
|
||||
if (response.ok) return;
|
||||
if (response.ok) return response.json() as Promise<unknown>;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OzonCredentialError(response.status);
|
||||
}
|
||||
@@ -44,3 +130,18 @@ export async function verifyOzonCredentials(
|
||||
}
|
||||
throw new OzonRequestError(response.status);
|
||||
}
|
||||
|
||||
function readResultArray<T>(body: unknown, resource: string): T[] {
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
!Array.isArray((body as { result?: unknown }).result)
|
||||
) {
|
||||
throw new OzonRetryableError(`Ozon ${resource} 响应格式异常`);
|
||||
}
|
||||
return (body as { result: T[] }).result;
|
||||
}
|
||||
|
||||
function numberOrText(value: string) {
|
||||
return /^\d+$/.test(value) ? Number(value) : value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user