diff --git a/apps/api/src/product-editor/product-editor.controller.ts b/apps/api/src/product-editor/product-editor.controller.ts index ce2d331..5c4033d 100644 --- a/apps/api/src/product-editor/product-editor.controller.ts +++ b/apps/api/src/product-editor/product-editor.controller.ts @@ -15,6 +15,7 @@ import { z } from 'zod'; import { productPublishPreflightRequestSchema, saveProductEditorDraftRequestSchema, + saveProductSourceDescriptionRequestSchema, type Workspace, } from '@atlas/contracts'; import { ApiError } from '../common/api-error.js'; @@ -275,6 +276,43 @@ export class ProductEditorController { ) ).body; } + + /** 独立保存右侧采集面板的商品描述,不要求整页草稿处于可保存状态。 */ + @Put('products/:productId/source-description') + async saveSourceDescription( + @Param('productId') productId: string, + @Body() body: unknown, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Headers('if-match') ifMatch: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + ) { + const parsed = saveProductSourceDescriptionRequestSchema.safeParse(body); + if (!parsed.success) { + throw new ApiError( + 422, + 'VALIDATION_FAILED', + '请求参数不符合要求', + parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), + code: 'INVALID_FIELD', + message: issue.message, + })), + ); + } + return ( + await this.editor.saveSourceDescription( + workspace.id, + request.auth.userId, + requiredUuid('product_id', productId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('Idempotency-Key', key), + parseEditorRevision(ifMatch), + parsed.data, + ) + ).body; + } } /** 校验路由参数或请求头中的 UUID,并返回原始值。 */ diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index 2c9668a..540d0de 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -3,6 +3,7 @@ import type { ProductEditorContext, ProductEditorSnapshot, SaveProductEditorDraftRequest, + SaveProductSourceDescriptionRequest, } from '@atlas/contracts'; import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; @@ -1031,6 +1032,121 @@ export class ProductEditorService { ); } + /** 保存采集描述覆盖,避免未完成的整页草稿阻塞该独立编辑动作。 */ + async saveSourceDescription( + workspaceId: string, + userId: string, + productId: string, + platformId: string, + key: string, + expected: { product: number; profile: number }, + input: SaveProductSourceDescriptionRequest, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'PUT', + path: `/api/products/${productId}/source-description`, + key, + request: { + if_match: expected, + platform_id: platformId, + ...input, + }, + }, + async (transaction) => { + await this.requireTargetPlatform(transaction, platformId); + const product = await this.requireProduct(transaction, productId, true); + const profile = await this.findProfile(transaction, productId, platformId); + if (!profile) + throw new ApiError( + 409, + 'EDITOR_CONTEXT_REQUIRED', + '请先从采集箱进入商品编辑器', + ); + if (Number(product.revision) !== expected.product) + await this.throwProductRevisionConflict( + transaction, + productId, + expected.product, + ); + if (Number(profile.revision) !== expected.profile) + await this.throwProfileRevisionConflict( + transaction, + profile.id, + expected.profile, + ); + if ( + input.source_snapshot_revision !== + Number(product.source_snapshot_revision) + ) { + throw new ApiError( + 409, + 'SOURCE_SNAPSHOT_CHANGED', + '采集来源已更新,请重新加载并确认变更', + [ + { + field: 'source_snapshot_revision', + code: 'SOURCE_SNAPSHOT_CHANGED', + message: `当前采集版本为 ${product.source_snapshot_revision}`, + current_revision: Number(product.source_snapshot_revision), + expected_revision: input.source_snapshot_revision, + }, + ], + ); + } + const currentSource = resolveSourceMaterial(product); + const overriddenFields = new Set(currentSource.state.overridden_fields); + if (input.description === currentSource.state.latest.description) { + overriddenFields.delete('description'); + } else { + overriddenFields.add('description'); + } + const overrides = selectSourceMaterialOverrides( + { ...currentSource.effective, description: input.description }, + [...overriddenFields], + ); + const updatedProduct = await transaction.query<{ revision: number }>( + `UPDATE atlas.products + SET ai_source_material_overrides = $1::jsonb, + ai_source_material_base_revision = $2, + revision = revision + 1, updated_at = now() + WHERE id = $3 AND revision = $4 + RETURNING revision`, + [ + JSON.stringify(overrides), + input.source_snapshot_revision, + productId, + expected.product, + ], + ); + if (!updatedProduct.rows[0]) + await this.throwProductRevisionConflict( + transaction, + productId, + expected.product, + ); + const savedSource = resolveSourceMaterial({ + ...product, + ai_source_material_overrides: overrides, + ai_source_material_base_revision: input.source_snapshot_revision, + }); + return { + status: 200, + body: { + data: { + product_revision: Number(updatedProduct.rows[0].revision), + profile_revision: Number(profile.revision), + source_material: savedSource.effective, + source_material_state: savedSource.state, + }, + }, + }; + }, + ); + } + /** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */ private async ensureListings( transaction: DatabaseTransaction, diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index c2db362..9aaf313 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -1844,4 +1844,70 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { [asset], ); }); + + it('独立保存采集描述会持久化覆盖值并保持幂等', async () => { + const created = await createCollectionItem('独立保存描述测试商品'); + const entered = await request( + `/api/collection-items/${created.collectionItemId}/editor-contexts`, + { + method: 'POST', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': randomUUID(), + }), + }, + ); + assert.equal(entered.response.status, 201); + const before = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + const key = randomUUID(); + const body = { + description: '独立持久化后的采集商品描述', + source_snapshot_revision: + before.body.data.source_material.source_snapshot_revision, + }; + const saved = await request( + `/api/products/${created.productId}/source-description`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': key, + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(body), + }, + ); + assert.equal(saved.response.status, 200); + assert.equal( + saved.body.data.source_material.description, + '独立持久化后的采集商品描述', + ); + assert.ok( + saved.body.data.source_material_state.overridden_fields.includes( + 'description', + ), + ); + const replay = await request( + `/api/products/${created.productId}/source-description`, + { + method: 'PUT', + headers: headers({ + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': key, + 'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`, + }), + body: JSON.stringify(body), + }, + ); + assert.deepEqual(replay.body, saved.body); + const after = await request(`/api/products/${created.productId}/editor`, { + headers: headers({ 'X-Atlas-Platform-Id': platformId }), + }); + assert.equal( + after.body.data.source_material.description, + '独立持久化后的采集商品描述', + ); + }); }); diff --git a/apps/extension/lib/description-format.ts b/apps/extension/lib/description-format.ts new file mode 100644 index 0000000..a979aad --- /dev/null +++ b/apps/extension/lib/description-format.ts @@ -0,0 +1,22 @@ +/** + * 将采集到的描述规范为可阅读的纯文本。保留原有段落和列表边界;只有来源 + * 已把结构压成一行时,才依据常见商品段落标题补出最小必要的换行。 + */ +export function formatCollectedDescription(value: string): string { + const withoutMarkup = value + .replace(/\r\n?/g, '\n') + .replace(/<\s*br\s*\/?\s*>/gi, '\n') + .replace(/<\s*\/\s*(?:p|div|section|article|li|h[1-6])\s*>/gi, '\n') + .replace(/<\s*li\b[^>]*>/gi, '\n- ') + .replace(/<[^>]*>/g, ''); + const formatted = withoutMarkup + .replace( + /\s*(Описание(?:\s+товара)?|Характеристики|Преимущества|Особенности|Комплектация|Технические\s+характеристики|Specifications?|Features?|Package\s+contents?|商品描述|产品描述|商品信息|规格参数|产品参数|产品特点|包装清单)\s*([::])\s*/gi, + '\n\n$1$2\n', + ) + .replace(/([^\n])\s*[-•·]\s+(?=[\p{L}\p{N}])/gu, '$1\n- ') + .replace(/[\t\f\v ]*\n[\t\f\v ]*/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + return formatted; +} diff --git a/apps/extension/lib/ozon-parser.ts b/apps/extension/lib/ozon-parser.ts index 8ab03b6..963b066 100644 --- a/apps/extension/lib/ozon-parser.ts +++ b/apps/extension/lib/ozon-parser.ts @@ -1,5 +1,6 @@ import type { CollectedProductPayload } from '@atlas/contracts'; import { dedupeImageUrl, normaliseImageUrl } from './product-media.js'; +import { formatCollectedDescription } from './description-format.js'; export type OzonPageJsonRequest = { kind: 'modal-aspects' | 'product' | 'product-detail'; @@ -341,7 +342,7 @@ export async function collectOzonProduct( itemKey: currentSku, canonicalUrl: canonical, collectedAt: new Date().toISOString(), - parserVersion: 'ozon-public-2.3.0', + parserVersion: 'ozon-public-2.3.1', }, common: { ...(title @@ -650,10 +651,7 @@ export function extractOzonDescription( } catch { // Some older page responses already contain plain text here. } - value = value - .replace(/\s+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); + value = formatCollectedDescription(value); if (!value) continue; return { value: value.slice(0, 100_000), @@ -678,7 +676,7 @@ export function extractOzonJsonLdDescription( : ''; if (!raw) continue; return { - value: raw.replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').slice(0, 100_000), + value: formatCollectedDescription(raw).slice(0, 100_000), rawValue: raw.slice(0, 120_000), }; } catch { diff --git a/apps/extension/lib/source-parsers.ts b/apps/extension/lib/source-parsers.ts index 47f1835..ca4bddf 100644 --- a/apps/extension/lib/source-parsers.ts +++ b/apps/extension/lib/source-parsers.ts @@ -5,6 +5,7 @@ import { type OzonPageJsonFetcher, } from './ozon-parser.js'; import { collectPlatformDomAssets } from './product-media.js'; +import { formatCollectedDescription } from './description-format.js'; type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon'; type Json = Record; @@ -112,7 +113,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C axes, uniqueVariants(variants), evidence, - '1688-structured-2.0.0', + '1688-structured-2.0.1', state, ); } @@ -187,7 +188,7 @@ function collectTaobao( axes, uniqueVariants(variants), evidence, - `${platform}-structured-2.0.0`, + `${platform}-structured-2.0.1`, state, ); } @@ -357,7 +358,11 @@ function extractMarketplaceFacts(root: Document, states: unknown[], platform: Ex return { title: (isRecord(item) ? stringAt(item, ['title', 'name']) : undefined) ?? (isRecord(temp) ? stringAt(temp, ['offerTitle']) : undefined) ?? (product ? stringAt(product, ['name']) : undefined), price, - description: (product ? stringAt(product, ['description']) : undefined) ?? scopedDescription(root, platform), + description: formatCollectedDescription( + (product ? stringAt(product, ['description']) : undefined) ?? + scopedDescription(root, platform) ?? + '', + ) || undefined, attributes, categories: [...new Set(categoryNames)].map((name) => ({ key: `${platform}:category:${name.toLowerCase()}`.slice(0, 300), name })), }; @@ -368,7 +373,10 @@ function scopedDescription(root: Document, platform: Exclude(selector); + const value = formatCollectedDescription( + element?.innerText ?? element?.textContent ?? '', + ); if (value && value.length >= 10) return value.slice(0, 100_000); } return undefined; diff --git a/apps/extension/test/product-media.test.mts b/apps/extension/test/product-media.test.mts index d1d3f08..7be4361 100644 --- a/apps/extension/test/product-media.test.mts +++ b/apps/extension/test/product-media.test.mts @@ -9,6 +9,7 @@ import { extractOzonGalleryImageUrls, extractOzonGalleryVideos, } from '../lib/ozon-parser.ts'; +import { formatCollectedDescription } from '../lib/description-format.ts'; import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts'; test('Ozon gallery fixture only returns product-gallery images', async () => { @@ -93,6 +94,15 @@ test('Ozon JSON-LD Product description is the fallback when rich content is abse ); }); +test('采集描述保留段落和列表,并为压平的 Ozon 结构标题补出换行', () => { + assert.equal( + formatCollectedDescription( + 'Описание товара:适合户外使用。Характеристики:- 容量:25 L- 材质:尼龙Преимущества:- 防水\n\nКомплектация:
背包', + ), + 'Описание товара:\n适合户外使用。\n\nХарактеристики:\n- 容量:25 L\n- 材质:尼龙\n\nПреимущества:\n- 防水\n\nКомплектация:\n背包', + ); +}); + test('Ozon full characteristics merge with the five short header characteristics', () => { const attributes = extractOzonCharacteristics([ { diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 8fdb5db..995b791 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -416,6 +416,7 @@ const ProductEditPage = () => { const [catalogRefresh, setCatalogRefresh] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [savingSourceDescription, setSavingSourceDescription] = useState(false); const [materializingTransferId, setMaterializingTransferId] = useState(null); const [dirty, setDirty] = useState(false); const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); @@ -849,6 +850,68 @@ const ProductEditPage = () => { }); setDirty(true); }; + /** 独立持久化采集描述,避免要求页面上其他未完成字段一并通过整页校验。 */ + const saveSourceDescription = async (description: string) => { + if (!productId || !currentPlatform || !snapshot) return; + setSavingSourceDescription(true); + try { + const saved = await productEditorApi.saveSourceDescription( + productId, + currentPlatform.id, + { product: snapshot.product.revision, profile: snapshot.profile.revision }, + { + description, + source_snapshot_revision: + (sourceDraft ?? snapshot.source_material).source_snapshot_revision, + } + ); + setSnapshot((current) => + current + ? { + ...current, + product: { + ...current.product, + revision: saved.product_revision, + }, + profile: { + ...current.profile, + revision: saved.profile_revision, + }, + source_material: saved.source_material, + source_material_state: saved.source_material_state, + ai_input: { + ...current.ai_input, + source_material: saved.source_material, + }, + } + : current + ); + setSourceDraft((current) => + current + ? { + ...current, + description: saved.source_material.description, + source_snapshot_revision: + saved.source_material.source_snapshot_revision, + } + : saved.source_material + ); + setSourceOverriddenFields((current) => { + const next = new Set(current); + if (saved.source_material_state.overridden_fields.includes('description')) { + next.add('description'); + } else { + next.delete('description'); + } + return [...next]; + }); + message.success('商品描述已保存'); + } catch (error) { + message.error(`商品描述保存失败:${apiErrorMessage(error)}`); + } finally { + setSavingSourceDescription(false); + } + }; const restoreSourceField = (field: Extract) => { if (!snapshot) return; setSourceDraft({ @@ -1130,6 +1193,8 @@ const ProductEditPage = () => { overriddenFields={sourceOverriddenFields} onRestore={restoreSourceField} onUpdate={updateSourceField} + savingDescription={savingSourceDescription} + onSaveDescription={(description) => void saveSourceDescription(description)} onShowDifferences={showSourceDifferences} onAcceptLatest={acceptLatestSource} /> diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx index e878d2c..7489b88 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx @@ -28,6 +28,8 @@ type Props = { field: Extract, value: string ) => void; + savingDescription: boolean; + onSaveDescription: (value: string) => void; onShowDifferences: () => void; onAcceptLatest: () => void; }; @@ -48,6 +50,8 @@ export function CollectedProductInfoPanel({ overriddenFields, onRestore, onUpdate, + savingDescription, + onSaveDescription, onShowDifferences, onAcceptLatest, }: Props) { @@ -137,14 +141,16 @@ export function CollectedProductInfoPanel({ setDescriptionDraft(event.target.value)} />
diff --git a/apps/web/src/services/productEditor.ts b/apps/web/src/services/productEditor.ts index 82c3f3d..2120b0b 100644 --- a/apps/web/src/services/productEditor.ts +++ b/apps/web/src/services/productEditor.ts @@ -10,6 +10,8 @@ import type { Operation, SaveProductEditorDraftRequest, SaveProductEditorDraftResponse, + SaveProductSourceDescriptionRequest, + SaveProductSourceDescriptionResponse, } from '@atlas/contracts'; import { api } from './api'; @@ -42,6 +44,23 @@ export const productEditorApi = { 'If-Match': `"${revisions.product}:${revisions.profile}"`, }, }), + saveSourceDescription: ( + productId: string, + platformId: string, + revisions: { product: number; profile: number }, + payload: SaveProductSourceDescriptionRequest + ) => + api.put( + `/products/${productId}/source-description`, + payload, + { + headers: { + 'X-Atlas-Platform-Id': platformId, + 'Idempotency-Key': crypto.randomUUID(), + 'If-Match': `"${revisions.product}:${revisions.profile}"`, + }, + } + ), categoryOptions: (profileId: string, platformId: string, search?: string) => api.get( `/product-platform-profiles/${profileId}/category-options`, diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md index 47fc59a..8fb1002 100644 --- a/docs/api/product-editor-and-publishing.md +++ b/docs/api/product-editor-and-publishing.md @@ -126,6 +126,18 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key 成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。 +### 2.1 独立保存采集商品描述 + +`PUT /api/products/{product_id}/source-description` + +右侧“采集的商品信息 → 商品描述”的“保存描述”按钮使用该接口,不依赖整页草稿可通过校验。Headers:`X-Atlas-Platform-Id`、`If-Match: "{product_revision}:{profile_revision}"` 和 `Idempotency-Key` 均必填。请求体为: + +```json +{ "description": "用户补充后的来源描述", "source_snapshot_revision": 3 } +``` + +服务端仅更新 `ai_source_material_overrides.description`,保留其他来源字段已有的覆盖值,并递增 `product.revision`;`profile.revision` 不变。若描述与当前最新采集描述完全相同,则移除该字段覆盖,使它继续跟随后续复采。接口返回最新双版本号及合并后的 `source_material` / `source_material_state`,页面据此立即更新右侧面板;刷新编辑页后仍能读取已保存描述。采集版本或编辑版本不一致时返回 `409`,客户端显示保存失败提示,不得以旧描述覆盖新采集快照。 + ## 3. 目标店铺、类目与属性 `PUT /api/product-platform-profiles/{profile_id}/target-store` diff --git a/packages/contracts/src/product-editor.ts b/packages/contracts/src/product-editor.ts index df0e38d..796b93c 100644 --- a/packages/contracts/src/product-editor.ts +++ b/packages/contracts/src/product-editor.ts @@ -422,6 +422,25 @@ export const productAiSourceMaterialOverridesSchema = .partial() .strict(); +/** 右侧采集面板独立保存商品描述时的最小请求体。 */ +export const saveProductSourceDescriptionRequestSchema = z + .object({ + description: nullableText(100_000), + source_snapshot_revision: z.number().int().positive(), + }) + .strict(); + +export type SaveProductSourceDescriptionRequest = z.infer< + typeof saveProductSourceDescriptionRequestSchema +>; + +export type SaveProductSourceDescriptionResponse = { + product_revision: number; + profile_revision: number; + source_material: ProductAiSourceMaterial; + source_material_state: ProductAiSourceMaterialState; +}; + export const productPublishPreflightRequestSchema = z .object({ variant_ids: z