Files
atlas-seller-kit/docs/api/product-editor-and-publishing.md
T

233 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 商品编辑与首次上架 API
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和关键词字典仍按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
## 1. 进入编辑与读取编辑器
`POST /api/collection-items/{collection_item_id}/editor-contexts`
Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空。服务端验证采集记录归属、平台可用,并取得或创建该商品在当前平台的 `product_platform_profile`:
- 首次创建时使用该平台默认店铺作为 `target_store_id`,并为所有未删除 SKU 建立本平台 `product_variant_platform_listing`;
- 当前平台没有默认店铺时返回 `422 DEFAULT_STORE_REQUIRED`,只给出店铺管理引导,不创建缺少目标门店的 profile;
- 已存在 profile 时只读取,绝不因再次进入而覆盖用户已选门店、类目、属性或价格;
- 不创建跨平台 profile,也不改变其他平台的上架状态。
返回:
```json
{ "data": {
"collection_item_id": "uuid",
"collection_item_id": "uuid", "product_id": "uuid", "profile_id": "uuid",
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
"editor_path": "/products/uuid/editor"
} }
```
`GET /api/products/{product_id}/editor`
要求 header,返回当前平台 profile 的完整编辑快照。核心形状:
```json
{ "data": {
"product": {
"id": "uuid", "revision": 12,
"common": { "source_title": "来源标题", "brand": null, "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null },
"default_physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": "missing" },
"default_package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": "missing" }
},
"profile": {
"id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs",
"target_store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
"title": "俄语标题", "description": null, "tags": [],
"category": { "id": "uuid", "platform_type_id": "123", "source": "source_capture", "name": "收纳箱", "path": "家居 › 收纳 › 收纳箱" },
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" }
},
"variants": [], "attributes": [],
"source_material": {
"title": "用户整理后的来源标题", "description": "用户整理后的来源描述",
"price": { "amount": "99.90", "currency_code": "CNY" }, "brand": "来源品牌",
"attributes": [{ "key": "material", "name": "材质", "value": "棉" }],
"categories": [{ "key": "bags", "name": "箱包" }], "source_snapshot_revision": 3
},
"source_material_state": {
"latest": {},
"overridden_fields": ["title", "description"],
"changed_fields": ["title"],
"base_source_snapshot_revision": 2,
"has_source_updates": true
},
"ai_input": {
"source_material": {}, "default_physical_spec": {}, "default_package_spec": {},
"variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [],
"effective_physical_spec": {}, "effective_package_spec": {} }]
},
"warnings": []
} }
```
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。动态属性 schema 由第 3 节的独立接口按当前类目读取。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。
## 2. 原子保存草稿与 SKU 规则
`PUT /api/products/{product_id}/editor-draft`
Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key` 必填。请求是整个编辑页的原子快照:
```json
{
"ai_source_material": {
"title": "用户整理后的来源标题", "description": "用户整理后的来源描述",
"price": { "amount": "99.90", "currency_code": "CNY" }, "brand": "来源品牌",
"attributes": [], "categories": [], "source_snapshot_revision": 3
},
"ai_source_overridden_fields": ["title", "description"],
"common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null,
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } },
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
"store_product_category_id": "uuid", "category_source": "manual" },
"variants": [{
"id": "uuid-or-null", "client_key": "new-row-key", "copy_from_variant_id": null, "display_name": "黑色 / M", "source_sku": null,
"specification_values": [{ "axis_key": "color", "value_key": "black", "value_name": "黑色" }], "purchase_cost_cny": "80.00",
"physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": null },
"package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": null },
"offer_id": null,
"media_asset_ids": ["uuid", "uuid"]
}],
"attributes": [{ "scope": "common", "variant_id": null, "platform_attribute_id": "100", "complex_id": "0", "values": ["value"], "language_code": "ru", "source": "manual", "confirmation_status": "confirmed" }]
}
```
语义与校验:
- `ai_source_material` 是客户端当前看到的完整有效素材,`ai_source_overridden_fields` 仅列出用户明确修改的顶层字段。服务端只持久化这些字段的覆盖值,其余字段继续跟随最新采集结果;恢复字段时将其从该数组移除即可。
- 来源素材与商品/SKU 草稿在同一事务保存,受 `product.revision` 保护。`ai_source_material.source_snapshot_revision` 必须等于服务端当前采集版本;保存期间发生复采时返回 `409 SOURCE_SNAPSHOT_CHANGED`,客户端应重新读取合并结果并让用户确认,不能用旧页面覆盖新来源。
- 保存来源素材不会修改原始采集证据。`ai_source_overridden_fields` 只允许 `title`、`description`、`price`、`brand`、`attributes`、`categories`,且每项按完整顶层值覆盖,不做数组或对象内部的隐式合并。
- `profile.store_product_category_id` 必须是当前目标门店的活动类目;服务端从门店类目记录取得 `platform_type_id`,客户端不能伪造类型 ID。`attributes` 是完整属性草稿集合,属性 ID、复杂属性 ID、值类型、单/多值上限和字典值均由当前门店类目 schema 校验。
- 类目切换或平台 schema 移除属性时,原有不再适用的属性不会进入新的发布草稿;服务端将它们写入 `unmapped_attributes` 并在编辑快照中返回。用户删除 SKU 时,其 SKU 属性随 SKU 一起移除,不生成无主未映射属性。
- `variants` 是当前完整未删除 SKU 集合。省略既有 SKU 表示软删除;无论当前 SPU 状态都允许新增、复制、删除。`copy_from_variant_id` 仅用于新行,复制通用 SKU 数据但不复制任何平台导入映射,生成的新 SKU 从 `not_listed` 开始。
- 删除已导入 SKU 只从 Atlas 草稿及当前平台可发布集合移除,**不删除、不下架、不修改** Ozon 商品或在线镜像;其他平台历史映射保留。删除后当前平台只剩已导入 SKU 时,SPU 汇总自然为“已上架”。
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。实物规格只影响尺寸图,不用于物流。
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。
- `common.model` 是用户填写的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户填写的目标平台卖家货号;采集时不得从 `source_sku` 自动生成或填入。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
## 3. 目标店铺、类目与属性
`PUT /api/product-platform-profiles/{profile_id}/target-store`
Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "uuid" }`。这是编辑器完整保存之外的快捷动作,遵守“仅未导入 SKU 更换未来目标店铺”的规则。
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 与 `is_aspect`。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`;可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`。首期只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
返回当前平台的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,不提供同步按钮;Worker 仅使用显式配置的共享 Ozon 缓存源预热该树。
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
为已选门店活动类目创建 `store_category_attribute_sync` operation。同步以单类目事务替换该门店 schema;下一次编辑草稿保存会依最新 schema 校验并将不匹配旧值转为未映射草稿。
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
按关键词(至少两个字符)创建 `store_attribute_dictionary_sync` operation,Worker 使用该门店的 Ozon 字典搜索接口并 upsert 命中的本地值。不会为打开一个字段而全量下载大字典;前端先读本地缓存,缺少结果时由用户明确选择“从 Ozon 搜索”。
`GET /api/product-platform-profiles/{profile_id}/validation`
返回 profile 与每个未删除 SKU 的本地首次上架校验:商品型号、类目/属性、标题、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
## 4. SKU 定价确认
`POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/pricing-confirms`
Headers:`Idempotency-Key` 必填。Body:
```json
{ "preview_id": "opaque-id", "selected_option_id": "candidate-1", "one_time_overrides": null }
```
服务端重新解析 SKU 的有效包装规格、验证预览仍未过期且配置/汇率/规则未变化,重新计算后写入 `pricing_quotes`、全部 `pricing_quote_options` 和当前平台 listing 的售价/划线价/币种/报价引用。成功返回 `201` 与不可变报价摘要。
包装规格缺失或 `ambiguous`、预览失效、候选不属于预览、目标店铺不一致、物流不适用时返回 `422`,不覆盖既有价格。临时更换方案或运输方式仅写入本次报价,不改门店默认方案。
## 5. 素材选择与水印派生
媒体选择通常随第 2 节草稿一起保存。为支持图片弹窗的异步“添加水印”,提供:
`POST /api/product-variant-platform-listings/{variant_listing_id}/watermark-derivatives`
Headers:`Idempotency-Key` 必填。Body:
```json
{ "source_asset_id": "uuid", "watermark_template_id": "uuid" }
```
服务端验证 source asset 是该 SKU 当前已选图片、模板属于当前工作空间且活动;固化模板**当前版本**后创建 `media_derivatives` 和 Worker 任务,返回 `202` 及 operation。任务成功后返回独立 output asset;原图和其他 SKU 的选择均不改变。用户在图片弹窗确认后,将 output asset 写入下一次 `editor-draft.media_asset_ids` 来替换原图位置;可再次选择原图恢复。首期不在发布时自动添加水印。
`GET /api/media-derivatives/{derivative_id}` 返回任务状态、脱敏错误及成功时的输出素材短时预览;不能泄露 `object_key`。
## 6. AI 任务与人工应用
所有 AI 接口都只创建任务并返回 `202` + operation/`ai_task_id`,绝不直接改写草稿或发布数据。
创建 AI 任务前,前端必须先保存当前编辑草稿。服务端从已持久化的 `ai_input` 组装输入,不接收客户端自行拼装的规格:文案和类目任务可读取全部 SKU 的规格摘要;套图和尺寸图任务只读取目标 SKU 的 `effective_physical_spec` / `effective_package_spec`。因此 AI 使用的是用户在编辑页保存后的包装规格与商品规格,而不是采集载荷中的尺寸猜测值。
| 接口 | body 核心字段 | 成功结果 |
|---|---|---|
| `POST /api/product-platform-profiles/{profile_id}/ai/category-suggestions` | 可选 `hint` | 读取采集证据和当前平台类目树,创建类目候选任务。Ozon 来源默认不自动调用,但用户可主动调用。 |
| `POST /api/product-platform-profiles/{profile_id}/ai/copy` | `fields: [title,tags,description]`、可选语言 | 创建当前平台文案候选。首期不生成富内容。 |
| `POST /api/product-platform-profiles/{profile_id}/ai/suite-plans` | `variant_ids` 必填、`prompt` 可选 | 每个目标 SKU 明确绑定,创建套图规划/生成任务。 |
| `POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/ai/dimension-images` | 可选样式 | 固化该 SKU 合并后的**实物**规格快照;实物规格缺失/待确认返回 `422`。 |
`GET /api/product-ai-tasks/{ai_task_id}` 返回状态、模型/输入摘要、候选输出或脱敏错误。
`GET /api/product-ai-tasks/{ai_task_id}/category-suggestions` 返回候选类目、置信度、依据、rank。
`POST /api/product-ai-tasks/{ai_task_id}/category-suggestions/{suggestion_id}/apply`
Headers:`If-Match`、`Idempotency-Key` 必填。用户确认后才将候选写入 profile 的类目/类型并标记 `category_source=ai_suggestion`;随后要求重新读取 schema、审查未映射属性。文案/图片 AI 输出同样由用户在下一次 `editor-draft` 中明确选择并保存。
## 7. 部分 SKU 首次上架
`POST /api/product-platform-profiles/{profile_id}/publish-jobs`
Headers:`Idempotency-Key` 必填。Body:
```json
{ "variant_ids": ["uuid", "uuid"] }
```
服务端在创建任务前重新运行第 3 节校验。只允许当前 profile 中状态为 `not_listed` 或 `failed` 的未删除 SKU;`submitting` 返回 `409 SKU_IMPORT_IN_PROGRESS`,`imported` 返回 `422 SKU_ALREADY_IMPORTED`,不得重新提交或把编辑草稿当平台更新。
校验通过后在同一事务创建 `product_publish_job` 和每个 SKU 的 `product_publish_job_item`,固化 profile、目标店铺、类目/属性、价格快照、图片访问 URL、offer ID 与平台适配请求,再投递 Worker。返回 `202`:
```json
{ "data": { "publish_job": { "id": "uuid", "status": "queued", "store_id": "uuid", "item_count": 2 } } }
```
首期 Ozon `/v3/product/import` 不传仓库或库存。Worker 按 SKU 保存提交、轮询、成功/失败和平台原因;成功 SKU 状态为 `imported` 后不能从采集箱再次上架。SPU 的未上架/已部分上架/已上架由当前平台未删除 SKU 汇总,采集箱记录始终保留。
`GET /api/product-publish-jobs/{publish_job_id}`
返回 job、每个 SKU item 的 `queued/submitting/polling/imported/failed/skipped`、Ozon task/product ID、时间和可展示的脱敏错误。发布失败不删除草稿或报价;用户修正未导入 SKU 后可以创建新的首次上架任务。在线商品镜像由另一个同步模块维护,不能以此 job 的状态替代 Ozon 当前 `visible`、库存或审核事实。
## 8. 已替代的旧草案
本文替代 [`../product/api.md`](../product/api.md) 中与商品编辑有关的路径。旧草案保留为历史背景,实施以本文件、[`pricing.md`](./pricing.md) 与 [`collection-and-extension.md`](./collection-and-extension.md) 为准。