fix(edit): 类目问题修改

This commit is contained in:
R524809
2026-09-11 10:36:25 +08:00
parent 05a00d46b9
commit 2c4e94ab3d
11 changed files with 869 additions and 79 deletions
@@ -251,6 +251,43 @@ export class ProductEditorService {
ORDER BY is_default DESC, name, id`,
[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<{
total: string;
imported: string;
@@ -336,6 +373,7 @@ export class ProductEditorService {
list_price: variant.list_price,
currency_code: variant.currency_code,
},
media: mediaByListing.get(variant.listing_id) ?? [],
}));
return {
collection_item_id: product.collection_item_id,
@@ -845,10 +883,18 @@ export class ProductEditorService {
id: string;
}> = [];
const retainedIds: string[] = [];
const variantMedia: Array<{
variantId: string;
assetIds: string[];
}> = [];
for (let index = 0; index < input.variants.length; index += 1) {
const variant = input.variants[index];
if (variant.id) {
retainedIds.push(variant.id);
variantMedia.push({
variantId: variant.id,
assetIds: variant.media_asset_ids,
});
await transaction.query(
`UPDATE atlas.product_variants
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
@@ -959,6 +1005,10 @@ export class ProductEditorService {
);
const id = inserted.rows[0].id;
retainedIds.push(id);
variantMedia.push({
variantId: id,
assetIds: variant.media_asset_ids,
});
createdVariants.push({
client_key: variant.client_key!,
id,
@@ -1004,6 +1054,11 @@ export class ProductEditorService {
attributes: input.attributes,
retainedVariantIds: retainedIds,
});
await this.persistListingMedia(transaction, {
product,
profile,
variants: variantMedia,
});
return {
status: 200,
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(
transaction: DatabaseTransaction,
input: {
+101 -1
View File
@@ -126,6 +126,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_variant_listing_media WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
[workspaceId],
@@ -157,6 +161,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
workspaceId,
]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
workspaceId,
]);
});
await database.query(
'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 tx.query(
`UPDATE atlas.collection_items
SET source_platform_id = $1,
SET source_kind = 'platform', source_platform_id = $1,
normalized_payload = $2::jsonb
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]);
});
});
@@ -0,0 +1,199 @@
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 { 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 调度集成测试', () => {
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 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"rootDir": "..",
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
@@ -147,7 +147,7 @@
}
.package-dimensions-controls {
display: grid;
grid-template-columns: 120px 22px 120px 22px 120px auto;
grid-template-columns: 130px 22px 130px 22px 130px auto;
align-items: center;
gap: 7px;
width: max-content;
@@ -163,6 +163,29 @@
font-size: 16px;
line-height: 40px;
}
.editor-standard-cascader .ant-select-selector {
min-height: 40px !important;
font-size: 16px !important;
}
.editor-standard-cascader .ant-select-selection-placeholder,
.editor-standard-cascader .ant-select-selection-item {
line-height: 38px !important;
font-size: 16px !important;
}
.editor-category-popup .ant-cascader-menu {
max-height: 320px;
}
.editor-category-popup .ant-cascader-menu-item {
display: flex;
min-height: 40px;
align-items: center;
font-size: 16px;
line-height: 24px;
}
.supply-link-control .ant-btn {
height: 40px;
min-width: 40px;
}
.category-row {
display: flex;
align-items: center;
@@ -411,6 +434,7 @@
gap: 10px;
}
.sku-media-item {
position: relative;
min-width: 0;
aspect-ratio: 3 / 4;
overflow: hidden;
@@ -418,6 +442,15 @@
border-radius: 8px;
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 img {
width: 100%;
+140 -39
View File
@@ -95,6 +95,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
physical_spec: variant.physical_spec,
package_spec: variant.package_spec,
offer_id: variant.listing.offer_id,
media_asset_ids: variant.media.map((media) => media.asset_id),
})),
};
}
@@ -110,7 +111,7 @@ const wait = (milliseconds: number) =>
type CategoryTreeOption = {
value: string;
label: string;
disabled?: boolean;
isLeaf?: boolean;
children?: CategoryTreeOption[];
};
@@ -121,7 +122,6 @@ function categoryTree(options: ProductEditorCategoryOption[]) {
nodes.set(category.id, {
value: category.id,
label: category.name,
...(category.is_active ? {} : { disabled: true }),
children: [],
});
}
@@ -134,6 +134,13 @@ function categoryTree(options: ProductEditorCategoryOption[]) {
if (parent) parent.children!.push(node);
else roots.push(node);
}
// Cascader navigation nodes must never be disabled: disabled folders cannot
// be opened. `changeOnSelect` remains false (the default), so the component
// only commits a value for a leaf node.
for (const category of options) {
const node = nodes.get(category.id)!;
node.isLeaf = !node.children?.length;
}
for (const category of options) {
const path: string[] = [];
const visited = new Set<string>();
@@ -320,7 +327,10 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
return result;
}
function variantImageUrls(recordValue: CollectionItemInspection | null, variant: SnapshotVariant) {
function variantImageCandidates(
recordValue: CollectionItemInspection | null,
variant: SnapshotVariant,
) {
if (!recordValue) return [];
const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item]));
return recordValue.asset_transfers
@@ -332,8 +342,10 @@ function variantImageUrls(recordValue: CollectionItemInspection | null, variant:
variant.source_variant_key
);
})
.map((transfer) => transfer.source_url)
.filter((url, index, urls) => !!url && urls.indexOf(url) === index);
.filter(
(transfer, index, transfers) =>
transfers.findIndex((item) => item.id === transfer.id) === index,
);
}
function PackageFields({ name }: { name: NamePath }) {
@@ -433,6 +445,7 @@ const ProductEditPage = () => {
const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form);
const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form);
const draftAttributes = Form.useWatch('attributes', form) ?? [];
const draftVariants = Form.useWatch('variants', form) ?? [];
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
const [sourceDraft, setSourceDraft] = useState<ProductAiSourceMaterial | null>(null);
@@ -450,6 +463,7 @@ const ProductEditPage = () => {
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
const blocker = useBlocker(dirty);
@@ -811,6 +825,29 @@ const ProductEditPage = () => {
'字典值已同步',
).then(() => void loadDictionaryValues(dictionaryId, query));
};
const materializeCandidate = async (transferId: string) => {
if (!snapshot) return;
setMaterializingTransferId(transferId);
try {
const queued = await collectionApi.materializeAssets(snapshot.collection_item_id, {
transfer_ids: [transferId],
});
const completed = await waitForCatalogOperation(queued.operation.id);
const refreshed = await collectionApi.inspection(snapshot.collection_item_id);
setCollectionRecord(refreshed);
if (completed?.status === 'succeeded') {
message.success('素材已转存,可选择并随草稿保存');
} else if (completed?.status === 'failed') {
message.error(completed.error?.message ?? '素材转存失败,请稍后重试');
} else {
message.info('素材正在转存,稍后重新展开此 SKU 即可选择');
}
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setMaterializingTransferId(null);
}
};
if (!currentPlatform) return <Alert type="warning" message="正在读取平台上下文" />;
if (loading && !snapshot)
@@ -914,6 +951,9 @@ const ProductEditPage = () => {
>
<Cascader
className="editor-standard-cascader"
classNames={{
popup: { root: 'editor-category-popup' },
}}
showSearch
placeholder="选择当前店铺可发布的产品类目"
options={categories.options}
@@ -1298,6 +1338,7 @@ const ProductEditPage = () => {
status: null,
},
offer_id: null,
media_asset_ids: [],
} satisfies VariantValues);
return (
<div className="sku-card-list">
@@ -1315,9 +1356,33 @@ const ProductEditPage = () => {
field.name,
]) as VariantValues;
const expanded = expandedVariantKeys.has(field.key);
const imageUrls = original
? variantImageUrls(collectionRecord, original)
const imageCandidates = original
? variantImageCandidates(collectionRecord, original)
: [];
const selectedAssetIds =
draftVariants[field.name]?.media_asset_ids ?? [];
const selectedImages = selectedAssetIds
.map(
(assetId) =>
imageCandidates.find(
(candidate) =>
candidate.asset_id === assetId
)?.source_url ??
original?.media.find(
(media) => media.asset_id === assetId
)?.source_url
)
.filter((url): url is string => !!url);
const toggleImage = (assetId: string) => {
const next = selectedAssetIds.includes(assetId)
? selectedAssetIds.filter((id) => id !== assetId)
: [...selectedAssetIds, assetId];
form.setFieldValue(
['variants', field.name, 'media_asset_ids'],
next
);
setDirty(true);
};
const copyVariant = () =>
add({
...variantValue,
@@ -1328,6 +1393,7 @@ const ProductEditPage = () => {
? `${variantValue.display_name} 副本`
: 'SKU 副本',
offer_id: null,
media_asset_ids: [],
});
const toggle = () =>
setExpandedVariantKeys((previous) => {
@@ -1357,6 +1423,12 @@ const ProductEditPage = () => {
>
<Input />
</Form.Item>
<Form.Item
name={[field.name, 'media_asset_ids']}
hidden
>
<Input />
</Form.Item>
<div className="sku-card-top">
<span className="sku-card-index">
{index + 1}
@@ -1420,10 +1492,10 @@ const ProductEditPage = () => {
<div className="sku-card-field sku-image-field">
<span>主图</span>
<div className="sku-main-thumbnail">
{imageUrls[0] ? (
{selectedImages[0] ? (
<Image
preview={false}
src={imageUrls[0]}
src={selectedImages[0]}
alt="SKU 主图"
/>
) : (
@@ -1500,38 +1572,67 @@ const ProductEditPage = () => {
<Typography.Title level={5}>
SKU 图片
</Typography.Title>
{imageUrls.length ? (
{imageCandidates.length ? (
<div className="sku-media-grid">
<div className="sku-media-item main">
<Image
src={imageUrls[0]}
alt="SKU 主图"
/>
</div>
<div className="sku-media-secondary">
{imageUrls
.slice(1, 5)
.map(
(
url,
imageIndex
) => (
<div
className="sku-media-item"
key={
url
{imageCandidates.map((candidate) => {
const selectable =
candidate.status === 'succeeded' &&
!!candidate.asset_id;
const selected =
selectable &&
selectedAssetIds.includes(
candidate.asset_id!
);
return (
<div
className={`sku-media-item ${selected ? 'selected' : ''}`}
key={candidate.id}
>
<Image
src={candidate.source_url}
alt="采集候选图片"
/>
{selectable ? (
<Button
size="small"
type={selected ? 'primary' : 'default'}
onClick={() =>
toggleImage(
candidate.asset_id!
)
}
>
<Image
src={
url
}
alt={`SKU 图片 ${imageIndex + 2}`}
/>
</div>
)
)}
</div>
{selected ? '已选择' : '选择'}
</Button>
) : (
<Button
size="small"
loading={
materializingTransferId ===
candidate.id
}
disabled={
candidate.status === 'queued' ||
candidate.status === 'running' ||
candidate.status === 'failed'
}
onClick={() =>
void materializeCandidate(
candidate.id
)
}
>
{candidate.status ===
'pending_selection'
? '转存后选择'
: candidate.status === 'failed'
? '转存失败'
: '转存中'}
</Button>
)}
</div>
);
})}
</div>
) : (
<div className="sku-media-empty">
@@ -1679,7 +1780,7 @@ const ProductEditPage = () => {
/>
<div className="editor-form-grid">
<Form.Item label="货源链接" className="span-two">
<Space.Compact block>
<Space.Compact block className="supply-link-control">
<Form.Item
name={['common', 'purchase_url']}
noStyle
+8 -34
View File
@@ -18,6 +18,7 @@ import {
syncOzonCatalog,
type CatalogSyncScope,
} from './ozon-catalog-sync.js';
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.js';
const connection = new Redis(workerConfig.redisUrl, {
maxRetriesPerRequest: null,
@@ -161,8 +162,8 @@ async function completeVerification(
rejectedStatus?: 401 | 403,
) {
await database.workspaceTransaction(job.workspaceId, async (tx) => {
const current = await tx.query<{ id: string }>(
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
const current = await tx.query<{ id: string; platform_id: string }>(
`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
AND secret.revoked_at IS NULL FOR UPDATE`,
[job.payload.store_id, job.payload.secret_id ?? ''],
@@ -216,38 +217,11 @@ async function completeVerification(
],
);
if (!valid) return;
const catalogOperation = await tx.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`,
[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,
],
);
await scheduleStoreCategoryTreeSync(tx, {
storeId: job.payload.store_id,
platformId: current.rows[0].platform_id,
secretId: job.payload.secret_id ?? '',
});
});
}
@@ -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 };
}