Compare commits
3 Commits
05a00d46b9
...
da7342b466
| Author | SHA1 | Date | |
|---|---|---|---|
| da7342b466 | |||
| 1b03ea5871 | |||
| 2c4e94ab3d |
@@ -0,0 +1,14 @@
|
|||||||
|
# AGENTS.md — 工作区工作约定
|
||||||
|
|
||||||
|
本工作区(`atlas-seller-kit-frontend` worktree,`zcode/frontend` 分支)的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
|
||||||
|
|
||||||
|
## 1. 只改前端
|
||||||
|
|
||||||
|
- 只修改 `apps/web/` 及前端相关文档(即协作约定中「前端体验」领域,见 `docs/project/development-collaboration-contract.md` §2)。
|
||||||
|
- `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/` 等服务端领域的代码一律不改;发现问题时在回复中说明,由维护者/后端处理。
|
||||||
|
|
||||||
|
## 2. 不要启动服务端和 Worker
|
||||||
|
|
||||||
|
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。
|
||||||
|
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。
|
||||||
|
- 前端 vite dev server 属于前端领域,可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
|
||||||
@@ -251,6 +251,43 @@ export class ProductEditorService {
|
|||||||
ORDER BY is_default DESC, name, id`,
|
ORDER BY is_default DESC, name, id`,
|
||||||
[platformId],
|
[platformId],
|
||||||
);
|
);
|
||||||
|
const media = await transaction.query<{
|
||||||
|
listing_id: string;
|
||||||
|
asset_id: string;
|
||||||
|
content_type: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
byte_size: string;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
source_url: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT m.listing_id, m.asset_id, a.content_type, a.byte_size::text,
|
||||||
|
a.width_px, a.height_px, min(t.source_url) AS source_url
|
||||||
|
FROM atlas.product_variant_listing_media m
|
||||||
|
JOIN atlas.media_assets a ON a.id = m.asset_id
|
||||||
|
LEFT JOIN atlas.collection_asset_transfers t
|
||||||
|
ON t.asset_id = m.asset_id AND t.product_id = $2
|
||||||
|
WHERE m.profile_id = $1
|
||||||
|
GROUP BY m.id, m.listing_id, m.asset_id, a.content_type,
|
||||||
|
a.byte_size, a.width_px, a.height_px
|
||||||
|
ORDER BY m.listing_id, m.sort_order, m.id`,
|
||||||
|
[profile.id, productId],
|
||||||
|
);
|
||||||
|
const mediaByListing = new Map<
|
||||||
|
string,
|
||||||
|
ProductEditorSnapshot['variants'][number]['media']
|
||||||
|
>();
|
||||||
|
for (const item of media.rows) {
|
||||||
|
const items = mediaByListing.get(item.listing_id) ?? [];
|
||||||
|
items.push({
|
||||||
|
asset_id: item.asset_id,
|
||||||
|
content_type: item.content_type,
|
||||||
|
byte_size: Number(item.byte_size),
|
||||||
|
width_px: item.width_px,
|
||||||
|
height_px: item.height_px,
|
||||||
|
source_url: item.source_url,
|
||||||
|
});
|
||||||
|
mediaByListing.set(item.listing_id, items);
|
||||||
|
}
|
||||||
const listingCounts = await transaction.query<{
|
const listingCounts = await transaction.query<{
|
||||||
total: string;
|
total: string;
|
||||||
imported: string;
|
imported: string;
|
||||||
@@ -336,6 +373,7 @@ export class ProductEditorService {
|
|||||||
list_price: variant.list_price,
|
list_price: variant.list_price,
|
||||||
currency_code: variant.currency_code,
|
currency_code: variant.currency_code,
|
||||||
},
|
},
|
||||||
|
media: mediaByListing.get(variant.listing_id) ?? [],
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
collection_item_id: product.collection_item_id,
|
collection_item_id: product.collection_item_id,
|
||||||
@@ -845,10 +883,18 @@ export class ProductEditorService {
|
|||||||
id: string;
|
id: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
const retainedIds: string[] = [];
|
const retainedIds: string[] = [];
|
||||||
|
const variantMedia: Array<{
|
||||||
|
variantId: string;
|
||||||
|
assetIds: string[];
|
||||||
|
}> = [];
|
||||||
for (let index = 0; index < input.variants.length; index += 1) {
|
for (let index = 0; index < input.variants.length; index += 1) {
|
||||||
const variant = input.variants[index];
|
const variant = input.variants[index];
|
||||||
if (variant.id) {
|
if (variant.id) {
|
||||||
retainedIds.push(variant.id);
|
retainedIds.push(variant.id);
|
||||||
|
variantMedia.push({
|
||||||
|
variantId: variant.id,
|
||||||
|
assetIds: variant.media_asset_ids,
|
||||||
|
});
|
||||||
await transaction.query(
|
await transaction.query(
|
||||||
`UPDATE atlas.product_variants
|
`UPDATE atlas.product_variants
|
||||||
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
|
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
|
||||||
@@ -959,6 +1005,10 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
const id = inserted.rows[0].id;
|
const id = inserted.rows[0].id;
|
||||||
retainedIds.push(id);
|
retainedIds.push(id);
|
||||||
|
variantMedia.push({
|
||||||
|
variantId: id,
|
||||||
|
assetIds: variant.media_asset_ids,
|
||||||
|
});
|
||||||
createdVariants.push({
|
createdVariants.push({
|
||||||
client_key: variant.client_key!,
|
client_key: variant.client_key!,
|
||||||
id,
|
id,
|
||||||
@@ -1004,6 +1054,11 @@ export class ProductEditorService {
|
|||||||
attributes: input.attributes,
|
attributes: input.attributes,
|
||||||
retainedVariantIds: retainedIds,
|
retainedVariantIds: retainedIds,
|
||||||
});
|
});
|
||||||
|
await this.persistListingMedia(transaction, {
|
||||||
|
product,
|
||||||
|
profile,
|
||||||
|
variants: variantMedia,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
@@ -1045,6 +1100,163 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist final image order only after every supplied asset has passed the
|
||||||
|
* product/SKU provenance check. A completed transfer is the bridge from
|
||||||
|
* an untrusted source URL to an Atlas-owned media asset; callers cannot
|
||||||
|
* attach arbitrary workspace assets to a listing.
|
||||||
|
*/
|
||||||
|
private async persistListingMedia(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
input: {
|
||||||
|
product: ProductRow;
|
||||||
|
profile: ProfileRow;
|
||||||
|
variants: Array<{ variantId: string; assetIds: string[] }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const variantIds = input.variants.map((variant) => variant.variantId);
|
||||||
|
const listings = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
product_variant_id: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, product_variant_id
|
||||||
|
FROM atlas.product_variant_platform_listings
|
||||||
|
WHERE profile_id = $1 AND product_variant_id = ANY($2::uuid[])`,
|
||||||
|
[input.profile.id, variantIds],
|
||||||
|
);
|
||||||
|
if (listings.rows.length !== input.variants.length)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'LISTING_CONTEXT_CHANGED',
|
||||||
|
'SKU 发布记录已变化,请重新加载编辑器',
|
||||||
|
);
|
||||||
|
const listingByVariant = new Map(
|
||||||
|
listings.rows.map((listing) => [listing.product_variant_id, listing]),
|
||||||
|
);
|
||||||
|
const assetIds = [
|
||||||
|
...new Set(input.variants.flatMap((variant) => variant.assetIds)),
|
||||||
|
];
|
||||||
|
const assets = assetIds.length
|
||||||
|
? await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
content_type: string;
|
||||||
|
byte_size: string;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, content_type, byte_size::text, width_px, height_px
|
||||||
|
FROM atlas.media_assets WHERE id = ANY($1::uuid[])`,
|
||||||
|
[assetIds],
|
||||||
|
)
|
||||||
|
: { rows: [] };
|
||||||
|
const assetById = new Map(assets.rows.map((asset) => [asset.id, asset]));
|
||||||
|
const existing = await transaction.query<{
|
||||||
|
product_variant_id: string;
|
||||||
|
asset_id: string;
|
||||||
|
}>(
|
||||||
|
`SELECT l.product_variant_id, m.asset_id
|
||||||
|
FROM atlas.product_variant_listing_media m
|
||||||
|
JOIN atlas.product_variant_platform_listings l ON l.id = m.listing_id
|
||||||
|
WHERE m.profile_id = $1 AND m.product_variant_id = ANY($2::uuid[])`,
|
||||||
|
[input.profile.id, variantIds],
|
||||||
|
);
|
||||||
|
const existingByVariant = new Map<string, Set<string>>();
|
||||||
|
for (const item of existing.rows) {
|
||||||
|
const ids = existingByVariant.get(item.product_variant_id) ?? new Set();
|
||||||
|
ids.add(item.asset_id);
|
||||||
|
existingByVariant.set(item.product_variant_id, ids);
|
||||||
|
}
|
||||||
|
const candidates = assetIds.length
|
||||||
|
? await transaction.query<{
|
||||||
|
asset_id: string;
|
||||||
|
source_variant_id: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT DISTINCT t.asset_id, t.source_variant_id
|
||||||
|
FROM atlas.collection_asset_transfers t
|
||||||
|
WHERE t.collection_item_id = $1 AND t.product_id = $2
|
||||||
|
AND t.source_snapshot_revision = $3
|
||||||
|
AND t.status = 'succeeded'
|
||||||
|
AND t.asset_id = ANY($4::uuid[])`,
|
||||||
|
[
|
||||||
|
input.product.collection_item_id,
|
||||||
|
input.product.id,
|
||||||
|
input.product.source_snapshot_revision,
|
||||||
|
assetIds,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: { rows: [] };
|
||||||
|
const candidateVariants = new Map<string, Set<string | null>>();
|
||||||
|
for (const candidate of candidates.rows) {
|
||||||
|
const variants = candidateVariants.get(candidate.asset_id) ?? new Set();
|
||||||
|
variants.add(candidate.source_variant_id);
|
||||||
|
candidateVariants.set(candidate.asset_id, variants);
|
||||||
|
}
|
||||||
|
for (const variant of input.variants) {
|
||||||
|
for (const [index, assetId] of variant.assetIds.entries()) {
|
||||||
|
const asset = assetById.get(assetId);
|
||||||
|
const dimensionsKnown =
|
||||||
|
asset?.width_px !== null && asset?.height_px !== null;
|
||||||
|
if (
|
||||||
|
!asset ||
|
||||||
|
!['image/jpeg', 'image/png', 'image/webp'].includes(
|
||||||
|
asset.content_type,
|
||||||
|
) ||
|
||||||
|
Number(asset.byte_size) > 20 * 1024 * 1024 ||
|
||||||
|
!dimensionsKnown ||
|
||||||
|
asset.width_px! > 10_000 ||
|
||||||
|
asset.height_px! > 10_000
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'LISTING_MEDIA_INVALID',
|
||||||
|
'SKU 图片必须是已校验的 JPG、PNG 或 WebP,且不超过 20MB / 10000px',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
index === 0 &&
|
||||||
|
Math.min(asset.width_px!, asset.height_px!) < 700
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'LISTING_MAIN_IMAGE_TOO_SMALL',
|
||||||
|
'短边小于 700px 的图片不能作为 SKU 主图',
|
||||||
|
);
|
||||||
|
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
||||||
|
continue;
|
||||||
|
const allowedFor = candidateVariants.get(assetId);
|
||||||
|
if (!allowedFor?.has(null) && !allowedFor?.has(variant.variantId))
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||||
|
'只能选择当前采集快照中该 SKU 或商品通用候选已转存的图片',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const listingIds = listings.rows.map((listing) => listing.id);
|
||||||
|
await transaction.query(
|
||||||
|
`DELETE FROM atlas.product_variant_listing_media
|
||||||
|
WHERE listing_id = ANY($1::uuid[])`,
|
||||||
|
[listingIds],
|
||||||
|
);
|
||||||
|
for (const variant of input.variants) {
|
||||||
|
const listing = listingByVariant.get(variant.variantId)!;
|
||||||
|
for (const [sortOrder, assetId] of variant.assetIds.entries()) {
|
||||||
|
await transaction.query(
|
||||||
|
`INSERT INTO atlas.product_variant_listing_media
|
||||||
|
(workspace_id, product_id, profile_id, listing_id, product_variant_id, asset_id, sort_order)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
input.product.id,
|
||||||
|
input.profile.id,
|
||||||
|
listing.id,
|
||||||
|
variant.variantId,
|
||||||
|
assetId,
|
||||||
|
sortOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async persistDynamicAttributes(
|
private async persistDynamicAttributes(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -207,6 +207,44 @@ export class StoresController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':storeId/category-tree-syncs')
|
||||||
|
@ApiOperation({ summary: '同步刷新门店类目树' })
|
||||||
|
@ApiOkResponse({ description: 'Ozon 类目树已成功写入门店缓存' })
|
||||||
|
public async categoryTreeSync(
|
||||||
|
@Param('storeId') storeId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
) {
|
||||||
|
return withStatus(
|
||||||
|
response,
|
||||||
|
await this.storesService.requestCategoryTreeSync(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('store_id', storeId),
|
||||||
|
requireIdempotencyKey(idempotencyKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('default/category-tree-debug')
|
||||||
|
@ApiOperation({ summary: '读取当前平台默认门店的 Ozon 原始类目树(调试)' })
|
||||||
|
public async defaultCategoryTreeDebug(
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (!platformId) {
|
||||||
|
throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: await this.storesService.fetchDefaultCategoryTreeDebug(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':storeId/warehouse-syncs')
|
@Post(':storeId/warehouse-syncs')
|
||||||
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
@ApiOperation({ summary: '请求异步同步门店仓库' })
|
||||||
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
@ApiAcceptedResponse({ description: '仓库同步操作已入队' })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ApiError } from '../common/api-error.js';
|
|||||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
import { DATABASE } from '../database/database.module.js';
|
import { DATABASE } from '../database/database.module.js';
|
||||||
import { encryptStoreSecret } from './store-secret.crypto.js';
|
import { decryptStoreSecret, encryptStoreSecret } from './store-secret.crypto.js';
|
||||||
|
|
||||||
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
|
||||||
credential_checked_at: Date | string | null;
|
credential_checked_at: Date | string | null;
|
||||||
@@ -46,6 +46,23 @@ type WarehouseRow = {
|
|||||||
status: string;
|
status: string;
|
||||||
synced_at: Date | string;
|
synced_at: Date | string;
|
||||||
};
|
};
|
||||||
|
type OzonCategoryNode = {
|
||||||
|
description_category_id?: number | string;
|
||||||
|
category_name?: string | null;
|
||||||
|
type_id?: number | string | null;
|
||||||
|
type_name?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: OzonCategoryNode[];
|
||||||
|
};
|
||||||
|
type FlatCategory = {
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string;
|
||||||
|
parent_key: string | null;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
source_payload: OzonCategoryNode;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StoresService {
|
export class StoresService {
|
||||||
@@ -335,6 +352,170 @@ export class StoresService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手动刷新是同步请求:Ozon 完整响应成功落库后才向浏览器返回成功。 */
|
||||||
|
public async requestCategoryTreeSync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/stores/${storeId}/category-tree-syncs`,
|
||||||
|
key: idempotencyKey,
|
||||||
|
request: {},
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
const store = await this.requireStore(transaction, storeId);
|
||||||
|
if (store.status === 'disabled') {
|
||||||
|
throw new ApiError(422, 'STORE_DISABLED', '门店已停用');
|
||||||
|
}
|
||||||
|
if (store.credential_status !== 'valid') {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
'门店凭据尚未验证通过,无法刷新类目',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持门店行锁直到完成写入,防止同一门店的两次刷新交错覆盖缓存。
|
||||||
|
await transaction.query(
|
||||||
|
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
const source = await transaction.query<{
|
||||||
|
platform_id: string;
|
||||||
|
platform_code: string;
|
||||||
|
platform_client_id: string;
|
||||||
|
secret_ciphertext: Buffer;
|
||||||
|
secret_nonce: Buffer;
|
||||||
|
}>(
|
||||||
|
`SELECT store.platform_id, platform.code AS platform_code, store.platform_client_id,
|
||||||
|
secret.secret_ciphertext, secret.secret_nonce
|
||||||
|
FROM atlas.stores store
|
||||||
|
JOIN atlas.platforms platform ON platform.id = store.platform_id
|
||||||
|
JOIN atlas.store_secrets secret ON secret.store_id = store.id
|
||||||
|
WHERE store.id = $1 AND store.deleted_at IS NULL
|
||||||
|
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||||
|
AND secret.status = 'valid'
|
||||||
|
LIMIT 1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
const current = source.rows[0];
|
||||||
|
if (!current) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
'门店凭据尚未验证通过,无法刷新类目',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (current.platform_code.toUpperCase() !== 'OZON') {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_CATALOG_UNSUPPORTED',
|
||||||
|
'当前平台暂不支持类目缓存同步',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const apiKey = decryptStoreSecret(
|
||||||
|
current.secret_ciphertext,
|
||||||
|
current.secret_nonce,
|
||||||
|
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||||
|
);
|
||||||
|
const tree = await requestOzonCategoryTree(
|
||||||
|
current.platform_client_id,
|
||||||
|
apiKey,
|
||||||
|
);
|
||||||
|
const result = await persistCategoryTreeInTransaction(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
current.platform_id,
|
||||||
|
tree,
|
||||||
|
);
|
||||||
|
return { status: 200, body: { data: result } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-only read-through for the current platform's default store. The
|
||||||
|
* credential is decrypted only for this outbound request and is never
|
||||||
|
* placed in the browser response, logs, operations, or outbox payloads.
|
||||||
|
*/
|
||||||
|
public async fetchDefaultCategoryTreeDebug(
|
||||||
|
workspaceId: string,
|
||||||
|
platformId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const source = await this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const rows = await transaction.query<{
|
||||||
|
platform_client_id: string;
|
||||||
|
secret_ciphertext: Buffer;
|
||||||
|
secret_nonce: Buffer;
|
||||||
|
}>(
|
||||||
|
`SELECT store.platform_client_id, secret.secret_ciphertext, secret.secret_nonce
|
||||||
|
FROM atlas.stores store
|
||||||
|
JOIN atlas.platforms platform ON platform.id = store.platform_id
|
||||||
|
JOIN atlas.store_secrets secret ON secret.store_id = store.id
|
||||||
|
WHERE store.platform_id = $1 AND store.is_default
|
||||||
|
AND store.deleted_at IS NULL AND store.status <> 'disabled'
|
||||||
|
AND store.credential_status = 'valid'
|
||||||
|
AND platform.code = 'OZON'
|
||||||
|
AND secret.kind = 'seller_api_key' AND secret.status = 'valid'
|
||||||
|
AND secret.revoked_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[platformId],
|
||||||
|
);
|
||||||
|
return rows.rows[0] ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!source) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||||
|
'当前平台没有凭据已验证的默认 Ozon 门店',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = decryptStoreSecret(
|
||||||
|
source.secret_ciphertext,
|
||||||
|
source.secret_nonce,
|
||||||
|
this.config.STORE_SECRET_ENCRYPTION_KEY,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://api-seller.ozon.ru/v1/description-category/tree',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Client-Id': source.platform_client_id,
|
||||||
|
'Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as unknown;
|
||||||
|
if (response.ok) return body;
|
||||||
|
throw new ApiError(
|
||||||
|
502,
|
||||||
|
'OZON_CATEGORY_TREE_DEBUG_FAILED',
|
||||||
|
`Ozon 类目树请求失败(HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error;
|
||||||
|
throw new ApiError(
|
||||||
|
502,
|
||||||
|
'OZON_CATEGORY_TREE_DEBUG_UNAVAILABLE',
|
||||||
|
'Ozon 类目树请求暂时不可用,请稍后重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async requestWarehouseSync(
|
public async requestWarehouseSync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -661,6 +842,163 @@ export class StoresService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestOzonCategoryTree(
|
||||||
|
clientId: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<OzonCategoryNode[]> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Client-Id': clientId,
|
||||||
|
'Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ language: 'ZH_HANS' }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
502,
|
||||||
|
'OZON_CATEGORY_TREE_UNAVAILABLE',
|
||||||
|
'Ozon 类目请求暂时不可用,请稍后重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
502,
|
||||||
|
'OZON_CATEGORY_TREE_REFRESH_FAILED',
|
||||||
|
`Ozon 类目请求失败(HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let body: { result?: unknown };
|
||||||
|
try {
|
||||||
|
body = (await response.json()) as { result?: unknown };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(
|
||||||
|
502,
|
||||||
|
'OZON_CATEGORY_TREE_INVALID',
|
||||||
|
'Ozon 返回的类目树格式无效',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body?.result)) {
|
||||||
|
throw new ApiError(
|
||||||
|
502,
|
||||||
|
'OZON_CATEGORY_TREE_INVALID',
|
||||||
|
'Ozon 返回的类目树格式无效',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body.result as OzonCategoryNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistCategoryTreeInTransaction(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
storeId: string,
|
||||||
|
platformId: string,
|
||||||
|
roots: OzonCategoryNode[],
|
||||||
|
) {
|
||||||
|
const records = [
|
||||||
|
...new Map(
|
||||||
|
flattenCategoryTree(roots).map((record) => [
|
||||||
|
`${record.external_category_id}:${record.external_type_id}`,
|
||||||
|
record,
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
const syncTime = new Date();
|
||||||
|
await transaction.query(
|
||||||
|
`WITH incoming AS (
|
||||||
|
SELECT * FROM jsonb_to_recordset($3::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.store_product_categories
|
||||||
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path,
|
||||||
|
is_active, source_payload, synced_at)
|
||||||
|
SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id,
|
||||||
|
name, path, is_active, source_payload, $4::timestamptz
|
||||||
|
FROM incoming
|
||||||
|
ON CONFLICT (store_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()`,
|
||||||
|
[storeId, platformId, JSON.stringify(records), syncTime],
|
||||||
|
);
|
||||||
|
await transaction.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.store_product_categories child
|
||||||
|
ON child.store_id = $1
|
||||||
|
AND child.external_category_id = incoming.external_category_id
|
||||||
|
AND child.external_type_id = incoming.external_type_id
|
||||||
|
LEFT JOIN atlas.store_product_categories parent
|
||||||
|
ON parent.store_id = $1
|
||||||
|
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||||||
|
)
|
||||||
|
UPDATE atlas.store_product_categories category
|
||||||
|
SET parent_id = resolved.parent_id, updated_at = now()
|
||||||
|
FROM resolved WHERE category.id = resolved.child_id`,
|
||||||
|
[storeId, JSON.stringify(records)],
|
||||||
|
);
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.store_product_categories SET is_active = false, updated_at = now()
|
||||||
|
WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||||
|
[storeId, syncTime],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
category_count: records.length,
|
||||||
|
active_category_count: records.filter((record) => record.is_active).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenCategoryTree(
|
||||||
|
nodes: OzonCategoryNode[],
|
||||||
|
path: string[] = [],
|
||||||
|
parentKey: string | null = null,
|
||||||
|
inheritedCategory?: { id: string; name: string },
|
||||||
|
): FlatCategory[] {
|
||||||
|
const records: FlatCategory[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const explicitCategoryId = String(node.description_category_id ?? '').trim();
|
||||||
|
const explicitCategoryName = node.category_name?.trim() ?? '';
|
||||||
|
const categoryId = explicitCategoryId || inheritedCategory?.id || '';
|
||||||
|
const categoryName = explicitCategoryName || inheritedCategory?.name || '';
|
||||||
|
const typeId = String(node.type_id ?? 0);
|
||||||
|
const name = explicitCategoryName || node.type_name?.trim() || categoryName;
|
||||||
|
if (!categoryId || !name) continue;
|
||||||
|
const key = `${categoryId}:${typeId}`;
|
||||||
|
const nextPath = [...path, name];
|
||||||
|
records.push({
|
||||||
|
external_category_id: categoryId,
|
||||||
|
external_type_id: typeId,
|
||||||
|
parent_key: parentKey,
|
||||||
|
name,
|
||||||
|
path: nextPath.join(' › '),
|
||||||
|
is_active: node.disabled !== true && typeId !== '0',
|
||||||
|
source_payload: node,
|
||||||
|
});
|
||||||
|
records.push(
|
||||||
|
...flattenCategoryTree(
|
||||||
|
node.children ?? [],
|
||||||
|
nextPath,
|
||||||
|
key,
|
||||||
|
explicitCategoryId && explicitCategoryName
|
||||||
|
? { id: explicitCategoryId, name: explicitCategoryName }
|
||||||
|
: inheritedCategory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
const storeSelect = `
|
const storeSelect = `
|
||||||
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code,
|
||||||
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.product_variant_listing_media WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -157,6 +161,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
|
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
await database.query(
|
await database.query(
|
||||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
@@ -1058,7 +1065,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`UPDATE atlas.collection_items
|
`UPDATE atlas.collection_items
|
||||||
SET source_platform_id = $1,
|
SET source_kind = 'platform', source_platform_id = $1,
|
||||||
normalized_payload = $2::jsonb
|
normalized_payload = $2::jsonb
|
||||||
WHERE id = $3`,
|
WHERE id = $3`,
|
||||||
[
|
[
|
||||||
@@ -1193,4 +1200,97 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||||
|
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
});
|
||||||
|
const variant = before.body.data.variants[0];
|
||||||
|
const asset = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const created = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
|
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||||
|
byte_size, checksum_sha256, width_px, height_px)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
||||||
|
'image/jpeg', 1024, $2, 1200, 900)
|
||||||
|
RETURNING id`,
|
||||||
|
[`m36/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '')],
|
||||||
|
);
|
||||||
|
const snapshot = await tx.query<{ source_snapshot_revision: string }>(
|
||||||
|
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
|
||||||
|
[first.collectionItemId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.collection_asset_transfers
|
||||||
|
(workspace_id, collection_item_id, product_id, source_variant_id, role,
|
||||||
|
source_url, status, asset_id, source_snapshot_revision, completed_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
||||||
|
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
|
||||||
|
[
|
||||||
|
first.collectionItemId,
|
||||||
|
first.productId,
|
||||||
|
variant.id,
|
||||||
|
created.rows[0].id,
|
||||||
|
snapshot.rows[0].source_snapshot_revision,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return created.rows[0].id;
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
ai_source_material: before.body.data.source_material,
|
||||||
|
ai_source_overridden_fields:
|
||||||
|
before.body.data.source_material_state.overridden_fields,
|
||||||
|
common: {
|
||||||
|
...before.body.data.product.common,
|
||||||
|
default_physical_spec: before.body.data.product.default_physical_spec,
|
||||||
|
default_package_spec: before.body.data.product.default_package_spec,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
target_store_id: before.body.data.profile.target_store.id,
|
||||||
|
fulfillment: before.body.data.profile.fulfillment,
|
||||||
|
title: before.body.data.profile.title,
|
||||||
|
description: before.body.data.profile.description,
|
||||||
|
tags: before.body.data.profile.tags,
|
||||||
|
store_product_category_id: before.body.data.profile.category.id,
|
||||||
|
category_source: before.body.data.profile.category.source,
|
||||||
|
},
|
||||||
|
attributes: before.body.data.attributes.map((attribute: any) => ({
|
||||||
|
scope: attribute.scope,
|
||||||
|
variant_id: attribute.variant_id,
|
||||||
|
platform_attribute_id: attribute.platform_attribute_id,
|
||||||
|
complex_id: attribute.complex_id,
|
||||||
|
values: attribute.values,
|
||||||
|
language_code: attribute.language_code,
|
||||||
|
source: attribute.source,
|
||||||
|
confirmation_status: attribute.confirmation_status,
|
||||||
|
})),
|
||||||
|
variants: before.body.data.variants.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
client_key: null,
|
||||||
|
copy_from_variant_id: null,
|
||||||
|
display_name: item.display_name,
|
||||||
|
source_sku: item.source_sku,
|
||||||
|
specification_values: item.specification_values,
|
||||||
|
purchase_cost_cny: item.purchase_cost_cny,
|
||||||
|
physical_spec: item.physical_spec,
|
||||||
|
package_spec: item.package_spec,
|
||||||
|
offer_id: item.listing.offer_id,
|
||||||
|
media_asset_ids: item.id === variant.id ? [asset] : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers({
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
assert.equal(saved.response.status, 200);
|
||||||
|
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(after.body.data.variants[0].media.map((item: any) => item.asset_id), [asset]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,6 +231,115 @@ describe('门店 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('同步刷新门店类目:等待 Ozon 返回并完整落库后才成功', async () => {
|
||||||
|
const owner = await register('stores-category-refresh');
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: headers(owner),
|
||||||
|
});
|
||||||
|
const platform = success<Array<{ id: string; code: string }>>(
|
||||||
|
platforms.body,
|
||||||
|
).data.find((item) => item.code === 'OZON');
|
||||||
|
assert.ok(platform);
|
||||||
|
const store = await createStore(owner, platform.id, '类目刷新测试店');
|
||||||
|
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.stores
|
||||||
|
SET status = 'active', credential_status = 'valid'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.store_secrets SET status = 'valid'
|
||||||
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_categories
|
||||||
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||||
|
name, path, synced_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||||
|
'cached-category', 'cached-type', '已有缓存', '已有缓存', now())`,
|
||||||
|
[store.id, platform.id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let ozonCalls = 0;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
if (String(input).startsWith('https://api-seller.ozon.ru/')) {
|
||||||
|
ozonCalls += 1;
|
||||||
|
assert.equal(init?.method, 'POST');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
description_category_id: 17028750,
|
||||||
|
category_name: '汽车脚垫和车座套',
|
||||||
|
disabled: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type_name: '汽车罩',
|
||||||
|
type_id: 93587,
|
||||||
|
disabled: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const idempotencyKey = randomUUID();
|
||||||
|
const first = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
});
|
||||||
|
assert.equal(first.response.status, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
success<{ category_count: number; active_category_count: number }>(first.body).data,
|
||||||
|
{ category_count: 2, active_category_count: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const replay = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(owner, idempotencyKey),
|
||||||
|
});
|
||||||
|
assert.equal(replay.response.status, 200);
|
||||||
|
assert.deepEqual(replay.body, first.body);
|
||||||
|
assert.equal(ozonCalls, 1);
|
||||||
|
|
||||||
|
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
||||||
|
const categories = await tx.query<{
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT external_category_id, external_type_id, name, path, is_active
|
||||||
|
FROM atlas.store_product_categories
|
||||||
|
WHERE store_id = $1 ORDER BY path`,
|
||||||
|
[store.id],
|
||||||
|
);
|
||||||
|
assert.deepEqual(categories.rows.filter((row) => row.is_active), [
|
||||||
|
{
|
||||||
|
external_category_id: '17028750',
|
||||||
|
external_type_id: '93587',
|
||||||
|
name: '汽车罩',
|
||||||
|
path: '汽车脚垫和车座套 › 汽车罩',
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
||||||
const owner = await register('stores-lifecycle');
|
const owner = await register('stores-lifecycle');
|
||||||
const otherOwner = await register('stores-isolation');
|
const otherOwner = await register('stores-isolation');
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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 { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.js';
|
||||||
|
import { flattenCategories } from '../../worker/src/ozon-catalog-sync.js';
|
||||||
|
import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl = '';
|
||||||
|
let workspaceId = '';
|
||||||
|
let userId = '';
|
||||||
|
let token = '';
|
||||||
|
let platformId = '';
|
||||||
|
let storeId = '';
|
||||||
|
let secretId = '';
|
||||||
|
|
||||||
|
async function request(path: string, init: RequestInit = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
});
|
||||||
|
return { response, body: (await response.json()) as any };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleWithStoreLock() {
|
||||||
|
return database.workspaceTransaction(workspaceId, async (transaction) => {
|
||||||
|
await transaction.query(
|
||||||
|
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
return scheduleStoreCategoryTreeSync(transaction, {
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
secretId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('门店类目树 Worker 调度集成测试', () => {
|
||||||
|
it('类型叶子继承父类目 ID,并使用 type_name 作为可选项名称', () => {
|
||||||
|
const records = flattenCategories([
|
||||||
|
{
|
||||||
|
description_category_id: 17027495,
|
||||||
|
category_name: '汽车用品',
|
||||||
|
disabled: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
description_category_id: 17028750,
|
||||||
|
category_name: '汽车脚垫和车座套',
|
||||||
|
disabled: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type_name: '汽车罩',
|
||||||
|
type_id: 93587,
|
||||||
|
disabled: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
records.map((record) => ({
|
||||||
|
category: record.external_category_id,
|
||||||
|
type: record.external_type_id,
|
||||||
|
name: record.name,
|
||||||
|
path: record.path,
|
||||||
|
parent: record.parent_key,
|
||||||
|
active: record.is_active,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
category: '17027495', type: '0', name: '汽车用品', path: '汽车用品',
|
||||||
|
parent: null, active: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '17028750', type: '0', name: '汽车脚垫和车座套',
|
||||||
|
path: '汽车用品 › 汽车脚垫和车座套', parent: '17027495:0', active: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '17028750', type: '93587', name: '汽车罩',
|
||||||
|
path: '汽车用品 › 汽车脚垫和车座套 › 汽车罩', parent: '17028750:0', active: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const created = await createApplication();
|
||||||
|
app = created.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `catalog-worker-${randomUUID()}@example.test`,
|
||||||
|
password: 'correct-horse-battery-staple-123',
|
||||||
|
password_confirmation: 'correct-horse-battery-staple-123',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
workspaceId = registration.body.data.workspace.id;
|
||||||
|
userId = registration.body.data.user.id;
|
||||||
|
token = registration.body.data.access_token;
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
platformId = platforms.body.data[0].id;
|
||||||
|
const seeded = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const 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 (current_setting('app.workspace_id')::uuid, $1, '类目 Worker 测试店',
|
||||||
|
$2, 'RUB', true, 'active', 'valid')
|
||||||
|
RETURNING id`,
|
||||||
|
[platformId, `catalog-worker-${randomUUID()}`],
|
||||||
|
);
|
||||||
|
const secret = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.store_secrets
|
||||||
|
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||||
|
encryption_key_version, fingerprint, status)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||||||
|
$2, $3, 'test', $4, 'valid')
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
store.rows[0].id,
|
||||||
|
Buffer.from('catalog-worker-api-key-must-not-leak'),
|
||||||
|
Buffer.from('catalog-worker-nonce'),
|
||||||
|
randomUUID(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { storeId: store.rows[0].id, secretId: secret.rows[0].id };
|
||||||
|
});
|
||||||
|
storeId = seeded.storeId;
|
||||||
|
secretId = seeded.secretId;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.store_product_categories WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
});
|
||||||
|
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空缓存的并发验证只创建一个无密钥的树同步任务', async () => {
|
||||||
|
const results = await Promise.all([scheduleWithStoreLock(), scheduleWithStoreLock()]);
|
||||||
|
assert.equal(results.filter((result) => result.scheduled).length, 1);
|
||||||
|
assert.ok(results.some((result) => !result.scheduled && result.reason === 'already_queued'));
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const events = await tx.query<{ event_payload: unknown }>(
|
||||||
|
`SELECT event_payload FROM atlas.outbox_events
|
||||||
|
WHERE aggregate_id = $1 AND event_type = 'store_category_tree_sync.requested'`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
assert.equal(events.rows.length, 1);
|
||||||
|
const payload = JSON.stringify(events.rows[0].event_payload);
|
||||||
|
assert.ok(!payload.includes('catalog-worker-api-key-must-not-leak'));
|
||||||
|
assert.ok(!payload.includes('catalog-worker-nonce'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已有已同步的门店缓存时跳过 Ozon 请求调度', async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now()
|
||||||
|
WHERE kind = 'store_category_tree_sync' AND resource_id = $1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_categories
|
||||||
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||||
|
name, path, synced_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||||
|
'worker-cache', 'worker-type', '已缓存类目', '已缓存类目', now())`,
|
||||||
|
[storeId, platformId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const result = await scheduleWithStoreLock();
|
||||||
|
assert.deepEqual(result, { scheduled: false, reason: 'cache_available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为 Ozon 暂时失败配置五次指数退避', async () => {
|
||||||
|
const enqueued: Array<{ name: string; options: Record<string, unknown> }> = [];
|
||||||
|
const dispatcher = new OutboxDispatcher(
|
||||||
|
{
|
||||||
|
query: async () => ({ rows: [{ id: workspaceId }] }),
|
||||||
|
workspaceTransaction: async (_workspaceId: string, work: any) =>
|
||||||
|
work({
|
||||||
|
query: async (sql: string) =>
|
||||||
|
sql.includes('RETURNING event.id')
|
||||||
|
? {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
event_type: 'store_category_tree_sync.requested',
|
||||||
|
event_payload: {
|
||||||
|
store_id: storeId,
|
||||||
|
secret_id: secretId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { rows: [] },
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
add: async (name: string, _data: unknown, options: Record<string, unknown>) => {
|
||||||
|
enqueued.push({ name, options });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'worker-test',
|
||||||
|
);
|
||||||
|
await dispatcher.dispatchBatch();
|
||||||
|
assert.deepEqual(enqueued, [
|
||||||
|
{
|
||||||
|
name: 'store_category_tree_sync.requested',
|
||||||
|
options: {
|
||||||
|
jobId: enqueued[0]?.options.jobId,
|
||||||
|
attempts: 5,
|
||||||
|
backoff: { type: 'exponential', delay: 5_000 },
|
||||||
|
removeOnComplete: 1_000,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": ".",
|
"rootDir": "..",
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Web 前端交接:商品编辑器与门店类目
|
||||||
|
|
||||||
|
更新日期:2026-09-11
|
||||||
|
|
||||||
|
本文只覆盖 `apps/web/` 当前未提交的前端改动。开始继续开发前,请先查看仓库根目录的 `git status --short`;工作区同时存在服务端、Worker 和文档改动,它们不属于前端接手者的修改范围。
|
||||||
|
|
||||||
|
## 当前完成状态
|
||||||
|
|
||||||
|
### 商品编辑器
|
||||||
|
|
||||||
|
- 编辑页为 `/products/:id/editor`,主文件为 `src/pages/products/ProductEditPage.tsx`。
|
||||||
|
- 已接入门店级类目 Cascader:保存时只提交末级类目 ID;父级节点可展开,只有叶子节点可选。类目下拉菜单高度为 324px(相对 Ant Design 默认菜单高度提高 80%)。
|
||||||
|
- “更多特征”位于 **02 产品属性** Card 内,不再单独使用 Card;展开后按当前选定类目加载 schema,折叠再展开且类目未变时复用已加载的 schema。加载、空 schema、同步类目属性、字典值搜索、清除字段和旧属性告警均在该区处理。
|
||||||
|
- 动态属性字段按 schema 渲染:普通值为输入框、布尔值为选择框、字典值为可搜索下拉、多值为多选/标签输入;保存时会过滤空的可选属性值,避免提交无效空数组。
|
||||||
|
- “主题标签”和“JSON 富内容”属于产品属性的普通字段,不属于“更多特征”。
|
||||||
|
- 编辑页表单已去除页面级的字号、控件高度覆盖,恢复 Ant Design 主题默认规格。仍保留业务性宽度:型号/品牌/条码 170px,包装重量 150px,包装尺寸各 130px。
|
||||||
|
- 包装重量通过 `package-weight-field` 单独约束输入组宽度,避免 `addonAfter="g"` 与输入框分离;父表单的标签列为 190px、控件区域为可收缩 680px,避免控件被换到标签下一行。
|
||||||
|
|
||||||
|
### 组件拆分现状
|
||||||
|
|
||||||
|
已拆出的组件:
|
||||||
|
|
||||||
|
- `src/pages/products/components/ProductMainInfoSection.tsx`:主要信息、目标门店、类目、标题、品牌、型号、父商品包装规格。
|
||||||
|
- `src/pages/products/components/ProductOtherSection.tsx`:货源链接和备注。
|
||||||
|
- `src/pages/products/components/CollectedProductInfoPanel.tsx`:右侧“采集的商品信息”侧栏。
|
||||||
|
|
||||||
|
仍在 `ProductEditPage.tsx` 中的视图:产品属性/更多特征,以及变体设置。后续若继续组件化,应将它们分别迁为展示组件;父页面只保留快照读取、草稿保存、类目/schema/字典同步和状态编排。迁移时不要改变 `Form.List` 字段路径、乐观锁保存流程或动态属性的按需加载缓存条件。
|
||||||
|
|
||||||
|
### 门店与类目调试入口
|
||||||
|
|
||||||
|
- 店铺列表的“刷新门店类目”按钮在 `src/pages/shops/ShopsPage.tsx`;前端会等待请求完成才结束 loading,并展示成功或失败反馈。
|
||||||
|
- 左侧菜单新增“调试 → Ozon 类目树调试”,路由为 `/shops/category-tree-debug`。
|
||||||
|
- 调试页文件:`src/pages/shops/CategoryTreeDebugPage.tsx`、`CategoryTreeDebugPage.css`;服务封装为 `src/services/categoryTreeDebug.ts`。该页只负责调用已存在的前端 API 封装并以 Ant Design 树展示响应,不能在前端处理或持久化 API Key。
|
||||||
|
|
||||||
|
## 关键前端行为
|
||||||
|
|
||||||
|
1. 选定目标门店后才读取该门店的类目缓存;加载期间 Cascader 显示“正在读取该店铺的类目缓存”。
|
||||||
|
2. 切换类目会清空草稿属性;展开“更多特征”后才读取新类目的 schema。
|
||||||
|
3. 动态属性的字典搜索输入至少两个字符后,用户可显式选择“从 Ozon 搜索”;前端不传递或记录 API Key。
|
||||||
|
4. 页面 API、错误码和返回结构以 `docs/api/` 与 `@atlas/contracts` 为准。前端不要自行扩展接口字段或模拟同步完成状态。
|
||||||
|
|
||||||
|
## 已执行验证
|
||||||
|
|
||||||
|
本次前端改动已执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
../../node_modules/.bin/tsc -p tsconfig.json --noEmit
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。
|
||||||
|
|
||||||
|
## 前端边界
|
||||||
|
|
||||||
|
- 仅修改 `apps/web/` 及前端相关文档。
|
||||||
|
- 不启动、停止或修改 API/Worker;若类目刷新或 schema 请求失败,应先检查服务端运行状态与 API 契约,再交由服务端维护者处理。
|
||||||
|
- 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CalculatorOutlined,
|
CalculatorOutlined,
|
||||||
|
BugOutlined,
|
||||||
CodeSandboxOutlined,
|
CodeSandboxOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
@@ -37,6 +38,13 @@ export const routeMenuConfig: RouteMenuConfig[] = [
|
|||||||
icon: <InboxOutlined />,
|
icon: <InboxOutlined />,
|
||||||
label: '采集箱',
|
label: '采集箱',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 放在 /shops 之前,避免前缀匹配时被“店铺管理”抢先选中。
|
||||||
|
path: '/shops/category-tree-debug',
|
||||||
|
key: '/shops/category-tree-debug',
|
||||||
|
icon: <BugOutlined />,
|
||||||
|
label: '调试',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/shops',
|
path: '/shops',
|
||||||
key: '/shops',
|
key: '/shops',
|
||||||
@@ -70,6 +78,12 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
|
|||||||
{ icon: <EditOutlined />, label: '商品编辑' },
|
{ icon: <EditOutlined />, label: '商品编辑' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (path === '/shops/category-tree-debug') {
|
||||||
|
return [
|
||||||
|
{ icon: <ShopOutlined />, label: '店铺管理' },
|
||||||
|
{ icon: <BugOutlined />, label: 'Ozon 类目树调试' },
|
||||||
|
];
|
||||||
|
}
|
||||||
if (path === '/settings/profile') {
|
if (path === '/settings/profile') {
|
||||||
return [
|
return [
|
||||||
{ icon: <SettingOutlined />, label: '设置' },
|
{ icon: <SettingOutlined />, label: '设置' },
|
||||||
|
|||||||
@@ -104,50 +104,27 @@
|
|||||||
color: #344054;
|
color: #344054;
|
||||||
font-weight: 600;
|
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 {
|
.package-fields {
|
||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
border-top: 1px solid #f0eef4;
|
border-top: 1px solid #f0eef4;
|
||||||
}
|
}
|
||||||
.package-fields > .ant-form-item:first-child {
|
.package-fields > .package-weight-field {
|
||||||
width: 150px;
|
width: max-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
.package-fields > .package-weight-field .ant-input-group-wrapper {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
.package-fields > .package-weight-field .ant-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.package-dimensions {
|
.package-dimensions {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.package-dimensions-controls {
|
.package-dimensions-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 22px 120px 22px 120px auto;
|
grid-template-columns: 130px 22px 130px 22px 130px auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@@ -160,8 +137,19 @@
|
|||||||
}
|
}
|
||||||
.package-dimension-unit {
|
.package-dimension-unit {
|
||||||
color: #667085;
|
color: #667085;
|
||||||
font-size: 16px;
|
line-height: 32px;
|
||||||
line-height: 40px;
|
}
|
||||||
|
.editor-category-popup .ant-cascader-menu {
|
||||||
|
/* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */
|
||||||
|
height: 324px !important;
|
||||||
|
max-height: 324px;
|
||||||
|
}
|
||||||
|
.editor-category-popup .ant-cascader-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.supply-link-control .ant-btn {
|
||||||
|
min-width: 40px;
|
||||||
}
|
}
|
||||||
.category-row {
|
.category-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -194,63 +182,46 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.category-select-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 4px 8px 6px;
|
padding: 4px 8px 6px;
|
||||||
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
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 {
|
.dynamic-attributes {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding-top: 22px;
|
padding-top: 22px;
|
||||||
border-top: 1px solid #eeeaf4;
|
border-top: 1px solid #eeeaf4;
|
||||||
}
|
}
|
||||||
.dynamic-attributes h5.ant-typography {
|
.dynamic-attributes h5.ant-typography {
|
||||||
margin: 0 0 14px;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
.dynamic-attributes-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dynamic-attributes-heading .ant-typography-secondary {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.dynamic-attributes-heading .ant-btn {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.editor-short-input.ant-input {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
.dynamic-attributes-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 132px;
|
||||||
|
color: #7a7485;
|
||||||
|
}
|
||||||
.dynamic-attribute-list {
|
.dynamic-attribute-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -276,23 +247,16 @@
|
|||||||
}
|
}
|
||||||
.sku-section .ant-input {
|
.sku-section .ant-input {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
.sku-section textarea.ant-input {
|
.sku-section textarea.ant-input {
|
||||||
min-height: 58px;
|
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
.sku-section .ant-input-group-addon {
|
.sku-section .ant-input-group-addon {
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
.sku-section .ant-input-group .ant-input {
|
.sku-section .ant-input-group .ant-input {
|
||||||
border-radius: 8px 0 0 8px;
|
border-radius: 8px 0 0 8px;
|
||||||
}
|
}
|
||||||
.sku-section .ant-input:not(textarea) {
|
|
||||||
height: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
.sku-card-list {
|
.sku-card-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -411,6 +375,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.sku-media-item {
|
.sku-media-item {
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
aspect-ratio: 3 / 4;
|
aspect-ratio: 3 / 4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -418,6 +383,15 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #f5f3f7;
|
background: #f5f3f7;
|
||||||
}
|
}
|
||||||
|
.sku-media-item .ant-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.sku-media-item.selected {
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
}
|
||||||
.sku-media-item .ant-image,
|
.sku-media-item .ant-image,
|
||||||
.sku-media-item img {
|
.sku-media-item img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -450,8 +424,6 @@
|
|||||||
flex: 0 0 86px !important;
|
flex: 0 0 86px !important;
|
||||||
}
|
}
|
||||||
.sku-spec-panel .ant-form-item-label > label {
|
.sku-spec-panel .ant-form-item-label > label {
|
||||||
height: 32px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.sku-spec-panel .ant-form-item-control {
|
.sku-spec-panel .ant-form-item-control {
|
||||||
@@ -518,13 +490,6 @@
|
|||||||
.source-update-alert .ant-alert-description {
|
.source-update-alert .ant-alert-description {
|
||||||
font-size: 12px;
|
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 {
|
.collected-field {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
@@ -685,7 +650,7 @@
|
|||||||
.editor-form-grid .span-two {
|
.editor-form-grid .span-two {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
.package-fields > .ant-form-item:first-child,
|
.package-fields > .package-weight-field,
|
||||||
.package-dimensions-controls {
|
.package-dimensions-controls {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
|||||||
|
import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd';
|
||||||
|
import type {
|
||||||
|
ProductAiSourceField,
|
||||||
|
ProductAiSourceMaterial,
|
||||||
|
ProductEditorSnapshot,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
|
||||||
|
export type CollectedProductSummary = {
|
||||||
|
price: string;
|
||||||
|
brand: string;
|
||||||
|
physicalDimensions: string;
|
||||||
|
physicalWeight: string;
|
||||||
|
packageDimensions: string;
|
||||||
|
packageWeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
snapshot: ProductEditorSnapshot;
|
||||||
|
collected: ProductAiSourceMaterial | null;
|
||||||
|
summary: CollectedProductSummary;
|
||||||
|
overriddenFields: ProductAiSourceField[];
|
||||||
|
onRestore: (field: Extract<ProductAiSourceField, 'title' | 'description'>) => void;
|
||||||
|
onUpdate: (field: Extract<ProductAiSourceField, 'title' | 'description'>, value: string) => void;
|
||||||
|
onShowDifferences: () => void;
|
||||||
|
onAcceptLatest: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CollectedProductInfoPanel({
|
||||||
|
snapshot,
|
||||||
|
collected,
|
||||||
|
summary,
|
||||||
|
overriddenFields,
|
||||||
|
onRestore,
|
||||||
|
onUpdate,
|
||||||
|
onShowDifferences,
|
||||||
|
onAcceptLatest,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<aside className="editor-aside">
|
||||||
|
<Card className="collected-info-panel" bordered={false}>
|
||||||
|
<div className="collected-info-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>采集的商品信息</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">AI 输入素材 · 可编辑</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Tag>第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集</Tag>
|
||||||
|
</div>
|
||||||
|
{snapshot.source_material_state.has_source_updates && (
|
||||||
|
<Alert
|
||||||
|
className="source-update-alert"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="检测到新的采集信息"
|
||||||
|
description="未编辑字段已采用最新值;用户修改过的字段仍保持不变。"
|
||||||
|
action={
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Button size="small" onClick={onShowDifferences}>
|
||||||
|
查看变化
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={onAcceptLatest}>
|
||||||
|
全部采用最新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="collected-field">
|
||||||
|
<div className="collected-field-label">
|
||||||
|
<span>标题</span>
|
||||||
|
{overriddenFields.includes('title') && (
|
||||||
|
<Button type="link" size="small" onClick={() => onRestore('title')}>
|
||||||
|
恢复来源值
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={collected?.title ?? snapshot.product.common.source_title ?? ''}
|
||||||
|
placeholder="采集标题"
|
||||||
|
onChange={(event) => onUpdate('title', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="collected-field">
|
||||||
|
<div className="collected-field-label">
|
||||||
|
<span>商品描述</span>
|
||||||
|
{overriddenFields.includes('description') && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onRestore('description')}
|
||||||
|
>
|
||||||
|
恢复来源值
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
value={collected?.description ?? ''}
|
||||||
|
rows={12}
|
||||||
|
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
|
||||||
|
onChange={(event) => onUpdate('description', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="collected-field">
|
||||||
|
<span>采集基础数据</span>
|
||||||
|
<div className="collected-summary">
|
||||||
|
<div><b>价格</b><strong>{summary.price}</strong></div>
|
||||||
|
<div><b>品牌</b><strong>{summary.brand}</strong></div>
|
||||||
|
<div><b>商品尺寸</b><strong>{summary.physicalDimensions}</strong></div>
|
||||||
|
<div><b>商品重量</b><strong>{summary.physicalWeight}</strong></div>
|
||||||
|
<div><b>包装尺寸</b><strong>{summary.packageDimensions}</strong></div>
|
||||||
|
<div><b>包装重量</b><strong>{summary.packageWeight}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="collected-field">
|
||||||
|
<span>规格 / 参数</span>
|
||||||
|
{collected?.attributes.length ? (
|
||||||
|
<div className="collected-attributes">
|
||||||
|
{collected.attributes.map((attribute) => (
|
||||||
|
<div key={`${attribute.key}:${attribute.value}`}>
|
||||||
|
<b>{attribute.name}</b><span>{attribute.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">本次采集未取得规格或参数。</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Card, Cascader, Form, Input, Select, Typography } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||||
|
|
||||||
|
type NamePath = Array<string | number>;
|
||||||
|
type CategoryOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
isLeaf?: boolean;
|
||||||
|
children?: CategoryOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function SectionTitle() {
|
||||||
|
return (
|
||||||
|
<div className="editor-section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>
|
||||||
|
<span className="editor-section-number">01</span>主要信息
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
上架所需的核心资料。包装信息用于定价、物流与发布。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageFields({ name }: { name: NamePath }) {
|
||||||
|
return (
|
||||||
|
<div className="package-fields">
|
||||||
|
<Form.Item
|
||||||
|
name={[...name, 'shipping_weight_g']}
|
||||||
|
label="包装重量"
|
||||||
|
rules={[{ required: true, message: '请填写包装重量' }]}
|
||||||
|
>
|
||||||
|
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="包装尺寸" required className="package-dimensions">
|
||||||
|
<div className="package-dimensions-controls">
|
||||||
|
<Form.Item name={[...name, 'length_mm']} noStyle>
|
||||||
|
<Input type="number" min="0" step="any" placeholder="长" />
|
||||||
|
</Form.Item>
|
||||||
|
<span className="package-dimension-separator">×</span>
|
||||||
|
<Form.Item name={[...name, 'width_mm']} noStyle>
|
||||||
|
<Input type="number" min="0" step="any" placeholder="宽" />
|
||||||
|
</Form.Item>
|
||||||
|
<span className="package-dimension-separator">×</span>
|
||||||
|
<Form.Item name={[...name, 'height_mm']} noStyle>
|
||||||
|
<Input type="number" min="0" step="any" placeholder="高" />
|
||||||
|
</Form.Item>
|
||||||
|
<span className="package-dimension-unit">mm</span>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: FormInstance;
|
||||||
|
snapshot: ProductEditorSnapshot;
|
||||||
|
selectedCategoryId: string | null | undefined;
|
||||||
|
categoryOptions: CategoryOption[];
|
||||||
|
categoryPaths: Map<string, string[]>;
|
||||||
|
categoryNotFoundContent: string;
|
||||||
|
onCategorySearch: (query: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductMainInfoSection({
|
||||||
|
form,
|
||||||
|
snapshot,
|
||||||
|
selectedCategoryId,
|
||||||
|
categoryOptions,
|
||||||
|
categoryPaths,
|
||||||
|
categoryNotFoundContent,
|
||||||
|
onCategorySearch,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Card className="editor-section" bordered={false}>
|
||||||
|
<SectionTitle />
|
||||||
|
<div className="editor-form-grid">
|
||||||
|
<Form.Item
|
||||||
|
name={['profile', 'target_store_id']}
|
||||||
|
label="上架店铺"
|
||||||
|
className="span-full"
|
||||||
|
rules={[{ required: true, message: '请选择上架店铺' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
className="editor-standard-select"
|
||||||
|
options={snapshot.profile.available_target_stores.map((store) => ({
|
||||||
|
value: store.id,
|
||||||
|
label: `${store.name}(${store.currency_code})`,
|
||||||
|
}))}
|
||||||
|
onChange={() => {
|
||||||
|
form.setFieldValue(['profile', 'store_product_category_id'], null);
|
||||||
|
form.setFieldValue(['profile', 'category_source'], null);
|
||||||
|
form.setFieldValue('attributes', []);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={['profile', 'store_product_category_id']}
|
||||||
|
label="产品类目"
|
||||||
|
className="span-full"
|
||||||
|
rules={[{ required: true, message: '请选择产品类目' }]}
|
||||||
|
getValueProps={(categoryId: string | null) => ({
|
||||||
|
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
|
||||||
|
})}
|
||||||
|
getValueFromEvent={(value: string[]) => value.at(-1) ?? null}
|
||||||
|
>
|
||||||
|
<Cascader
|
||||||
|
className="editor-standard-cascader"
|
||||||
|
classNames={{ popup: { root: 'editor-category-popup' } }}
|
||||||
|
showSearch
|
||||||
|
onSearch={onCategorySearch}
|
||||||
|
placeholder="选择当前店铺可发布的产品类目"
|
||||||
|
options={categoryOptions}
|
||||||
|
notFoundContent={categoryNotFoundContent}
|
||||||
|
getPopupContainer={(node) => node.parentElement ?? document.body}
|
||||||
|
onChange={(value) => {
|
||||||
|
const categoryId = value.at(-1) ?? null;
|
||||||
|
if (categoryId !== selectedCategoryId)
|
||||||
|
form.setFieldValue('attributes', []);
|
||||||
|
form.setFieldValue(
|
||||||
|
['profile', 'category_source'],
|
||||||
|
categoryId ? 'manual' : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={['common', 'source_title']}
|
||||||
|
label="标题"
|
||||||
|
className="span-full"
|
||||||
|
rules={[{ required: true, message: '请填写标题' }, { max: 1000 }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="整理后的商品标题" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={['common', 'model']}
|
||||||
|
label="型号"
|
||||||
|
rules={[{ required: true, message: '请填写型号' }]}
|
||||||
|
>
|
||||||
|
<Input className="editor-short-input" placeholder="首次上架前需填写" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={['common', 'brand']}
|
||||||
|
label="品牌"
|
||||||
|
rules={[{ required: true, message: '请填写品牌;无品牌请填写“无品牌”' }]}
|
||||||
|
>
|
||||||
|
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<PackageFields name={['common', 'default_package_spec']} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Card, Form, Input, Space, Tooltip, Typography } from 'antd';
|
||||||
|
import { ExportOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
canOpenSupplyUrl: boolean;
|
||||||
|
platformName: string;
|
||||||
|
onOpenSupplyUrl: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) {
|
||||||
|
return (
|
||||||
|
<Card className="editor-section other-section" bordered={false}>
|
||||||
|
<div className="editor-section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>
|
||||||
|
<span className="editor-section-number">04</span>其它
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">与供货和发布相关的补充信息。</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-form-grid">
|
||||||
|
<Form.Item label="货源链接" className="span-two">
|
||||||
|
<Space.Compact block className="supply-link-control">
|
||||||
|
<Form.Item
|
||||||
|
name={['common', 'purchase_url']}
|
||||||
|
noStyle
|
||||||
|
rules={[{ type: 'url', message: '请输入有效 URL' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="https://..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Tooltip title="打开货源链接">
|
||||||
|
<Button
|
||||||
|
className="supply-open-button"
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
disabled={!canOpenSupplyUrl}
|
||||||
|
onClick={onOpenSupplyUrl}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['common', 'supply_note']} label="备注" className="span-full">
|
||||||
|
<Input.TextArea rows={2} maxLength={4000} placeholder="供应商、采购注意事项等" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已上架 SKU 的编辑仅保存 Atlas 草稿,不会自动更新 {platformName} 商品。
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.category-debug-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-intro {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-intro .ant-typography {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-alert {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-result .ant-card-body {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-loading {
|
||||||
|
display: grid;
|
||||||
|
min-height: 240px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-tree {
|
||||||
|
max-height: 640px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-node-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-debug-json pre {
|
||||||
|
max-height: 720px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Collapse, Empty, Space, Spin, Tree, Typography } from 'antd';
|
||||||
|
import type { DataNode } from 'antd/es/tree';
|
||||||
|
import { ApiOutlined, BugOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { apiErrorMessage } from '@/services/api';
|
||||||
|
import { categoryTreeDebugApi } from '@/services/categoryTreeDebug';
|
||||||
|
import { usePlatform } from '@/services/platform';
|
||||||
|
import './CategoryTreeDebugPage.css';
|
||||||
|
|
||||||
|
type OzonCategoryNode = {
|
||||||
|
description_category_id?: number | string;
|
||||||
|
category_name?: string;
|
||||||
|
type_id?: number | string | null;
|
||||||
|
type_name?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: OzonCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function categoryNodes(value: unknown): OzonCategoryNode[] {
|
||||||
|
if (!value || typeof value !== 'object') return [];
|
||||||
|
const result = (value as { result?: unknown }).result;
|
||||||
|
return Array.isArray(result) ? (result as OzonCategoryNode[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeData(nodes: OzonCategoryNode[], ancestry = ''): DataNode[] {
|
||||||
|
return nodes.map((node, index) => {
|
||||||
|
const id = String(node.description_category_id ?? `unknown-${index}`);
|
||||||
|
const key = `${ancestry}/${id}:${String(node.type_id ?? 0)}:${index}`;
|
||||||
|
const children = Array.isArray(node.children) ? node.children : [];
|
||||||
|
const isTypeLeaf = !node.category_name?.trim() && Boolean(node.type_name?.trim());
|
||||||
|
const name = node.category_name?.trim() || node.type_name?.trim() || '未命名类目';
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
title: (
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<Typography.Text>{name}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" className="category-debug-node-meta">
|
||||||
|
{isTypeLeaf
|
||||||
|
? `类型 ID=${String(node.type_id ?? 0)}`
|
||||||
|
: `类目 ID=${id} · 类型 ID=${String(node.type_id ?? 0)}`}
|
||||||
|
</Typography.Text>
|
||||||
|
{node.disabled === true && <Typography.Text type="warning">不可建品</Typography.Text>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: children.length ? toTreeData(children, key) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ozon 类目树诊断页。请求始终由 API 服务端发起,浏览器没有也不会收到门店密钥。
|
||||||
|
*/
|
||||||
|
const CategoryTreeDebugPage = () => {
|
||||||
|
const { currentPlatform, context, contextLoading } = usePlatform();
|
||||||
|
const [rawResponse, setRawResponse] = useState<unknown>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const nodes = useMemo(() => categoryNodes(rawResponse), [rawResponse]);
|
||||||
|
const treeData = useMemo(() => toTreeData(nodes), [nodes]);
|
||||||
|
const rawJson = useMemo(
|
||||||
|
() => (rawResponse === null ? '' : JSON.stringify(rawResponse, null, 2)),
|
||||||
|
[rawResponse],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchTree = async () => {
|
||||||
|
if (!currentPlatform) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await categoryTreeDebugApi.fetchDefaultStoreTree(currentPlatform.id);
|
||||||
|
setRawResponse(response);
|
||||||
|
} catch (requestError) {
|
||||||
|
setRawResponse(null);
|
||||||
|
setError(apiErrorMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unavailable = !currentPlatform || !context?.default_store;
|
||||||
|
return (
|
||||||
|
<div className="category-debug-page">
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<BugOutlined />
|
||||||
|
Ozon 类目树调试
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
disabled={unavailable || contextLoading}
|
||||||
|
onClick={() => void fetchTree()}
|
||||||
|
>
|
||||||
|
获取类目
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="middle" className="category-debug-intro">
|
||||||
|
<Typography.Paragraph>
|
||||||
|
使用当前平台的默认门店请求 Ozon 官方
|
||||||
|
<Typography.Text code>/v1/description-category/tree</Typography.Text>,并原样展示响应。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
icon={<ApiOutlined />}
|
||||||
|
message={
|
||||||
|
context?.default_store
|
||||||
|
? `默认门店:${context.default_store.name}`
|
||||||
|
: '当前平台未设置默认门店'
|
||||||
|
}
|
||||||
|
description="请求由 Atlas 服务端代发;Client ID 与 API Key 不会发送到浏览器、日志或响应内容。"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && <Alert className="category-debug-alert" type="error" showIcon message={error} />}
|
||||||
|
|
||||||
|
<Card className="category-debug-result" title="类目树">
|
||||||
|
{loading ? (
|
||||||
|
<div className="category-debug-loading"><Spin tip="正在向 Ozon 获取完整类目树…" /></div>
|
||||||
|
) : treeData.length ? (
|
||||||
|
<Tree
|
||||||
|
className="category-debug-tree"
|
||||||
|
showLine={{ showLeafIcon: false }}
|
||||||
|
defaultExpandParent={false}
|
||||||
|
treeData={treeData}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="点击“获取类目”查看 Ozon 返回的类目树" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{rawResponse !== null && (
|
||||||
|
<Collapse
|
||||||
|
className="category-debug-json"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'raw-json',
|
||||||
|
label: `原始 JSON 响应(${rawJson.length.toLocaleString()} 字符)`,
|
||||||
|
children: <pre>{rawJson}</pre>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryTreeDebugPage;
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { MenuProps, TableProps } from 'antd';
|
import type { MenuProps, TableProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
|
BugOutlined,
|
||||||
CloudDownloadOutlined,
|
CloudDownloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
@@ -361,6 +362,14 @@ const ShopsPage = () => {
|
|||||||
if (result?.operation) void pollOperation(result.operation.id);
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSyncCategoryTree = (store: Store) =>
|
||||||
|
withPending(store, async () => {
|
||||||
|
const result = await storesApi.requestCategoryTreeSync(store.id);
|
||||||
|
message.success(
|
||||||
|
`门店类目已刷新:${result.active_category_count}/${result.category_count} 个可发布类目`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const handleSyncWarehouses = (store: Store) =>
|
const handleSyncWarehouses = (store: Store) =>
|
||||||
withPending(store, async () => {
|
withPending(store, async () => {
|
||||||
const result = await storesApi.requestWarehouseSync(store.id);
|
const result = await storesApi.requestWarehouseSync(store.id);
|
||||||
@@ -401,6 +410,7 @@ const ShopsPage = () => {
|
|||||||
disabled: store.is_default,
|
disabled: store.is_default,
|
||||||
},
|
},
|
||||||
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
|
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
|
||||||
|
{ key: 'sync-category-tree', icon: <CloudDownloadOutlined />, label: '刷新门店类目' },
|
||||||
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
|
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true },
|
{ key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true },
|
||||||
@@ -411,6 +421,7 @@ const ShopsPage = () => {
|
|||||||
({ key }) => {
|
({ key }) => {
|
||||||
if (key === 'set-default') void handleSetDefault(store);
|
if (key === 'set-default') void handleSetDefault(store);
|
||||||
else if (key === 'verify') void handleVerify(store);
|
else if (key === 'verify') void handleVerify(store);
|
||||||
|
else if (key === 'sync-category-tree') void handleSyncCategoryTree(store);
|
||||||
else if (key === 'sync') void handleSyncWarehouses(store);
|
else if (key === 'sync') void handleSyncWarehouses(store);
|
||||||
else if (key === 'delete') handleDelete(store);
|
else if (key === 'delete') handleDelete(store);
|
||||||
};
|
};
|
||||||
@@ -549,6 +560,10 @@ const ShopsPage = () => {
|
|||||||
<Card
|
<Card
|
||||||
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
|
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button href="/shops/category-tree-debug" icon={<BugOutlined />}>
|
||||||
|
类目调试
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@@ -557,6 +572,7 @@ const ShopsPage = () => {
|
|||||||
>
|
>
|
||||||
添加店铺
|
添加店铺
|
||||||
</Button>
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{platforms.length === 0 && !currentPlatform ? (
|
{platforms.length === 0 && !currentPlatform ? (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ProductEditPage from '@/pages/products/ProductEditPage';
|
|||||||
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
||||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||||
|
import CategoryTreeDebugPage from '@/pages/shops/CategoryTreeDebugPage';
|
||||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||||
import { PlatformProvider } from '@/services/platform';
|
import { PlatformProvider } from '@/services/platform';
|
||||||
import { useSession } from '@/services/session';
|
import { useSession } from '@/services/session';
|
||||||
@@ -59,6 +60,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'collection', element: <CollectionPage /> },
|
{ path: 'collection', element: <CollectionPage /> },
|
||||||
{ path: 'collection-test/:id', element: <CollectionInspectionPage /> },
|
{ path: 'collection-test/:id', element: <CollectionInspectionPage /> },
|
||||||
{ path: 'shops', element: <ShopsPage /> },
|
{ path: 'shops', element: <ShopsPage /> },
|
||||||
|
{ path: 'shops/category-tree-debug', element: <CategoryTreeDebugPage /> },
|
||||||
{ path: 'watermarks', element: <WatermarksPage /> },
|
{ path: 'watermarks', element: <WatermarksPage /> },
|
||||||
{ path: 'pricing', element: <PricingPage /> },
|
{ path: 'pricing', element: <PricingPage /> },
|
||||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
/** 仅调试页使用:服务端代表当前平台默认门店读取 Ozon 原始响应。 */
|
||||||
|
export const categoryTreeDebugApi = {
|
||||||
|
fetchDefaultStoreTree: (platformId: string) =>
|
||||||
|
api.post<unknown>('/stores/default/category-tree-debug', undefined, {
|
||||||
|
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -17,6 +17,11 @@ export interface UpdateStoreOptions {
|
|||||||
revision: number;
|
revision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CategoryTreeRefreshResult {
|
||||||
|
category_count: number;
|
||||||
|
active_category_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。
|
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。
|
||||||
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
|
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
|
||||||
@@ -57,6 +62,12 @@ export const storesApi = {
|
|||||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** 同步重新拉取并写入该门店完整类目树;密钥仅在服务端短暂使用。 */
|
||||||
|
requestCategoryTreeSync: (storeId: string) =>
|
||||||
|
api.post<CategoryTreeRefreshResult>(`/stores/${storeId}/category-tree-syncs`, undefined, {
|
||||||
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
}),
|
||||||
|
|
||||||
requestWarehouseSync: (storeId: string) =>
|
requestWarehouseSync: (storeId: string) =>
|
||||||
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
|
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
|
||||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
|
|||||||
+8
-34
@@ -18,6 +18,7 @@ import {
|
|||||||
syncOzonCatalog,
|
syncOzonCatalog,
|
||||||
type CatalogSyncScope,
|
type CatalogSyncScope,
|
||||||
} from './ozon-catalog-sync.js';
|
} from './ozon-catalog-sync.js';
|
||||||
|
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.js';
|
||||||
|
|
||||||
const connection = new Redis(workerConfig.redisUrl, {
|
const connection = new Redis(workerConfig.redisUrl, {
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
@@ -161,8 +162,8 @@ async function completeVerification(
|
|||||||
rejectedStatus?: 401 | 403,
|
rejectedStatus?: 401 | 403,
|
||||||
) {
|
) {
|
||||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
const current = await tx.query<{ id: string }>(
|
const current = await tx.query<{ id: string; platform_id: string }>(
|
||||||
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
`SELECT s.id, s.platform_id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||||
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||||
AND secret.revoked_at IS NULL FOR UPDATE`,
|
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||||
[job.payload.store_id, job.payload.secret_id ?? ''],
|
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||||
@@ -216,38 +217,11 @@ async function completeVerification(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
const catalogOperation = await tx.query<{
|
await scheduleStoreCategoryTreeSync(tx, {
|
||||||
id: string;
|
storeId: job.payload.store_id,
|
||||||
}>(
|
platformId: current.rows[0].platform_id,
|
||||||
`INSERT INTO atlas.operations
|
secretId: job.payload.secret_id ?? '',
|
||||||
(workspace_id, kind, resource_type, resource_id)
|
});
|
||||||
VALUES (current_setting('app.workspace_id')::uuid,
|
|
||||||
'store_category_tree_sync', 'store', $1)
|
|
||||||
RETURNING id`,
|
|
||||||
[job.payload.store_id],
|
|
||||||
);
|
|
||||||
const operationId = catalogOperation.rows[0]?.id;
|
|
||||||
if (!operationId) throw new Error('Unable to create store catalog sync operation');
|
|
||||||
await tx.query(
|
|
||||||
`INSERT INTO atlas.outbox_events
|
|
||||||
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, 'store', $1,
|
|
||||||
'store_category_tree_sync.requested', $2::jsonb, $3)`,
|
|
||||||
[
|
|
||||||
job.payload.store_id,
|
|
||||||
JSON.stringify({
|
|
||||||
store_id: job.payload.store_id,
|
|
||||||
secret_id: job.payload.secret_id,
|
|
||||||
operation_id: operationId,
|
|
||||||
platform_id: (await tx.query<{ platform_id: string }>(
|
|
||||||
`SELECT platform_id FROM atlas.stores WHERE id = $1`,
|
|
||||||
[job.payload.store_id],
|
|
||||||
)).rows[0]?.platform_id,
|
|
||||||
scope: 'tree',
|
|
||||||
}),
|
|
||||||
operationId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,28 +369,47 @@ async function persistCategoryTree(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenCategories(
|
export function flattenCategories(
|
||||||
nodes: OzonCategoryNode[],
|
nodes: OzonCategoryNode[],
|
||||||
path: string[] = [],
|
path: string[] = [],
|
||||||
parentKey: string | null = null,
|
parentKey: string | null = null,
|
||||||
|
inheritedCategory?: { id: string; name: string },
|
||||||
): FlatCategory[] {
|
): FlatCategory[] {
|
||||||
const flattened: FlatCategory[] = [];
|
const flattened: FlatCategory[] = [];
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const categoryId = String(node.description_category_id ?? '').trim();
|
// Ozon's last level may contain only type_name/type_id. It is still a
|
||||||
if (!categoryId || !node.category_name?.trim()) continue;
|
// publishable option for the closest description category, so inherit
|
||||||
|
// its category ID while retaining the type's own display name and ID.
|
||||||
|
const explicitCategoryId = String(node.description_category_id ?? '').trim();
|
||||||
|
const explicitCategoryName = node.category_name?.trim() ?? '';
|
||||||
|
const categoryId = explicitCategoryId || inheritedCategory?.id || '';
|
||||||
|
const categoryName = explicitCategoryName || inheritedCategory?.name || '';
|
||||||
const typeId = String(node.type_id ?? 0);
|
const typeId = String(node.type_id ?? 0);
|
||||||
const nextPath = [...path, node.category_name.trim()];
|
const typeName = node.type_name?.trim() ?? '';
|
||||||
|
const name = explicitCategoryName || typeName || categoryName;
|
||||||
|
if (!categoryId || !name) continue;
|
||||||
|
const nextPath = [...path, name];
|
||||||
const key = categoryKey(categoryId, typeId);
|
const key = categoryKey(categoryId, typeId);
|
||||||
flattened.push({
|
flattened.push({
|
||||||
external_category_id: categoryId,
|
external_category_id: categoryId,
|
||||||
external_type_id: typeId,
|
external_type_id: typeId,
|
||||||
parent_key: parentKey,
|
parent_key: parentKey,
|
||||||
name: node.category_name.trim(),
|
name,
|
||||||
path: nextPath.join(' › '),
|
path: nextPath.join(' › '),
|
||||||
is_active: node.disabled !== true && typeId !== '0',
|
is_active: node.disabled !== true && typeId !== '0',
|
||||||
source_payload: node,
|
source_payload: node,
|
||||||
});
|
});
|
||||||
flattened.push(...flattenCategories(node.children ?? [], nextPath, key));
|
const nextInheritedCategory = explicitCategoryId && explicitCategoryName
|
||||||
|
? { id: explicitCategoryId, name: explicitCategoryName }
|
||||||
|
: inheritedCategory;
|
||||||
|
flattened.push(
|
||||||
|
...flattenCategories(
|
||||||
|
node.children ?? [],
|
||||||
|
nextPath,
|
||||||
|
key,
|
||||||
|
nextInheritedCategory,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return flattened;
|
return flattened;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ export class OzonRequestError extends Error {
|
|||||||
export class OzonRetryableError extends Error {}
|
export class OzonRetryableError extends Error {}
|
||||||
|
|
||||||
export type OzonCategoryNode = {
|
export type OzonCategoryNode = {
|
||||||
description_category_id: number | string;
|
/** 类型叶子会省略这两个字段,继承最近的父类目。 */
|
||||||
category_name: string;
|
description_category_id?: number | string;
|
||||||
|
category_name?: string | null;
|
||||||
type_id?: number | string | null;
|
type_id?: number | string | null;
|
||||||
type_name?: string | null;
|
type_name?: string | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { DatabaseTransaction } from '@atlas/database';
|
||||||
|
|
||||||
|
type ScheduleInput = {
|
||||||
|
storeId: string;
|
||||||
|
platformId: string;
|
||||||
|
secretId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreCategoryTreeScheduleResult =
|
||||||
|
| { scheduled: true; operationId: string }
|
||||||
|
| { scheduled: false; reason: 'cache_available' | 'already_queued' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule exactly one usable tree refresh for a store.
|
||||||
|
*
|
||||||
|
* The caller must hold the store row with `FOR UPDATE`; credential verification
|
||||||
|
* already does this, which turns the lookup + insert below into a per-store
|
||||||
|
* critical section without introducing a process-local lock.
|
||||||
|
*/
|
||||||
|
export async function scheduleStoreCategoryTreeSync(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
input: ScheduleInput,
|
||||||
|
): Promise<StoreCategoryTreeScheduleResult> {
|
||||||
|
const cache = await transaction.query<{ present: boolean }>(
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM atlas.store_product_categories
|
||||||
|
WHERE store_id = $1 AND synced_at IS NOT NULL
|
||||||
|
) AS present`,
|
||||||
|
[input.storeId],
|
||||||
|
);
|
||||||
|
if (cache.rows[0]?.present)
|
||||||
|
return { scheduled: false, reason: 'cache_available' };
|
||||||
|
|
||||||
|
const queued = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
secret_id: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT operation.id, event.event_payload ->> 'secret_id' AS secret_id
|
||||||
|
FROM atlas.operations operation
|
||||||
|
LEFT JOIN atlas.outbox_events event
|
||||||
|
ON event.event_type = 'store_category_tree_sync.requested'
|
||||||
|
AND event.event_payload ->> 'operation_id' = operation.id::text
|
||||||
|
WHERE operation.kind = 'store_category_tree_sync'
|
||||||
|
AND operation.resource_type = 'store'
|
||||||
|
AND operation.resource_id = $1
|
||||||
|
AND operation.status IN ('queued', 'running')
|
||||||
|
ORDER BY operation.created_at DESC
|
||||||
|
FOR UPDATE OF operation`,
|
||||||
|
[input.storeId],
|
||||||
|
);
|
||||||
|
if (queued.rows.some((operation) => operation.secret_id === input.secretId))
|
||||||
|
return { scheduled: false, reason: 'already_queued' };
|
||||||
|
|
||||||
|
// A task tied to a replaced secret can no longer supply this store's cache.
|
||||||
|
// Mark it cancelled before adding the current credential's task; a worker
|
||||||
|
// that has already picked it up independently re-checks the secret and
|
||||||
|
// leaves this terminal state intact.
|
||||||
|
if (queued.rows.length) {
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.operations
|
||||||
|
SET status = 'cancelled', completed_at = now(),
|
||||||
|
error_code = 'CATALOG_SYNC_SOURCE_REPLACED',
|
||||||
|
error_message = '门店凭据已更新,旧类目同步已取消'
|
||||||
|
WHERE id = ANY($1::uuid[]) AND status IN ('queued', 'running')`,
|
||||||
|
[queued.rows.map((operation) => operation.id)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await transaction.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.operations
|
||||||
|
(workspace_id, kind, resource_type, resource_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid,
|
||||||
|
'store_category_tree_sync', 'store', $1)
|
||||||
|
RETURNING id`,
|
||||||
|
[input.storeId],
|
||||||
|
);
|
||||||
|
const operationId = created.rows[0]?.id;
|
||||||
|
if (!operationId)
|
||||||
|
throw new Error('Unable to create store catalog sync operation');
|
||||||
|
await transaction.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'store', $1,
|
||||||
|
'store_category_tree_sync.requested', $2::jsonb, $3)`,
|
||||||
|
[
|
||||||
|
input.storeId,
|
||||||
|
JSON.stringify({
|
||||||
|
store_id: input.storeId,
|
||||||
|
secret_id: input.secretId,
|
||||||
|
operation_id: operationId,
|
||||||
|
platform_id: input.platformId,
|
||||||
|
scope: 'tree',
|
||||||
|
}),
|
||||||
|
operationId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { scheduled: true, operationId };
|
||||||
|
}
|
||||||
@@ -148,6 +148,10 @@ Headers:`Idempotency-Key` 必填。路径店铺在其平台下成为默认店
|
|||||||
|
|
||||||
Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与 operation。店铺停用或删除时返回 `422`。
|
Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与 operation。店铺停用或删除时返回 `422`。
|
||||||
|
|
||||||
|
`POST /api/stores/{store_id}/category-tree-syncs`
|
||||||
|
|
||||||
|
Headers:`Idempotency-Key` 必填。同步请求 Ozon `POST /v1/description-category/tree` 并写入目标门店缓存;只有完整响应成功落库后才返回 `200` 与类目数量。必须是凭据已验证的 Ozon 门店,否则返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。Ozon 请求或落库失败返回脱敏错误,旧缓存保持不变。此手动操作不经 Worker 或队列,API Key 仅在服务端请求期间短暂存在。
|
||||||
|
|
||||||
`POST /api/stores/{store_id}/warehouse-syncs`
|
`POST /api/stores/{store_id}/warehouse-syncs`
|
||||||
|
|
||||||
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
|
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
|
||||||
|
|||||||
@@ -41,31 +41,32 @@
|
|||||||
{
|
{
|
||||||
"result": [
|
"result": [
|
||||||
{
|
{
|
||||||
"description_category_id": 17033876,
|
"description_category_id": 17000001,
|
||||||
"category_name": "Термокружки",
|
"category_name": "Дом и сад",
|
||||||
"type_id": 97114,
|
|
||||||
"type_name": "Термокружка",
|
|
||||||
"disabled": false,
|
|
||||||
"children": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description_category_id": 17028922,
|
|
||||||
"category_name": "Посуда",
|
|
||||||
"type_id": 0,
|
"type_id": 0,
|
||||||
"type_name": "",
|
"type_name": "",
|
||||||
"disabled": true,
|
"disabled": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"description_category_id": 17033876,
|
"description_category_id": 17000002,
|
||||||
"category_name": "Термокружки",
|
"category_name": "Сувениры и подарки",
|
||||||
"type_id": 97114,
|
"type_id": 0,
|
||||||
"type_name": "Термокружка",
|
"type_name": "",
|
||||||
|
"disabled": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"description_category_id": 17032010,
|
||||||
|
"category_name": "Копилки",
|
||||||
|
"type_id": 93770,
|
||||||
|
"type_name": "Копилка",
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"children": []
|
"children": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,16 +78,18 @@
|
|||||||
| category_name | string | 类目名称 |
|
| category_name | string | 类目名称 |
|
||||||
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
|
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
|
||||||
| type_name | string | 商品类型名称 |
|
| type_name | string | 商品类型名称 |
|
||||||
| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) |
|
| disabled | boolean | **是否可建品**。`true` = 当前节点不可用于建品;`false` = 可用于建品(通常是末级类目) |
|
||||||
| children | array | 子类目(递归结构) |
|
| children | array | 子类目(递归结构) |
|
||||||
|
|
||||||
|
> **类型叶子例外**:Ozon 的最后一层可能只返回 `type_name`、`type_id`、`disabled` 和空 `children`,而不重复返回 `description_category_id`、`category_name`。此时它属于最近的父类目:缓存时应继承父类目的类目 ID,展示名称使用 `type_name`,发布时使用“父类目 `description_category_id` + 叶子 `type_id`”。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 关键约束
|
## 关键约束
|
||||||
|
|
||||||
1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品
|
1. **只有可发布的末级类目可建品**:选择结果必须是没有 `children` 且 `disabled=false` 的节点
|
||||||
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
|
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
|
||||||
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级
|
3. **层级结构**:`children` 是递归结构,类目可有三级或更深层级;不能假定只有两层,也不能只展开一层
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,15 +100,16 @@
|
|||||||
```
|
```
|
||||||
① 请求类目树(language=RU,给俄文用户看)
|
① 请求类目树(language=RU,给俄文用户看)
|
||||||
② 递归展开树形结构
|
② 递归展开树形结构
|
||||||
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级)
|
③ 所有有子节点的目录都保持可展开;不要把目录节点映射为组件的 disabled
|
||||||
④ 选中后保存 description_category_id + type_id
|
④ 仅允许选择没有子节点且 disabled=false 的节点
|
||||||
|
⑤ 选中后保存 description_category_id + type_id
|
||||||
```
|
```
|
||||||
|
|
||||||
### 场景 2:服务端门店缓存
|
### 场景 2:服务端门店缓存
|
||||||
|
|
||||||
```
|
```
|
||||||
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取类目树(Atlas 使用 `ZH_HANS`)
|
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取完整类目树(Atlas 使用 `ZH_HANS`)
|
||||||
② 按门店存入本地类目缓存;不与其他门店混用
|
② 递归保存每一层及其父子关系,按门店存入本地类目缓存;不与其他门店混用
|
||||||
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
|
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
|
||||||
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
|
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
|
||||||
```
|
```
|
||||||
@@ -141,7 +145,7 @@ async def fetch_category_tree(
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
# 递归扁平化
|
# 递归扁平化;不要限制层级数。
|
||||||
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
|
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
|
||||||
flat = []
|
flat = []
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
@@ -184,19 +188,24 @@ async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
|
|||||||
return data.result;
|
return data.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转为 antd Tree 数据结构
|
// 转为 antd Cascader 数据结构。目录永远不能设为 disabled,
|
||||||
|
// 否则用户无法继续展开到下一级。
|
||||||
function toTreeData(nodes: CategoryNode[]): any[] {
|
function toTreeData(nodes: CategoryNode[]): any[] {
|
||||||
return nodes.map(node => ({
|
return nodes.map(node => {
|
||||||
key: `${node.description_category_id}-${node.type_id}`,
|
const hasChildren = node.children.length > 0;
|
||||||
title: node.category_name,
|
return {
|
||||||
disabled: node.disabled, // 父类目禁止选择
|
value: `${node.description_category_id}-${node.type_id}`,
|
||||||
children: node.children.length > 0 ? toTreeData(node.children) : undefined,
|
label: node.category_name,
|
||||||
// 保存原始数据,选中时取用
|
isLeaf: !hasChildren,
|
||||||
|
// 仅不可发布的叶子不可选择;目录节点必须可展开。
|
||||||
|
disabled: !hasChildren && node.disabled,
|
||||||
|
children: hasChildren ? toTreeData(node.children) : undefined,
|
||||||
data: {
|
data: {
|
||||||
description_category_id: node.description_category_id,
|
description_category_id: node.description_category_id,
|
||||||
type_id: node.type_id
|
type_id: node.type_id
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -204,48 +213,29 @@ function toTreeData(nodes: CategoryNode[]): any[] {
|
|||||||
|
|
||||||
## 缓存策略
|
## 缓存策略
|
||||||
|
|
||||||
### 全局缓存(推荐)
|
### 按门店缓存(Atlas 方案)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 类目树与店铺无关,所有店铺共用一份
|
# 类目数据的读取凭证属于门店。即使结构通常相近,也必须按 store_id 隔离缓存。
|
||||||
# 启动时拉取,存内存 + 数据库
|
# 验证成功或缓存失效时,由 Worker 用该门店凭证拉取完整树并递归写库。
|
||||||
# TTL 24h(类目变化不频繁)
|
# 前端只读取该门店的缓存,绝不携带或读取 Client-Id / Api-Key。
|
||||||
|
async def get_store_category_tree(store_id: str):
|
||||||
|
cached = await load_store_category_cache(store_id)
|
||||||
|
if cached and cached.is_valid:
|
||||||
|
return cached.tree
|
||||||
|
|
||||||
from functools import lru_cache
|
await enqueue_store_catalog_sync(store_id)
|
||||||
from datetime import datetime, timedelta
|
raise CatalogCachePreparingError(store_id)
|
||||||
|
|
||||||
_category_tree_cache = None
|
|
||||||
_cache_time = None
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
async def get_category_tree_cached(language: str = "DEFAULT"):
|
|
||||||
global _category_tree_cache, _cache_time
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24):
|
|
||||||
return _category_tree_cache
|
|
||||||
|
|
||||||
# 从任意店铺拉(类目树全局一致)
|
|
||||||
tree = await fetch_category_tree(any_client_id, any_api_key, language)
|
|
||||||
_category_tree_cache = tree
|
|
||||||
_cache_time = now
|
|
||||||
|
|
||||||
# 同时写数据库
|
|
||||||
await save_to_db(tree)
|
|
||||||
|
|
||||||
return tree
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 按需更新
|
### 按需更新
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 用户反馈「找不到某类目」时手动刷新
|
# 用户在门店管理中手动刷新时,API 同步请求 Ozon 并在成功后原子替换目标门店缓存。
|
||||||
async def refresh_category_tree():
|
# 请求或落库失败会保留旧缓存并向用户返回错误,不经 Worker 队列。
|
||||||
global _category_tree_cache, _cache_time
|
async def refresh_store_category_tree(store_id: str):
|
||||||
_category_tree_cache = None
|
tree = await fetch_category_tree_for_store(store_id)
|
||||||
_cache_time = None
|
return await replace_store_category_cache_atomically(store_id, tree)
|
||||||
get_category_tree_cached.cache_clear()
|
|
||||||
return await get_category_tree_cached()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -254,7 +244,7 @@ async def refresh_category_tree():
|
|||||||
|
|
||||||
### Q1: 类目树很大吗?
|
### Q1: 类目树很大吗?
|
||||||
|
|
||||||
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。
|
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。完整树在一次响应中以递归 `children` 返回,首次拉取需几秒,后续从门店缓存读取。
|
||||||
|
|
||||||
### Q2: 多久更新一次?
|
### Q2: 多久更新一次?
|
||||||
|
|
||||||
@@ -262,67 +252,20 @@ async def refresh_category_tree():
|
|||||||
|
|
||||||
### Q3: 不同语言的类目树结构一样吗?
|
### Q3: 不同语言的类目树结构一样吗?
|
||||||
|
|
||||||
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议:
|
**A**: 通常 `description_category_id` / `type_id` 相同,仅 `category_name` / `type_name` 翻译不同。Atlas 统一由 Worker 为每个门店缓存 `ZH_HANS`,前端直接读取该门店缓存,不能为展示语言绕过缓存直接携带门店密钥请求 Ozon。
|
||||||
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
|
|
||||||
- 前端按用户语言拉取 `RU`(俄文,展示用)
|
|
||||||
|
|
||||||
### Q4: `type_id=0` 是什么意思?
|
### Q4: `type_id=0` 是什么意思?
|
||||||
|
|
||||||
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。
|
**A**: 目录节点通常 `type_id=0`,但前端不能据此把所有目录设为 disabled,否则它们无法展开。是否允许提交必须同时判断:它是叶子节点、`disabled=false`,并且有可用的 `type_id`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## V2 项目集成
|
## Atlas 当前集成
|
||||||
|
|
||||||
### API 层(已实现)
|
- **Worker**:以门店凭证调用 Ozon,递归持久化完整 `children` 树及父子关系;缓存命中时不重复调用 Ozon。
|
||||||
|
- **API**:按当前目标门店返回缓存的扁平节点(包含 `parent_id`);不会将 `Client-Id` 或 `Api-Key` 返回给浏览器。
|
||||||
```python
|
- **前端**:根据 `parent_id` 重建任意深度的 Cascader;目录可展开,只有可发布的叶子能写入商品草稿。
|
||||||
# server/api/categories.py
|
- **调试**:若需要查看 Ozon 原始响应,应由受权限保护的服务端调试能力获取并脱敏展示;浏览器不得直接用门店密钥请求 Ozon。
|
||||||
|
|
||||||
@router.get("/categories/tree")
|
|
||||||
async def get_tree(
|
|
||||||
lang: str = Query("RU"),
|
|
||||||
shop_id: str = Query(...), # 需要店铺凭证
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
shop = await get_shop(db, shop_id)
|
|
||||||
tree = await ozon_client.get_category_tree(
|
|
||||||
shop.client_id_dec,
|
|
||||||
shop.api_key_dec,
|
|
||||||
lang
|
|
||||||
)
|
|
||||||
return {"result": tree}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端(待实现)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// studio/src/pages/product/components/CategoryPicker.tsx
|
|
||||||
|
|
||||||
import { Tree } from 'antd';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function CategoryPicker({ shopId, onChange }) {
|
|
||||||
const [treeData, setTreeData] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => setTreeData(toTreeData(data.result)));
|
|
||||||
}, [shopId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tree
|
|
||||||
treeData={treeData}
|
|
||||||
onSelect={(keys, { node }) => {
|
|
||||||
if (!node.disabled) {
|
|
||||||
onChange(node.data); // { description_category_id, type_id }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -14,9 +14,9 @@
|
|||||||
|
|
||||||
当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。
|
当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。
|
||||||
|
|
||||||
当前没有完成的是“可直接用于发布”的闭环:候选素材选择/物化、定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
当前没有完成的是“可直接用于发布”的闭环:候选素材已经可以选择、转存并保存为 SKU 的平台素材,但定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
||||||
|
|
||||||
当前最高优先级是完成真实样本回归,然后进入候选素材选择/物化这一条发布前纵向能力。类目树改造已落地:Worker 使用显式指定的 Ozon 缓存源预热共享树,编辑器直接读取 Cascader 树,并会在空类目时按 Ozon 来源层级回填;详见 [1.28](#128-开发进度2026-09-10类目树预热与来源回填)。
|
当前最高优先级是以真实来源样本回归素材转存与选择,然后进入定价确认能力。类目树改造已落地:Worker 使用显式指定的 Ozon 缓存源预热共享树,编辑器直接读取 Cascader 树,并会在空类目时按 Ozon 来源层级回填;详见 [1.28](#128-开发进度2026-09-10类目树预热与来源回填)。
|
||||||
|
|
||||||
接手前请先阅读 `git status --short`;当前 M3 商品编辑和 M3.4/M3.5 缓存改动均已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
接手前请先阅读 `git status --short`;当前 M3 商品编辑和 M3.4/M3.5 缓存改动均已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
||||||
|
|
||||||
@@ -255,7 +255,25 @@
|
|||||||
3. 启动本地 PostgreSQL 与 Redis,执行 `022_store_catalog_cache.sql`,运行 API 集成测试并检查 RLS、外键和历史 profile 兼容性。
|
3. 启动本地 PostgreSQL 与 Redis,执行 `022_store_catalog_cache.sql`,运行 API 集成测试并检查 RLS、外键和历史 profile 兼容性。
|
||||||
4. 使用真实已验证 Ozon 门店和样本,在 `http://localhost:8100` 回归:类目树回显、切换店铺、保存 UUID、来源回填、不覆盖手选类目以及属性/字典按需同步。
|
4. 使用真实已验证 Ozon 门店和样本,在 `http://localhost:8100` 回归:类目树回显、切换店铺、保存 UUID、来源回填、不覆盖手选类目以及属性/字典按需同步。
|
||||||
|
|
||||||
当前阻塞:本机 PostgreSQL 与 Redis 未运行,`apps/api/test/product-editor.e2e.test.ts` 连接报 `ECONNREFUSED`,因此 API 集成测试和迁移尚未实际验证。
|
### 1.30 开发进度(2026-09-11):M3.5 类目缓存可靠性收口
|
||||||
|
|
||||||
|
- 已使用专用 migrator 实际执行 `022_store_catalog_cache.sql`;三张门店类目缓存表均已创建,并实际检查了强制 RLS、`product_platform_profiles` 的门店类目外键及旧 `platform_product_category_id` 列的兼容保留。
|
||||||
|
- 门店凭据验证完成后,树同步调度现在以已锁定的门店行为临界区:已有 `synced_at` 缓存直接跳过;同一凭据的并发验证或重复事件只保留一条 queued/running 同步;凭据替换会取消旧任务并为新密钥安排新任务。队列载荷始终仅含门店、密钥记录、operation 与平台 ID,不含 API Key 明文。
|
||||||
|
- 新增 `apps/api/test/worker-catalog-scheduler.e2e.test.ts`,在真实 PostgreSQL 上覆盖空缓存的并发互斥、缓存跳过、无明文密钥 outbox 载荷,以及 BullMQ 的 5 次指数退避配置。现有 `product-editor.e2e.test.ts` 同时修复了 Ozon 来源测试数据必须将 `source_kind` 设为 `platform` 的约束问题。
|
||||||
|
- 已执行 contracts、API(含测试)、Worker TypeScript 检查,`product-editor.e2e.test.ts` 与 Worker 调度测试共 10 项通过,Web production build 通过。
|
||||||
|
- 真实浏览器回归:重新验证一个已有 Ozon 门店后,类目树同步 operation 成功,写入 568 个门店级类目;编辑器 Cascader 能加载完整树。再次验证同一门店后,类目树 operation 仍只有 1 条,确认有效缓存不会触发重复同步。已采集样本的公开来源层级与 Seller API 树不完全一致时,不会错误自动落类目,仍保留人工选择或 AI 推荐入口;这符合“仅精确路径匹配才自动回填”的保护规则。
|
||||||
|
|
||||||
|
M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目命中率,需先确认是否可使用稳定的 Ozon 类目 ID 映射,不能用名称模糊匹配替代。
|
||||||
|
|
||||||
|
### 1.31 开发进度(2026-09-11):M3.6 候选素材选择与物化
|
||||||
|
|
||||||
|
- 新增迁移 `023_product_listing_media.sql`:最终图片归属 `SKU × 平台 listing`,而不是来源商品或门店全局;复合外键强制同一工作空间、商品、profile 与 SKU 的归属。每个 listing 最多 15 张,素材和排序均不可重复,表启用强制 RLS。
|
||||||
|
- 编辑器快照返回每个 SKU 已选的 Atlas 图片素材;草稿请求的 `media_asset_ids` 是最终顺序。服务端只接受当前采集快照中已经成功转存、且关系为该 SKU 或商品通用的候选。旧的已选资产在复采后仍可保留,不会因来源快照更新而丢失。
|
||||||
|
- 保存时二次校验 JPG/PNG/WebP、20MB、10000px、主图短边至少 700px、最多 15 张及去重。候选 `source_url` 只用于页面预览,发布事实永远是 `media_asset_id`,调用方无法引用任意工作空间素材。
|
||||||
|
- Web 在展开 SKU 后按来源关系显示候选图:`pending_selection` 显示“转存后选择”,已转存候选显示“选择 / 已选择”,选中后主图立即跟随第一个素材。复制 SKU 默认不复制来源绑定图片,避免把原 SKU 的专属候选误带到新 SKU。
|
||||||
|
- 已实际执行 `023`;`product-editor.e2e.test.ts` 8 项通过(含候选→正式素材顺序保存和读回);contracts、API、Web TypeScript 检查与 Web production build 通过。ego-browser 验证了真实编辑器的 SKU 展开、候选显示、选择状态和主图即时回显。浏览器手工草稿因未填类目、品牌、型号和包装规格被既有表单校验阻止提交;持久化路径由集成测试完成验证。
|
||||||
|
|
||||||
|
下一步:在真实采集来源中回归首次按需物化、Worker 下载失败重试与已选图片保存;然后实施定价确认。来源域限流、429/503 延迟重试和跨候选 URL 下载去重属于 Worker 下载器的后续可靠性加固,尚未在本 M3.6 变更中完成。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ export type ProductEditorSnapshot = {
|
|||||||
list_price: string | null;
|
list_price: string | null;
|
||||||
currency_code: string | null;
|
currency_code: string | null;
|
||||||
};
|
};
|
||||||
|
/** Ordered final image assets for this platform listing. Source URLs
|
||||||
|
* are preview-only evidence and are never used for publishing. */
|
||||||
|
media: Array<{
|
||||||
|
asset_id: string;
|
||||||
|
content_type: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
byte_size: number;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
source_url: string | null;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
attributes: Array<{
|
attributes: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -391,6 +401,14 @@ export const saveProductEditorDraftRequestSchema = z
|
|||||||
physical_spec: variantPhysicalSpec,
|
physical_spec: variantPhysicalSpec,
|
||||||
package_spec: variantPackageSpec,
|
package_spec: variantPackageSpec,
|
||||||
offer_id: nullableText(500),
|
offer_id: nullableText(500),
|
||||||
|
media_asset_ids: z
|
||||||
|
.array(uuid)
|
||||||
|
.max(15)
|
||||||
|
.refine(
|
||||||
|
(ids) => new Set(ids).size === ids.length,
|
||||||
|
'SKU 图片不能重复',
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((variant, context) => {
|
.superRefine((variant, context) => {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- A platform listing owns the final, ordered image set. Keeping product_id,
|
||||||
|
-- profile_id and variant_id here makes the tenant and aggregate boundaries
|
||||||
|
-- enforceable even when a UUID is supplied by a client.
|
||||||
|
ALTER TABLE product_variant_platform_listings
|
||||||
|
ADD CONSTRAINT uq_variant_listing_aggregate
|
||||||
|
UNIQUE (workspace_id, id, product_id, profile_id, product_variant_id);
|
||||||
|
|
||||||
|
CREATE TABLE product_variant_listing_media (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
product_id UUID NOT NULL,
|
||||||
|
profile_id UUID NOT NULL,
|
||||||
|
listing_id UUID NOT NULL,
|
||||||
|
product_variant_id UUID NOT NULL,
|
||||||
|
asset_id UUID NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL CHECK (sort_order >= 0 AND sort_order < 15),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, listing_id, product_id, profile_id, product_variant_id)
|
||||||
|
REFERENCES product_variant_platform_listings(workspace_id, id, product_id, profile_id, product_variant_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, asset_id)
|
||||||
|
REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, listing_id, sort_order),
|
||||||
|
UNIQUE (workspace_id, listing_id, asset_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_product_variant_listing_media_profile
|
||||||
|
ON product_variant_listing_media (workspace_id, profile_id, product_variant_id, sort_order);
|
||||||
|
|
||||||
|
ALTER TABLE product_variant_listing_media ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE product_variant_listing_media FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY workspace_isolation ON product_variant_listing_media
|
||||||
|
USING (workspace_id = app_current_workspace_id())
|
||||||
|
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||||
Reference in New Issue
Block a user