diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index da88317..3ad8f4a 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -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>(); + 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>(); + 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: { diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 446f14a..782296b 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -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]); + }); }); diff --git a/apps/api/test/worker-catalog-scheduler.e2e.test.ts b/apps/api/test/worker-catalog-scheduler.e2e.test.ts new file mode 100644 index 0000000..46f4a0a --- /dev/null +++ b/apps/api/test/worker-catalog-scheduler.e2e.test.ts @@ -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); + 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 }> = []; + 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) => { + 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, + }, + }, + ]); + }); +}); diff --git a/apps/api/tsconfig.test.json b/apps/api/tsconfig.test.json index 7c54ada..3a4a781 100644 --- a/apps/api/tsconfig.test.json +++ b/apps/api/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": ".", + "rootDir": "..", "noEmit": true }, "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index bff14e9..6694cf5 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -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%; diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 0a74183..58aba22 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -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(); @@ -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(null); const [collectionRecord, setCollectionRecord] = useState(null); const [sourceDraft, setSourceDraft] = useState(null); @@ -450,6 +463,7 @@ const ProductEditPage = () => { const [catalogSyncing, setCatalogSyncing] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [materializingTransferId, setMaterializingTransferId] = useState(null); const [dirty, setDirty] = useState(false); const [expandedVariantKeys, setExpandedVariantKeys] = useState>(() => 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 ; if (loading && !snapshot) @@ -914,6 +951,9 @@ const ProductEditPage = () => { > { status: null, }, offer_id: null, + media_asset_ids: [], } satisfies VariantValues); return (
@@ -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 = () => { > +
{index + 1} @@ -1420,10 +1492,10 @@ const ProductEditPage = () => {
主图
- {imageUrls[0] ? ( + {selectedImages[0] ? ( SKU 主图 ) : ( @@ -1500,38 +1572,67 @@ const ProductEditPage = () => { SKU 图片 - {imageUrls.length ? ( + {imageCandidates.length ? (
-
- SKU 主图 -
-
- {imageUrls - .slice(1, 5) - .map( - ( - url, - imageIndex - ) => ( -
{ + const selectable = + candidate.status === 'succeeded' && + !!candidate.asset_id; + const selected = + selectable && + selectedAssetIds.includes( + candidate.asset_id! + ); + return ( +
+ 采集候选图片 + {selectable ? ( +
- ) - )} -
+ {selected ? '已选择' : '选择'} + + ) : ( + + )} +
+ ); + })}
) : (
@@ -1679,7 +1780,7 @@ const ProductEditPage = () => { />
- + { - 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 ?? '', + }); }); } diff --git a/apps/worker/src/store-category-tree-scheduler.ts b/apps/worker/src/store-category-tree-scheduler.ts new file mode 100644 index 0000000..e94dae9 --- /dev/null +++ b/apps/worker/src/store-category-tree-scheduler.ts @@ -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 { + 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 }; +} diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 0ecac3a..a0a501a 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -14,9 +14,9 @@ 当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 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 检查。 @@ -255,7 +255,25 @@ 3. 启动本地 PostgreSQL 与 Redis,执行 `022_store_catalog_cache.sql`,运行 API 集成测试并检查 RLS、外键和历史 profile 兼容性。 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. 已确认产品决策 diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts index 58f3e88..bbfe695 100644 --- a/packages/contracts/src/product-editor.ts +++ b/packages/contracts/src/product-editor.ts @@ -147,6 +147,16 @@ export type ProductEditorSnapshot = { list_price: 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<{ id: string; @@ -391,6 +401,14 @@ export const saveProductEditorDraftRequestSchema = z physical_spec: variantPhysicalSpec, package_spec: variantPackageSpec, offer_id: nullableText(500), + media_asset_ids: z + .array(uuid) + .max(15) + .refine( + (ids) => new Set(ids).size === ids.length, + 'SKU 图片不能重复', + ) + .default([]), }) .strict() .superRefine((variant, context) => { diff --git a/packages/database/migrations/023_product_listing_media.sql b/packages/database/migrations/023_product_listing_media.sql new file mode 100644 index 0000000..b218205 --- /dev/null +++ b/packages/database/migrations/023_product_listing_media.sql @@ -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());