diff --git a/apps/api/test/product-publish.e2e.test.ts b/apps/api/test/product-publish.e2e.test.ts index b904a63..8215153 100644 --- a/apps/api/test/product-publish.e2e.test.ts +++ b/apps/api/test/product-publish.e2e.test.ts @@ -45,7 +45,7 @@ async function request( }); return { status: res.status, body: (await res.json()) as any }; } -async function fixture(count = 2) { +async function fixture(count = 2, mediaCount = 1) { const created = await request( '/collection-items', 'POST', @@ -106,16 +106,18 @@ async function fixture(count = 2) { [profile, `TEST-${randomUUID()}`, quote, variant], ) ).rows[0].id; - const asset = ( - await tx.query<{ id: string }>( - `INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`, - [workspace, randomUUID(), randomUUID()], - ) - ).rows[0].id; - await tx.query( - `INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,0)`, - [workspace, product, profile, listing, variant, asset], - ); + for (let mediaIndex = 0; mediaIndex < mediaCount; mediaIndex++) { + const asset = ( + await tx.query<{ id: string }>( + `INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`, + [workspace, randomUUID(), randomUUID()], + ) + ).rows[0].id; + await tx.query( + `INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7)`, + [workspace, product, profile, listing, variant, asset, mediaIndex], + ); + } } }); return { profile, product, ids }; @@ -666,7 +668,9 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { }); assert.equal(job.items[0].status, 'polling'); const offers = job.items.map((i) => i.offer_id); - job = await run(job, async () => ({ + job = await run(job, async (_c, _k, path) => path === '/v2/product/pictures/info' ? ({ + items: [{ product_id: 101, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }], + }) : ({ result: { items: [ { @@ -735,12 +739,70 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { }); assert.equal(job.status, 'reconciling'); assert.equal(imports, 1); - job = await run(job, async () => ({ - items: [{ offer_id: job.items[0].offer_id, id: 999 }], + job = await run(job, async (_c, _k, path) => ({ + items: path === '/v2/product/pictures/info' + ? [{ product_id: 999, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }] + : [{ offer_id: job.items[0].offer_id, id: 999 }], })); assert.equal(job.status, 'succeeded'); assert.equal(imports, 1); }); + it('导入成功后等待图片结果,缺图警告保留到 HTTP 结果与 SKU,并且不重复导入', async () => { + const f = await fixture(1, 3); + let job = (await publish(f)).body.data.publish_job as ProductPublishJob; + let imports = 0; + job = await run(job, async (_c, _k, path) => { + if (path === '/v3/product/info/list') return { items: [] }; + assert.equal(path, '/v3/product/import'); + imports++; + return { result: { task_id: 456 } }; + }); + const imported = { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 102, + status: 'imported', errors: [] }] } }; + job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? imported + : { items: [{ product_id: 102, primary_photo: [], photo: [], errors: [] }] }); + assert.equal(job.status, 'running'); + assert.equal(job.items[0].status, 'polling'); + job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? { + result: { items: [{ ...imported.result.items[0], errors: [{ code: 'primary_image_load_failed', + message: 'https://private.example.test/primary.png?token=hidden' }] }] }, + } : { items: [{ product_id: 102, primary_photo: ['https://images.example.test/second.png'], photo: [], + errors: [{ url: 'https://private.example.test/primary.png?token=hidden', message: 'request process failed: unknown error host unreachable' }, + { url: 'https://private.example.test/third.png?token=hidden', message: 'failed https://private.example.test/third.png?token=hidden' }] }] }); + assert.equal(job.status, 'succeeded'); + assert.equal(job.items[0].status, 'imported'); + assert.equal(job.items[0].platform_product_id, '102'); + assert.ok(job.items[0].errors.some((error) => error.code === 'OZON_IMAGES_INCOMPLETE' && error.message.includes('1/3'))); + assert.ok(job.items[0].errors.some((error) => error.code === 'primary_image_load_failed')); + assert.ok(job.items[0].errors.some((error) => error.message.includes('全球加速'))); + assert.ok(!JSON.stringify(job).includes('token=')); + assert.ok(!JSON.stringify(job).includes('private.example.test')); + const listing = (await db.workspaceTransaction(workspace, tx => tx.query( + 'SELECT status,last_error FROM atlas.product_variant_platform_listings WHERE profile_id=$1', [f.profile], + ))).rows[0]; + assert.equal(listing.status, 'imported'); + assert.deepEqual(listing.last_error, job.items[0].errors); + await run(job, async () => { imports++; throw new Error('终态不得重复上报'); }); + assert.equal(imports, 1); + }); + it('图片全部到齐才报告无警告成功,长时间未齐时以已导入并提示缺图结束', async () => { + for (const completePictures of [true, false]) { + const f = await fixture(1, 3); + let job = (await publish(f)).body.data.publish_job as ProductPublishJob; + job = await run(job, async (_c, _k, path) => path === '/v3/product/info/list' + ? { items: [] } : { result: { task_id: 457 } }); + if (!completePictures) await db.workspaceTransaction(workspace, tx => tx.query( + 'UPDATE atlas.product_publish_jobs SET poll_count=121 WHERE id=$1', [job.id], + )); + job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' + ? { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 103, status: 'imported', errors: [] }] } } + : { items: [{ product_id: 103, primary_photo: completePictures ? ['https://images.example.test/1.png'] : [], + photo: completePictures ? ['https://images.example.test/2.png', 'https://images.example.test/3.png'] : [], errors: [] }] }); + assert.equal(job.status, 'succeeded'); + assert.equal(job.items[0].status, 'imported'); + assert.equal(job.items[0].errors.length, completePictures ? 0 : 1); + } + }); it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => { const f = await fixture(2); const created = (await publish(f)).body.data diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index c588a86..89d5f19 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -42,6 +42,7 @@ import { ProductImageSuiteDrawer } from './components/ProductImageSuiteDrawer'; import type { SkuEditorImage } from './components/SkuImageCard'; import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay'; import { composeSkuOfferId, lockedSkuOfferIds, skuOfferSuffix, syncSkuOfferIds } from './skuOfferId'; +import { useDraftSaveShortcut } from './useDraftSaveShortcut'; import { ProductCopyComparisonModal, SkuCopyAiModal, @@ -498,6 +499,8 @@ const ProductEditPage = () => { const [catalogRefresh, setCatalogRefresh] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const savingLock = useRef(false); + useDraftSaveShortcut(!!snapshot && !!currentPlatform && !loading && !saving, () => form.submit()); const [savingSourceDescription, setSavingSourceDescription] = useState(false); const [dirty, setDirty] = useState(false); const [publishStatusContainer, setPublishStatusContainer] = useState(null); @@ -902,7 +905,7 @@ const ProductEditPage = () => { /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { values = { ...values, ...form.getFieldsValue(true) }; - if (!productId || !currentPlatform || !snapshot) return false; + if (!productId || !currentPlatform || !snapshot || savingLock.current) return false; const draft = normalizedValues({ ...values, ai_source_material: sourceDraft ?? snapshot.source_material, @@ -922,6 +925,7 @@ const ProductEditPage = () => { message.error('完整货号(含型号前缀)最多 50 字符,请缩短型号或 SKU 后缀'); return false; } + savingLock.current = true; setSaving(true); try { await productEditorApi.save( @@ -959,6 +963,7 @@ const ProductEditPage = () => { } else message.error(apiErrorMessage(error)); return false; } finally { + savingLock.current = false; setSaving(false); } }; @@ -1699,6 +1704,8 @@ const ProductEditPage = () => { htmlType="submit" loading={saving} icon={} + title="保存草稿(Command / Ctrl + S)" + aria-keyshortcuts="Meta+S Control+S" > 保存草稿 diff --git a/apps/web/src/pages/products/components/ProductPublishActions.tsx b/apps/web/src/pages/products/components/ProductPublishActions.tsx index e30f171..39a2ef0 100644 --- a/apps/web/src/pages/products/components/ProductPublishActions.tsx +++ b/apps/web/src/pages/products/components/ProductPublishActions.tsx @@ -216,8 +216,10 @@ export function ProductPublishActions({ ), ] : []; + const hasImportWarnings = job?.items.some((item) => item.status === 'imported' && item.errors.length > 0); + const resultLabel = job?.status === 'succeeded' && hasImportWarnings ? '已导入,有提示' : job ? jobLabels[job.status] : ''; const statusTone = job?.status === 'succeeded' - ? 'success' + ? hasImportWarnings ? 'warning' : 'success' : job?.status === 'failed' ? 'error' : job?.status === 'partially_succeeded' || job?.status === 'reconciling' @@ -243,14 +245,14 @@ export function ProductPublishActions({ type="button" className={`editor-publish-status editor-publish-status--${statusTone}`} onClick={showJob} - aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`} + aria-label={`查看发布任务详情:${resultLabel},${job.item_count} 个 SKU`} > - 发布任务 · {jobLabels[job.status]} + 发布任务 · {resultLabel} {job.item_count} 个 SKU @@ -350,18 +352,20 @@ export function ProductPublishActions({ type={ job.status === 'failed' ? 'error' - : job.status === 'reconciling' + : job.status === 'reconciling' || job.status === 'partially_succeeded' || hasImportWarnings ? 'warning' : terminal(job) ? 'success' : 'info' } showIcon - title={`${jobLabels[job.status]} · ${job.item_count} 个 SKU`} + title={`${resultLabel} · ${job.item_count} 个 SKU`} description={ job.status === 'reconciling' ? '平台受理结果尚未确认,系统将继续对账,请勿重复导入。' - : '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。' + : hasImportWarnings + ? '商品已导入,部分项目仍有提示,请按下方结果说明检查图片或商品资料。' + : '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。' } style={{ marginBottom: 16 }} /> @@ -378,13 +382,13 @@ export function ProductPublishActions({ - {itemLabels[i.status]} + {i.status === 'imported' && i.errors.length ? '已导入(有提示)' : itemLabels[i.status]} ), }, diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index b2f2a06..ede0beb 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -694,20 +694,11 @@ export function ProductVariantSection({ [image], collectionRecord?.id ?? null ); - const derivative = await createWatermarkedImage( + return createWatermarkedImage( assetId, template.id, snapshot.product.id ); - const asset = derivative.output_asset; - if (!asset) throw new Error('水印处理结果不可用'); - return { - id: `asset:${asset.id}`, - url: asset.preview_url, - label: `${image.label} · ${template.name}`, - origin: 'derivative', - assetId: asset.id, - }; }} onResolveAsset={async (image) => { const [assetId] = await resolveSkuImageSelection( diff --git a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx index 6022376..a4e5aed 100644 --- a/apps/web/src/pages/products/components/SkuImageEditorModal.tsx +++ b/apps/web/src/pages/products/components/SkuImageEditorModal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { App as AntdApp, Alert, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd'; import type { MenuProps } from 'antd'; import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons'; -import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts'; +import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkDerivative, WatermarkTemplate } from '@atlas/contracts'; import { apiErrorMessage } from '@/services/api'; import { mediaApi } from '@/services/media'; import { watermarksApi } from '@/services/watermarks'; @@ -29,7 +29,7 @@ type Props = { onConfirm: (images: SkuEditorImage[]) => Promise; onUploadFile: (file: File) => Promise; onMakeThreeFour: (image: SkuEditorImage) => Promise; - onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise; + onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise; onResolveAsset: (image: SkuEditorImage) => Promise; onImageDeleted?: (image: SkuEditorImage) => void; onDownloadImage: (image: SkuEditorImage) => Promise; @@ -82,7 +82,8 @@ export function SkuImageEditorModal({ const [processingThreeFour, setProcessingThreeFour] = useState(false); const [processingWatermark, setProcessingWatermark] = useState(false); const [watermarkTargets, setWatermarkTargets] = useState([]); - const [watermarkUndo, setWatermarkUndo] = useState>({}); + const [watermarkUndo, setWatermarkUndo] = useState>({}); + const [watermarkReplacements, setWatermarkReplacements] = useState>({}); const [aiTarget, setAiTarget] = useState(null); const [aiImages, setAiImages] = useState([]); const [processedRecords, setProcessedRecords] = useState([]); @@ -97,8 +98,11 @@ export function SkuImageEditorModal({ assetId: task.output_asset.id, }] : [])); }, [productId]); + const processedRequestVersion = useRef(0); const refreshProcessedImages = useCallback(async () => { + const version = ++processedRequestVersion.current; const records = await mediaApi.listProductImageDerivatives(productId); + if (version !== processedRequestVersion.current) return; setProcessedRecords(records); setProcessedError(false); }, [productId]); @@ -136,6 +140,9 @@ export function SkuImageEditorModal({ setPreview(null); setWatermarkTargets([]); setWatermarkUndo({}); + setWatermarkReplacements({}); + setProcessedRecords([]); + setProcessedError(false); setAiTarget(null); return () => { if (editorSession.current === session) { @@ -143,6 +150,7 @@ export function SkuImageEditorModal({ deleteDialog.current?.destroy(); deleteDialog.current = null; defaultWatermarkRequest.current = null; + processedRequestVersion.current += 1; } }; }, [open, productId, skuKey]); @@ -276,45 +284,60 @@ export function SkuImageEditorModal({ if (processingLock.current || !targets.length) return; processingLock.current = true; setProcessingWatermark(true); + const session = editorSession.current; let succeeded = 0; - let selectedCount = images.length; const selectedAssetIds = new Set(images.flatMap((image) => image.assetId ? [image.assetId] : [])); try { for (const image of targets) { - const originalSelected = images.some((item) => sameImage(item, image)); - if (!originalSelected && selectedCount >= MAX_IMAGES) { - message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`); - break; - } try { - const output = await onApplyWatermark(image, template); + const derivative = await onApplyWatermark(image, template); + if (session !== editorSession.current) return; + const asset = derivative.output_asset; + if (!asset) throw new Error('水印处理结果不可用'); + const output: SkuEditorImage = { + id: `asset:${asset.id}`, url: asset.preview_url, + label: `${image.label} · ${template.name}`, origin: 'derivative', assetId: asset.id, + }; + // 成功产物立即归档,较早发出的列表请求不得覆盖本次结果。 + processedRequestVersion.current += 1; + setProcessedRecords((current) => [{ + id: derivative.id, kind: 'watermark', source_asset_id: derivative.source_asset_id, + created_at: asset.created_at, output_asset: asset, + }, ...current.filter((record) => record.id !== derivative.id)]); + setProcessedError(false); + setWatermarkReplacements((current) => { + const sourceId = Object.keys(current).find((id) => sameImage(current[id], image)) ?? image.id; + return { ...current, [sourceId]: output }; + }); + setWatermarkUndo((current) => ({ + ...current, [output.id]: current[image.id] ?? image, + })); + succeeded += 1; if (output.assetId && selectedAssetIds.has(output.assetId) && output.assetId !== image.assetId) { message.info(`${image.label} 的水印图片已在已选区,保留原图`); continue; } - if (originalSelected && image.assetId) selectedAssetIds.delete(image.assetId); + const originalSelected = images.some((item) => sameImage(item, image)); + // 候选图处理只更新当前卡片;已选图仅替换原位置,不自动增加选图。 + if (!originalSelected) continue; + if (image.assetId) selectedAssetIds.delete(image.assetId); if (output.assetId) selectedAssetIds.add(output.assetId); - if (!originalSelected) selectedCount += 1; setImages((current) => { const index = current.findIndex((item) => sameImage(item, image)); - if (index < 0) return [...current, output].slice(0, MAX_IMAGES); + if (index < 0) return current; const next = [...current]; next[index] = output; return next; }); setMarkedIds((current) => current.map((id) => id === image.id ? output.id : id)); - setWatermarkUndo((current) => ({ - ...current, - [output.id]: image.id in current ? current[image.id] : originalSelected ? image : null, - })); - succeeded += 1; } catch (error) { + if (session !== editorSession.current) return; message.error(`${image.label} 添加水印失败:${apiErrorMessage(error)}`); } } if (succeeded) { - message.success(`已生成 ${succeeded} 张水印图片,请确认后保存草稿`); + message.success(`已生成 ${succeeded} 张水印图片,已存入图片处理记录`); void refreshProcessedImages().catch(() => setProcessedError(true)); } else throw new Error('没有图片成功添加水印,请检查模板和素材后重试'); @@ -351,12 +374,11 @@ export function SkuImageEditorModal({ const undoWatermark = (image: SkuEditorImage) => { if (processingLock.current || !(image.id in watermarkUndo)) return; const original = watermarkUndo[image.id]; - setImages((current) => original - ? current.map((item) => item.id === image.id ? original : item) - : current.filter((item) => item.id !== image.id)); - setMarkedIds((current) => original - ? current.map((id) => id === image.id ? original.id : id) - : current.filter((id) => id !== image.id)); + setImages((current) => current.map((item) => item.id === image.id ? original : item)); + setMarkedIds((current) => current.map((id) => id === image.id ? original.id : id)); + setWatermarkReplacements((current) => Object.fromEntries( + Object.entries(current).filter(([, output]) => !sameImage(output, image)) + )); setWatermarkUndo((current) => { const next = { ...current }; delete next[image.id]; @@ -530,7 +552,8 @@ export function SkuImageEditorModal({ const showPreview = (items: SkuEditorImage[], index: number) => setPreview({ images: items, index }); const renderSourceImages = (candidates: SkuEditorImage[]) => { - const items = candidates.filter(image => !removedImages.some(removed => sameImage(removed, image))); + const items = candidates.map((image) => watermarkReplacements[image.id] ?? image) + .filter(image => !removedImages.some(removed => sameImage(removed, image))); return items.length ? (
{items.map((image, index) => { @@ -562,7 +585,7 @@ export function SkuImageEditorModal({ })); const renderProcessedImages = () => { if (processedLoading) return
; - if (processedError) return + if (processedError && !visibleProcessedRecords.length) return ; if (!visibleProcessedRecords.length) return ; diff --git a/apps/web/src/pages/products/useDraftSaveShortcut.ts b/apps/web/src/pages/products/useDraftSaveShortcut.ts new file mode 100644 index 0000000..545c74d --- /dev/null +++ b/apps/web/src/pages/products/useDraftSaveShortcut.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +/** 页面保存复用表单提交;弹窗内尚未确认的编辑不应触发背景草稿保存。 */ +export function useDraftSaveShortcut(enabled: boolean, onSave: () => void) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey + || (event.code !== 'KeyS' && event.key.toLowerCase() !== 's')) return; + if (event.defaultPrevented) return; + event.preventDefault(); + if (!enabled || event.repeat || event.isComposing) return; + const hasDialog = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"]')] + .some((dialog) => dialog.getClientRects().length > 0 && getComputedStyle(dialog).visibility !== 'hidden'); + if (!hasDialog) onSave(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [enabled, onSave]); +} diff --git a/apps/worker/src/product-publish.ts b/apps/worker/src/product-publish.ts index d27fbc9..6f0e635 100644 --- a/apps/worker/src/product-publish.ts +++ b/apps/worker/src/product-publish.ts @@ -36,6 +36,11 @@ type Job = { poll_count: number; }; type ErrorInfo = { code: string; field?: string; message: string }; +const imageErrorMessages: Record = { + primary_image_load_failed: 'Ozon 下载主图失败,已用附加图片替代,请检查图片交付域名并重新上传。', + pics_http_error: 'Ozon 无法下载图片,请检查下载域名的海外连通性及访问限制。', + some_image_failed: '部分图片未被 Ozon 下载,请检查图片并重新上传。', +}; export type PublishDependencies = { request: typeof requestOzon; images: ( @@ -116,7 +121,7 @@ export function safePublishErrors(input: unknown): ErrorInfo[] { ? { field: clean(item.field, 200) } : {}), message: clean( - item.message ?? + imageErrorMessages[String(item.code)] ?? item.message ?? item.description ?? 'Ozon 拒绝该 SKU,请核对商品资料', 800, @@ -312,6 +317,45 @@ export async function runProductPublish( credentials.secret_ciphertext, credentials.secret_nonce, ); + const completeImported = async (item: Item, productId: string, importErrors: unknown = []) => { + const errors = safePublishErrors(importErrors); + try { + const pictures = await dependencies.request( + credentials.platform_client_id, apiKey, '/v2/product/pictures/info', + { product_id: [productId] }, + ) as { items?: Array<{ + product_id: number | string; primary_photo?: string[]; photo?: string[]; + errors?: Array<{ message?: string }>; + }> }; + if (!Array.isArray(pictures.items)) throw new Error('Ozon 图片状态响应格式异常'); + const picture = pictures.items.find((entry) => String(entry.product_id) === productId); + const count = new Set([...(picture?.primary_photo ?? []), ...(picture?.photo ?? [])]).size; + const pictureErrors = picture?.errors ?? []; + // 商品导入与图片抓取异步完成,图片尚未返回时继续轮询,不能提前报全量成功。 + if (count < item.snapshot.media.length && !pictureErrors.length + && !errors.some((entry) => entry.code in imageErrorMessages) + && job.poll_count <= 120) return; + if (count < item.snapshot.media.length) errors.push({ + code: 'OZON_IMAGES_INCOMPLETE', field: 'images', + message: `商品已导入,但 Ozon 当前仅接收 ${count}/${item.snapshot.media.length} 张图片,请检查图片交付域名后重新上传。`, + }); + errors.push(...safePublishErrors(pictureErrors.map((entry) => ({ + code: 'OZON_IMAGE_DOWNLOAD_FAILED', field: 'images', + message: entry.message?.includes('host unreachable') + ? 'Ozon 无法连接图片下载域名,请检查海外或全球加速配置后重新上传图片。' + : entry.message || 'Ozon 图片下载失败,请检查图片直链并重新上传。', + })))); + } catch (error) { + if (job.poll_count <= 120) throw error; + errors.push({ + code: 'OZON_IMAGE_STATUS_UNCONFIRMED', field: 'images', + message: '商品已导入,但暂未确认图片是否全部接收,请在 Ozon 后台核对图片。', + }); + } + const unique = errors.filter((entry, index) => errors.findIndex((other) => + other.code === entry.code && other.field === entry.field && other.message === entry.message) === index); + await complete(item, 'imported', productId, unique.slice(0, 30)); + }; const remote = async () => parseInfoProducts( await dependencies.request( @@ -349,11 +393,10 @@ export async function runProductPublish( outcome.product_id && String(outcome.product_id) !== '0' ) - await complete( + await completeImported( item, - 'imported', String(outcome.product_id), - [], + outcome.errors, ); else if (outcome.status === 'failed') await complete( @@ -374,7 +417,7 @@ export async function runProductPublish( (r) => r.offer_id === item.offer_id, ); if (found) - await complete(item, 'imported', found.id, [ + await completeImported(item, found.id, [ { code: 'OZON_IMPORT_RECONCILED', message: @@ -397,7 +440,7 @@ export async function runProductPublish( for (const item of items) { const match = found.find((r) => r.offer_id === item.offer_id); if (match) - await complete(item, 'imported', match.id, [ + await completeImported(item, match.id, [ { code: 'OZON_IMPORT_RECONCILED', message: diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index f234b4d..0f9710d 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -341,7 +341,7 @@ Headers:`X-Atlas-Platform-Id`、`Idempotency-Key`、`If-Match: "product_revisi `GET /api/product-publish-jobs/{publish_job_id}` 返回 `{data: ProductPublishJob}`;必须传平台上下文,仅可访问当前工作空间和平台。`GET /api/product-platform-profiles/{profile_id}/publish-jobs` 返回 `{data: ProductPublishJob[]}`,最近 10 次任务,供页面刷新恢复。实际 `items` 含 id、variant_id、display_name、offer_id、status、platform_product_id、errors(code/field/message),不返回发布快照、签名 URL、密钥。job 状态为 `queued/running/succeeded/partially_succeeded/failed/reconciling`;item 状态为 `queued/submitting/polling/imported/failed/skipped/reconciling`。`reconciling` 表示提交结果不确定,listing 保持 `submitting`,禁止再次导入。202/任务 ID 仅表示受理。 -Worker 提交前查询远端货号;已存在则标 `skipped` 并保留原因,不能调用导入更新它。每批最多 50 个 SKU,提交前生成七牛独立 HTTPS 私有签名图地址(有效期可配置,WebP 转 JPEG);来源与派生对象身份固定,地址仅在内存使用。导入请求的 task ID 持久化后通过延迟 Outbox 轮询,逐项处理 imported/failed/skipped。提交前预写 `submitting`:如果超时、5xx 或响应丢失,下次仅按货号对账,不盲目再次导入;无法确认则保持 reconciling。明确 429 可按 Retry-After 延迟再提交。读请求失败可以重试。平台 errors 必须脱敏,不得包含签名 URL 或请求体。 +Worker 提交前查询远端货号;已存在则标 `skipped` 并保留原因,不能调用导入更新它。每批最多 50 个 SKU,提交前生成七牛独立 HTTPS 私有签名图地址(有效期可配置,WebP 转 JPEG);交付域名应配置海外或全球加速并保证 Ozon 抓取端可访问,本地可下载不能替代海外连通性验收。来源与派生对象身份固定,地址仅在内存使用。导入请求的 task ID 持久化后通过延迟 Outbox 轮询,逐项处理 imported/failed/skipped。Ozon 商品导入与图片抓取独立异步完成:收到 imported 后通过 `/v2/product/pictures/info` 核对主图和附加图数量;图片尚未齐且无图片错误时继续既有轮询,不提前报告全量成功。确认缺图或图片下载错误时保留 imported 身份及脱敏警告,结果说明包含实际接收数/快照图片数,Web 显示「已导入(有提示)」。超过既有 120 次快速轮询窗口仍未齐或无法核对时,以 imported 并提示图片未齐/尚未确认结束,不把已创建商品降为可重复导入失败。平台导入返回的警告也必须保留;图片链接只用于内存核对,不持久化。提交前预写 `submitting`:如果超时、5xx 或响应丢失,下次仅按货号对账,不盲目再次导入;无法确认则保持 reconciling。明确 429 可按 Retry-After 延迟再提交。读请求失败可以重试。平台 errors 必须脱敏,不得包含签名 URL 或请求体。 导入成功锁定历史货号/product ID 与商品型号;其他草稿属性可修改,不隐式更新平台。存在提交中 SKU 时禁止删除该 SKU、改型号或变更目标店铺。任一 SKU 已导入后型号不可修改。沿用现有草稿换店行为:没有正在提交的 SKU 时可调整目标店铺,已导入 SKU 保留原店铺和导入身份,仍不能重复上架。失败保留草稿/报价,可修复并新建任务;任务成功不代表审核通过或有库存,在线商品事实仍由独立同步维护。 diff --git a/docs/api/watermarks-and-assets.md b/docs/api/watermarks-and-assets.md index cd89973..c20644b 100644 --- a/docs/api/watermarks-and-assets.md +++ b/docs/api/watermarks-and-assets.md @@ -234,7 +234,7 @@ Headers:`Idempotency-Key` 必填;请求体为 `{}`。将当前工作空间 商品编辑的“图片悬浮 → 编辑 → 添加水印”使用 `POST /api/media-assets/{asset_id}/watermark-derivatives` 创建异步派生任务,需 `Idempotency-Key`,请求体为 `{ "watermark_template_id": "uuid", "product_id": "uuid" }`。`product_id` 在商品编辑器中传入,旧调用可省略。仅接受当前工作空间内已校验、最大 20 MiB、最大边长 10,000px 的 JPG/PNG/WebP 正式素材和活动模板。创建时固定模板当前版本,在同一事务写入派生任务、operation 和 Outbox,返回 HTTP `202`;`GET /api/media-watermark-derivatives/{derivative_id}` 查询进度。响应包含 `id`、`status`、`source_asset_id`、`watermark_template_id`、`watermark_template_version`、`output_asset` 和 `error_code`。成功时结果为保留源图显示方向及尺寸的独立 JPG 素材,透明源图用白底合成,返回短时预览地址;失败只返回稳定错误码。 -单张和批量处理逐张创建任务。成功结果在图片弹窗中替换对应位置;来源候选图成功后加入已选区,失败图保持原样。弹窗可撤销本次水印替换;确认后才写入当前编辑表单,页面保存后持久化 SKU 选图。上架任务不会临时加水印。 +单张和批量水印处理逐张创建任务。成功结果进入当前商品的图片处理记录,并在图片弹窗中替换当前卡片;已选图仅替换原位置,未选的来源候选图不自动加入已选区,且处理不受已选图片数量上限影响。失败图保持原样。弹窗可撤销本次水印替换,撤销不删除已归档的处理记录;用户可主动从候选区或处理记录选用结果,确认后才写入当前编辑表单,页面保存后持久化 SKU 选图。上架任务不会临时加水印。 `GET /api/products/{product_id}/image-derivatives` 返回当前工作空间内该商品最近 100 条成功的 3:4 与水印处理记录,按处理时间倒序;商品不存在返回 `404`。每条记录包含 `id`、`kind`(`aspect_ratio_3_4` / `watermark`)、`source_asset_id`、`created_at` 和带短时预览地址的 `output_asset`。编辑页选图弹窗的「图片处理记录」Tab 用它找回结果,选用后仍需确认选图并保存页面。未绑定商品的历史任务无法确定商品归属,不进入该列表;来源图与原素材不被覆盖。 diff --git a/docs/product/product-ai-editor.md b/docs/product/product-ai-editor.md index 274372e..2ea963a 100644 --- a/docs/product/product-ai-editor.md +++ b/docs/product/product-ai-editor.md @@ -25,6 +25,8 @@ 页面以编辑草稿工作,不采用字段失焦即写库。保存草稿需原子提交父商品、SKU、属性和媒体顺序;存在未保存修改时关闭页面须二次确认。SKU 定价确认是例外:它会即时持久化该 SKU 的价格与定价快照。 +商品编辑页支持 Command + S(Mac)或 Ctrl + S(Windows/Linux)保存草稿,输入框内同样生效,沿用保存按钮的表单校验与结果提示,并拦截浏览器保存网页动作。页面加载、保存期间、输入法组合输入、按键持续重复以及未确认的弹窗内不触发草稿保存;离开编辑页后移除快捷键监听。 + 主要信息中的“中文商品名”用于 Atlas 内部识别;“俄文标题”默认在 SPU 通用,也可切换为按 SKU 设置。草稿保存允许标题留空;首次上架前按当前标题模式校验所需标题。 采集进入采集箱时,商品**型号**和各 SKU 的目标平台**货号(Ozon `offer_id`)**默认都为空。进入编辑页后,用户可手动填写或点击“AI 填报”生成并直接填入表单;生成货号不直接照搬来源 SKU。型号属于商品通用资料,货号属于当前平台下的每个 SKU。两者允许随草稿保存而暂时为空,但首次上架校验必须全部通过:商品型号非空,且每个待上架 SKU 的货号非空并在目标店铺内唯一。详见[AI 填报方案](./ai-autofill-plan.md)。 @@ -73,7 +75,7 @@ SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右 2026-10-03:AI 套图使用从右侧滑出的三列抽屉:商品资料、生图策略、生成结果。资料含目标 SKU、最多 8 张底图(建议 3–6)、商品名/颜色/实物尺寸重量、采集信息及核心卖点文本域。选图来源顺序为采集图片、AI 生图记录、图片处理记录、上传图片、网络图片;底图首张决定商品主体,每张生成图使用最多 3 张相关参考图。策略保留白底、场景、卖点、其他四类,默认 4 张(1/1/2/0):白底主图、场景展示图、核心卖点图、细节特写图各 1 张,展开后可逐张修改类型、主题、描述、依据或删除;智能规划使用 DeepSeek,按当前总张数预算和 SKU 资料重新分配用途,不强制凑齐图类;完成后全部展开供确认,不自动生图。五种视觉风格放在策略列,可修改描述并恢复默认;比例为 3:4、1:1、4:3,文字语言为俄/中/英/阿拉伯语。尺寸图只使用有依据的实物尺寸;默认方案不含尺寸图,可手动添加或由智能规划选择。Pcore 逐张生成,可关闭后后台运行、保留部分成功结果、取消及重试未完成项。结果加入目标 SKU 只回填表单,保存商品后持久化。契约见 [商品 AI 套图](../api/product-image-suites.md)。 -打开「SKU 图片选择」时静默读取当前工作空间默认水印。点击单张或批量「添加水印」时,有默认模板便直接生成带水印的派生素材;没有默认模板或读取失败才打开每行最多三张卡片的选模板弹窗,水印管理在新标签页打开。原图和其他 SKU 的选择不被改写;生成结果替换当前图片位置,并可在同一弹窗撤销。弹窗确认只更新编辑表单,页面保存后才持久化选图;发布时不会自动加水印。 +打开「SKU 图片选择」时静默读取当前工作空间默认水印。点击单张或批量「添加水印」时,有默认模板便直接生成带水印的派生素材;没有默认模板或读取失败才打开每行最多三张卡片的选模板弹窗,水印管理在新标签页打开。原图和其他 SKU 的选择不被改写;生成结果替换当前图片卡片并进入当前商品的「图片处理记录」,可在同一弹窗撤销。已选图片只替换原位置;未选候选图不自动加入左侧已选区,即使已选数量达到上限仍可处理。撤销只恢复本次卡片替换,不删除处理记录。弹窗确认只更新编辑表单,页面保存后才持久化选图;发布时不会自动加水印。 首期素材规则:发布图片仅接受 JPG、PNG、WebP;单文件最大 20 MB,最短边低于 700 px 给出警告且不得作为主图,超过 10,000 px 的边长在上传时拒绝;建议 1:1 或 3:4,允许其他比例但在预览中提示裁切风险。采集候选素材可保留原始文件供编辑选择,实际发布时始终按目标平台的图片数、格式和可访问性校验。Ozon 发布图片必须是可由 Ozon 拉取的 HTTPS 地址。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index c0be309..cfbe297 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -823,3 +823,29 @@ Contracts/API(含测试)/Web 类型检查和构建通过;API 商品编辑 维护者要求简介/产品描述上报和复制保留换行。本轮修改 Worker 发布请求适配,新增 ozon-description.ts;Web 新增 CopyTextButton 并接入通用描述、SKU 描述、中俄对照和 SKU AI 结果预览;同步 API 契约说明与发布集成回归,无 API 字段、数据库迁移或草稿存储格式变更。Ozon 官方商品描述规范明确简介支持 br 换行标签。Worker 仅在提交边界将属性 4191 的纯文本 CRLF/CR/LF 转为 br,空行保留连续 br,&/< /> 转义为实体;不修改持久化发布快照或其他属性。编辑与剪贴板继续使用含换行的原文,SKU 空白描述复制有效 SPU 继承值。修复只影响后续提交,不自动更新已导入商品。 API 源码/测试、Worker 和 Web 类型检查及构建通过;13 项发布 HTTP/数据库/Worker 模拟回归通过,新增 SKU 覆盖、SPU 继承、空行/特殊字符、实际请求内容和快照不可变验证。ego-browser TaskSpace 16 先核对官方规则,再使用真实组件和模拟剪贴板验证通用/SKU/继承描述以及中俄对照、AI 结果复制内容逐字一致,显示使用 pre-wrap;临时 QA 页面和浏览器已清理。没有修改真实商品、调用收费 AI 或向 Ozon 发布,没有手动启停维护者服务,Web 原有包体警告保留。 + +### 2026-10-06:SKU 选图水印结果显示与归档修复 + +维护者要求「编辑 → 添加水印」仅处理当前图,结果进入图片处理记录,未选候选图不得自动加入左侧已选区。本轮修改 Web `SkuImageEditorModal`、`ProductVariantSection` 与水印相关产品/API 说明,沿用现有派生任务与商品记录接口,没有 HTTP 字段、API/Worker、共享契约或数据库变更。候选卡片即时显示水印结果;已选图仅替换原位置,未选图保持未选且不受 30 张选图上限影响。成功结果立即进入处理记录,较早的列表响应不能覆盖新结果;刷新失败时已有记录仍显示。撤销恢复卡片,保留归档结果。只读核查本地 16 次水印任务均成功,无水印失败队列记录;原候选卡片未更新是本次发现的展示问题。 + +Web 类型检查、生产构建、差异检查通过,Worker 既有水印渲染回归 5 项通过。ego-browser TaskSpace 17 用真实弹窗/真实水印客户端及模拟接口验证:满 30 张仍可处理单张候选,另一候选不变且左侧数量不增加;默认模板与无默认模板选择;候选/已选图撤销;手动选用处理记录;重开恢复记录;失败保留原图且无新增选图/记录。临时回归页面与任务浏览器已清理,未保存真实商品、未手动启停服务。生产构建保留既有包体大小警告。 + +### 2026-10-07:Ozon 图片下载缺失与发布结果修复 + +维护者指定商品 `26662645-a6cf-4978-8fa1-154945bf2510` 的「月读鼬【高约22CM】盒装」作为样例:快照 3 图,Ozon 仅 1 图。只读核查确认前两张的签名有效且文件可下载,平台报 host unreachable;七牛交付域名为 china 加速,无访问限制。Atlas 22 秒结束任务,Ozon 约 90 秒才完成图片处理。修复范围为 Worker 发布结果核对、Web 发布结果警告样式、API 发布集成回归和文档,沿用既有接口/状态/Outbox/RLS,无共享字段或数据库迁移。imported 后等待图片查询,缺图和下载错误保留 imported 身份及脱敏提示,不因缺图允许重复创建商品;完整图片结果继续显示正常成功。 + +15 项发布闭环回归、6 项 Worker 既有媒体/包装回归、Worker/Web 类型检查与构建、API 测试类型检查通过。ego-browser TaskSpace 18 核对官方文档,并用真实结果组件验证缺图警告与完整成功样式;临时页面已清理。未手动启停维护者服务,保留 Web 原有包体警告。真实三图恢复尚未执行:涉及七牛域名改为 global 与指定 SKU 图片专用更新,需要维护者确认。详细证据与操作范围见 `docs/reviews/ozon-image-delivery-diagnosis.md`。 + +### 2026-10-07:商品编辑页保存草稿快捷键 + +本轮仅修改 Web `ProductEditPage`、新增 `useDraftSaveShortcut` 及产品说明,无 API、共享契约、Worker 或数据库变更。Command/Ctrl + S 在页面输入框内复用 `form.submit()`,保留现有校验、版本冲突处理与保存反馈,拦截浏览器保存网页。加载、保存、输入法组合输入、按键重复或可见弹窗期间不提交背景草稿;离开编辑页移除监听。保存入口新增同步锁,避免表单校验完成后并发保存;按钮提示与可访问性属性说明快捷键。 + +Web 类型检查、生产构建和差异检查通过。ego-browser TaskSpace 19 使用真实 Hook 与 AntD 表单验证 Mac/Windows 快捷键、当前输入与多行描述、必填校验、保存中拦截、弹窗输入拦截、按键重复/组合输入/其他组合键边界、非英文键盘物理 S 键及卸载清理;未保存真实用户草稿。临时页面与浏览器已清理,未手动启停现有服务,保留既有包体大小警告。 + +七牛后续状态:维护者已批准 global 切换,2026-10-07 20:56:36 请求成功受理;最后观测为 processing。维护者随后要求停止监控,已停止本会话轮询进程,尚未向 Ozon 提交图片恢复。须等待维护者通知就绪后再执行后续动作,具体范围与暂停记录见 `docs/reviews/ozon-image-delivery-diagnosis.md`。 + +### 2026-10-07:七牛全球生效后的指定 SKU 三图恢复完成 + +维护者通知配置完成并要求继续后,只读确认七牛 global/success,三张发布副本下载和 SHA-256 校验通过。仅使用 Ozon 图片专用接口更新 `ShouBan-HYRZ-V1-001`,未提交标题、价格或属性更新,未修改 Atlas 草稿/历史发布记录。第一轮任务 `5770626999` 无下载错误但旧替代主图保留,内容最终顺序为 3、1、2;核对官方 V2 schema 后,第二轮任务 `5770634249` 以 primary_image 指定原第一张,images 传原第二、三张,最终 imported,无错误。 + +最终图片专用查询为 1 主图 + 2 附加图,图片错误为空;下载实际平台图片并与原图内容比对,顺序 1、2、3、平均像素差均 0。前后标题、价格和类目相关字段一致。临时恢复脚本/状态文件和 ego-browser TaskSpace 20 已清理,无后续监控进程,未手动启停现有服务。此前业务代码回归结果继续有效,本轮仅外部图片恢复及结果文档更新,无新增代码或契约变更。详细证据见 `docs/reviews/ozon-image-delivery-diagnosis.md`。 diff --git a/docs/requirements/account-store-watermark.md b/docs/requirements/account-store-watermark.md index 48d2fd8..5469d04 100644 --- a/docs/requirements/account-store-watermark.md +++ b/docs/requirements/account-store-watermark.md @@ -144,7 +144,7 @@ ### 4.3 商品编辑时主动加水印规则 -打开「SKU 图片选择」时静默读取当前工作空间默认水印。用户点击“编辑 → 添加水印”后,有默认模板就直接生成带水印的派生图;没有默认模板或读取失败才在弹窗中选择工作空间水印模板。生成后以派生图替换此 SKU 当前图片位置。规则如下: +打开「SKU 图片选择」时静默读取当前工作空间默认水印。用户点击“编辑 → 添加水印”后,有默认模板就直接生成带水印的派生图;没有默认模板或读取失败才在弹窗中选择工作空间水印模板。生成后以派生图替换当前图片卡片并进入当前商品的「图片处理记录」;已选图只替换原位置,未选候选图不自动加入左侧已选区,也不受已选数量上限影响。弹窗撤销只恢复卡片,不删除处理记录。规则如下: - 商品级候选图片与每个 SKU 已选图片都可处理;最终仅提交用户明确选中的图片集合。 - 原始图片不被改写;水印处理产物是独立、可追溯的派生素材,用户可在编辑弹窗恢复使用原图。 diff --git a/docs/reviews/ozon-image-delivery-diagnosis.md b/docs/reviews/ozon-image-delivery-diagnosis.md new file mode 100644 index 0000000..3fe2d47 --- /dev/null +++ b/docs/reviews/ozon-image-delivery-diagnosis.md @@ -0,0 +1,42 @@ +# Ozon 图片交付排查(2026-10-07) + +商品 `26662645-a6cf-4978-8fa1-154945bf2510`,SKU「月读鼬【高约22CM】盒装」,货号 `ShouBan-HYRZ-V1-001`,Ozon product_id `6537891375`。 + +## 已确认事实 + +- 发布快照包含 3 张正式 PNG 素材,显示顺序未丢失。三张发布副本均能通过原交付域名下载,HTTP 200,尺寸均为 1086×1448,大小分别为 1,226,554、1,524,027、2,025,426 字节。 +- Ozon `/v2/product/pictures/info` 仅返回 1 张主图、0 张附加图;第 1、2 张交付链接均返回 `request process failed: unknown error host unreachable`。失败链接签名正确,截止时间为 2026-10-14 15:15:35(北京时间),并非签名过期。 +- 七牛只读域名配置显示 `img-dev.atlas.joeyone.cn` 为 `geoCover=china`、HTTPS、正常运行、七牛桶回源;没有 Referer/IP/UA 限制。结合 Ozon 抓取错误,当前大陆加速域名的跨境连通性是本次最明确的原因。 +- Atlas 发布任务在 2026-10-07 15:15:54 完成;Ozon 图片错误于约 15:17:12 出现。原流程收到商品 imported 就结束,图片仍在抓取,且 imported 分支丢弃平台警告。 + +## 本地修复与验证 + +Worker 收到 imported 后继续查询图片状态,等待完整结果;存在下载错误或缺图时保留导入身份、图片数量和脱敏提示。已创建商品不因缺图重新进入首次上架。发布结果界面对有提示的已导入任务使用警告样式,展示逐项说明;接口字段和数据库结构不变。 + +15 项发布 HTTP/数据库/Worker 回归通过,覆盖商品先导入、图片后到、1/3 缺图、完整成功、长时间未齐、平台警告持久化、签名链接脱敏与不重复导入。6 项既有 Worker 媒体交付/包装规格回归通过。Worker/Web 类型检查与构建、API 测试类型检查和差异检查通过;真实结果组件的模拟界面验证通过。没有修改真实商品资料或向 Ozon 提交写请求,未手动启停维护者服务。 + +## 已授权的线上恢复范围 + +1. 将该七牛域名的加速区域从 `china` 改为 `global`,保留域名、HTTPS、回源和访问控制配置。此域名的其他图片也会使用新的加速区域。 +2. 域名变更生效后重新验证三张交付副本,生成新的短期签名地址。 +3. 通过 Ozon `/v2/product/pictures/import` 仅更新 `ShouBan-HYRZ-V1-001` 的原 3 张图片和顺序,以 `primary_image` 明确指定第一张、`images` 传其余两张;不调用商品导入更新价格、标题或属性。 +4. 查询图片导入任务和图片专用结果,必须确认主图加附加图共 3 张且无下载错误,才宣称该 SKU 恢复。 + +维护者已于 2026-10-07 确认执行。七牛区域变更请求于北京时间 20:56:36 受理(HTTP 200 / code 200),配置已显示 `global`;21:28:20 仍为 `processing`,域名、CNAME 和 HTTPS 保持不变。维护者随后要求停止监控,监控及其子进程已停止;暂停期间未向 Ozon 提交图片更新。维护者后续通知全球配置完成并明确要求继续,本次恢复已完成,详见下节。 + +## 实际恢复结果 + +- 恢复前七牛接口已确认 `geoCover=global`、`operatingState=success`、HTTPS 与 CNAME 不变,配置更新时间为 `2026-10-07T13:32:01.835Z`。原三张交付副本重新下载均 HTTP 200,SHA-256 与冻结发布快照一致。 +- 第一轮图片任务 `5770626999` 已导入且无错误。仅提交三图的 `images` 后,平台先保留旧主图及三张附加图,随后收敛为三图,但内容顺序为 3、1、2。未仅凭任务 imported 或图片数量宣称恢复。 +- 核对官方 V2 请求 schema 后,第二轮任务 `5770634249` 显式传 `primary_image=原第1张`、`images=[原第2张, 原第3张]`,最终任务 imported,无任务错误。 +- 最终图片查询为 1 张主图、2 张附加图,图片错误为空。下载 Ozon 三张实际图片后以统一尺寸 RGB 内容比对原图,匹配顺序为 1、2、3,平均像素差均为 0,确认主图与附加图顺序正确。 +- 仅调用图片专用更新接口,没有提交商品导入或价格/属性更新。前后核对 name、price、old_price、min_price、currency_code、description_category_id、type_id 一致;未改写 Atlas 草稿或历史发布记录。 +- 临时恢复脚本、任务状态文件与官方文档浏览器已清理,无持续监控进程,未手动启停维护者服务。 + +## 官方依据 + +- [Ozon 图片查询](https://docs.ozon.ru/api/seller/#operation/ProductAPI_ProductInfoPicturesV2) +- [Ozon 图片更新](https://docs.ozon.ru/api/seller/#operation/ProductImportPicturesV2):每次提交完整目标图片集合;`primary_image` 可显式指定主图,`images` 指定其余图片顺序。本次旧主图替代场景已验证须显式恢复主图。 +- [Ozon 图片直链指南](https://seller-edu.ozon.ru/libra/work-with-goods/trebovaniya-k-kartochkam-tovarov/media/foto-i-video-tovara) +- [七牛 CDN 跨境访问说明](https://developer.qiniu.com/fusion/6389/function-of-consulting-the-faq) +- [七牛加速区域配置接口](https://developer.qiniu.com/fusion/13364/fusion-api-domain-config)