feat(edit): 优化图片导入ozon问题
This commit is contained in:
@@ -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,6 +106,7 @@ async function fixture(count = 2) {
|
||||
[profile, `TEST-${randomUUID()}`, quote, variant],
|
||||
)
|
||||
).rows[0].id;
|
||||
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`,
|
||||
@@ -113,10 +114,11 @@ async function fixture(count = 2) {
|
||||
)
|
||||
).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],
|
||||
`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
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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={<SaveOutlined />}
|
||||
title="保存草稿(Command / Ctrl + S)"
|
||||
aria-keyshortcuts="Meta+S Control+S"
|
||||
>
|
||||
保存草稿
|
||||
</Button>
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<span className="editor-publish-status-icon" aria-hidden="true">
|
||||
{statusIcon}
|
||||
</span>
|
||||
<span className="editor-publish-status-content">
|
||||
<span className="editor-publish-status-title">
|
||||
发布任务 · {jobLabels[job.status]}
|
||||
发布任务 · {resultLabel}
|
||||
</span>
|
||||
<span className="editor-publish-status-count">{job.item_count} 个 SKU</span>
|
||||
</span>
|
||||
@@ -350,17 +352,19 @@ 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'
|
||||
? '平台受理结果尚未确认,系统将继续对账,请勿重复导入。'
|
||||
: hasImportWarnings
|
||||
? '商品已导入,部分项目仍有提示,请按下方结果说明检查图片或商品资料。'
|
||||
: '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -378,13 +382,13 @@ export function ProductPublishActions({
|
||||
<Tag
|
||||
color={
|
||||
i.status === 'imported'
|
||||
? 'green'
|
||||
? i.errors.length ? 'gold' : 'green'
|
||||
: i.status === 'failed'
|
||||
? 'red'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{itemLabels[i.status]}
|
||||
{i.status === 'imported' && i.errors.length ? '已导入(有提示)' : itemLabels[i.status]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void>;
|
||||
onUploadFile: (file: File) => Promise<SkuEditorImage>;
|
||||
onMakeThreeFour: (image: SkuEditorImage) => Promise<SkuEditorImage>;
|
||||
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<SkuEditorImage>;
|
||||
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<WatermarkDerivative>;
|
||||
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
|
||||
onImageDeleted?: (image: SkuEditorImage) => void;
|
||||
onDownloadImage: (image: SkuEditorImage) => Promise<void>;
|
||||
@@ -82,7 +82,8 @@ export function SkuImageEditorModal({
|
||||
const [processingThreeFour, setProcessingThreeFour] = useState(false);
|
||||
const [processingWatermark, setProcessingWatermark] = useState(false);
|
||||
const [watermarkTargets, setWatermarkTargets] = useState<SkuEditorImage[]>([]);
|
||||
const [watermarkUndo, setWatermarkUndo] = useState<Record<string, SkuEditorImage | null>>({});
|
||||
const [watermarkUndo, setWatermarkUndo] = useState<Record<string, SkuEditorImage>>({});
|
||||
const [watermarkReplacements, setWatermarkReplacements] = useState<Record<string, SkuEditorImage>>({});
|
||||
const [aiTarget, setAiTarget] = useState<SkuEditorImage | null>(null);
|
||||
const [aiImages, setAiImages] = useState<SkuEditorImage[]>([]);
|
||||
const [processedRecords, setProcessedRecords] = useState<ProductImageDerivativeRecord[]>([]);
|
||||
@@ -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 ? (
|
||||
<div className="sku-image-source-grid">
|
||||
{items.map((image, index) => {
|
||||
@@ -562,7 +585,7 @@ export function SkuImageEditorModal({
|
||||
}));
|
||||
const renderProcessedImages = () => {
|
||||
if (processedLoading) return <div className="sku-image-processed-status"><Spin /></div>;
|
||||
if (processedError) return <Empty description="图片处理记录加载失败">
|
||||
if (processedError && !visibleProcessedRecords.length) return <Empty description="图片处理记录加载失败">
|
||||
<Button onClick={() => void refreshProcessedImages().catch(() => setProcessedError(true))}>重试</Button>
|
||||
</Empty>;
|
||||
if (!visibleProcessedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
|
||||
|
||||
@@ -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<HTMLElement>('[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]);
|
||||
}
|
||||
@@ -36,6 +36,11 @@ type Job = {
|
||||
poll_count: number;
|
||||
};
|
||||
type ErrorInfo = { code: string; field?: string; message: string };
|
||||
const imageErrorMessages: Record<string, string> = {
|
||||
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:
|
||||
|
||||
@@ -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 保留原店铺和导入身份,仍不能重复上架。失败保留草稿/报价,可修复并新建任务;任务成功不代表审核通过或有库存,在线商品事实仍由独立同步维护。
|
||||
|
||||
|
||||
@@ -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 用它找回结果,选用后仍需确认选图并保存页面。未绑定商品的历史任务无法确定商品归属,不进入该列表;来源图与原素材不被覆盖。
|
||||
|
||||
|
||||
@@ -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 地址。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
### 4.3 商品编辑时主动加水印规则
|
||||
|
||||
打开「SKU 图片选择」时静默读取当前工作空间默认水印。用户点击“编辑 → 添加水印”后,有默认模板就直接生成带水印的派生图;没有默认模板或读取失败才在弹窗中选择工作空间水印模板。生成后以派生图替换此 SKU 当前图片位置。规则如下:
|
||||
打开「SKU 图片选择」时静默读取当前工作空间默认水印。用户点击“编辑 → 添加水印”后,有默认模板就直接生成带水印的派生图;没有默认模板或读取失败才在弹窗中选择工作空间水印模板。生成后以派生图替换当前图片卡片并进入当前商品的「图片处理记录」;已选图只替换原位置,未选候选图不自动加入左侧已选区,也不受已选数量上限影响。弹窗撤销只恢复卡片,不删除处理记录。规则如下:
|
||||
|
||||
- 商品级候选图片与每个 SKU 已选图片都可处理;最终仅提交用户明确选中的图片集合。
|
||||
- 原始图片不被改写;水印处理产物是独立、可追溯的派生素材,用户可在编辑弹窗恢复使用原图。
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user