feat: 方案设计、数据库和接口设计基本完成

This commit is contained in:
R524809
2026-09-02 14:47:18 +08:00
parent a3fe066a5f
commit 04c94e75db
30 changed files with 3305 additions and 122 deletions
+37
View File
@@ -0,0 +1,37 @@
# Atlas HTTP API 设计
> 状态:第一批可评审契约。本文档描述预期的 NestJS HTTP API,不代表接口已经实现。接口统一以前缀 `/api` 提供,JSON 字段使用 `snake_case`,时间使用 ISO 8601 UTC 字符串,ID 使用 UUID。
## 1. 文档路线
| 文档 | 状态 | 范围 |
|---|---|---|
| [`conventions.md`](./conventions.md) | 已设计 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
| [`auth.md`](./auth.md) | 已设计 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 已设计 | 平台目录、页头上下文、店铺授权、凭据校验、仓库、默认店铺。 |
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 |
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
## 2. 首期的身份与上下文边界
- 登录身份决定用户和当前工作空间;客户端**不能**传 `workspace_id` 作为数据归属或隔离条件。
- 首期注册原子创建一个个人工作空间与 `owner` 成员关系。API 响应仍会返回 `workspace`,为未来协作预留。
- 平台是页头全局工作上下文,而非租户边界。平台范围接口使用 `X-Atlas-Platform-Id`,或在文档明确标注时使用 `platform_id` 查询参数;服务端验证该平台存在且已启用。
- “默认店铺”由服务端按 `workspace + platform` 读取。不能相信客户端宣称某个店铺是默认店铺;需要指定店铺的写操作使用路径中的 `store_id` 并验证它属于当前工作空间和平台。
- 采集箱不带平台/店铺上下文;在线商品永远是平台 + 门店独有资源。这两个模块将在后续文档中分别约束。
## 3. 未纳入第一批的事项
- 手机号、短信登录、邮箱验证、忘记密码、第三方登录、MFA 与设备管理;
- 团队成员、工作空间切换和角色管理 UI;
- Cookie 授权/保存;
- 平台、货币、字体、物流商、类目、佣金和汇率的后台维护接口。
这些事项不会由首期客户端私自调用管理接口;运营数据由受控 seed、migration 或后台任务维护。
相关需求:[账户、门店与水印](../requirements/account-store-watermark.md)、[基础数据模型](../architecture/foundation-data-model.md)、[数据库安全](../architecture/database-security.md)。
+107
View File
@@ -0,0 +1,107 @@
# 账户与会话 API
> 首期只支持邮箱 + 密码。邮箱验证、密码找回、手机号、短信登录、第三方登录和 MFA 均不在本契约中。
## 1. 数据形状
```json
{
"user": {
"id": "uuid",
"email": "owner@example.com",
"display_name": null,
"email_verified_at": null,
"created_at": "2026-09-02T08:00:00Z"
},
"workspace": {
"id": "uuid",
"name": "owner 的工作空间",
"role": "owner"
}
}
```
`email` 返回规范化后的地址;密码散列、会话哈希、手机号及任何风控信息永不返回。
## 2. 注册
`POST /api/auth/register`
```json
{
"email": "owner@example.com",
"password": "a-strong-password",
"password_confirmation": "a-strong-password",
"display_name": "Joey"
}
```
规则:
- `email` 去除首尾空格后以不区分大小写的形式判重;必须满足基础邮箱格式。
- `password` 和确认值一致;最小长度与复杂度仍待认证安全规则确认,服务端将作为唯一校验方,前端只能展示同一规则而不能自行放宽。
- `display_name` 可选,最大 80 个字符。
- 成功时在同一事务创建 `user`、个人 `workspace` 和 `owner` membership,并创建登录会话。
成功返回 `201 Created`、`Set-Cookie` refresh token 和:
```json
{
"data": {
"access_token": "eyJ…",
"access_token_expires_in": 900,
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z" },
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" }
}
}
```
重复邮箱返回 `409 EMAIL_ALREADY_REGISTERED`。首期不通过“邮箱不存在/密码错误”以外的差异化响应泄露登录状态。
## 3. 登录
`POST /api/auth/login`
```json
{ "email": "owner@example.com", "password": "a-strong-password" }
```
成功响应与注册相同,刷新 token 写入新的 `HttpOnly` cookie。失败统一返回 `401 INVALID_CREDENTIALS`;禁用账户返回 `403 ACCOUNT_DISABLED`。接口应按 IP 与邮箱摘要双维度限流,但不保存明文密码或将其记入日志。
## 4. 刷新与退出
`POST /api/auth/refresh`
请求不需要 body,浏览器自动携带 refresh cookie。服务端轮换 refresh token、撤销旧 session 并返回新的短期 access token:
```json
{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } }
```
无效、过期或已撤销 refresh token 返回 `401 SESSION_EXPIRED`,并清除 refresh cookie。
`POST /api/auth/logout`
要求 Bearer access token;撤销当前 refresh session、清除 refresh cookie,返回 `204 No Content`。首期不提供“退出所有设备”。
## 5. 当前用户与初始页头数据
`GET /api/auth/me`
返回已登录用户、首期唯一工作空间、可用平台及当前页头建议值:
```json
{
"data": {
"user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null },
"workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" },
"platforms": [{ "id": "uuid", "code": "OZON", "name": "Ozon" }],
"suggested_context": {
"platform_id": "uuid",
"default_store": null
}
}
}
```
首期建议按平台配置顺序优先 Ozon;若该平台有默认店铺,`default_store` 返回店铺摘要,否则为 `null`。这只是页头初始建议,不在服务端建立“用户全局已选平台”状态;客户端切换平台后,在后续平台范围请求传 `X-Atlas-Platform-Id`。
+148
View File
@@ -0,0 +1,148 @@
# 采集箱与浏览器插件 API
> 采集箱只属于工作空间:采集时没有目标平台、目标门店或店铺凭据。列表上架状态则按 ERP 页头当前平台汇总;它不是 Ozon 在线商品可见性,也不因切换到另一平台而继承。
## 1. 浏览器扩展授权
扩展不读取 ERP 网页 localStorage,也不用 Web 端 refresh cookie。每个发布扩展在服务端登记 `client_id` 和允许的 `chrome-extension://{extension-id}/…` 回调地址,扩展使用 PKCE 授权码流程。
1. 扩展打开:`GET /api/extension/authorize?client_id={id}&redirect_uri={uri}&state={random}&code_challenge={S256}&code_challenge_method=S256`。
2. 用户在 Atlas Web 完成登录;首期不额外展示工作空间选择/授权确认页。
3. 服务端只向已登记的 `redirect_uri` 回跳 `?code={one-time}&state={original}`。授权码 60 秒有效、只能交换一次,且绑定 client、回调地址和 PKCE challenge。
4. 扩展调用 `POST /api/extension/auth/exchange`:
```json
{
"client_id": "atlas-collector",
"code": "one-time-code",
"redirect_uri": "chrome-extension://extension-id/callback",
"code_verifier": "original-pkce-verifier"
}
```
成功返回扩展专用短期 access token 与可轮换 refresh token;令牌只存 Chrome 扩展专属存储:
```json
{ "data": {
"access_token": "eyJ…", "expires_in": 900, "refresh_token": "opaque", "refresh_token_expires_in": 2592000,
"token_type": "Bearer"
} }
```
`POST /api/extension/auth/refresh` 接收 `{ "refresh_token": "opaque" }`,轮换并返回同形状令牌;`POST /api/extension/auth/revoke` 撤销当前扩展 refresh token,返回 `204`。令牌续期、撤销和错误响应不得泄露 Web 会话或其他工作空间信息。
`GET /api/extension/session`
使用扩展 access token,返回最小化状态:
```json
{ "data": { "authenticated": true, "workspace": { "id": "uuid", "available": true }, "can_collect": true } }
```
不返回平台、默认店铺、Cookie、Ozon 凭据或 Web refresh token。令牌失效返回 `401`,插件只展示“登录 Atlas ERP”。
## 2. 插件上报采集结果
`POST /api/extension/materials`
Headers:扩展 Bearer token 与 `Idempotency-Key` 必填。请求体为已定义的 `CollectedProductPayload`,其 `source` 至少包含 `platform`、`itemKey`、`canonicalUrl`、`collectedAt`、`parserVersion`;`variants` 仅列来源有稳定证据的真实组合,媒体只传来源 URL/关系和证据,不传图片二进制。
请求体不得包含或传入 `workspace_id`、`target_platform_id`、`platform_id`、`store_id`、`api_key`、Cookie、授权头或来源站会话字段。服务端从扩展令牌解析工作空间,并将来源 `platform` 映射为系统已启用的来源平台。
服务端在一个事务中:检查工作空间采集箱上限 → 按 `workspace + source_platform + source_item_key` 查找活动记录 → 创建或复用 `collection_item`、`product`、来源确认的 `product_variants` 和持久化 `collection_asset_transfers` → 再经 outbox 投递素材 Worker。重复上报不会增加采集箱配额,也不覆盖用户已编辑草稿。
成功返回 `201`(新建)或 `200`(复用):
```json
{ "data": {
"collection_item": { "id": "uuid", "status": "materializing", "source_title": "商品标题", "variant_count": 3 },
"product_id": "uuid", "reused": false,
"editor_entry_path": "/collection-items/uuid/edit",
"materialization": { "total": 12, "queued": 12, "succeeded": 0, "failed": 0 }
} }
```
达到工作空间上限时返回 `422 COLLECTION_LIMIT_REACHED`,并附 `{current_count, limit}`;不能因重复点击或并发请求绕过 1000 条限制。单个来源素材失败不使采集事务失败,关键商品/SKU 解析失败才使记录进入 `failed` 并返回可展示的脱敏摘要。
## 3. Ozon Seller 网页本地观察
`POST /api/collection-items/{collection_item_id}/seller-enrichment-observations`
仅接受扩展 token。用户在浏览器已登录 `seller.ozon.ru` 标签页内,同源调用已白名单的四个网页内部接口后,将**净化结果**或不可用原因上报:
```json
{
"interface_code": "whitelisted-interface-code",
"source_variant_key": "optional-source-variant-id",
"status": "observed",
"observed_at": "2026-09-02T08:00:00Z",
"parser_version": "collector-1.0.0",
"sanitized_payload": { "only": "whitelisted business fields" }
}
```
不可用时使用 `status: "unavailable"` 并传 `unavailable_reason`;请求、响应在客户端和服务端均须过滤 Cookie、`Authorization`、店铺/个人无关信息、会话 ID 及完整 `company_id`。标准公开页面采集已成功时,本接口失败或未调用不得改变采集箱主状态、更不能阻断编辑。
服务端解析 `source_variant_key` 到当前采集商品的 SKU 后写入 `collection_seller_enrichment_observations`。接口代码不在白名单、记录不属于该工作空间或 payload 含禁用会话字段时返回 `422`;不把原始敏感 payload 写日志。
## 4. 采集箱列表与详情
`GET /api/collection-items?cursor={opaque}&limit=20&q={keyword}&source_platform_code={code}&listing_status={not_listed|partially_listed|listed}`
要求 `X-Atlas-Platform-Id`,按 `updated_at DESC, id DESC` 返回当前工作空间未删除记录。`q` 模糊匹配标题;`source_platform_code` 是**来源**筛选,不等于页头目标平台;`listing_status` 是按页头目标平台汇总的状态。
```json
{ "data": [{
"id": "uuid", "product_id": "uuid", "source": { "kind": "platform", "platform_code": "1688", "url": "https://…" },
"title": "商品标题", "thumbnail": { "asset_id": "uuid", "preview_url": "short-lived-or-null", "status": "succeeded" },
"source_price": { "min": "20.00", "max": "35.00", "currency_code": "CNY" },
"variant_summary": { "count": 3, "needs_review": false }, "note": "待确认材质",
"collection_status": "ready_for_edit",
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入;2 个等待首次上架", "last_error": null },
"created_at": "2026-09-02T08:00:00Z", "updated_at": "2026-09-02T08:05:00Z", "revision": 4
}], "meta": { "next_cursor": null, "has_more": false, "active_count": 12, "collection_limit": 1000 } }
```
`listing_summary.status` 映射 UI 的未上架/已部分上架/已上架;`collection_status` 是采集/素材处理状态,二者绝不混用。首张素材转存未完成时 thumbnail 为占位状态;商品未命名时 `title` 返回 `null`,前端展示“未命名商品”。
`GET /api/collection-items/{collection_item_id}`
无平台 header 时仅返回采集基础信息、SKU/素材转存摘要、净化 Seller 观察结果摘要与警告;带 header 时附加 `listing_summary`。观察结果详情只对当前工作空间成员可见。
`PATCH /api/collection-items/{collection_item_id}`
Headers:`If-Match`、`Idempotency-Key` 必填。首期仅允许修改 `note`;标题、SKU 和素材在商品编辑 API 中修改。响应返回更新后的采集记录。`If-Match` 对应记录的 `revision`,包括采集 Worker 状态更新在内的每次修改都递增;不得覆盖 Worker 的状态更新。
## 5. 手动创建、删除与素材重试
`POST /api/collection-items`
Headers:`Idempotency-Key` 必填。
```json
{ "title": "未命名手工商品", "note": "可选备注" }
```
创建 `source_kind=manual` 的采集记录、空商品草稿和默认 SKU,返回 `201`、`collection_item`、`product_id` 与 `editor_entry_path`。它仍不绑定目标平台/店铺,也不绕过之后的类目、定价和首次发布校验。
`POST /api/collection-items/{collection_item_id}/asset-retries`
Headers:`Idempotency-Key` 必填。请求 `{ "transfer_ids": ["uuid"] }`;只能重试该采集记录中 `failed` 或 `cancelled` 的转存任务。返回 `202` 和 operation。不能用它重试来源商品解析或已成功素材。
`POST /api/collection-items/bulk-delete`
Headers:`Idempotency-Key` 必填。请求 `{ "ids": ["uuid"] }`,原子软删除其中当前工作空间的全部有效记录,返回 `202`、`deleted_count` 和延迟素材清理 operation。已上架记录同样可删除,但响应/前端确认文案必须说明:不删除、不下架任何 Ozon 商品,也不移除在线商品镜像。
`POST /api/collection-items/clear`
Headers:`Idempotency-Key` 必填。先由列表读取 `active_count` 并展示二次确认;确认请求:
```json
{ "confirm": true, "expected_active_count": 12 }
```
服务端锁定工作空间后比较当前有效数量;数量变化返回 `409 COLLECTION_COUNT_CHANGED`,要求用户重新确认。匹配时软删除全部有效记录并返回 `202`、删除数与延迟清理 operation。它不影响平台商品、发布快照或在线商品。
## 6. 与商品编辑接口的边界
`editor_entry_path` 只是 ERP 前端入口。下一批商品编辑接口会在用户进入该入口时根据页头目标平台取得/创建 `product_platform_profile`、预选默认店铺、允许切换同平台店铺,并定义 SKU 编辑、AI、定价确认、图片选择与仅未导入 SKU 的首次上架。
+105
View File
@@ -0,0 +1,105 @@
# API 统一约定
## 1. 鉴权与工作空间隔离
除 `POST /api/auth/register`、`POST /api/auth/login`、`POST /api/auth/refresh` 外,所有接口都要求:
```http
Authorization: Bearer <short-lived-access-token>
```
访问令牌只携带用户会话标识,不以客户端传入的 `workspace_id` 判定数据归属。服务端解析用户、选择首期唯一的有效工作空间、验证成员关系后,在每一个数据库事务中执行 `SET LOCAL app.workspace_id`。RLS 仅是第二道防线,业务层仍须验证资源归属。
Web 登录后的 refresh token 放在 `HttpOnly`、`Secure`(生产)、`SameSite=Lax` 的 cookie 中,永不出现在 JSON、日志或前端持久化存储。access token 由前端仅在内存中短暂持有;插件使用独立授权码流程,留待采集插件接口文档定义。
## 2. 页头平台与默认店铺
平台范围接口使用:
```http
X-Atlas-Platform-Id: 57ad…-uuid
```
- 无此 header 的接口不依赖平台,例如 `GET /api/auth/me`、水印和通用素材接口。
- 需要平台却没有 header 时返回 `400 PLATFORM_CONTEXT_REQUIRED`。
- header 指向停用或不存在的平台时返回 `422 PLATFORM_UNAVAILABLE`。
- 默认店铺是服务端按工作空间和该 header 查出的状态,响应中以 `default_store` 明确返回;客户端不应缓存为事实。
列表筛选中的 `platform_id` 只用于平台目录或跨平台管理页,不能绕开数据授权;同一请求不得同时带 `X-Atlas-Platform-Id` 和语义相同的 `platform_id`。
## 3. 响应、分页和字段格式
成功响应统一为:
```json
{ "data": { "id": "uuid" } }
```
列表响应:
```json
{
"data": [{ "id": "uuid" }],
"meta": { "next_cursor": "opaque-string-or-null", "has_more": false }
}
```
- 游标是服务端不透明值,客户端只能原样传回 `cursor`;默认按文档指定稳定排序。
- `limit` 默认 `20`,最大 `100`;超出时返回 `422 VALIDATION_FAILED`。
- 金额与费率以 JSON **字符串**返回和提交,例如 `"199.90"`、`"0.125"`,避免 JavaScript 浮点误差;币种用 ISO 4217 大写代码。
- 所有时间用 UTC,例如 `"2026-09-02T08:00:00Z"`;日期用 `YYYY-MM-DD`。
- 不返回 `password_hash`、API Key、Cookie、密文、对象存储永久密钥或长期访问 URL。
## 4. 错误格式和 HTTP 状态
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不符合要求",
"request_id": "req_…",
"details": [
{ "field": "email", "code": "EMAIL_ALREADY_REGISTERED", "message": "该邮箱已注册" }
]
}
}
```
| 状态 | 使用场景 |
|---:|---|
| `400` | 缺少平台上下文、请求语义矛盾、无法解析的 JSON。 |
| `401` | 缺少、失效或撤销的 access token。 |
| `403` | 已登录但无工作空间成员资格或无操作权限。 |
| `404` | 当前工作空间范围内不存在该资源;不暴露其他工作空间资源。 |
| `409` | 重复唯一资源、幂等键与既有请求不一致、乐观锁冲突。 |
| `422` | 字段格式、业务状态或平台能力不满足。 |
| `429` | 登录、刷新、上传签发或重试操作触发限流。 |
| `500` | 未预期服务错误;不返回秘密或平台原始敏感响应。 |
## 5. 写操作的幂等、并发和异步
- 创建店铺、上传意图、删除水印模板等可重试写操作应携带 `Idempotency-Key: <UUID>`。服务端按“当前用户 + 工作空间 + HTTP 方法 + 规范化实际路径 + key”保存至少 24 小时(首期默认 48 小时);请求摘要保存为服务端 HMAC,而非明文请求体。相同键、相同请求返回已保存响应;相同键、不同请求返回 `409 IDEMPOTENCY_KEY_REUSED`;原请求仍在执行返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`。
- 更新可编辑资源时,读取响应携带 `revision`;客户端在 `PATCH`/`PUT` 中提交 `If-Match: "<revision>"`。版本已变化返回 `409 VERSION_CONFLICT` 和最新 `current_revision` 摘要。`revision` 是并发控制版本;不要把水印的渲染 `current_version` 当作它。
- 耗时动作(店铺凭据验证、仓库同步、媒体处理)先返回 `202 Accepted`,并在资源中返回 `operation`。客户端轮询 `GET /api/operations/{operation_id}`;后续可追加 SSE,不改变资源接口。
- 所有启动异步工作的业务写入,必须在同一 PostgreSQL 事务中写入业务资源/`operation` 和 `outbox_events`;独立 Dispatcher 再投递 BullMQ。BullMQ 和 Redis 不得作为任务存在或最终业务结果的唯一事实。
`operation` 统一形状:
```json
{
"id": "uuid",
"kind": "store_credential_verification",
"status": "queued",
"created_at": "2026-09-02T08:00:00Z",
"completed_at": null,
"error": null
}
```
操作接口只返回当前工作空间可见的任务,且任务状态不能作为唯一事实;业务资源中的状态字段才是页面最终展示依据。
`GET /api/operations/{operation_id}` 返回上述 operation 形状。任务不属于当前工作空间时返回 `404`;`error` / `error_message` 只能是可展示的脱敏摘要,不能包含 API Key、Cookie、对象存储签名或平台原始敏感响应。
## 6. 删除与软删除
首期店铺和水印模板使用软删除。成功删除返回 `204 No Content`;之后以普通 `GET` 读取返回 `404`,列表默认不包含删除记录。历史发布、素材派生和审计记录保留其快照,不因软删除被改写。
+104
View File
@@ -0,0 +1,104 @@
# 在线商品镜像与同步 API
> 在线商品是平台 + 门店独有的外部事实镜像,一行对应一个 Ozon `product_id` / `offer_id` SKU。它包含 Atlas 发布商品和外部商品;不能跨店铺、跨平台复用,也不能当作采集箱 SPU。首期接口只读镜像和触发同步,不支持在此编辑、删除、下架、改价或设置库存。
## 1. 当前门店与镜像边界
所有接口要求 `X-Atlas-Platform-Id`。`store_id` 未传时服务端取当前工作空间该平台默认店铺;没有默认店铺返回 `422 DEFAULT_STORE_REQUIRED`。显式传入的 `store_id` 必须属于当前工作空间和 header 平台。
- 采集箱的“已上架”表示 Atlas 首次导入 SKU 的成功事实;它不由本模块的 `visible`、库存或审核状态推导。
- `platform_listings.visible` 是 Ozon 同步返回的实际可见性;`null` 表示尚未获取,不能当作 `false`。
- Atlas 发布 SKU 以 `store_id + offer_id` 和回填的 Ozon `product_id` 映射。外部同步商品 `source=external_sync`,没有 Atlas 草稿关联也必须显示。
- 同步失败始终保留上一轮成功镜像;不把失败、超时或尚未见到的条目直接解释为已下架。
## 2. 在线商品列表与详情
`GET /api/platform-listings?store_id={uuid}&cursor={opaque}&limit=20&q={keyword}&visibility={all|visible|invisible|unknown}&source={atlas_published|external_sync}&moderation_status={value}&include_archived=false`
默认按 `updated_at DESC, id DESC` 返回当前店铺镜像。`q` 匹配 `name`、`offer_id`、`remote_product_id`、`remote_sku`;`visibility=unknown` 只匹配 `visible=null`;`include_archived=false` 排除同步后标记已归档的历史镜像。
```json
{ "data": [{
"id": "uuid", "store_id": "uuid", "platform_id": "uuid",
"remote_product_id": "123456", "offer_id": "atlas-abc", "remote_sku": "987654",
"name": "商品标题", "currency_code": "RUB", "selling_price": "999.00", "list_price": "1110.00",
"visible": true, "remote_state": "processed", "moderation_status": "approved", "validation_status": "success",
"stock_summary": { "total": 0, "has_data": true },
"image": { "url": "https://platform-image-or-null", "width_px": null, "height_px": null },
"errors": [], "visibility_details": {},
"source": "atlas_published", "atlas_origin": { "collection_item_id": "uuid", "product_id": "uuid", "variant_id": "uuid" },
"last_synced_at": "2026-09-02T08:00:00Z", "missing_since": null, "archived_at": null
}], "meta": {
"next_cursor": null, "has_more": false,
"store": { "id": "uuid", "name": "Ozon 主店" },
"last_successful_sync_at": "2026-09-02T08:00:00Z", "last_sync_status": "succeeded"
} }
```
`image.url` 是 Ozon 返回的图片引用或 `null`,不是 Atlas `media_assets` 的永久地址;没有图片不意味着 Atlas 素材丢失。`errors` 仅返回页面可展示的结构化摘要,隐藏 Ozon 原始响应中可能存在的无关敏感字段。
`GET /api/platform-listings/{listing_id}`
返回单个 SKU 镜像的完整业务摘要、可见性详情、库存摘要、审核/失败原因、Atlas 来源链接和最近同步信息。它不返回 `raw_payload`;需要平台原始响应排障时仅由受控运营工具读取。
## 3. 手动触发同步
`POST /api/stores/{store_id}/platform-product-sync-runs`
Headers:`Idempotency-Key` 必填。Body 可省略,或为:
```json
{ "reason": "manual_refresh" }
```
服务端验证店铺连接处于可同步状态,然后创建一次**全量**同步 run 并投递 Worker:
```text
/v3/product/list(分页,最多每页 1000)
→ /v3/product/info/list(每批最多 100)
→ 按 store + remote_product_id / offer_id upsert 镜像
→ 完整成功后将本轮未见记录标记 missing_since / archived,不物理删除
```
返回 `202 Accepted`:
```json
{ "data": { "sync_run": {
"id": "uuid", "store_id": "uuid", "status": "queued", "trigger_source": "manual",
"listed_count": 0, "upserted_count": 0, "failed_count": 0, "created_at": "2026-09-02T08:00:00Z"
} } }
```
同一工作空间同一店铺只允许一个 `queued` / `running` 全量同步;正在同步时返回 `409 SYNC_ALREADY_RUNNING` 和现有 `sync_run_id`。无有效 Seller API 凭据时返回 `422 STORE_CREDENTIAL_UNAVAILABLE`,不尝试使用浏览器 Cookie。
## 4. 同步状态与历史
`GET /api/platform-product-sync-runs/{sync_run_id}`
返回当前/历史同步 run:
```json
{ "data": {
"id": "uuid", "store_id": "uuid", "status": "running", "trigger_source": "manual",
"cursor_started": null, "cursor_finished": null,
"listed_count": 1000, "upserted_count": 100, "failed_count": 0,
"started_at": "2026-09-02T08:00:10Z", "completed_at": null,
"error": null
} }
```
`GET /api/stores/{store_id}/platform-product-sync-runs?cursor={opaque}&limit=20`
按创建时间倒序返回该店铺同步历史。失败 run 的 `error` 是可展示摘要;前端应保留并标注上一轮成功同步时间,而不是清空在线列表。
Worker 只在完成所有分页和详情批次后把 run 标记 `succeeded`。部分请求失败时 run 为 `failed`,已成功 upsert 的记录仍保留,但不执行“未见记录归档”步骤,避免将网络错误放大为商品消失。
## 5. 明确不在首期接口中的操作
- 从在线商品编辑 Ozon 商品、重新发布或更新已导入 SKU;
- 设置库存、仓库、可见性或下架;
- 认领/导入外部同步商品到采集箱;
- 自动同步的频率、手动同步冷却时间和多店并发策略;
- 运营人员查看完整 Ozon 原始 payload。
这些操作均需要单独定义 Ozon 可修改字段、审核/重试边界和审计规则,不能借用采集箱“首次上架”接口。
+188
View File
@@ -0,0 +1,188 @@
# 定价工具 API
> 所有接口要求 `X-Atlas-Platform-Id`。首期仅支持 Ozon、市场 `RU`、`rfbs` 和已审核启用的 CEL 规则。报价引擎只读取 Atlas 已持久化的汇率、佣金和运价版本;请求期间绝不直接请求 CBR、CEL 或 Ozon。
## 1. 统一口径
- API 使用规范单位:采购成本、国内费用为 `CNY` 字符串;重量为 `kg` 字符串;尺寸为 `cm` 字符串。前端的 g/mm 输入必须先转换。
- 所有比例以 `0`–`1` 字符串表示,如 `"0.20"` 是 20%。`desired_markup_rate` 是目标净利润 ÷ 采购成本;`list_price_discount_rate` 是买家相对划线价的折扣率。
- 销售价和划线价均为 `RUB`;`merchandise_value_cny` 是销售价按本次汇率折算得到的 CNY,专门用于选择 CEL 产品类型。
- 引擎枚举“佣金档 × CEL 产品类型 × 运输方式”,再回验佣金价格区间、CEL 货值区间、重量、尺寸及计费重。没有稳定有效组合时不能返回建议售价。
- 系统汇率、方案固定汇率、一次性覆盖及临时佣金率都固化到后续 SKU 报价快照,不会改写全局规则或定价方案。
## 2. 只读上下文
### 2.1 当前上下文
`GET /api/pricing/context`
返回当前平台、默认市场、当前工作空间该平台的默认店铺及默认定价方案。没有默认店铺返回 `422 DEFAULT_STORE_REQUIRED`,不猜测目标店铺。
```json
{ "data": {
"platform": { "id": "uuid", "code": "OZON", "name": "Ozon" },
"market_code": "RU",
"store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
"default_pricing_policy": { "id": "uuid", "name": "CEL 常规" }
} }
```
### 2.2 商品类目、物流目录与系统汇率
`GET /api/platform-product-categories?parent_id={uuid|null}&q={keyword}&active_only=true`
返回 header 平台的商品类目。`parent_id`(懒加载树)与 `q`(搜索)不能同时使用。每项有 `id`、`name`、`path`、`has_children`、`pricing_rule_status`:
- `available`:当前市场、履约模式下存在唯一有效映射和正式佣金规则;
- `missing`:缺少有效映射或规则;
- `ambiguous`:多个同优先级映射,普通用户不能猜测。
不暴露佣金类目 ID 让客户端自行映射。
`GET /api/pricing/catalog?fulfillment=rfbs&store_id={uuid}`
`store_id` 省略时取默认店铺;传入时必须属于当前工作空间与 header 平台。仅返回当前已启用价卡、服务商和运输方式:
```json
{ "data": { "providers": [{
"id": "uuid", "code": "CEL", "name": "CEL",
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "effective_from": "2026-09-01T00:00:00Z" },
"transport_methods": [
{ "id": "uuid", "code": "air", "name": "陆空特快", "speed_label": "5–10 天" },
{ "id": "uuid", "code": "land_air", "name": "陆空标准", "speed_label": "10–15 天" },
{ "id": "uuid", "code": "land", "name": "陆运经济", "speed_label": "15–25 天" }
]
}] } }
```
若规则仍为 `draft` 或未通过真实账单复核,catalog 不返回它;试算请求返回 `422 PRICING_RULES_UNAVAILABLE`。
`GET /api/pricing/exchange-rate?base_currency_code=CNY&quote_currency_code=RUB`
读取最新 CBR `official_reference`:
```json
{ "data": {
"id": "uuid", "base_currency_code": "CNY", "quote_currency_code": "RUB", "rate": "12.33430000",
"source": "cbr", "kind": "official_reference", "effective_on": "2026-09-02",
"fetched_at": "2026-09-02T02:00:00Z", "is_stale": false
} }
```
没有系统汇率返回 `404 SYSTEM_EXCHANGE_RATE_NOT_FOUND`;超过 72 小时仍返回但 `is_stale=true`,前端必须明确提示。
## 3. 即时试算
`POST /api/pricing/quote-previews`
Headers:`Idempotency-Key` 必填。接口不创建 `pricing_quotes`;`preview_id` 最多可用于后续 SKU 确认 15 分钟,确认时仍会重新计算。
```json
{
"store_id": "uuid",
"fulfillment": "rfbs",
"platform_product_category_id": "uuid",
"purchase_cost_cny": "80.00",
"package": { "weight_kg": "0.990", "length_cm": "30", "width_cm": "31", "height_cm": "11" },
"pricing": {
"pricing_policy_id": "uuid",
"transport_method_ids": ["uuid", "uuid", "uuid"],
"domestic_shipping_and_label_fee_cny": "5.00",
"ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02",
"desired_markup_rate": "0.20", "list_price_discount_rate": "0.10",
"fx": { "mode": "system" },
"temporary_commission_rate": null
}
}
```
规则:
- `store_id` 可省略,服务端取默认店铺;非空时必须与 header 平台一致。
- `pricing_policy_id` 非空时方案提供默认值;请求中的费用、目标、运输方式和汇率仅覆盖本次试算,不修改方案。
- 无方案时所有费用、费率、目标、汇率模式和至少一个运输方式必填。
- `fx.mode` 为 `system`、`policy_fixed` 或 `one_time_override`;后者必填 `rate`,建议附 `reason`。`policy_fixed` 只对 `fx_strategy=fixed` 方案有效。
- 正式佣金规则缺失时,仅无方案即时试算或方案 `allow_commission_override=true` 可传 `temporary_commission_rate`。它必须被明确标记为临时覆盖。
- 未指定运输方式时取方案已启用的比较方式;仍为空则取方案默认运输方式。它们必须属于同一启用价卡。
返回:
```json
{ "data": {
"preview_id": "opaque-short-lived-id", "expires_at": "2026-09-02T08:15:00Z",
"context": {
"store_id": "uuid", "platform_id": "uuid", "market_code": "RU",
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17" },
"exchange_rate": { "rate": "12.33430000", "source": "cbr", "effective_on": "2026-09-02", "is_stale": false }
},
"recommended_option_id": "candidate-1",
"options": [{
"id": "candidate-1", "is_valid": true,
"transport_method": { "id": "uuid", "code": "land_air", "name": "陆空标准" },
"logistics_product_type": { "code": "budget", "name": "Budget" },
"commission": { "rate": "0.120000", "source": "manual_rule", "version": "2026-09", "price_range": { "min": null, "max": "1500.00", "max_inclusive": true } },
"prices": { "selling_price_rub": "999.00", "list_price_rub": "1110.00", "merchandise_value_cny": "80.99" },
"weights": { "actual_weight_kg": "0.990", "volume_weight_kg": null, "billable_weight_kg": "0.990", "billing_basis": "actual_only" },
"fees": {
"purchase_cost_rub": "986.74", "logistics_fee_cny": "49.6600", "logistics_fee_rub": "612.55",
"domestic_fee_cny": "5.0000", "domestic_fee_rub": "61.67", "commission_fee_rub": "119.88",
"ad_fee_rub": "29.97", "last_mile_fee_rub": "19.98", "withdrawal_fee_rub": "9.99", "other_fee_rub": "19.98",
"total_cost_rub": "1860.76", "profit_rub": "-861.76", "gross_margin_rate": "-0.862"
},
"calculation_trace": { "rounding": [], "formula_steps": [], "rule_versions": [] }, "warnings": []
}],
"unavailable_options": [{ "transport_method_id": "uuid", "code": "PACKAGE_DIMENSION_EXCEEDED", "message": "最长边超过该方式限制" }]
} }
```
数值只示意响应形状,非真实 CEL 结果。每种运输方式独立成功或列出不可报价原因;一个方式失败不阻断其他方式。推荐仅按完整成本、目标利润和规则有效性计算,不承诺时效。
缺少成本、有效包装规格、类目、汇率或佣金规则时返回 `422` 和可修复字段;不返回伪造价格。
## 4. 定价方案
方案属于 `store + platform`,币种从店铺读取。方案资源的核心形状:
```json
{
"id": "uuid", "store_id": "uuid", "platform_id": "uuid", "name": "CEL 常规", "note": null,
"status": "active", "is_default": true, "fulfillment": "rfbs",
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "provider": { "code": "CEL", "name": "CEL" } },
"default_transport_method_id": "uuid", "comparison_transport_method_ids": ["uuid", "uuid"],
"fees": { "domestic_shipping_and_label_fee_cny": "5.00", "ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02" },
"desired_markup_rate": "0.20", "list_price_discount_rate": "0.10", "allow_commission_override": false,
"fx": { "strategy": "system", "fixed_cny_to_rub_rate": null }, "revision": 1
}
```
`GET /api/stores/{store_id}/pricing-policies?status=active&cursor={opaque}&limit=20`
按 `is_default DESC, updated_at DESC, id DESC` 返回方案。路径店铺必须属于当前工作空间与 header 平台。
`GET /api/pricing-policies/{policy_id}` 返回完整方案。
`POST /api/stores/{store_id}/pricing-policies`
Headers:`Idempotency-Key` 必填。请求体采用上方形状的可写字段:创建时 `rate_card_id` 代替 `rate_card`,运输方式以 UUID 数组提交。首个 active 方案强制默认;后续 `is_default=true` 在同一事务取消旧默认。
`PATCH /api/pricing-policies/{policy_id}`
Headers:`If-Match`、`Idempotency-Key` 必填。仅改提交字段;变更履约、价卡或运输方式时服务端重新校验所有关联。变更只影响未来试算/SKU 新确认,绝不重算旧报价。
`POST /api/pricing-policies/{policy_id}/set-default`
Headers:`Idempotency-Key` 必填。仅 active 方案可设默认。
`DELETE /api/pricing-policies/{policy_id}`
Headers:`Idempotency-Key` 必填。将方案置为 `archived`,不物理删除快照。删除默认方案时,若还有 active 方案则返回 `422 DEFAULT_PRICING_POLICY_REPLACEMENT_REQUIRED`,必须先选择替代默认;否则允许无默认方案。
## 5. SKU 确认衔接
即时试算不落 `pricing_quotes`。商品编辑模块将定义“确认 SKU 报价”:接收 `preview_id`、SKU、最终选中候选及可选一次性覆盖,重新计算后以事务写入 `pricing_quotes + pricing_quote_options`,固化输入、汇率、佣金、CEL 规则版本和费用拆分。
客户端不能提交预览中的价格、物流费或规则 ID 作为确认事实。预览过期、规则/汇率更新或包装规格变化时,服务端拒绝旧预览并要求重新试算。临时切换方案或运输方式只进入本次 SKU 快照,绝不修改默认方案。
## 6. 后台维护边界
普通卖家端不提供佣金类目、佣金规则、物流价卡或汇率历史的写接口。它们由受控运营后台、seed/migration 或 Worker 维护;若将来开放运营 API,必须独立鉴权、审计、版本发布和回滚。
+182
View File
@@ -0,0 +1,182 @@
# 商品编辑与首次上架 API
> 此处的商品始终是采集箱内的 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", "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" },
"listing_summary": { "status": "partially_listed", "detail": "1 个 SKU 已导入" }
},
"variants": [], "attributes": [], "media_candidates": [], "attribute_schema": null,
"warnings": []
} }
```
`variants` 每项同时返回通用 SKU、合并后实物/包装规格、当前平台 listing 摘要(含 `offer_id`、价格、首次导入状态、最近脱敏失败原因)及已选媒体。`attribute_schema` 仅在当前类目已确定时返回。候选和已选媒体只含 `asset_id`、尺寸、MIME、来源角色与短时预览 URL;不含 `object_key` 或永久 URL。
## 2. 原子保存草稿与 SKU 规则
`PUT /api/products/{product_id}/editor-draft`
Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key` 必填。请求是整个编辑页的原子快照:
```json
{
"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"],
"platform_product_category_id": "uuid", "platform_type_id": "123", "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" }]
}
```
语义与校验:
- `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、字典端点、`is_required`、`is_collection`、`max_value_count`、`is_aspect` 和适用 scope。类别未选返回 `422 CATEGORY_REQUIRED`。
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
转发并缓存平台字典值;不让前端直接持有 Ozon 密钥。字典缺失、过期或类目/属性不匹配时返回 `422`。
`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`,绝不直接改写草稿或发布数据。
| 接口 | 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) 为准。
+152
View File
@@ -0,0 +1,152 @@
# 平台上下文与店铺管理 API
## 1. 平台目录与页头上下文
`GET /api/platforms`
返回当前可选择、已启用的平台及客户端所需的非敏感能力:
```json
{
"data": [{
"id": "uuid",
"code": "OZON",
"name": "Ozon",
"supported_currencies": ["CNY", "RUB"],
"default_market_code": "RU",
"default_locale": "ru-RU"
}]
}
```
`GET /api/platform-context`
要求 `X-Atlas-Platform-Id`,返回平台摘要、该工作空间在此平台的默认店铺摘要和店铺数。没有默认店铺不是错误:`default_store` 为 `null`,编辑/发布类接口会另外要求可用店铺。
## 2. 店铺资源形状
```json
{
"id": "uuid",
"platform_id": "uuid",
"name": "Ozon 俄罗斯店",
"platform_client_id": "123456",
"currency_code": "RUB",
"is_default": true,
"note": "主店",
"status": "active",
"credential_status": "valid",
"credential_checked_at": "2026-09-02T08:00:00Z",
"last_error": null,
"api_key_expires_at": null,
"product_limit": null,
"product_limit_checked_at": null,
"warehouse_count": 3,
"warehouses_synced_at": "2026-09-02T08:01:00Z",
"cookie_status": null,
"watermark_template": { "id": "uuid", "name": "右下角品牌" },
"created_at": "2026-09-02T08:00:00Z",
"updated_at": "2026-09-02T08:00:00Z",
"revision": 1
}
```
`api_key_expires_at` 和 `product_limit` 无平台事实来源时为 `null`,前端显示 `—`;不能估算。首期 `cookie_status` 固定为 `null`,不暗示 Cookie 可用于正式发布。响应绝不包含 `api_key`、密文、指纹或仓库原始平台 payload。
## 3. 查询店铺
`GET /api/stores?platform_id={uuid}&cursor={opaque}&limit=20`
`platform_id` 必填,按 `created_at DESC, id DESC` 返回当前工作空间未删除店铺。该管理列表可使用查询参数,不能同时传平台 header。
`GET /api/stores/{store_id}`
返回店铺资源。资源不属于当前工作空间或已删除时返回 `404`。
## 4. 添加店铺授权
`POST /api/stores`
Headers:`Idempotency-Key` 必填。
```json
{
"platform_id": "uuid",
"name": "Ozon 俄罗斯店",
"client_id": "123456",
"api_key": "secret-only-in-request",
"currency_code": "RUB",
"is_default": true,
"note": "主店"
}
```
校验 `currency_code` 是否为目标平台可用币种。服务端立即把 API Key 信封加密保存、创建 `status=verifying` 的店铺,随后投递“低风险凭据验证 + 仓库同步”操作;不得将 Key 放入任务 payload、日志或响应。
返回 `202 Accepted`:
```json
{
"data": {
"store": { "id": "uuid", "status": "verifying", "credential_status": "unknown" },
"operation": { "id": "uuid", "kind": "store_credential_verification", "status": "queued" }
}
}
```
首家未删除店铺强制为默认。对已有店铺提交 `is_default=true` 时,服务端在同一事务取消旧默认店铺;校验失败不把店铺置为 `active`,失败原因以可展示的脱敏 `last_error` 摘要(`code`、`message`、`at`)返回给后续读取接口。
## 5. 编辑、默认店铺、验证与删除
`PATCH /api/stores/{store_id}`
Headers:`If-Match` 与 `Idempotency-Key` 必填。
```json
{
"name": "Ozon 俄罗斯主店",
"currency_code": "RUB",
"note": "更新备注",
"api_key": "optional-replacement-secret"
}
```
不可修改 `platform_id` 和 `client_id`,避免把一个授权连接变成另一个平台/账户。未传 `api_key` 代表保留旧密钥;传入时替换、撤销旧密钥并再次触发凭据验证。普通编辑返回 `200`;包含新 Key 时返回 `202` 和验证 `operation`。
`POST /api/stores/{store_id}/set-default`
Headers:`Idempotency-Key` 必填。路径店铺在其平台下成为默认店铺,返回更新后的店铺摘要。无 body。
`POST /api/stores/{store_id}/credential-verifications`
Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与 operation。店铺停用或删除时返回 `422`。
`POST /api/stores/{store_id}/warehouse-syncs`
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
`DELETE /api/stores/{store_id}`
Headers:`Idempotency-Key` 必填。仅软删除 Atlas 内的店铺连接,不影响 Ozon 后台。删除当前默认店铺时:若同平台仍有其他未删除店铺,返回 `422 DEFAULT_STORE_REPLACEMENT_REQUIRED`,客户端必须先选择新的默认店铺;若没有其他店铺,可以删除,平台默认店铺变为 `null`。
## 6. 仓库与推荐水印
`GET /api/stores/{store_id}/warehouses`
返回最近一次同步成功的仓库摘要,按名称排序:
```json
{ "data": [{ "id": "uuid", "platform_warehouse_id": "123", "name": "Москва", "fulfillment": "rfbs", "is_rfbs": true, "status": "active", "synced_at": "2026-09-02T08:01:00Z" }] }
```
从未成功同步时返回空数组及 `meta.warehouses_synced_at = null`,不把空数组解释为平台没有仓库。
`PUT /api/stores/{store_id}/watermark-binding`
Headers:`Idempotency-Key` 必填。
```json
{ "template_id": "uuid" }
```
传 `{ "template_id": null }` 表示“暂不绑定”。模板必须是当前工作空间的活动模板。服务端在事务内结束旧绑定、创建新绑定(或保持未绑定),返回店铺中的 `watermark_template` 摘要。此绑定只代表图片编辑弹窗的未来推荐值;首期不会自动改写已有图片或在发布时添加水印。
+193
View File
@@ -0,0 +1,193 @@
# 水印与素材 API
> 素材主数据只保存 `storage_provider + bucket + object_key`。API 只在需要时签发短时上传/访问 URL,绝不返回或持久化对象存储的公开永久 URL。
## 1. 媒体上传:两阶段直传
水印图片和后续商品图片都复用此协议。上传意图是短时、一次性的受控动作;它不是公开文件地址。
### 1.1 创建上传意图
`POST /api/media/upload-intents`
Headers:`Idempotency-Key` 必填。
```json
{
"purpose": "watermark_image",
"filename": "brand-mark.png",
"content_type": "image/png",
"byte_size": 182736,
"checksum_sha256": "hex-sha256"
}
```
首期允许 `image/png`、`image/jpeg`、`image/webp`。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和短时预签名上传地址。返回 `201 Created`:
```json
{
"data": {
"upload_id": "uuid",
"expires_at": "2026-09-02T08:10:00Z",
"upload": {
"method": "PUT",
"url": "https://short-lived-upload-url",
"headers": { "content-type": "image/png" }
}
}
}
```
前端必须以服务器返回的 method、URL 与 headers 上传,不能自行拼接对象路径;上传 URL 仅可写入该次目标对象。
### 1.2 完成与校验上传
`POST /api/media/upload-intents/{upload_id}/complete`
Headers:`Idempotency-Key` 必填。
```json
{ "checksum_sha256": "hex-sha256" }
```
服务端检查对象存在、文件大小、MIME/魔数、哈希和图片尺寸;校验通过后创建/复用 `media_asset` 并返回 `201 Created`。图片解码或安全扫描耗时时可先返回 `202` 与 `media_processing` operation。
```json
{
"data": {
"asset": {
"id": "uuid",
"content_type": "image/png",
"byte_size": 182736,
"width_px": 800,
"height_px": 400,
"created_at": "2026-09-02T08:02:00Z"
}
}
}
```
`object_key` 是服务端内部字段,不放在普通客户端响应。上传意图、完成状态和对象清理需要在实现时增加持久化记录;它不能只存在于 Redis 或预签名 token 中。
### 1.3 临时预览 URL
`POST /api/media-assets/{asset_id}/access-urls`
```json
{ "purpose": "preview" }
```
返回短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
## 2. 可用字体
`GET /api/watermark-fonts`
返回渲染服务已部署且状态为 `active` 的字体:
```json
{ "data": [{ "code": "NotoSans", "display_name": "Noto Sans" }] }
```
客户端不得读取浏览器或开发机字体列表;字体 code 是水印模板的稳定保存值。
## 3. 水印模板资源
水印模板的读取形状:
```json
{
"id": "uuid",
"name": "右下角品牌",
"kind": "image",
"current_version": 3,
"config": {
"image_asset": { "id": "uuid", "preview_url": "https://short-lived", "preview_url_expires_at": "2026-09-02T08:15:00Z" },
"opacity": "0.8000",
"scale_ratio": "0.2500",
"position": "bottom_right"
},
"created_at": "2026-09-02T08:00:00Z",
"updated_at": "2026-09-02T08:03:00Z",
"revision": 8
}
```
文字模板的 `config` 为:
```json
{
"text_content": "Atlas",
"font_code": "NotoSans",
"font_size_px": 24,
"opacity": "0.8000",
"position": "bottom_right"
}
```
`position` 只能是 `top_left`、`top_center`、`top_right`、`middle_left`、`center`、`middle_right`、`bottom_left`、`bottom_center`、`bottom_right`。`opacity` 范围 `"0"`–`"1"`;图片 `scale_ratio` 范围 `"0.1"`–`"2"`;文字 `font_size_px` 范围 12–60。
### 3.1 查询
`GET /api/watermark-templates?cursor={opaque}&limit=20`
按 `created_at DESC, id DESC` 返回当前工作空间活动模板。图片模板响应可带短时 `preview_url`;过期后应重新读取列表/详情或请求素材访问 URL。
`GET /api/watermark-templates/{template_id}`
返回模板当前版本,供编辑弹窗和“查看效果”使用。固定演示底图由前端内置,前端按 `config` 实时渲染预览,不在每次滑动 Slider 时创建服务端图片。
### 3.2 创建
`POST /api/watermark-templates`
Headers:`Idempotency-Key` 必填。
文字水印示例:
```json
{
"name": "右下角文字",
"kind": "text",
"config": {
"text_content": "Atlas",
"font_code": "NotoSans",
"font_size_px": 24,
"opacity": "0.8",
"position": "bottom_right"
}
}
```
图片水印示例:
```json
{
"name": "品牌图标",
"kind": "image",
"config": {
"image_asset_id": "uuid",
"scale_ratio": "0.25",
"opacity": "0.8",
"position": "bottom_right"
}
}
```
模板名称在当前工作空间内唯一;图片资产必须属于当前工作空间且已完成校验。成功返回 `201 Created` 与模板资源。
### 3.3 编辑与删除
`PATCH /api/watermark-templates/{template_id}`
Headers:`If-Match` 与 `Idempotency-Key` 必填。请求体与创建相同,但可只提交待改字段;若切换 `kind`,须提交新类型完整 `config`,服务端不保留旧类型专属字段。
每次影响渲染的更新都会创建新的 `watermark_template_version`,并增加模板 `current_version`。旧版本不会被覆盖,供已生成派生图追溯;模板改名只更新模板名称,不需要创建渲染版本。
`DELETE /api/watermark-templates/{template_id}`
Headers:`Idempotency-Key` 必填。服务端在同一事务软删除模板并解除所有当前店铺绑定,返回 `204 No Content`。不删除原始图片资产,也不影响已生成水印派生图和历史发布快照。
## 4. 与商品编辑的边界
本文件只定义水印模板和通用素材。商品编辑的“图片悬浮 → 编辑 → 添加水印”将在商品接口中定义:它选择这里的活动模板、创建持久化派生任务、固化模板版本,并将生成的独立素材替换当前 SKU 已选图片位置;不会在上架时临时加水印。