From 04c94e75db823e178a977711933e97ee0d50d1e6 Mon Sep 17 00:00:00 2001 From: R524809 Date: Wed, 2 Sep 2026 14:47:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B9=E6=A1=88=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E5=BA=93=E5=92=8C=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=9F=BA=E6=9C=AC=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 25 +- docs/api/README.md | 37 ++ docs/api/auth.md | 107 ++++ docs/api/collection-and-extension.md | 148 +++++ docs/api/conventions.md | 105 ++++ docs/api/online-listings.md | 104 +++ docs/api/pricing.md | 188 ++++++ docs/api/product-editor-and-publishing.md | 182 ++++++ docs/api/stores-and-platforms.md | 152 +++++ docs/api/watermarks-and-assets.md | 193 ++++++ docs/architecture/database-security.md | 59 ++ docs/architecture/exchange-rates.md | 80 +++ docs/architecture/foundation-data-model.md | 35 +- .../architecture/product-domain-data-model.md | 47 ++ docs/architecture/sql/001_foundation.sql | 160 ++++- docs/architecture/sql/002_product-domain.sql | 591 ++++++++++++++++++ .../architecture/technology-and-deployment.md | 10 +- .../transactional-outbox-and-idempotency.md | 79 +++ docs/guides/minio-local-deployment.md | 240 +++++++ docs/guides/object-storage-object-key.md | 127 ++++ docs/guides/postgresql-local-development.md | 235 +++++++ docs/product/api.md | 20 +- docs/product/data-model.md | 26 +- docs/product/product-ai-editor.md | 30 +- docs/project/HANDOFF.md | 62 +- docs/requirements/account-store-watermark.md | 26 +- .../collection-box-and-online-products.md | 132 ++++ .../collector-browser-extension.md | 93 +-- docs/requirements/pricing-tool.md | 6 +- .../implementation-readiness-open-items.md | 128 ++++ 30 files changed, 3305 insertions(+), 122 deletions(-) create mode 100644 docs/api/README.md create mode 100644 docs/api/auth.md create mode 100644 docs/api/collection-and-extension.md create mode 100644 docs/api/conventions.md create mode 100644 docs/api/online-listings.md create mode 100644 docs/api/pricing.md create mode 100644 docs/api/product-editor-and-publishing.md create mode 100644 docs/api/stores-and-platforms.md create mode 100644 docs/api/watermarks-and-assets.md create mode 100644 docs/architecture/database-security.md create mode 100644 docs/architecture/exchange-rates.md create mode 100644 docs/architecture/product-domain-data-model.md create mode 100644 docs/architecture/sql/002_product-domain.sql create mode 100644 docs/architecture/transactional-outbox-and-idempotency.md create mode 100644 docs/guides/minio-local-deployment.md create mode 100644 docs/guides/object-storage-object-key.md create mode 100644 docs/guides/postgresql-local-development.md create mode 100644 docs/requirements/collection-box-and-online-products.md create mode 100644 docs/reviews/implementation-readiness-open-items.md diff --git a/docs/README.md b/docs/README.md index c5415ac..330aaef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,14 +11,27 @@ | 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL | | 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 | | 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 | +| 学习指南 | [`guides/`](./guides/) | 项目所用技术概念与设计原则的入门说明 | +| HTTP API 设计 | [`api/`](./api/) | 可评审的服务端接口契约,按模块逐步补全。 | +| 设计评审 | [`reviews/`](./reviews/) | 实现前待决问题、风险和关闭记录。 | ## 当前阅读顺序 1. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。 2. [`requirements/collector-browser-extension.md`](./requirements/collector-browser-extension.md):来源商品采集、采集箱、多 SKU、插件认证和 Ozon Seller 网页观察实验。 -3. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。 -4. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。 -5. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。 -6. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。 -7. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。 -8. 需求确认后,才创建对应的接口和开发方案文档。 +3. [`requirements/collection-box-and-online-products.md`](./requirements/collection-box-and-online-products.md):采集箱、上架后编辑限制、按 SKU 展示的在线商品和 Ozon 异步同步需求。 +4. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。 +5. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。 +6. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。 +7. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。 +8. [`architecture/exchange-rates.md`](./architecture/exchange-rates.md):系统汇率、CBR、免费备用接口、更新任务和报价快照规则。 +9. [`architecture/product-domain-data-model.md`](./architecture/product-domain-data-model.md):采集箱、商品编辑、发布任务和在线商品镜像的数据库设计与 SQL。 +10. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。 +11. [`architecture/database-security.md`](./architecture/database-security.md):迁移/应用数据库角色、RLS 强制策略与部署职责。 +12. [`architecture/transactional-outbox-and-idempotency.md`](./architecture/transactional-outbox-and-idempotency.md):关键写请求去重、事务 Outbox、BullMQ 投递与验收规则。 +13. [`guides/object-storage-object-key.md`](./guides/object-storage-object-key.md):对象存储、`object_key`、来源 URL 与预签名 URL 的使用边界。 +14. [`guides/minio-local-deployment.md`](./guides/minio-local-deployment.md):MinIO 简介、本地 Docker 部署与应用侧使用方法。 +15. [`guides/postgresql-local-development.md`](./guides/postgresql-local-development.md):原生 PostgreSQL / Docker Compose 本地数据库、角色、迁移与建表。 +16. [`api/README.md`](./api/README.md):HTTP API 统一约定及第一批账户、门店、水印接口。 +17. [`reviews/implementation-readiness-open-items.md`](./reviews/implementation-readiness-open-items.md):实现前必须闭环的数据库/API 事项与已确认约束。 +18. 后续按模块补全定价、采集、商品编辑、发布和在线商品接口。 diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..080a218 --- /dev/null +++ b/docs/api/README.md @@ -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)。 diff --git a/docs/api/auth.md b/docs/api/auth.md new file mode 100644 index 0000000..fe3d1d3 --- /dev/null +++ b/docs/api/auth.md @@ -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`。 diff --git a/docs/api/collection-and-extension.md b/docs/api/collection-and-extension.md new file mode 100644 index 0000000..a3e8533 --- /dev/null +++ b/docs/api/collection-and-extension.md @@ -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 的首次上架。 diff --git a/docs/api/conventions.md b/docs/api/conventions.md new file mode 100644 index 0000000..8744122 --- /dev/null +++ b/docs/api/conventions.md @@ -0,0 +1,105 @@ +# API 统一约定 + +## 1. 鉴权与工作空间隔离 + +除 `POST /api/auth/register`、`POST /api/auth/login`、`POST /api/auth/refresh` 外,所有接口都要求: + +```http +Authorization: Bearer +``` + +访问令牌只携带用户会话标识,不以客户端传入的 `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: `。服务端按“当前用户 + 工作空间 + HTTP 方法 + 规范化实际路径 + key”保存至少 24 小时(首期默认 48 小时);请求摘要保存为服务端 HMAC,而非明文请求体。相同键、相同请求返回已保存响应;相同键、不同请求返回 `409 IDEMPOTENCY_KEY_REUSED`;原请求仍在执行返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`。 +- 更新可编辑资源时,读取响应携带 `revision`;客户端在 `PATCH`/`PUT` 中提交 `If-Match: ""`。版本已变化返回 `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`,列表默认不包含删除记录。历史发布、素材派生和审计记录保留其快照,不因软删除被改写。 diff --git a/docs/api/online-listings.md b/docs/api/online-listings.md new file mode 100644 index 0000000..cd7b437 --- /dev/null +++ b/docs/api/online-listings.md @@ -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 可修改字段、审核/重试边界和审计规则,不能借用采集箱“首次上架”接口。 diff --git a/docs/api/pricing.md b/docs/api/pricing.md new file mode 100644 index 0000000..5440d5c --- /dev/null +++ b/docs/api/pricing.md @@ -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"e_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,必须独立鉴权、审计、版本发布和回滚。 diff --git a/docs/api/product-editor-and-publishing.md b/docs/api/product-editor-and-publishing.md new file mode 100644 index 0000000..4c3eb35 --- /dev/null +++ b/docs/api/product-editor-and-publishing.md @@ -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) 为准。 diff --git a/docs/api/stores-and-platforms.md b/docs/api/stores-and-platforms.md new file mode 100644 index 0000000..4f05929 --- /dev/null +++ b/docs/api/stores-and-platforms.md @@ -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` 摘要。此绑定只代表图片编辑弹窗的未来推荐值;首期不会自动改写已有图片或在发布时添加水印。 diff --git a/docs/api/watermarks-and-assets.md b/docs/api/watermarks-and-assets.md new file mode 100644 index 0000000..4db570b --- /dev/null +++ b/docs/api/watermarks-and-assets.md @@ -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 已选图片位置;不会在上架时临时加水印。 diff --git a/docs/architecture/database-security.md b/docs/architecture/database-security.md new file mode 100644 index 0000000..f66c887 --- /dev/null +++ b/docs/architecture/database-security.md @@ -0,0 +1,59 @@ +# PostgreSQL 角色与 RLS 执行约束 + +## 1. 目标 + +数据库迁移和日常应用访问必须使用不同的登录身份,避免应用连接碰巧成为表所有者而静默绕过工作空间 RLS。 + +| 身份 | 用途 | 表所有权 | 日常数据访问 | +|---|---|---|---| +| `atlas_migrator` | 执行 schema migration | 拥有 `atlas` schema 和其中的表 | 仅部署时使用 | +| `atlas_app_login` | API、BullMQ Worker 连接数据库 | 不拥有任何业务表 | 通过 `atlas_app` 获得最小 DML 权限,受 RLS 限制 | +| `atlas_app` | 无登录的权限组角色 | 不拥有任何业务表 | 承载 API/Worker 的表和序列权限 | + +`atlas_app_login` 和 `atlas_app` 都必须是 `NOBYPASSRLS`,也不能是 PostgreSQL 超级用户。生产环境不得把 `atlas_migrator` 的连接串写入 API 或 Worker 的环境变量。 + +## 2. 一次性数据库初始化 + +以下由云数据库管理员或基础设施脚本执行;它不属于 NestJS 的业务 migration,因为托管 PostgreSQL 常不允许应用账户创建角色。密码应从密钥管理服务注入,示例中不填写真实密码。 + +```sql +CREATE ROLE atlas_migrator LOGIN NOINHERIT NOBYPASSRLS PASSWORD ''; +CREATE ROLE atlas_app NOLOGIN NOINHERIT NOBYPASSRLS; +CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD ''; + +GRANT atlas_app TO atlas_app_login; +CREATE SCHEMA IF NOT EXISTS atlas AUTHORIZATION atlas_migrator; + +GRANT USAGE ON SCHEMA atlas TO atlas_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA atlas TO atlas_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA atlas TO atlas_app; + +ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO atlas_app; +ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas + GRANT USAGE, SELECT ON SEQUENCES TO atlas_app; +``` + +首次 DDL 与后续 migration 都以 `atlas_migrator` 执行,确保新表的所有者一致。若云服务统一由管理员创建 schema,可将 `atlas_migrator` 设为 schema/表所有者或由管理员代为执行 migration;无论哪种方式,应用登录角色都不得成为表所有者。 + +## 3. RLS 的运行方式 + +迁移为每张带 `workspace_id` 的业务表执行: + +```sql +ALTER TABLE atlas.example ENABLE ROW LEVEL SECURITY; +ALTER TABLE atlas.example FORCE ROW LEVEL SECURITY; +``` + +`FORCE` 会让表所有者的普通 `SELECT` / `INSERT` / `UPDATE` / `DELETE` 也遵守策略,避免错误配置在通常情况下被掩盖。PostgreSQL 超级用户和拥有 `BYPASSRLS` 属性的角色仍能绕过它,因此它们只可用于受控运维,不可用于 API 或 Worker。 + +API 的数据库事务顺序固定为:验证登录用户与工作空间成员关系 → 开启事务 → `SET LOCAL app.workspace_id = ''` → 执行业务 SQL → 提交/回滚。`SET LOCAL` 只在当前事务内有效,连接归还连接池后不会遗留工作空间上下文。 + +Worker 没有浏览器会话,也必须先从受控任务记录解析出工作空间,再在每一笔业务事务中设置相同的 `app.workspace_id`。跨工作空间的运维任务使用专门的受控运维连接,不复用应用连接池。 + +## 4. 部署检查 + +- 使用迁移连接串运行 DDL;使用应用连接串启动 API 与 Worker。 +- 在 staging 中用应用连接验证:未设置 `app.workspace_id` 时查询不到业务数据;设置 A 工作空间后不能读取 B 的记录。 +- 检查 `pg_roles.rolbypassrls`,确保应用角色为 `false`;检查 `pg_tables.tableowner`,确保应用角色不是业务表所有者。 +- 业务服务仍要做成员授权、资源归属和平台/门店校验;RLS 是纵深防御,不是授权逻辑的替代品。 diff --git a/docs/architecture/exchange-rates.md b/docs/architecture/exchange-rates.md new file mode 100644 index 0000000..bbc45ff --- /dev/null +++ b/docs/architecture/exchange-rates.md @@ -0,0 +1,80 @@ +# 汇率:数据模型、数据源与更新策略 + +> 首期定价只需要 `CNY → RUB`,但表结构按任意法币对设计。汇率是全局参考数据,不属于平台、门店或工作空间。 + +## 1. 口径与使用边界 + +`exchange_rates.rate` 始终表示:**1 单位 `base_currency_code` 可兑换多少单位 `quote_currency_code`**。例如 CBR 返回 `Nominal=1`、`Value=12.3343` 的 CNY 数据,标准化保存为: + +```text +base_currency_code = CNY +quote_currency_code = RUB +rate = 12.3343 +``` + +这是一种报价参考口径,不等同于卖家实际购汇、物流商结算或提现的成交汇率。因此定价保留三种来源: + +| 报价来源 | 读取方式 | 是否写入 `exchange_rates` | +|---|---|---| +| 系统汇率 | 读取当前有效的正式参考汇率 | 是。首期为 CBR 的 `official_reference`。 | +| 方案固定汇率 | 读取 `pricing_policies.fixed_cny_to_rub_rate` | 否,报价快照记录该固定值。 | +| 本次覆盖 | 用户在定价弹窗输入 | 否,报价快照记录覆盖值和原因。 | + +无论哪种来源,`pricing_quotes` 都固化 `fx_cny_to_rub`、`fx_rate_origin`、可空的 `exchange_rate_id` 和完整输入快照。后续系统汇率更新绝不改写已确认报价。 + +## 2. 数据库设计 + +DDL 位于 [`sql/001_foundation.sql`](./sql/001_foundation.sql) 的 `exchange_rates` 表: + +| 字段 | 含义 | +|---|---| +| `base_currency_code` / `quote_currency_code` | 币种方向;严禁把 `CNY/RUB` 与 `RUB/CNY` 混用。 | +| `rate` | 标准化后的“1 base = rate quote”。使用 `NUMERIC`,不使用浮点数。 | +| `kind` | `official_reference`、`market_reference` 或 `manual`。 | +| `source` | `cbr`、`exchange_rate_api_open`、`frankfurter`、`manual`。 | +| `effective_on` | 数据源声明的适用日期;不是系统抓取日期。 | +| `observed_at` / `fetched_at` | 来源观测时间与 Atlas 抓取/录入时间。 | +| `source_reference` / `raw_payload` | 可追溯的来源链接、批次说明和原始响应。 | + +同一个“币种方向 + 口径 + 来源 + 适用日期”只能有一条记录。若来源在同一适用日修订汇率,Worker 应在事务中更新原始记录并保留修订审计,或另行建立修订表;首期 CBR 日汇率正常情况下不需要重复插入。 + +## 3. 首选数据源:俄罗斯央行 CBR + +首期正式系统汇率使用 CBR 的 CNY 对 RUB 官方参考汇率: + +```text +GET https://www.cbr.ru/scripts/XML_daily.asp +GET https://www.cbr.ru/scripts/XML_daily.asp?date_req=dd/mm/yyyy +``` + +- 不传 `date_req` 返回最近一个已发布工作日;传日期可补历史数据。 +- 解析 XML 中 `CharCode=CNY` 的 `Nominal` 与 `Value`;将逗号小数转换为 `NUMERIC`,按 `Value / Nominal` 标准化。 +- 记录 `kind=official_reference`、`source=cbr`、来源 XML URL、响应原文、响应对应的适用日期和抓取时间。 +- CBR 在周末与节假日可能返回上一工作日数据,这是正常结果,不创建重复记录也不标记请求失败。 + +CBR 是 Ozon-RU 定价的首选,因为它直接发布 CNY 对 RUB 的官方口径。它不保证等于卖家实际结算汇率,用户仍可选择固定或本次覆盖汇率。 + +## 4. 免费备用与未来扩展 + +| 服务 | 调用方式 | 适用范围 | 使用规则 | +|---|---|---|---| +| CBR | `https://www.cbr.ru/scripts/XML_daily.asp` | 首期 CNY→RUB 正式口径 | 主来源。免费、官方、按工作日发布。 | +| ExchangeRate-API Open Access | `https://open.er-api.com/v6/latest/CNY` | CNY→多币种可用性备用 | 无 Key、日更、需按其条款归属且有频率限制。只能标记为参考或备用,不能静默覆盖 CBR 正式记录。 | +| Frankfurter | `https://api.frankfurter.dev/v2/rate/CNY/RUB` | 后续多币种、历史查询和数据源对照 | 无 Key;默认可能混合多个来源。需要时应记录 provider 元数据,不能把混合口径伪装为 CBR 官方口径。 | + +不要在前端直接调用上述接口,也不要把第三方响应直接当作报价结果。所有请求只由后端 Worker 发起、校验、标准化、持久化后供报价引擎读取。 + +## 5. BullMQ 更新与故障策略 + +1. 定时 Worker 在俄罗斯工作日每 2 小时请求一次 CBR;不依赖未经确认的固定发布时间。 +2. 以 `effective_on` 去重:没有新适用日期时直接结束;有新日期时插入一条 `exchange_rates` 记录。 +3. 每次任务记录 HTTP 状态、解析结果、原始响应摘要和耗时;禁止记录无关敏感信息。 +4. CBR 请求失败时保留上一条成功汇率并告警;不得自动以备用来源覆盖正式 CBR 数据。 +5. 若最近 CBR 有效记录距当前超过 72 小时,报价界面显示“系统汇率可能过期”,但仍允许使用上次值、方案固定值或本次覆盖值。 +6. 定价服务只读取当前有效的 `CNY → RUB + official_reference + cbr` 最新记录;若不存在则不能输出系统汇率报价,应提示选择固定/覆盖汇率或等待更新任务成功。 + +## 6. 实施接口边界 + +- Worker 内部:`ExchangeRateProvider` 适配器接口,首期实现 `CbrProvider`;备用提供方独立实现,不影响报价公式。 +- 后台管理:查看当前系统汇率、历史记录、来源、抓取时间、异常与人工录入 `manual` 汇率;人工记录不得冒充 `cbr`。 +- 定价 API:返回最终汇率、来源、适用日期、抓取时间和是否过期;确认报价时只引用快照,不在请求中重新抓取第三方。 diff --git a/docs/architecture/foundation-data-model.md b/docs/architecture/foundation-data-model.md index c09575c..7386fb7 100644 --- a/docs/architecture/foundation-data-model.md +++ b/docs/architecture/foundation-data-model.md @@ -1,6 +1,6 @@ # 云端基础模块:数据模型与数据库边界 -> 状态:设计稿。数据库为 PostgreSQL;配套可执行 DDL 见 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。本阶段只产出设计和 SQL,不连接、不创建实际数据库。 +> 状态:设计稿。数据库为 PostgreSQL;基础 DDL 见 [`sql/001_foundation.sql`](./sql/001_foundation.sql),采集箱、商品编辑、发布与在线商品 DDL 见 [`sql/002_product-domain.sql`](./sql/002_product-domain.sql)。本阶段只产出设计和 SQL,不连接、不创建实际数据库。 ## 1. 核心边界 @@ -17,7 +17,7 @@ Logistics provider(全局) ── Rate card ── Product type × transport ``` - **工作空间即租户**:新账户创建一个工作空间,创建者是 `owner`。用户、工作空间、成员关系分离,以支持未来多人协作和一个用户管理多个工作空间。 -- **平台与当前店铺是全局上下文**:店铺属于“工作空间 + 平台”;每个平台最多一个默认店铺。所有门店业务数据均携带 `workspace_id`。 +- **平台、市场与当前店铺是全局上下文**:店铺属于“工作空间 + 平台”,且一个门店只能绑定一个平台;每个平台最多一个默认店铺。市场/国家、默认语言、支持语言和支持币种属于平台能力,不属于门店。首期 Ozon 使用平台默认市场 `RU`;未来多市场时由顶部全局上下文选择或使用平台默认值。所有门店业务数据均携带 `workspace_id`。 - **两套类目均强绑定平台**:平台商品类目来自 Ozon 等平台的商品分类;平台佣金类目来自该平台的佣金规则树。它们在同一平台内独立维护,并由版本化映射关联;SQL 使用 `platform_id + category_id` 的复合外键阻止跨平台映射,绝不按名称或 ID 猜测。 - **CEL 价表是全局规则数据**:由后台维护版本和生效期;报价只引用确定版本。首期仅 Ozon-rFBS + CEL,香港空运不录入首期规则。 - **报价是快照**:定价方案可以变,CEL/佣金规则可以更新,但已确认 SKU 报价始终保留当时输入、费率、规则版本和费用拆分。 @@ -26,32 +26,39 @@ Logistics provider(全局) ── Rate card ── Product type × transport | 领域 | 表 | 说明 | |---|---|---| -| 账户与租户 | `users`、`workspaces`、`workspace_memberships`、`user_sessions` | 手机号密码登录;短信验证/风控字段预留但一期不启用。 | -| 平台与门店 | `platforms`、`currencies`、`platform_currency_support`、`stores`、`store_secrets`、`store_warehouses` | API Key 单独加密保存;密钥过期时间、商品限额无数据时使用 `NULL`,界面显示 `—`。 | -| 水印 | `media_assets`、`watermark_fonts`、`watermark_templates`、`watermark_template_versions`、`store_watermark_bindings` | 店铺绑定一个当前模板;发布任务未来固化模板版本,而不修改原图。 | +| 账户与租户 | `users`、`workspaces`、`workspace_memberships`、`user_sessions` | 邮箱密码登录;首期不校验邮箱,`email_verified_at` 为后续验证预留。手机号、短信验证和风控后续以独立 migration 扩展。 | +| 可靠写入与异步投递 | `idempotency_requests`、`outbox_events`、`operations` | 关键写操作按幂等键安全重放;异步业务事务同时写入 Outbox,由 Dispatcher 补投 BullMQ。`operations` 是用户可见的任务状态,不以 Redis 队列作为唯一事实。 | +| 平台与门店 | `platforms`、`currencies`、`platform_currency_support`、`stores`、`store_secrets`、`store_warehouses` | 平台维护支持币种;门店只绑定一个平台,并保存默认计价币种和连接凭据。市场/国家与语言属于待补充的平台能力配置;密钥过期时间、商品限额无数据时使用 `NULL`,界面显示 `—`。 | +| 水印与素材 | `media_assets`、`media_upload_intents`、`watermark_fonts`、`watermark_templates`、`watermark_template_versions`、`store_watermark_bindings` | 素材保存 `storage_provider + bucket + object_key` 及元数据,不保存长期访问 URL;浏览器直传先建立持久化上传意图,再校验并落为素材。店铺绑定一个当前模板,发布任务未来固化模板版本,而不修改原图。 | | 类目佣金 | `platform_product_categories`、`commission_categories`、`commission_category_rules`、`platform_product_category_commission_mappings` | 平台商品类目和平台佣金类目都强绑定同一平台;佣金按销售价 RUB 阶梯计费,抓取数据以 `competitor_capture` 来源和待审核版本录入。 | | 跨境物流 | `logistics_providers`、`logistics_rate_cards`、`logistics_product_types`、`logistics_transport_methods`、`logistics_rate_rules` | CEL 产品类型、运输方式、货值/重量/尺寸/抛重规则、费率和版本分开保存。 | -| 定价 | `pricing_policies`、`pricing_policy_transport_methods`、`pricing_quotes`、`pricing_quote_options` | 店铺默认方案、SKU 单次临时选择、所有候选运输方式结果与最终确认结果。 | +| 定价 | `pricing_policies`、`pricing_policy_transport_methods`、`pricing_quotes`、`pricing_quote_options` | 店铺默认方案、SKU 单次临时选择、所有候选运输方式结果与最终确认结果。方案可明确控制“缺少正式佣金规则时是否允许本次临时覆盖”。 | +| 汇率 | `exchange_rates` | 全局不可变汇率历史;首期系统汇率为 CBR 的 CNY→RUB 官方参考汇率。固定汇率和本次覆盖仅固化到报价快照。 | ## 3. 必须由服务端保证的规则 -1. 在每一个请求事务开始后,先验证当前用户的工作空间成员身份,再执行 `SET LOCAL app.workspace_id = ''`;PostgreSQL RLS 作为第二道隔离。 -2. `store_secrets.secret_ciphertext` 只保存信封加密后的密文、nonce 和密钥版本。不得写日志、返回 API 响应或发往浏览器缓存。 -3. 一个工作空间在同一平台只有一个活动默认店铺;切换默认店铺必须在同一事务内完成。 -4. 定价引擎枚举“佣金档位 × CEL 产品类型 × 运输方式”,回验最终销售价的 RUB 佣金档和 CNY 商品货值档;不能沿用旧项目的固定 12% / 18% 佣金。 -5. `pricing_quotes.product_variant_id` 暂为逻辑外键:商品/SKU DDL 落地时会补为指向 `product_variants` 的真实外键。报价本身可在没有 SKU 的“即时试算”中存在。 +1. 每个请求事务先验证当前用户的工作空间成员身份,再执行 `SET LOCAL app.workspace_id = ''`;PostgreSQL RLS 是第二道隔离,不能替代业务授权。 +2. API 和 Worker 必须使用无表所有权、无 `BYPASSRLS` 属性的专用应用登录角色;迁移只能由表所有者(迁移角色)执行。应用绝不能连接为建表/迁移角色。 +3. 所有带 `workspace_id` 的业务表同时 `ENABLE` 和 `FORCE ROW LEVEL SECURITY`。这使表所有者的普通数据访问也受策略约束;PostgreSQL 超级用户及显式拥有 `BYPASSRLS` 的角色仍可绕过,不能用于应用连接。 +4. `store_secrets.secret_ciphertext` 只保存信封加密后的密文、nonce 和密钥版本。不得写日志、返回 API 响应或发往浏览器缓存。 +5. 一个工作空间在同一平台只有一个活动默认店铺;切换默认店铺必须在同一事务内完成。 +6. 定价引擎枚举“佣金档位 × CEL 产品类型 × 运输方式”,回验最终销售价的 RUB 佣金档和 CNY 商品货值档;不能沿用旧项目的固定 12% / 18% 佣金。 +7. `pricing_quotes.product_variant_id` 暂为逻辑外键:商品/SKU DDL 落地时会补为指向 `product_variants` 的真实外键。报价本身可在没有 SKU 的“即时试算”中存在。 +8. 关键可重试写操作使用 `idempotency_requests`;启动异步工作的业务事务同时写入 `outbox_events`,由 Dispatcher 至少一次投递到 BullMQ。完整规则见 [`transactional-outbox-and-idempotency.md`](./transactional-outbox-and-idempotency.md)。 ## 4. 初始数据与迁移顺序 1. 执行 `001_foundation.sql`。 2. 初始化 `OZON` 平台、`CNY` / `RUB` 币种和平台币种支持关系。 3. 建立 Ozon 平台商品类目和 Ozon 平台佣金类目树,再人工审核并导入同平台映射与阶梯佣金规则。 -4. 将已 Review 的 CEL Ozon-rFBS 规则录入一个 `draft` rate card,用真实 CEL 报价复核后发布为 `active`。 -5. 商品/SKU 模块落地后,增加 `product_variants` 外键并把 SKU “定价”按钮接到 `pricing_quotes`。 +4. 配置 CBR CNY→RUB 汇率 Worker,成功写入第一条 `exchange_rates` 后才启用“系统汇率”报价;详见 [`exchange-rates.md`](./exchange-rates.md)。 +5. 将已 Review 的 CEL Ozon-rFBS 规则录入一个 `draft` rate card,用真实 CEL 报价复核后发布为 `active`。 +6. 执行 `002_product-domain.sql`,它会补上 `pricing_quotes.product_variant_id` 的真实外键和 `product_platform_profile_id` 关联。 +7. 启动 Outbox Dispatcher 与过期幂等记录/已投递事件的清理 Worker;在 staging 按文档模拟投递故障并验证可恢复性。 ## 5. 仍不在此 DDL 内的内容 - 短信发送、验证码、风控供应商和密码找回流程; - Ozon API 的实际同步 Worker、密钥轮换 Worker 和对象存储上传; -- 商品/SPU/SKU、媒体处理任务与真正的上架任务表; +- 商品编辑和在线商品页面的具体列表字段、筛选与交互(表结构已预留,需求仍可继续细化); - 平台/物流/佣金规则的后台管理 UI 与导入程序。 diff --git a/docs/architecture/product-domain-data-model.md b/docs/architecture/product-domain-data-model.md new file mode 100644 index 0000000..b8a34d2 --- /dev/null +++ b/docs/architecture/product-domain-data-model.md @@ -0,0 +1,47 @@ +# 商品域数据模型与迁移 + +> 可执行 PostgreSQL DDL:[`sql/002_product-domain.sql`](./sql/002_product-domain.sql)。必须先执行 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。 + +## 1. 两个不能混用的商品域 + +```text +工作空间 +├─ 采集箱商品(跨平台 Atlas 草稿) +│ collection_items → products → product_variants +│ └→ product_platform_profiles(每个平台一份) +│ └→ product_variant_platform_listings(每个 SKU 首次导入状态) +└─ 在线商品(平台 + 门店独有的同步镜像) + platform_product_sync_runs → platform_listings → platform_listing_sync_snapshots +``` + +`products` / `product_variants` 只服务采集箱商品,保存来源、采购和跨平台可复用资料。`platform_listings` 则是目标平台、目标门店的实际商品镜像;它不能跨平台或跨门店复用,即使它关联了 Atlas SKU。`product_platform_profiles.market_code` 表示当前平台业务上下文,首期取平台默认 `RU`,不从门店派生。 + +## 2. 关键关系与状态 + +| 表 | 粒度 | 核心约束 | +|---|---|---| +| `collection_items` | 工作空间的一条采集箱记录 | 来源平台商品按 `workspace + source_platform + source_item_key` 幂等;手动商品无来源平台。`workspaces.collection_item_limit` 默认 1000,触发器在创建或恢复记录时以工作空间行锁保证不超限。 | +| `products` / `product_variants` | 采集箱 SPU / SKU | SKU 删除是 Atlas 软删除,不下架任何平台商品。 | +| `collection_asset_transfers` | 来源素材转存任务 | 来源 URL 先以持久化任务进入 Worker;成功后才创建 `media_assets` 与商品候选素材。失败可单独重试,不能把未下载素材伪装成资产。 | +| `collection_seller_enrichment_observations` | Ozon Seller 本地观察结果 | 记录浏览器同源调用四个网页内部接口后的净化结果或不可用原因;不保存 Cookie、授权头或完整会话信息。 | +| `product_platform_profiles` | 采集箱商品 × 目标平台 | 同一采集箱 SPU 在一个平台只有一份配置;A/B 平台的类目、属性、标题、价格和状态独立。 | +| `product_variant_platform_listings` | 平台配置 × Atlas SKU | 保存 `offer_id`、首次导入状态、Ozon `product_id`、实际提交时的门店和价格。`imported` 后不可再次从采集箱提交。 | +| `platform_listings` | 平台 × 门店 × Ozon 商品 SKU | 以店铺内 Ozon `product_id` / `offer_id` 唯一;保存 `visible`、库存、审核、错误和最新原始响应。 | + +采集箱页面按**当前页头平台**汇总 `product_variant_platform_listings`:无成功导入 SKU 为“未上架”,部分成功为“已部分上架”,全部未删除 SKU 成功为“已上架”。这与 `platform_listings.visible` 完全分离:后者只代表 Ozon 当前是否可购买/可见。 + +## 3. 媒体、AI、任务与审计 + +- `product_media_candidates` 是来源图、AI 图和手工上传图的候选池。 +- `product_variant_listing_media` 是当前平台 SKU 最终选图与顺序;Ozon 首期最多 15 张由发布适配器校验,数据库不硬编码该上限,以支持未来平台差异。 +- `media_derivatives` 记录“编辑图片 → 添加水印”生成的派生资产,固化水印模板版本;原图不被覆盖。 +- `product_ai_tasks` 保存 AI 文案、类目、套图和尺寸图任务的输入快照、模型信息、输出和错误;`ai_category_suggestions` 保存可供用户确认的类目候选。 +- `product_publish_jobs` / `product_publish_job_items` 是 BullMQ 之外的持久化发布事实;重试不能仅依赖 Redis/BullMQ 状态。 +- `platform_product_sync_runs` / `platform_listing_sync_snapshots` 为 Ozon 在线商品全量同步保留运行记录和响应审计。 + +## 4. 执行顺序 + +1. 在空库依次执行 `001_foundation.sql`、`002_product-domain.sql`。 +2. 使用 NestJS migration runner 记录迁移版本;生产环境禁止手工在控制台执行。 +3. 每个请求在成员授权后,在同一事务执行 `SET LOCAL app.workspace_id = ''`;API/Worker 使用专用非所有者、无 `BYPASSRLS` 的应用角色。两份迁移对业务表均 `FORCE ROW LEVEL SECURITY`,避免所有者静默绕过。 +4. 商品发布、图片处理、AI、Ozon 同步均先落事务表,再投递 BullMQ;Worker 应能据表中的未完成任务恢复。 diff --git a/docs/architecture/sql/001_foundation.sql b/docs/architecture/sql/001_foundation.sql index d2d5ce0..ae08412 100644 --- a/docs/architecture/sql/001_foundation.sql +++ b/docs/architecture/sql/001_foundation.sql @@ -19,14 +19,21 @@ CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_ CREATE TYPE fulfillment_mode AS ENUM ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown'); CREATE TYPE billing_weight_basis AS ENUM ('actual_only', 'max_actual_volume', 'conditional_volume'); CREATE TYPE quote_status AS ENUM ('draft', 'calculated', 'confirmed', 'invalid', 'superseded'); +CREATE TYPE exchange_rate_source AS ENUM ('cbr', 'exchange_rate_api_open', 'frankfurter', 'manual'); +CREATE TYPE exchange_rate_kind AS ENUM ('official_reference', 'market_reference', 'manual'); +CREATE TYPE fx_rate_origin AS ENUM ('system_rate', 'policy_fixed', 'quote_override'); +CREATE TYPE operation_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); +CREATE TYPE media_upload_status AS ENUM ('pending', 'uploaded', 'validating', 'ready', 'failed', 'expired'); +CREATE TYPE idempotency_request_status AS ENUM ('in_progress', 'completed'); +CREATE TYPE outbox_event_status AS ENUM ('pending', 'dispatching', 'dispatched', 'dead_letter'); CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - phone_e164 TEXT NOT NULL UNIQUE CHECK (phone_e164 ~ '^\\+[1-9][0-9]{5,14}$'), + email CITEXT NOT NULL UNIQUE CHECK (length(email) <= 254 AND position('@' IN email) > 1), password_hash TEXT NOT NULL, display_name TEXT, status account_status NOT NULL DEFAULT 'active', - phone_verified_at TIMESTAMPTZ, + email_verified_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -87,6 +94,26 @@ CREATE TABLE platform_currency_support ( PRIMARY KEY (platform_id, currency_code) ); +-- Global immutable rate history. rate always means one unit of base_currency_code equals rate units of quote_currency_code. +CREATE TABLE exchange_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + base_currency_code CHAR(3) NOT NULL REFERENCES currencies(code), + quote_currency_code CHAR(3) NOT NULL REFERENCES currencies(code), + rate NUMERIC(24,10) NOT NULL CHECK (rate > 0), + kind exchange_rate_kind NOT NULL, + source exchange_rate_source NOT NULL, + source_reference TEXT, + effective_on DATE NOT NULL, + observed_at TIMESTAMPTZ, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (base_currency_code <> quote_currency_code), + UNIQUE (base_currency_code, quote_currency_code, kind, source, effective_on) +); +CREATE INDEX ix_exchange_rates_current_lookup + ON exchange_rates (base_currency_code, quote_currency_code, kind, effective_on DESC, fetched_at DESC); + CREATE TABLE stores ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, @@ -99,12 +126,16 @@ CREATE TABLE stores ( status store_status NOT NULL DEFAULT 'verifying', credential_status credential_status NOT NULL DEFAULT 'unknown', credential_checked_at TIMESTAMPTZ, + last_error_code TEXT, + last_error_message TEXT, + last_error_at TIMESTAMPTZ, api_key_expires_at TIMESTAMPTZ, product_limit INTEGER CHECK (product_limit IS NULL OR product_limit >= 0), product_limit_checked_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), UNIQUE (workspace_id, id), UNIQUE (workspace_id, id, platform_id), UNIQUE (workspace_id, platform_id, platform_client_id) @@ -153,9 +184,94 @@ CREATE TABLE store_warehouses ( UNIQUE (workspace_id, store_id, platform_warehouse_id) ); +-- Durable API-visible operation state. Queue job ids are auxiliary and never the only record. +CREATE TABLE operations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + kind TEXT NOT NULL, + status operation_status NOT NULL DEFAULT 'queued', + resource_type TEXT, + resource_id UUID, + result JSONB NOT NULL DEFAULT '{}'::jsonb, + error_code TEXT, + error_message TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + CHECK (completed_at IS NULL OR completed_at >= created_at), + UNIQUE (workspace_id, id) +); +CREATE INDEX ix_operations_workspace_status ON operations (workspace_id, status, created_at DESC); + +-- Durable replay record for authenticated, retryable business writes. +-- request_fingerprint is an HMAC of canonical request content, never a raw request body. +CREATE TABLE idempotency_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + http_method TEXT NOT NULL CHECK (http_method IN ('POST', 'PUT', 'PATCH', 'DELETE')), + request_path TEXT NOT NULL CHECK (request_path ~ '^/api/'), + idempotency_key UUID NOT NULL, + request_fingerprint TEXT NOT NULL, + status idempotency_request_status NOT NULL DEFAULT 'in_progress', + response_status SMALLINT, + response_body JSONB, + resource_type TEXT, + resource_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '48 hours', + CHECK (expires_at > created_at), + CHECK ( + (status = 'in_progress' AND completed_at IS NULL AND response_status IS NULL) + OR (status = 'completed' AND completed_at IS NOT NULL AND response_status BETWEEN 200 AND 599) + ), + UNIQUE (workspace_id, user_id, http_method, request_path, idempotency_key), + UNIQUE (workspace_id, id) +); +CREATE INDEX ix_idempotency_requests_expiry + ON idempotency_requests (expires_at); + +-- Transactional Outbox. A dispatcher makes these events available to BullMQ only after their business transaction commits. +-- event_payload contains identifiers and immutable snapshots only; it must not contain credentials or access URLs. +CREATE TABLE outbox_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + aggregate_type TEXT NOT NULL, + aggregate_id UUID NOT NULL, + event_type TEXT NOT NULL, + event_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + deduplication_key TEXT, + status outbox_event_status NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + locked_by TEXT, + locked_until TIMESTAMPTZ, + dispatched_at TIMESTAMPTZ, + last_error_code TEXT, + last_error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + (status = 'dispatching' AND locked_by IS NOT NULL AND locked_until IS NOT NULL) + OR (status <> 'dispatching' AND locked_by IS NULL AND locked_until IS NULL) + ), + CHECK (dispatched_at IS NULL OR status = 'dispatched'), + UNIQUE (workspace_id, id) +); +CREATE INDEX ix_outbox_events_dispatch + ON outbox_events (workspace_id, status, next_attempt_at, created_at) + WHERE status IN ('pending', 'dispatching'); +CREATE INDEX ix_outbox_events_aggregate + ON outbox_events (workspace_id, aggregate_type, aggregate_id, created_at DESC); + CREATE TABLE media_assets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + storage_provider TEXT NOT NULL, -- local_minio / qiniu_kodo; provider configuration is server-side only. + bucket TEXT NOT NULL, object_key TEXT NOT NULL, content_type TEXT NOT NULL, byte_size BIGINT NOT NULL CHECK (byte_size > 0), @@ -164,10 +280,38 @@ CREATE TABLE media_assets ( height_px INTEGER CHECK (height_px IS NULL OR height_px > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (workspace_id, id), - UNIQUE (workspace_id, object_key), + UNIQUE (workspace_id, storage_provider, bucket, object_key), UNIQUE (workspace_id, checksum_sha256) ); +-- A short-lived, durable record for browser direct uploads. object_key is never returned as public file identity. +CREATE TABLE media_upload_intents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + purpose TEXT NOT NULL, + storage_provider TEXT NOT NULL, + bucket TEXT NOT NULL, + object_key TEXT NOT NULL, + expected_content_type TEXT NOT NULL, + expected_byte_size BIGINT NOT NULL CHECK (expected_byte_size > 0), + expected_checksum_sha256 TEXT NOT NULL, + status media_upload_status NOT NULL DEFAULT 'pending', + asset_id UUID, + error_code TEXT, + error_message TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + CHECK (expires_at > created_at), + CHECK (completed_at IS NULL OR completed_at >= created_at), + CHECK ((status = 'ready') = (asset_id IS NOT NULL)), + UNIQUE (workspace_id, id), + UNIQUE (storage_provider, bucket, object_key) +); +CREATE INDEX ix_media_upload_intents_expiry ON media_upload_intents (status, expires_at); + CREATE TABLE watermark_fonts ( code CITEXT PRIMARY KEY, display_name TEXT NOT NULL, @@ -185,6 +329,7 @@ CREATE TABLE watermark_templates ( deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), UNIQUE (workspace_id, id), UNIQUE (workspace_id, name) ); @@ -206,6 +351,7 @@ CREATE TABLE watermark_template_versions ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, image_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), UNIQUE (workspace_id, template_id, version), CHECK ( (kind = 'text' AND text_content IS NOT NULL AND font_code IS NOT NULL AND font_size_px IS NOT NULL AND image_asset_id IS NULL) @@ -415,10 +561,12 @@ CREATE TABLE pricing_policies ( other_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (other_rate >= 0 AND other_rate < 1), desired_markup_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (desired_markup_rate >= 0), list_price_discount_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (list_price_discount_rate >= 0 AND list_price_discount_rate < 1), + allow_commission_override BOOLEAN NOT NULL DEFAULT false, fx_strategy TEXT NOT NULL DEFAULT 'system' CHECK (fx_strategy IN ('system', 'fixed', 'override_allowed')), fixed_cny_to_rub_rate NUMERIC(18,8), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, FOREIGN KEY (platform_id, rate_card_id) REFERENCES logistics_rate_cards(platform_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), @@ -455,6 +603,8 @@ CREATE TABLE pricing_quotes ( package_width_cm NUMERIC(12,3) NOT NULL CHECK (package_width_cm > 0), package_height_cm NUMERIC(12,3) NOT NULL CHECK (package_height_cm > 0), fx_cny_to_rub NUMERIC(18,8) NOT NULL CHECK (fx_cny_to_rub > 0), + fx_rate_origin fx_rate_origin NOT NULL DEFAULT 'system_rate', + exchange_rate_id UUID REFERENCES exchange_rates(id) ON DELETE SET NULL, input_snapshot JSONB NOT NULL, status quote_status NOT NULL DEFAULT 'draft', selected_option_id UUID, @@ -512,12 +662,14 @@ DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ - 'stores', 'store_secrets', 'store_warehouses', 'media_assets', + 'stores', 'store_secrets', 'store_warehouses', 'operations', 'idempotency_requests', 'outbox_events', + 'media_assets', 'media_upload_intents', 'watermark_templates', 'watermark_template_versions', 'store_watermark_bindings', 'pricing_policies', 'pricing_policy_transport_methods', 'pricing_quotes', 'pricing_quote_options' ] LOOP EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name); EXECUTE format( 'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name diff --git a/docs/architecture/sql/002_product-domain.sql b/docs/architecture/sql/002_product-domain.sql new file mode 100644 index 0000000..60286d2 --- /dev/null +++ b/docs/architecture/sql/002_product-domain.sql @@ -0,0 +1,591 @@ +-- Atlas Seller Kit: collection box, product editor, publishing and online listing schema +-- PostgreSQL 15+. Run once after 001_foundation.sql through the migration tool. + +SET search_path TO atlas, public; + +CREATE TYPE collection_source_kind AS ENUM ('platform', 'manual'); +CREATE TYPE collection_item_status AS ENUM ( + 'collecting', 'received', 'materializing', 'ready_for_edit', + 'needs_review', 'failed', 'archived', 'deleted' +); +CREATE TYPE attribute_scope AS ENUM ('common', 'variant'); +CREATE TYPE data_confirmation_status AS ENUM ('confirmed', 'ambiguous', 'missing'); +CREATE TYPE platform_variant_listing_status AS ENUM ('not_listed', 'submitting', 'imported', 'failed'); +CREATE TYPE media_candidate_role AS ENUM ('source_main', 'source_sku', 'source_detail', 'ai_generated', 'manual_upload'); +CREATE TYPE media_derivative_kind AS ENUM ('watermark'); +CREATE TYPE processing_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); +CREATE TYPE seller_enrichment_observation_status AS ENUM ('observed', 'unavailable', 'failed'); +CREATE TYPE ai_task_kind AS ENUM ('copy', 'category_suggestion', 'image_suite', 'dimension_image'); +CREATE TYPE publish_job_status AS ENUM ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed', 'cancelled'); +CREATE TYPE publish_job_item_status AS ENUM ('queued', 'submitting', 'polling', 'imported', 'failed', 'skipped'); +CREATE TYPE platform_listing_source AS ENUM ('atlas_published', 'external_sync'); +CREATE TYPE platform_sync_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); + +-- Default can later be changed by subscription/operations policy without changing collection-item rows. +ALTER TABLE workspaces + ADD COLUMN collection_item_limit INTEGER NOT NULL DEFAULT 1000 CHECK (collection_item_limit >= 0); + +-- A collection item is workspace-wide and intentionally has no target platform or store. +CREATE TABLE collection_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + source_kind collection_source_kind NOT NULL, + source_platform_id UUID REFERENCES platforms(id) ON DELETE RESTRICT, + source_item_key TEXT NOT NULL, + source_url TEXT, + source_title TEXT, + source_currency_code CHAR(3) REFERENCES currencies(code), + source_min_price NUMERIC(18,4) CHECK (source_min_price IS NULL OR source_min_price >= 0), + source_max_price NUMERIC(18,4) CHECK (source_max_price IS NULL OR source_max_price >= 0), + note TEXT, + status collection_item_status NOT NULL DEFAULT 'received', + raw_evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + parser_version TEXT, + collected_at TIMESTAMPTZ, + last_error JSONB, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), + UNIQUE (workspace_id, id), + CHECK ( + (source_kind = 'platform' AND source_platform_id IS NOT NULL) + OR (source_kind = 'manual' AND source_platform_id IS NULL) + ), + CHECK (source_max_price IS NULL OR source_min_price IS NULL OR source_max_price >= source_min_price), + CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); +CREATE UNIQUE INDEX ux_collection_items_active_source_identity + ON collection_items (workspace_id, source_platform_id, source_item_key) + WHERE source_platform_id IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX ix_collection_items_workspace_list + ON collection_items (workspace_id, updated_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX ix_collection_items_workspace_source_status + ON collection_items (workspace_id, source_platform_id, status, updated_at DESC) WHERE deleted_at IS NULL; + +-- Locking the one workspace row serializes only collection-box creates/restores in that workspace. +CREATE OR REPLACE FUNCTION enforce_collection_item_limit() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +DECLARE + configured_limit INTEGER; + active_count INTEGER; +BEGIN + IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL) + OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN + SELECT collection_item_limit INTO configured_limit + FROM atlas.workspaces + WHERE id = NEW.workspace_id + FOR UPDATE; + + SELECT count(*) INTO active_count + FROM atlas.collection_items + WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL; + + IF active_count >= configured_limit THEN + RAISE EXCEPTION 'collection item limit (%) reached for workspace %', configured_limit, NEW.workspace_id + USING ERRCODE = 'check_violation'; + END IF; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER enforce_collection_item_limit_before_write + BEFORE INSERT OR UPDATE OF deleted_at ON collection_items + FOR EACH ROW EXECUTE FUNCTION enforce_collection_item_limit(); + + +-- products and product_variants are Atlas collection-box drafts, not online products. +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + collection_item_id UUID NOT NULL, + source_title TEXT, + brand TEXT, + model TEXT, + barcode TEXT, + purchase_url TEXT, + supply_note TEXT, + default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0), + default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0), + default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0), + default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0), + default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0), + default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0), + default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0), + default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0), + physical_spec_status data_confirmation_status NOT NULL DEFAULT 'missing', + package_spec_status data_confirmation_status NOT NULL DEFAULT 'missing', + version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, collection_item_id) + REFERENCES collection_items(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, collection_item_id) +); + +CREATE TABLE product_variants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + source_variant_key TEXT, + display_name TEXT, + source_sku TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0), + specification_values JSONB NOT NULL DEFAULT '[]'::jsonb, + physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0), + physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0), + physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0), + net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0), + package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0), + package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0), + package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0), + shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0), + physical_spec_status data_confirmation_status, + package_spec_status data_confirmation_status, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id) +); +CREATE UNIQUE INDEX ux_product_variants_source_key + ON product_variants (workspace_id, product_id, source_variant_key) + WHERE source_variant_key IS NOT NULL; +CREATE INDEX ix_product_variants_product ON product_variants (workspace_id, product_id, sort_order) WHERE deleted_at IS NULL; + +-- Source image URLs are untrusted remote inputs. Transfer state exists before a media_asset can be created. +CREATE TABLE collection_asset_transfers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + collection_item_id UUID NOT NULL, + product_id UUID NOT NULL, + source_variant_id UUID, + role media_candidate_role NOT NULL, + source_url TEXT NOT NULL, + referer_url TEXT, + source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1), + sort_order INTEGER NOT NULL DEFAULT 0, + status processing_status NOT NULL DEFAULT 'queued', + asset_id UUID, + error_payload JSONB, + task_key UUID NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, + FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + CHECK ((status = 'succeeded') = (asset_id IS NOT NULL)), + CHECK (completed_at IS NULL OR completed_at >= created_at), + UNIQUE (workspace_id, task_key) +); +CREATE INDEX ix_collection_asset_transfers_workspace_status + ON collection_asset_transfers (workspace_id, status, created_at DESC); + +-- Sanitized results from Ozon Seller web local-observation calls. No Cookie or authorization data is stored. +CREATE TABLE collection_seller_enrichment_observations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + collection_item_id UUID NOT NULL, + source_variant_id UUID, + interface_code TEXT NOT NULL, + status seller_enrichment_observation_status NOT NULL, + sanitized_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + unavailable_reason TEXT, + parser_version TEXT, + observed_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, + CHECK ((status = 'observed') = (unavailable_reason IS NULL)), + UNIQUE (workspace_id, id) +); +CREATE INDEX ix_seller_enrichment_observations_collection + ON collection_seller_enrichment_observations (workspace_id, collection_item_id, observed_at DESC); + +-- One collection draft can have a separate profile for every target platform. +CREATE TABLE product_platform_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + platform_id UUID NOT NULL, + target_store_id UUID NOT NULL, + market_code TEXT NOT NULL DEFAULT 'RU', + fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown', + title TEXT, + description TEXT, + brand TEXT, + model TEXT, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + platform_product_category_id UUID, + platform_type_id TEXT, + category_source TEXT CHECK (category_source IN ('source_capture', 'ai_suggestion', 'manual')), + version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, target_store_id, platform_id) + REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + FOREIGN KEY (platform_id, platform_product_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, id, platform_id), + UNIQUE (workspace_id, product_id, platform_id) +); +CREATE INDEX ix_product_platform_profiles_store ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC); + +-- Stores every SKU's first-import state in the current platform profile. Imported rows are never reused for re-import. +CREATE TABLE product_variant_platform_listings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + profile_id UUID NOT NULL, + platform_id UUID NOT NULL, + product_variant_id UUID NOT NULL, + store_id UUID NOT NULL, + offer_id TEXT, + currency_code CHAR(3) REFERENCES currencies(code), + selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0), + list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0), + pricing_quote_id UUID, + status platform_variant_listing_status NOT NULL DEFAULT 'not_listed', + platform_product_id TEXT, + first_imported_at TIMESTAMPTZ, + last_publish_job_item_id UUID, + last_error JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, profile_id, platform_id) + REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_variant_id) + REFERENCES product_variants(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, store_id, platform_id) + REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, profile_id, product_variant_id), + CHECK (list_price IS NULL OR selling_price IS NULL OR list_price >= selling_price), + CHECK ( + (status = 'imported' AND first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL) + OR status <> 'imported' + ) +); +CREATE UNIQUE INDEX ux_variant_platform_listings_store_offer + ON product_variant_platform_listings (workspace_id, store_id, platform_id, offer_id) + WHERE offer_id IS NOT NULL; +CREATE INDEX ix_variant_platform_listings_profile_status + ON product_variant_platform_listings (workspace_id, profile_id, status); + +CREATE TABLE product_attributes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + profile_id UUID NOT NULL, + product_variant_id UUID, + scope attribute_scope NOT NULL, + platform_attribute_id TEXT NOT NULL, + complex_id TEXT NOT NULL DEFAULT '0', + values JSONB NOT NULL DEFAULT '[]'::jsonb, + language_code TEXT, + source TEXT NOT NULL DEFAULT 'manual', + confirmation_status data_confirmation_status NOT NULL DEFAULT 'confirmed', + raw_value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE, + CHECK ((scope = 'common' AND product_variant_id IS NULL) OR (scope = 'variant' AND product_variant_id IS NOT NULL)) +); +CREATE UNIQUE INDEX ux_product_attributes_common + ON product_attributes (workspace_id, profile_id, platform_attribute_id, complex_id, COALESCE(language_code, '')) + WHERE scope = 'common'; +CREATE UNIQUE INDEX ux_product_attributes_variant + ON product_attributes (workspace_id, profile_id, product_variant_id, platform_attribute_id, complex_id, COALESCE(language_code, '')) + WHERE scope = 'variant'; + +CREATE TABLE product_media_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + asset_id UUID NOT NULL, + source_variant_id UUID, + role media_candidate_role NOT NULL, + source_url TEXT, + source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, + UNIQUE (workspace_id, product_id, asset_id, role) +); + +-- Selected media is platform-SKU-specific because each platform can have different image limits and watermarked variants. +CREATE TABLE product_variant_listing_media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + variant_listing_id UUID NOT NULL, + asset_id UUID NOT NULL, + source_asset_id UUID, + sort_order SMALLINT NOT NULL CHECK (sort_order >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, variant_listing_id) + REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, variant_listing_id, sort_order), + UNIQUE (workspace_id, variant_listing_id, asset_id) +); + +CREATE TABLE media_derivatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + kind media_derivative_kind NOT NULL, + source_asset_id UUID NOT NULL, + output_asset_id UUID, + watermark_template_version_id UUID, + status processing_status NOT NULL DEFAULT 'queued', + task_key UUID NOT NULL DEFAULT gen_random_uuid(), + error_payload JSONB, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, watermark_template_version_id) + REFERENCES watermark_template_versions(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, task_key), + CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)), + CHECK (completed_at IS NULL OR completed_at >= created_at) +); + +CREATE TABLE product_ai_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + product_id UUID NOT NULL, + profile_id UUID, + product_variant_id UUID, + kind ai_task_kind NOT NULL, + status processing_status NOT NULL DEFAULT 'queued', + provider_code TEXT, + model_code TEXT, + input_snapshot JSONB NOT NULL, + output_payload JSONB, + error_payload JSONB, + queue_job_id TEXT, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE, + UNIQUE (workspace_id, id), + CHECK (completed_at IS NULL OR completed_at >= created_at) +); +CREATE INDEX ix_product_ai_tasks_workspace_status ON product_ai_tasks (workspace_id, status, created_at DESC); + +CREATE TABLE ai_category_suggestions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + ai_task_id UUID NOT NULL, + profile_id UUID NOT NULL, + platform_id UUID NOT NULL, + platform_product_category_id UUID NOT NULL, + platform_type_id TEXT, + confidence NUMERIC(5,4) CHECK (confidence BETWEEN 0 AND 1), + rationale TEXT, + rank SMALLINT NOT NULL CHECK (rank > 0), + applied_at TIMESTAMPTZ, + applied_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, ai_task_id) REFERENCES product_ai_tasks(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, profile_id, platform_id) + REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (platform_id, platform_product_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, ai_task_id, rank) +); + +-- Durable publish intent and per-SKU Ozon import outcomes. BullMQ job ids are auxiliary, never the only state. +CREATE TABLE product_publish_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + profile_id UUID NOT NULL, + platform_id UUID NOT NULL, + store_id UUID NOT NULL, + status publish_job_status NOT NULL DEFAULT 'queued', + idempotency_key UUID NOT NULL DEFAULT gen_random_uuid(), + queue_job_id TEXT, + request_snapshot JSONB NOT NULL, + error_payload JSONB, + submitted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, profile_id, platform_id) + REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE RESTRICT, + FOREIGN KEY (workspace_id, store_id, platform_id) + REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, idempotency_key), + CHECK (completed_at IS NULL OR completed_at >= created_at) +); + +CREATE TABLE product_publish_job_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + publish_job_id UUID NOT NULL, + variant_listing_id UUID NOT NULL, + status publish_job_item_status NOT NULL DEFAULT 'queued', + platform_task_id TEXT, + platform_product_id TEXT, + request_snapshot JSONB NOT NULL, + response_snapshot JSONB, + error_payload JSONB, + submitted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, publish_job_id) REFERENCES product_publish_jobs(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, variant_listing_id) + REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, publish_job_id, variant_listing_id), + CHECK (completed_at IS NULL OR completed_at >= created_at) +); +ALTER TABLE product_variant_platform_listings + ADD CONSTRAINT fk_variant_listing_last_publish_job_item + FOREIGN KEY (last_publish_job_item_id) REFERENCES product_publish_job_items(id) ON DELETE SET NULL; + +-- Online products are a separate, store-owned Ozon mirror. They never become cross-platform collection-box drafts. +CREATE TABLE platform_product_sync_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + store_id UUID NOT NULL, + platform_id UUID NOT NULL, + status platform_sync_status NOT NULL DEFAULT 'queued', + trigger_source TEXT NOT NULL CHECK (trigger_source IN ('manual', 'scheduled', 'recovery')), + queue_job_id TEXT, + cursor_started TEXT, + cursor_finished TEXT, + listed_count INTEGER NOT NULL DEFAULT 0 CHECK (listed_count >= 0), + upserted_count INTEGER NOT NULL DEFAULT 0 CHECK (upserted_count >= 0), + failed_count INTEGER NOT NULL DEFAULT 0 CHECK (failed_count >= 0), + error_payload JSONB, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, + UNIQUE (workspace_id, id), + CHECK (completed_at IS NULL OR completed_at >= created_at) +); +CREATE INDEX ix_platform_product_sync_runs_store ON platform_product_sync_runs (workspace_id, store_id, created_at DESC); +CREATE UNIQUE INDEX ux_platform_product_sync_runs_one_active_per_store + ON platform_product_sync_runs (workspace_id, store_id) + WHERE status IN ('queued', 'running'); + +CREATE TABLE platform_listings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + store_id UUID NOT NULL, + platform_id UUID NOT NULL, + remote_product_id TEXT NOT NULL, + offer_id TEXT NOT NULL, + remote_sku TEXT, + name TEXT, + currency_code CHAR(3) REFERENCES currencies(code), + selling_price NUMERIC(18,4), + list_price NUMERIC(18,4), + visible BOOLEAN, + remote_state TEXT, + moderation_status TEXT, + validation_status TEXT, + stock_summary JSONB NOT NULL DEFAULT '{}'::jsonb, + image_summary JSONB NOT NULL DEFAULT '[]'::jsonb, + error_payload JSONB NOT NULL DEFAULT '[]'::jsonb, + visibility_details JSONB NOT NULL DEFAULT '{}'::jsonb, + source platform_listing_source NOT NULL, + atlas_variant_listing_id UUID, + last_sync_run_id UUID, + last_synced_at TIMESTAMPTZ NOT NULL, + missing_since TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + raw_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, atlas_variant_listing_id) + REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE SET NULL, + FOREIGN KEY (workspace_id, last_sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE SET NULL, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, store_id, platform_id, remote_product_id), + UNIQUE (workspace_id, store_id, platform_id, offer_id) +); +CREATE INDEX ix_platform_listings_store_visibility ON platform_listings (workspace_id, store_id, visible, updated_at DESC); +CREATE INDEX ix_platform_listings_atlas_mapping ON platform_listings (workspace_id, atlas_variant_listing_id) WHERE atlas_variant_listing_id IS NOT NULL; + +CREATE TABLE platform_listing_sync_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + platform_listing_id UUID NOT NULL, + sync_run_id UUID NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, platform_listing_id) REFERENCES platform_listings(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE CASCADE, + UNIQUE (workspace_id, platform_listing_id, sync_run_id) +); + +-- The foundation migration intentionally created product_variant_id as a logical FK. +ALTER TABLE pricing_quotes + ADD COLUMN product_platform_profile_id UUID, + ADD CONSTRAINT fk_pricing_quotes_product_variant + FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, + ADD CONSTRAINT fk_pricing_quotes_product_profile + FOREIGN KEY (workspace_id, product_platform_profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE SET NULL; + +CREATE OR REPLACE FUNCTION set_row_updated_at() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +DO $$ +DECLARE table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'product_platform_profiles', + 'product_variant_platform_listings', 'product_attributes', 'product_variant_listing_media', + 'product_publish_jobs', 'product_publish_job_items', 'platform_listings' + ] + LOOP + EXECUTE format('CREATE TRIGGER set_%I_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_row_updated_at()', table_name, table_name); + END LOOP; +END $$; + +-- Tenant isolation. app_current_workspace_id() is defined in 001_foundation.sql. +DO $$ +DECLARE table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'collection_seller_enrichment_observations', 'product_platform_profiles', + 'product_variant_platform_listings', 'product_attributes', 'product_media_candidates', + 'product_variant_listing_media', 'media_derivatives', 'product_ai_tasks', 'ai_category_suggestions', + 'product_publish_jobs', 'product_publish_job_items', 'platform_product_sync_runs', + 'platform_listings', 'platform_listing_sync_snapshots' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name); + EXECUTE format( + 'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', + table_name + ); + END LOOP; +END $$; diff --git a/docs/architecture/technology-and-deployment.md b/docs/architecture/technology-and-deployment.md index 6e2650a..29629ac 100644 --- a/docs/architecture/technology-and-deployment.md +++ b/docs/architecture/technology-and-deployment.md @@ -46,7 +46,7 @@ Redis 是高速的临时基础设施;BullMQ 使用 Redis 保存队列中的任 实现要求: -1. 所有长任务先在 PostgreSQL 创建任务记录,再在事务提交后投递队列;后续实施时使用 outbox 模式避免“数据库已提交但任务未投递”的遗漏。 +1. 所有长任务在同一 PostgreSQL 事务中创建业务任务/`operation` 和 `outbox_events`;事务提交后由独立 Dispatcher 投递 BullMQ,避免“数据库已提交但任务未投递”。详见 [`transactional-outbox-and-idempotency.md`](./transactional-outbox-and-idempotency.md)。 2. 任务必须幂等:相同 SKU 的一次发布任务重试,不得重复创建平台商品。 3. 重试必须有次数上限、指数退避和可见错误;Ozon 等第三方 API 需按店铺/API 限额控制并发。 4. Redis 可丢失或重建,Worker 应能根据 PostgreSQL 中的未完成任务恢复投递;业务结果不只存在于 Redis。 @@ -102,6 +102,8 @@ workspaces/{workspaceId}/ 不能把生产数据库作为开发数据库,也不建议所有日常开发直接共用同一云端数据库。数据库结构通过迁移文件保持一致;演示数据通过版本化 seed 脚本保持一致。迁移顺序必须是 local → staging → production,并在生产前备份和验证回滚方案。 +数据库权限分为迁移角色与应用角色:迁移角色拥有表并只在部署迁移时使用;API/Worker 的应用登录角色只获业务表所需的 DML 权限,不拥有表且没有 `BYPASSRLS`。RLS 策略使用每个事务设置的 `app.workspace_id`;业务表启用并强制 RLS。具体初始化和权限清单见 [`database-security.md`](./database-security.md)。 + ## 6. AI 能力边界 首期 AI 智能规划、文案和生图均通过 Provider Adapter 调用云模型 API。NestJS 负责权限、上下文组装、结构化结果校验、任务状态和人工确认;模型不得直接改价、发布或修改门店数据。 @@ -115,3 +117,9 @@ workspaces/{workspaceId}/ 首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。 在规模、可用性或多机部署出现明确瓶颈前,不引入 Kubernetes。 + +## 8. Ozon Seller 浏览器会话边界 + +一期浏览器插件可以在用户已登录的 `seller.ozon.ru` 标签页内,以同源请求调用已列白名单的四个网页内部接口,保存净化后的观察结果。该模式使用浏览器自动附带的会话 Cookie,但不提取完整 Cookie、不上传、不在 Atlas 保存,也不能交给云端 Worker 执行。 + +未来若要支持加密保存浏览器 Cookie,必须作为独立的二级实验能力:按门店验证会话归属,使用信封加密和可撤销的专用密钥记录,任务队列仅传密钥记录 ID,并逐接口限制用途。此类 Cookie 会话不替代官方 Seller API 的发布、库存或状态同步凭据。 diff --git a/docs/architecture/transactional-outbox-and-idempotency.md b/docs/architecture/transactional-outbox-and-idempotency.md new file mode 100644 index 0000000..842fcd5 --- /dev/null +++ b/docs/architecture/transactional-outbox-and-idempotency.md @@ -0,0 +1,79 @@ +# 幂等请求与 Transactional Outbox + +> 状态:已确认的基础设施设计。对应 DDL 位于 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。首期只接入关键写操作和异步链路,不要求所有接口一次完成。 + +## 1. 分别解决什么问题 + +`idempotency_requests` 防止同一个用户请求被重复执行;典型原因是重复点击、浏览器超时重试或网络重放。它的结果是:同一个写请求只产生一份门店、发布任务或 AI 任务。 + +`outbox_events` 防止业务提交与队列投递之间出现空档。它的结果是:即使 API 在数据库提交后、BullMQ 入队前崩溃,Dispatcher 仍能从 PostgreSQL 找到待投递事件并补投。 + +两者互补但不互相替代:幂等解决“重复发起”,Outbox 解决“任务漏投递”。`operations`、发布任务、素材转存任务等业务表仍是用户可见的事实来源;Outbox 只是可靠投递机制。 + +## 2. 幂等请求的统一规则 + +仅对已认证的业务写接口使用本机制;注册、登录、刷新令牌等认证端点采用各自的限流和安全规则,不保存密码相关请求体。 + +### 2.1 调用与保存 + +客户端为一次逻辑操作生成 UUID 并放入请求头: + +```http +Idempotency-Key: 8f421c70-… +``` + +服务端在事务中以“工作空间、用户、HTTP 方法、规范化实际路径、键”查找或创建 `idempotency_requests`。`request_fingerprint` 是由服务端密钥计算的 HMAC,输入为规范化请求体和影响语义的请求头;不保存明文请求体,也不用普通哈希,以避免低熵敏感输入被离线猜测。 + +- 同一键、相同指纹、记录已完成:返回已保存的 HTTP 状态和安全响应体,不重复执行。 +- 同一键、不同指纹:返回 `409 IDEMPOTENCY_KEY_REUSED`。 +- 同一键仍在执行:返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`,客户端稍后以同一键重试。 +- 业务事务回滚:幂等记录一并回滚,客户端可安全重试。 + +成功业务事务在写入资源、操作记录和 Outbox 事件后,将幂等记录标为 `completed` 并保存响应摘要。记录至少保留 24 小时;首期 DDL 默认 48 小时,由清理任务在过期后删除。 + +## 3. Outbox 的事务与投递 + +任何会启动异步工作的业务请求,必须在**同一 PostgreSQL 事务**内完成: + +```text +写入业务资源 / operation 或领域任务 + + +写入 outbox_events(待投递) + + +完成 idempotency_requests(如该接口要求幂等) + ↓ commit +Dispatcher 投递到 BullMQ + ↓ +Worker 执行业务工作并更新领域任务/operation +``` + +`event_payload` 仅保存事件处理所需的 ID、版本或不可变业务快照。Worker 再在受 RLS 约束的数据库事务内读取完整数据;不得把 API Key、Cookie、密码、对象存储永久密钥或预签名访问 URL 放入载荷或错误信息。 + +## 4. Dispatcher 与 Worker 规则 + +1. Dispatcher 按工作空间处理待投递事件,并在每笔数据库事务中设置 `app.workspace_id`;应用角色不使用 `BYPASSRLS`。 +2. 领取事件时使用 `FOR UPDATE SKIP LOCKED`,将状态置为 `dispatching`,记录 `locked_by` 和有限的 `locked_until`。 +3. 成功调用 BullMQ 后标记 `dispatched`;以 `outbox_events.id` 作为 BullMQ `jobId`。若“已入队但尚未来得及标记”时进程崩溃,锁到期后会再次投递,因此投递语义是**至少一次**。 +4. 投递失败时增加 `attempt_count`,使用指数退避更新 `next_attempt_at` 并恢复 `pending`;超过规定次数时标记 `dead_letter`,保留脱敏错误供运维处理。 +5. Worker 必须可重入:以领域任务状态、平台侧幂等能力或自己的去重键阻止重复创建平台商品。不得把 BullMQ 的“只执行一次”当作正确性保证。 +6. 已投递事件不立即删除;保留一段时间用于排障,再由保留期清理任务删除。`idempotency_requests` 过期后也由独立清理任务删除。 + +## 5. 首批接入范围 + +| 场景 | 幂等 | Outbox | 说明 | +|---|---:|---:|---| +| 创建/替换门店授权、凭据验证 | 是 | 是 | 避免重复门店和漏掉验证任务。 | +| 上传完成后的媒体校验/处理 | 是 | 是 | 防止同一对象重复派生。 | +| 采集素材转存 | 是 | 是 | HTTP 接收成功后可恢复执行转存。 | +| 首次上架发布 | 是 | 是 | 避免重复发布请求与发布任务丢失。 | +| 在线商品全量同步 | 是 | 是 | 同一门店同时只允许一个活跃同步任务。 | +| AI 文本、类目或图片任务 | 是 | 是 | 避免重复调用和重复计费。 | +| 普通商品草稿保存 | 否(首期) | 否 | 使用 `revision` / `If-Match` 处理并发即可。 | + +## 6. 实施验收 + +- 重放同一 `Idempotency-Key` 不产生第二份资源或第二个业务任务,并返回原响应。 +- 同一键但请求不同返回 `409`;两个并发相同请求仅一个进入业务执行。 +- 在“业务事务提交后、BullMQ 投递前”模拟进程崩溃,Dispatcher 能补投。 +- 模拟 BullMQ 已接受任务但 Dispatcher 未标记成功,重复投递不重复产生平台副作用。 +- 未设置工作空间 RLS 上下文时,应用角色无法读取或投递任一工作空间的 Outbox 事件。 diff --git a/docs/guides/minio-local-deployment.md b/docs/guides/minio-local-deployment.md new file mode 100644 index 0000000..d24f5fc --- /dev/null +++ b/docs/guides/minio-local-deployment.md @@ -0,0 +1,240 @@ +# MinIO 入门:本地对象存储的部署与使用 + +> 面向 Atlas Seller Kit 的学习文档。本文解释 MinIO 是什么、本项目为什么在本地开发使用它、如何用 Docker 部署,以及如何按本项目的 `object_key` 约定访问文件。`object_key` 与 URL 的概念区分见[对象存储入门](./object-storage-object-key.md)。 + +## 1. MinIO 是什么 + +MinIO 是一个开源的对象存储服务器,完整实现了 **Amazon S3 API**。它可以理解为"你自己跑的一个 S3": + +| 项目 | MinIO | 云端对象存储(如七牛 Kodo) | +|---|---|---| +| 形态 | 单个容器/二进制,跑在自己机器或服务器上 | 托管服务,开箱即用 | +| 接口 | S3 兼容 API | S3 兼容 API(基础能力子集) | +| 数据位置 | 本机 Docker 卷 | 云端 | +| 运维 | 自己负责(本地开发无所谓) | 云厂商负责 | +| 费用 | 免费 | 按存储/流量计费 | + +它只有三个核心概念(与所有 S3 兼容存储通用): + +- **Bucket(存储桶)**:文件的顶层容器,例如 `atlas-dev-assets`; +- **Object Key(对象键)**:桶内文件的唯一键,例如 `workspaces/ws_123/products/p_456/original/asset_789.jpg`; +- **预签名 URL**:带签名和过期时间的临时访问地址,用于把私有文件的读写权限短暂借给浏览器。 + +## 2. 本项目为什么用 MinIO + +Atlas 的技术选型是:**本地开发用 MinIO,staging/production 用七牛 Kodo**(见[技术栈与部署](../architecture/technology-and-deployment.md)第 4 节)。选择这个组合的原因: + +1. **代码同构**:两者都讲 S3 方言。应用代码只写一套 S3 客户端逻辑,本地和生产只差 endpoint、凭证、桶名这几个配置项,不差代码。 +2. **行为可验证**:私有桶权限、预签名 URL 过期、ETag/Content-Type、MIME 校验这些行为,在本地 MinIO 上验证过的逻辑,到 Kodo 上基本原样成立。如果本地直接读写磁盘目录,这些逻辑完全测不到。 +3. **可随时重建**:MinIO 数据在 Docker 卷里,删掉卷重启就是全新的存储,适合配合版本化 seed 数据做开发。 +4. **成本为零**:开发阶段不产生云存储费用,也不会误用生产 Bucket。 + +注意边界:MinIO 只进**本地开发环境**。首期不在普通单台云服务器上自建生产 MinIO——磁盘冗余、备份、故障恢复、TLS、监控都要自己做,"部署了 MinIO"不等于获得托管对象存储的可靠性。 + +## 3. 用 Docker 部署本地 MinIO + +### 3.1 单命令快速启动 + +只想临时体验时: + +```bash +docker run -d --name atlas-minio \ + -p 9000:9000 -p 9001:9001 \ + -v atlas-minio-data:/data \ + minio/minio server /data --console-address ":9001" +``` + +两个端口各有用途: + +| 端口 | 用途 | +|---|---| +| `9000` | **S3 API 端口**。应用代码(SDK、`mc` 客户端)访问这个。 | +| `9001` | **Web Console 端口**。浏览器打开 `http://localhost:9001` 管理文件。 | + +### 3.2 接入项目的 Docker Compose + +正式开发应把它写进项目根目录的 `docker-compose.yml`,与 `postgres`、`redis` 并列: + +```yaml +services: + minio: + image: minio/minio:latest # 团队建议固定到具体 RELEASE tag,避免本地环境漂移 + command: server /data --console-address ":9001" + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web Console + environment: + MINIO_ROOT_USER: atlas-dev + MINIO_ROOT_PASSWORD: atlas-dev-secret # 仅本地开发用,不得用于任何共享环境 + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + minio_data: +``` + +说明: + +- `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 是这个实例的 root 账号,仅本地使用;真正的云端密钥永远走环境变量或密钥管理,不提交 Git。 +- `-v minio_data:/data` 把数据落在命名卷里;`docker compose down` 不丢数据,`docker compose down -v` 会连数据一起清掉(重置开发环境的常用手段)。 +- 建议把这两个值放进 `.env`(该文件已在 `.gitignore` 中),compose 里用 `MINIO_ROOT_USER: ${MINIO_ROOT_USER}` 引用。 + +### 3.3 应用读取的配置约定 + +NestJS API/Worker 通过环境变量拿到连接信息,本地 `.env` 示例: + +```bash +# 对象存储(本地 MinIO / 生产 Kodo 只换这一组值) +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 # MinIO 不校验 region,任意合法值即可 +S3_BUCKET=atlas-dev-assets +S3_ACCESS_KEY=atlas-dev +S3_SECRET_KEY=atlas-dev-secret +S3_FORCE_PATH_STYLE=true # MinIO 必须用 path-style,见第 6.3 节 +``` + +## 4. 创建 Bucket + +MinIO 的桶默认是**私有**的,符合本项目"Bucket 默认私有"的规则。两种创建方式任选。 + +### 4.1 Web Console(直观) + +1. 打开 `http://localhost:9001`,用 root 账号登录; +2. 左侧 **Object Browser → Create Bucket**; +3. 名称填 `atlas-dev-assets`,保持默认(不勾选公开访问),确认创建。 + +### 4.2 mc 命令行(可脚本化) + +`mc` 是 MinIO 官方 CLI,也可以用来管理任何 S3 兼容存储。用一次性容器执行,不必本机安装: + +```bash +# 1. 给本地实例起个别名 +docker run --rm --network host minio/mc alias set local http://localhost:9000 atlas-dev atlas-dev-secret + +# 2. 创建桶 +docker run --rm --network host minio/mc mb local/atlas-dev-assets + +# 3. 确认桶是私有的(输出应为 none,即不允许匿名访问) +docker run --rm --network host minio/mc anonymous get local/atlas-dev-assets +``` + +首次启动时"确保桶存在"也可以由 API 启动逻辑完成(本地环境 `head bucket` 失败就 `create bucket`),这样新同事 `docker compose up` 后无需手工步骤。 + +## 5. 日常使用 + +### 5.1 Console 里的常见操作 + +| 操作 | 位置 | +|---|---| +| 浏览/搜索对象 | Object Browser,按前缀逐级进入 | +| 手动上传/下载 | 进入桶后右上角 Upload / 对象行 Download | +| 查看对象元数据 | 对象行右侧菜单 → Object info(大小、ETag、Content-Type) | +| 生成临时分享链接 | 对象行菜单 → Share(本质是预签名 GET URL,可设过期时间) | +| 管理访问密钥 | Identity → Access Keys(可给本地工具发独立密钥,不共用 root) | + +### 5.2 mc 常用命令速查 + +```bash +alias set local http://localhost:9000 USER PASS # 配置连接别名 +mb local/atlas-dev-assets # 创建桶 +ls local/atlas-dev-assets/workspaces/ # 列出前缀 +cp ./test.jpg local/atlas-dev-assets/tmp/test.jpg # 上传 +cp local/atlas-dev-assets/tmp/test.jpg ./ # 下载 +stat local/atlas-dev-assets/tmp/test.jpg # 查看元数据 +rm local/atlas-dev-assets/tmp/test.jpg # 删除对象 +du local/atlas-dev-assets # 桶占用 +``` + +### 5.3 应用代码访问(NestJS / Node) + +本项目统一使用 AWS S3 SDK v3 指向自定义 endpoint,本地 MinIO 与生产 Kodo 走同一条代码路径: + +```ts +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT!, // http://localhost:9000 + region: process.env.S3_REGION!, // MinIO 任意值即可 + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY!, + secretAccessKey: process.env.S3_SECRET_KEY!, + }, + forcePathStyle: true, // MinIO 必须;Kodo 走虚拟主机式,配置驱动即可 +}); + +// 预签名上传 URL:前端拿到后直传,浏览器不保存永久密钥 +export async function presignUpload(objectKey: string, contentType: string) { + const cmd = new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: objectKey, ContentType: contentType }); + return getSignedUrl(s3, cmd, { expiresIn: 600 }); // 10 分钟 +} + +// 预签名下载 URL:预览/下载私有文件 +export async function presignDownload(objectKey: string) { + const cmd = new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: objectKey }); + return getSignedUrl(s3, cmd, { expiresIn: 900 }); // 15 分钟 +} +``` + +服务端直接读写(如 Worker 转存采集素材)则不经过预签名,直接调 `PutObjectCommand` 等。 + +### 5.4 与 Atlas 的 object_key 约定结合 + +本地桶内对象键沿用[技术文档第 4.2 节](../architecture/technology-and-deployment.md)的分层: + +```text +atlas-dev-assets/ + workspaces/{workspaceId}/ + watermarks/{assetId}/ + products/{productId}/original/{assetId}.jpg + products/{productId}/processed/{assetId}.jpg + products/{productId}/ai-generated/{assetId}.jpg + imports/{taskId}/{filename} +``` + +配套规则(详见[对象存储入门](./object-storage-object-key.md)): + +- `media_assets` 表保存 `storage_provider='local_minio'` + `bucket` + `object_key`,**任何 URL 不入库**; +- 预览/下载 URL 由服务端按 `object_key` 短时签发,过期即失效; +- 来源站的原始图片 URL 只保存在采集证据里,转存成功后文件身份以 `object_key` 为准。 + +## 6. 常见问题 + +### 6.1 SDK 报错找不到桶或 DNS 异常 + +MinIO 默认不支持 `bucket.endpoint` 虚拟主机域名形式,SDK 必须开启 **path-style**(地址形如 `http://localhost:9000/atlas-dev-assets/...`)。AWS SDK v3 对应 `forcePathStyle: true`。该项目配置驱动这一点:本地为 `true`,Kodo 按其要求配置。 + +### 6.2 预签名 URL 在浏览器里打不开 + +预签名 URL 的 host 就是 `S3_ENDPOINT`。`http://localhost:9000` 只在**本机浏览器**可用;如果要给局域网同事访问,endpoint 需要用局域网 IP 并让 MinIO 监听对应地址。签名与 host 绑定,换了 host 签名即失效。 + +### 6.3 上传后 Content-Type 或文件名不对 + +预签名 `PutObjectCommand` 时设置的 `ContentType` 必须与前端实际上传时携带的 `Content-Type` 一致,否则 S3 签名校验会拒绝。项目在 Worker/服务端二次校验 MIME 与魔数,不信任浏览器声明。 + +### 6.4 想清空本地存储重来 + +```bash +docker compose down -v # -v 连同 minio_data 卷一起删除 +docker compose up -d +``` + +然后重建桶(或让应用启动逻辑自动创建),即可配合 seed 数据得到干净环境。 + +### 6.5 时钟偏差报错(RequestTimeTooSkewed) + +S3 签名对时间敏感。宿主机睡眠/恢复后 Docker 容器时钟可能漂移,重启容器或同步系统时间即可。 + +## 7. 本项目边界 + +- MinIO 仅用于 **local** 环境;staging/production 使用七牛 Kodo 独立 Bucket,严禁混用。 +- Bucket 保持私有;原图、水印模板图、AI 生成图和导入文件不设公开读。 +- 浏览器只持有短时上传/下载凭证;永久密钥只存在于服务端环境配置。 +- 对象物理删除前必须确认无数据库引用;由后台清理任务在软删除保留期结束后执行。 + +相关文档:[对象存储与 object_key](./object-storage-object-key.md)、[技术栈、异步任务与部署方案](../architecture/technology-and-deployment.md)、[浏览器采集插件需求](../requirements/collector-browser-extension.md)。 diff --git a/docs/guides/object-storage-object-key.md b/docs/guides/object-storage-object-key.md new file mode 100644 index 0000000..e60652c --- /dev/null +++ b/docs/guides/object-storage-object-key.md @@ -0,0 +1,127 @@ +# 对象存储入门:为什么数据库保存 `object_key` + +> 面向 Atlas Seller Kit 的学习文档。本文解释对象存储、`object_key`、来源 URL 与预签名 URL 的不同,以及本项目应如何保存和访问商品素材。 + +## 1. 一句话结论 + +**对象存储 URL 是“此刻如何访问文件”,`object_key` 是“文件在存储桶中的稳定身份”。** + +Atlas 的数据库以 `storage_provider + bucket + object_key` 标识已转存的文件;不以 URL 作为文件主数据,也不长期保存带授权参数的访问 URL。 + +## 2. 用一张商品图片理解三个概念 + +同一张商品原图在生产环境可能保存为: + +```text +storage_provider: qiniu_kodo +bucket: atlas-prod-assets +object_key: workspaces/ws_123/products/p_456/original/asset_789.jpg +``` + +它在不同环境、不同时间可以生成不同 URL: + +```text +本地 MinIO URL: +http://localhost:9000/atlas-dev-assets/workspaces/ws_123/... + +七牛分发 URL: +https://cdn.example.com/workspaces/ws_123/... + +短时私有访问 URL: +https://cdn.example.com/workspaces/ws_123/...? +e=1780000000&token=temporary-signature +``` + +这些 URL 可能随环境、域名、CDN、签名和过期时间而变化;稳定不变的是存储桶和 `object_key`。 + +| 项目 | 对象存储 URL | `object_key` | +|---|---|---| +| 含义 | 当前通过网络读取/写入文件的地址 | Bucket 内文件的唯一定位键 | +| 示例 | `https://cdn.example.com/a.jpg?token=...` | `workspaces/ws_123/.../a.jpg` | +| 稳定性 | 域名、CDN、签名、环境变化时会变化 | 由 Atlas 生成并管理,作为长期主标识 | +| 是否自带读取权限 | 可能带有预签名授权 | 不带授权,只是定位信息 | +| 是否作为数据库主字段 | 否 | 是 | + +## 3. Atlas 的正确资产记录 + +```text +media_assets +- id +- workspace_id +- storage_provider # local_minio / qiniu_kodo +- bucket +- object_key +- checksum_sha256 +- content_type +- byte_size +- width / height +- created_at +``` + +图片、视频、水印模板和 AI 生成文件都遵循这个规则。SKU 与媒体的关联保存 `media_asset.id`,而不是图片名称、来源 URL 或对象存储 URL。 + +## 4. 不同 URL 分别应保存在哪里 + +### 4.1 来源 URL:可以保存,但只是采集证据 + +采集 1688、淘宝、天猫、Ozon 公开页时得到的图片链接是 `source_url`。它可能过期、防盗链、重定向或失效,因此只应与采集时间、来源页面、解析器版本一起保存在采集证据中。Worker 下载并转存成功后,正式素材身份切换为 `object_key`。 + +### 4.2 Atlas 访问 URL:临时生成,不长期保存 + +用户预览或下载私有素材时: + +```text +浏览器请求素材 + → NestJS 验证当前用户是否有该 asset 的权限 + → 根据 storage_provider + bucket + object_key 签发短时 GET URL + → 浏览器用该 URL 显示或下载文件 +``` + +短时 URL 会过期,包含签名时还代表临时授权,不能存到商品表、素材表、日志或前端长期缓存。 + +### 4.3 平台发布 URL:发布时派生并记录在快照 + +Ozon 导入要求图片为 HTTPS 公网可访问 URL。发布适配器可以从 Atlas 的 `object_key` 派生一个让 Ozon 可拉取的受控分发 URL;该 URL 仅用于这次平台请求,并与请求/响应一起保存在发布快照中,不能反过来替代素材的 `object_key`。 + +## 5. 上传和转存流程 + +### 用户手动上传 + +```text +前端请求上传 + → API 创建 asset ID 和 object_key + → API 签发短时上传 URL + → 浏览器直传 MinIO/Kodo + → API/Worker 校验 MIME、大小、哈希和图片尺寸 + → media_assets 状态变为 ready +``` + +浏览器不保存 MinIO/Kodo 的永久密钥。 + +### 浏览器插件采集 + +```text +插件上报 source_url 和采集证据 + → Worker 下载来源文件 + → 校验并计算哈希 + → 上传 MinIO/Kodo + → 写入 storage_provider + bucket + object_key + → 建立商品/SKU 媒体关联 +``` + +## 6. 为什么不能把 URL 当主数据 + +1. 本地 MinIO 切换到生产七牛 Kodo 时,域名与 URL 结构不同;`object_key` 不变,不需要批量改业务数据。 +2. 更换 CDN、绑定新域名或调整私有访问策略时,不需要重写数据库。 +3. 预签名 URL 到期后可重新生成;若保存它,只会积累失效链接。 +4. 私有原图不必永久公开,访问权限可按工作空间、商品和 SKU 动态校验。 +5. 去重、删除检查、生命周期清理都围绕稳定资产 ID、哈希和 `object_key` 进行。 + +## 7. 本项目的边界 + +- 本地开发使用 MinIO;共享测试与生产使用七牛 Kodo。 +- Bucket 默认私有;原图、水印、AI 图和导入文件不长期公开。 +- `object_key` 本身不是访问凭据,但也不应作为绕过权限校验的下载入口。 +- 对象删除前必须确认没有有效数据库引用;软删除或保留期结束后再由后台任务物理清理。 + +相关文档:[技术栈、异步任务与部署方案](../architecture/technology-and-deployment.md)、[浏览器采集插件需求](../requirements/collector-browser-extension.md)、[商品与 SKU 数据模型](../product/data-model.md)。 diff --git a/docs/guides/postgresql-local-development.md b/docs/guides/postgresql-local-development.md new file mode 100644 index 0000000..5d69c36 --- /dev/null +++ b/docs/guides/postgresql-local-development.md @@ -0,0 +1,235 @@ +# 本地 PostgreSQL:原生安装与 Docker Compose 使用指南 + +> 面向 Atlas Seller Kit 的开发环境。本文讲两种等价的本地运行方式:使用已安装在 macOS 上的 PostgreSQL,或使用 Docker Compose 容器。二选一即可;不要让二者同时占用 `5432` 端口。本项目表结构以版本化 SQL migration 为准,而不是由 NestJS 自动同步。 + +## 1. 先理解三个层次 + +| 层次 | 谁负责 | 是否要手工做 | +|---|---|---| +| PostgreSQL 服务 | Homebrew/安装包,或 Docker Compose | 第一次启动服务 | +| 数据库与角色 | 本地开发者的初始化步骤 | 每个本地环境一次 | +| 表、索引、RLS、函数 | 版本化 migration | 用命令执行,不逐表手工创建 | + +当前可执行结构文件是: + +1. [`001_foundation.sql`](../architecture/sql/001_foundation.sql):账户、工作空间、平台、门店、水印、物流、定价和 RLS 基础。 +2. [`002_product-domain.sql`](../architecture/sql/002_product-domain.sql):采集箱、商品编辑、发布任务、在线商品镜像和 RLS。 + +它们只用于**全新的空数据库**的初始建表。项目初始化 NestJS 后,会将其纳入 migration runner;届时日常只运行统一的 `db:migrate` 命令,不再直接逐个执行 SQL 文件。 + +## 2. 共同的本地约定 + +| 名称 | 本地建议值 | 用途 | +|---|---|---| +| 数据库 | `atlas_dev` | 仅 Atlas 的本地开发数据 | +| 迁移角色 | `atlas_migrator` | 拥有 `atlas` schema 和表,只执行 migration | +| 应用角色 | `atlas_app_login` | 未来 NestJS API/Worker 的连接身份,不拥有表 | +| 权限组 | `atlas_app` | 授予 API/Worker 所需的 DML 权限 | +| schema | `atlas` | 所有 Atlas 表、类型、函数和策略 | + +这三个角色的分工不能省略为“应用直接用管理员连接”:应用连接需要受 RLS 限制,迁移角色不能写入 API/Worker 的环境变量。完整原理见[数据库角色与 RLS 执行约束](../architecture/database-security.md)。 + +> 以下示例密码仅是本地占位符。不要复用到 staging/production,也不要提交 `.env` 文件。 + +## 3. 方式 A:使用已安装的本机 PostgreSQL + +适合你已经通过 Homebrew 或 PostgreSQL.app 安装并日常使用 PostgreSQL 的情况。 + +### 3.1 确认服务和客户端 + +```bash +psql --version +pg_isready -h 127.0.0.1 -p 5432 +``` + +第二行输出 `accepting connections` 即可。若通过 Homebrew 安装而服务未运行,可查看服务状态: + +```bash +brew services list +``` + +启动命令因你的 PostgreSQL 大版本而不同,通常可用: + +```bash +brew services start postgresql@16 +``` + +若实际安装的是其他版本,将 `16` 换成对应版本;也可以在 PostgreSQL.app 中直接启动数据库服务。 + +### 3.2 一次性创建角色和数据库 + +以具有建角色、建库权限的本机管理员账号连接。Homebrew 的默认管理员通常是当前 macOS 用户,因此先试: + +```bash +psql -d postgres +``` + +进入 `psql` 后执行下面 SQL。将尖括号内容替换为你自己的**本地**密码: + +```sql +CREATE ROLE atlas_migrator LOGIN NOINHERIT NOBYPASSRLS PASSWORD ''; +CREATE ROLE atlas_app NOLOGIN NOINHERIT NOBYPASSRLS; +CREATE ROLE atlas_app_login LOGIN NOINHERIT NOBYPASSRLS PASSWORD ''; +GRANT atlas_app TO atlas_app_login; + +CREATE DATABASE atlas_dev OWNER atlas_migrator; +``` + +退出后,用迁移角色创建 schema、安装扩展并建表: + +```bash +psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -p 5432 -U atlas_migrator -d atlas_dev \ + -f docs/architecture/sql/001_foundation.sql + +psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -p 5432 -U atlas_migrator -d atlas_dev \ + -f docs/architecture/sql/002_product-domain.sql +``` + +`-v ON_ERROR_STOP=1` 很重要:任一 SQL 出错时命令立即失败,而不是继续执行留下半成品结构。 + +### 3.3 授予应用角色权限 + +仍以 `atlas_migrator` 连接 `atlas_dev`,执行一次: + +```sql +GRANT USAGE ON SCHEMA atlas TO atlas_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA atlas TO atlas_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA atlas TO atlas_app; + +ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO atlas_app; +ALTER DEFAULT PRIVILEGES FOR ROLE atlas_migrator IN SCHEMA atlas + GRANT USAGE, SELECT ON SEQUENCES TO atlas_app; +``` + +未来 API/Worker 使用的本地连接串形如: + +```bash +DATABASE_URL=postgresql://atlas_app_login:@127.0.0.1:5432/atlas_dev?options=-c%20search_path%3Datlas%2Cpublic +DATABASE_MIGRATOR_URL=postgresql://atlas_migrator:@127.0.0.1:5432/atlas_dev?options=-c%20search_path%3Datlas%2Cpublic +``` + +`DATABASE_MIGRATOR_URL` 仅供 migration 命令使用;API 与 Worker 只读取 `DATABASE_URL`。 + +## 4. 方式 B:使用 Docker Compose + +适合希望 PostgreSQL 版本、参数和数据卷与团队统一,或不想管理本机 PostgreSQL 服务的情况。若本机 PostgreSQL 已占用 `5432`,Docker 映射到 `5433`,避免冲突。 + +项目初始化时将在仓库根目录提供 Compose 配置。在此之前,可将以下内容保存为本机未提交的 `compose.postgres.local.yml`: + +```yaml +services: + postgres: + image: postgres:16-alpine + container_name: atlas-postgres + ports: + - "5433:5432" # 本机已有 PostgreSQL 时使用 5433;没有冲突可改为 5432:5432 + environment: + POSTGRES_USER: atlas_admin + POSTGRES_PASSWORD: atlas-admin-local-only + POSTGRES_DB: postgres + volumes: + - atlas_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U atlas_admin -d postgres"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + atlas_postgres_data: +``` + +启动并确认状态: + +```bash +docker compose -f compose.postgres.local.yml up -d +docker compose -f compose.postgres.local.yml ps +``` + +初始化角色和数据库: + +```bash +docker exec -it atlas-postgres psql -U atlas_admin -d postgres +``` + +在交互终端执行第 3.2 节的角色与 `atlas_dev` 创建 SQL。然后从仓库根目录执行 migration;此处端口使用 Docker 映射出的 `5433`: + +```bash +psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -p 5433 -U atlas_migrator -d atlas_dev \ + -f docs/architecture/sql/001_foundation.sql + +psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -p 5433 -U atlas_migrator -d atlas_dev \ + -f docs/architecture/sql/002_product-domain.sql +``` + +最后按第 3.3 节给 `atlas_app` 授权。Docker 方式对应的连接串只有端口不同: + +```bash +DATABASE_URL=postgresql://atlas_app_login:@127.0.0.1:5433/atlas_dev?options=-c%20search_path%3Datlas%2Cpublic +``` + +日常停止/再次启动不会删除数据: + +```bash +docker compose -f compose.postgres.local.yml stop +docker compose -f compose.postgres.local.yml up -d +``` + +## 5. 检查表是否创建成功 + +用迁移角色连接后可执行: + +```bash +psql -h 127.0.0.1 -p 5432 -U atlas_migrator -d atlas_dev +``` + +Docker 方式把 `5432` 改为 `5433`。进入后: + +```sql +SELECT schemaname, tablename, tableowner +FROM pg_tables +WHERE schemaname = 'atlas' +ORDER BY tablename; + +SELECT c.relname AS table_name, c.relrowsecurity, c.relforcerowsecurity +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'atlas' AND c.relkind = 'r' +ORDER BY c.relname; +``` + +你应看到 `users`、`workspaces`、`stores`、`products`、`platform_listings` 等表;带 `workspace_id` 的业务表应显示 `rowsecurity = true` 和 `forcerowsecurity = true`。 + +## 6. 常见问题和重置边界 + +### 6.1 `role already exists` 或 `database already exists` + +说明这台机器已经初始化过。不要再执行创建角色/数据库命令,直接检查并执行尚未应用的 migration。当前两个初始 SQL 不能安全地重复执行;以后由 migration runner 记录版本并避免重复。 + +### 6.2 `permission denied to create extension` + +`001_foundation.sql` 需要 `citext`、`pgcrypto` 扩展。原生本机库可用管理员账号预先执行: + +```sql +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +``` + +然后再用迁移角色执行 migration。云托管数据库则由运维账户或云控制台预启用扩展。 + +### 6.3 要完全重建 Docker 开发库 + +下列命令会删除 Docker 命名卷中的**全部本地 Atlas 数据**,只用于你确定无需保留当前开发数据时: + +```bash +docker compose -f compose.postgres.local.yml down -v +``` + +之后重新启动、创建角色/数据库并执行两份 migration。原生 PostgreSQL 不建议为重置而删除整个服务;只处理明确的 `atlas_dev` 数据库,并先确认其中没有需要保留的数据。 + +## 7. 与 staging / production 的差异 + +本地可以自行执行上述初始化;staging/production 不这样手工操作。云数据库管理员或基础设施脚本先创建角色和数据库,部署流程使用迁移角色执行 migration,随后 API/Worker 用应用角色启动。绝不把生产库当作家/公司开发库。 + +相关文档:[技术栈与部署](../architecture/technology-and-deployment.md)、[数据库角色与 RLS](../architecture/database-security.md)、[基础数据模型](../architecture/foundation-data-model.md)。 diff --git a/docs/product/api.md b/docs/product/api.md index 0b6301b..2f99e7f 100644 --- a/docs/product/api.md +++ b/docs/product/api.md @@ -1,16 +1,20 @@ # 商品编辑 API 契约 -> 接口前缀与鉴权细节待基础架构确认。所有请求由当前用户、工作空间、门店上下文授权;客户端不能跨租户指定 ID。 +> 接口前缀与鉴权细节待基础架构确认。所有请求由当前用户、工作空间、门店上下文授权;客户端不能跨租户指定 ID。浏览器插件采集入口以 [`../requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md) 为准。 ## 1. 采集与读取 | 接口 | 用途 | |---|---| -| `POST /api/stores/{store_id}/materials` | 上报采集商品与可选 SKU 数组;无 SKU 数组时创建默认 SKU。 | +| `GET /api/extension/session` | 供插件确认当前 Atlas 登录状态和工作空间是否可用;不返回平台、门店或凭据。 | +| `POST /api/extension/materials` | 扩展上报 `CollectedProductPayload`。服务端从扩展令牌解析工作空间,幂等创建/复用未绑定目标平台/门店的采集记录、商品草稿、真实 SKU 草稿和素材转存任务。请求体不得传入 `workspace_id`、`platform_id` 或 `store_id`。 | +| `POST /api/collection-items/{collection_item_id}/seller-enrichment-observations` | 插件在本地 Seller 观察开关开启时上报第 7.3 节四接口的净化结果;不接收 Cookie。 | +| `GET /api/collection-items/{collection_item_id}` | 返回采集记录、已发现 SKU、素材转存状态、Seller 观察结果摘要与警告。 | +| `POST /api/collection-items/{collection_item_id}/start-editing` | ERP 根据页头当前 `platform_id` 进入编辑。服务端验证平台,取得/创建该商品的平台发布配置并预选当前工作空间在该平台的默认店铺后返回编辑页地址;请求不得传 `store_id`。 | | `GET /api/products/{product_id}/editor` | 返回编辑页所需商品、SKU、属性 schema、媒体和草稿版本。 | | `GET /api/products/{product_id}/validation` | 返回父商品和逐 SKU 的本地发布校验。 | -采集变体按 `platform + source_item_key + source_variant_key` 幂等。只有来源明确给出 SKU—图片关联时才能绑定 SKU;其他图片进入商品候选池。未知口径的规格保留原文并标为 `ambiguous`。 +采集记录在同一工作空间内按 `source_platform + source_item_key` 幂等;其变体按 `source_platform + source_item_key + source_variant_key` 幂等。采集阶段不写入目标平台或店铺。只有来源明确给出 SKU—图片关联时才能绑定 SKU;其他图片进入商品候选池。未知口径的规格保留原文并标为 `ambiguous`。扩展的四个 Seller 网页内部接口必须在用户浏览器本地执行,API 只接收净化结果或 `unavailable` 原因。 ## 2. 草稿、SKU 与属性 @@ -21,6 +25,9 @@ | `PATCH /api/products/{product_id}/variants/{variant_id}` | 修改 SKU;可继承字段传 `null` 恢复默认值。 | | `DELETE /api/products/{product_id}/variants/{variant_id}` | 软删除未发布 SKU;已发布 SKU 改停用并留给发布流程处理。 | | `GET /api/products/{product_id}/attribute-schema` | 按门店平台和当前类目返回动态属性 schema/字典。 | +| `PUT /api/products/{product_id}/platform-profiles/{platform_id}/target-store` | 更改当前平台发布配置预选的已授权门店;不修改既有 SKU 发布快照。 | +| `POST /api/products/{product_id}/platform-profiles/{platform_id}/ai-category-suggestions` | 基于采集证据和当前平台类目树创建 AI 类目候选任务;返回候选、置信度与依据。 | +| `POST /api/products/{product_id}/platform-profiles/{platform_id}/ai-category-suggestions/{suggestion_id}/apply` | 用户确认后应用一个候选类目,再拉取对应属性 schema。 | schema 适配器对 Ozon 调用 `/v1/description-category/attribute` 和字典值接口,读取 `type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id`、`is_aspect`。对其他平台提供同一内部 schema 形状。 @@ -31,6 +38,7 @@ schema 适配器对 Ozon 调用 `/v1/description-category/attribute` 和字典 | `POST /api/products/{product_id}/variants/{variant_id}/pricing/quote` | 用服务端有效包装规格计算预览,不落价。 | | `POST /api/products/{product_id}/variants/{variant_id}/pricing/confirm` | 重新计算、校验、保存快照并回填售价/划线价。 | | `GET/PUT /api/products/{product_id}/variants/{variant_id}/media` | 读取候选媒体/保存 SKU 已选媒体及顺序。 | +| `POST /api/products/{product_id}/variants/{variant_id}/media/{asset_id}/watermark` | 用用户选择的模板创建水印派生图;确认后替换当前 SKU 图片位置,原图不变。 | | `POST /api/products/{product_id}/ai/copy` | 生成父商品的多语言标题、标签、简介等候选草稿。 | | `POST /api/products/{product_id}/ai/suite-plans` | 创建显式绑定目标 SKU 的套图规划。 | | `POST /api/products/{product_id}/variants/{variant_id}/ai/dimension-image` | 创建单 SKU 尺寸图任务,并固化实物规格快照。 | @@ -39,4 +47,8 @@ schema 适配器对 Ozon 调用 `/v1/description-category/attribute` 和字典 ## 4. 后续发布 -未来平台适配器接受“父商品 + 单 SKU”规范化对象。Ozon 实现将每个启用 SKU 转换为一个 `/v3/product/import.items[]`:复制通用标题、简介、类目和通用属性;填入 SKU offer、价格、包装规格、图片和变体属性。批量按平台限制分组,结果按 SKU 独立保存。 +平台适配器接受“平台发布配置 + 单 SKU”规范化对象。Ozon 实现将用户选中的、尚未成功导入的 SKU 转换为一个 `/v3/product/import.items[]`:复制当前平台的标题、简介、类目和通用属性;填入 SKU offer、价格、包装规格、图片和变体属性。批量按平台限制分组,结果按 SKU 独立保存;成功导入 SKU 不允许由采集箱再次提交。 + +首期导入不调用库存接口,也不选择目标仓库。`/v3/product/import` 不要求仓库;`/v2/products/stocks` 才要求 `warehouse_id`,且库存 `0` 会令商品不可购买。因此库存由用户在 Ozon 后台设置,采集箱的“上架”仅表示首次导入成功,在线商品另行展示实际 `visible` 和库存状态。 + +Ozon 导入请求的 `images` 字段需要 HTTPS 公网可访问 URL,但 Atlas 不把该 URL 作为素材的长期主数据。发布适配器从已选 `media_asset` 的 `storage_provider + bucket + object_key` 生成适合 Ozon 拉取的短时或受控分发 URL,并将实际发送值固化在发布快照/请求审计中;素材表本身始终以 `object_key` 为准。 diff --git a/docs/product/data-model.md b/docs/product/data-model.md index cb4a3ce..b3b5850 100644 --- a/docs/product/data-model.md +++ b/docs/product/data-model.md @@ -9,20 +9,26 @@ users ── workspace_memberships ── workspaces └─ stores ── platform_connections ``` -`stores` 保存平台标识、市场/国家、默认 locale、默认币种和连接状态。敏感凭据经加密后保存在专用连接密钥记录中,绝不放进前端、日志或商品 JSON。 +`stores` 只保存其唯一绑定的平台标识、店铺默认计价币种和连接状态;一个门店不能跨平台使用。市场/国家、默认语言、可用语言和可用币种属于平台能力配置,不能从门店推导。敏感凭据经加密后保存在专用连接密钥记录中,绝不放进前端、日志或商品 JSON。 + +商品编辑、定价和佣金规则中的 `market_code` 是当前**平台业务上下文**的一部分:首期 Ozon 使用平台默认市场 `RU`;未来平台支持多市场时,由顶部全局上下文选择或使用平台默认值,而非读取门店字段。 ## 2. 商品领域 ```text -stores 1 ── N products (SPU) 1 ── N product_variants (SKU) - ├── product_attributes - ├── product_assets ── variant_media - └── pricing_defaults ── variant_pricing_snapshots +workspaces 1 ── N collection_items ── 0..1 products (SPU) 1 ── N product_variants (SKU) + │ │ + └─ N product_platform_profiles ── N product_variant_platform_listings + (平台发布配置) (平台 SKU 首次发布映射) ``` -每个父商品至少有一条 SKU。`products` 保存通用标题、简介、型号、条形码、采买地址、货源备注、目标市场/类目引用、富内容、默认规格及默认计价参数。可翻译内容不能只存一列,应以 `product_translations`(语言/市场、字段、值、来源、状态)或等价模型保存。 +`collection_items` 是工作空间级采集箱记录,保存来源平台、来源商品键、原始证据、规范化结果、素材任务和可空的草稿商品关联;它在采集阶段没有 `target_platform_id` 或 `target_store_id`。每个父商品至少有一条 SKU。`products` 仅是**采集箱商品**的跨平台可复用资料:来源标题、用户填写的型号、条形码、采买地址、货源备注、默认规格和来源证据;采集阶段型号允许为空,不能从来源数据猜测生成。首次上架前型号必须已由用户确认填写;不把任何一个平台的类目、价格、属性或上架状态直接写到该表。 -`product_variants` 保存:稳定来源键、展示名、启用/排序、SKU 货号、采购价、售价、划线价、币种、SKU 规格、包装物流规格、实物规格、发布映射和时间戳。`offer_id` 对同一门店/平台连接必须唯一。 +`product_platform_profiles` 是 `product_id + platform_id` 唯一的平台发布配置。用户在页头选中平台并进入编辑时才创建或读取它;它保存当前目标门店、该平台的标题/简介/标签、类目和类型、平台通用属性及本平台草稿版本。A 平台与 B 平台独立配置、独立状态;A 已上架不会让 B 显示已上架。门店只是配置中可改的当前发布目标,实际每次发布仍会在 SKU 映射中固化当时的 `store_id`。 + +`product_variants` 保存:稳定来源键、展示名、删除状态、排序、采购价、SKU 规格、包装物流规格、实物规格和时间戳。`product_variant_platform_listings` 保存平台 SKU 维度的数据:`profile_id`、`variant_id`、用户填写的本平台 `offer_id`(货号)、售价、划线价、币种、平台变体属性、首次导入状态/失败原因、首次导入任务和当时 `store_id`、Ozon `product_id`。采集时不得根据 `source_sku` 自动填入 `offer_id`;用户填写后,它对同一门店/平台连接必须唯一,并且是首次上架的必填项。成功导入的 SKU 映射不可再次创建导入任务;删除 Atlas SKU 只软删 Atlas 关联,不删除平台商品和审计记录。 + +`platform_listings`(实际建表名待定)保存店铺级在线商品 SKU 镜像,一条记录对应 Ozon 的一个 `product_id` / `offer_id`,而非采集箱 SPU:`store_id`、平台 `product_id`、`offer_id`、平台 SKU、名称、价格、可见性、审核/错误、库存、图片摘要、完整同步快照、最近同步时间和来源(`atlas_published` / `external_sync`)。它是**平台 + 门店独有**的外部事实记录,绝不跨平台、跨门店复用,也不因关联了采集箱 SKU 就变成采集箱商品。它由异步同步任务更新,包含非 Atlas 发布的商品;通过 `store_id + offer_id/product_id` 可选关联 Atlas SKU。采集箱不因成功上架而删除,其当前平台状态由首次导入映射汇总;在线商品的 `visible` 只用于在线商品实际可见性展示。 ## 3. 双规格和继承 @@ -35,10 +41,10 @@ stores 1 ── N products (SPU) 1 ── N product_variants (SKU) ## 4. 动态属性和媒体 -`product_attributes` 使用结构化行保存 `scope`(common/variant)、本地 key、平台属性 ID、复杂属性 ID、JSON 值、语言、来源与确认状态。平台 schema 驱动类型、必填、字典、多值和 `is_aspect`;`is_aspect=true` 默认 variant scope,但允许业务覆盖。 +`product_attributes` 使用结构化行保存 `profile_id`、可空 `variant_id`、本地 key、平台属性 ID、复杂属性 ID、JSON 值、语言、来源与确认状态。平台 schema 驱动类型、必填、字典、多值和 `is_aspect`;`is_aspect=true` 默认 variant scope,但允许业务覆盖。AI 类目推荐保存为独立候选记录,包含输入证据摘要、候选类目、置信度、依据、模型版本、用户确认结果和时间,不可直接覆盖人工选择。 -`product_assets` 保存资产事实和对象存储 URL;`variant_media` 保存 SKU 对资产的图片/视频/封面选择与顺序。资产归属一律用稳定 ID;采集平台无法稳定给 SKU 归属时,只作为商品级候选池。 +`product_assets`(落地时复用基础表 `media_assets` 或以其为资产事实来源)保存 `storage_provider`、`bucket`、`object_key`、内容哈希、MIME 类型、尺寸和归属关系;不保存长期有效或公开的对象存储 URL。`variant_media` 首期只保存 SKU 对图片资产稳定 ID 的选择、原图/水印派生图关系与顺序;视频相关字段可为未来预留但不在首期 UI 或发布请求中启用。资产归属一律用稳定 ID;采集平台无法稳定给 SKU 归属时,只作为商品级候选池。来源页面图片 URL 仅保存在采集证据中;前端预览/下载 URL 由服务端按 `object_key` 临时签发。 ## 5. 定价、任务和发布 -`variant_pricing_snapshots` 保存确认时的输入、合并后的包装规格、公式版本、汇率、结果、告警和时间;导出/发布时服务端重新校验,不能信任浏览器结果。所有 AI、媒体、导出和发布任务保存 `workspace_id`、`product_id` 与可空/必填的 `variant_id`,可重试、可恢复并隔离租户。 +`variant_pricing_snapshots` 保存确认时的输入、合并后的包装规格、公式版本、汇率、结果、告警和时间;报价快照还应关联 `profile_id`,因为货币和店铺定价方案属于平台发布配置。导出/发布时服务端重新校验,不能信任浏览器结果。所有 AI、媒体、导出和发布任务保存 `workspace_id`、`product_id`、可空/必填的 `profile_id` 与 `variant_id`,可重试、可恢复并隔离租户。 diff --git a/docs/product/product-ai-editor.md b/docs/product/product-ai-editor.md index 10927e2..466fe13 100644 --- a/docs/product/product-ai-editor.md +++ b/docs/product/product-ai-editor.md @@ -4,31 +4,41 @@ 页面编辑一个父商品及其 SKU 集合。通用信息只维护一次;每个 SKU 是独立可售单元。单规格商品也显示一张默认 SKU 卡片,因而定价、媒体、货号与发布没有特殊分支。 +采集箱商品本身不属于平台或门店。这里的“草稿”指 Atlas 内尚未或不再向平台提交的可编辑数据,并非 Ozon 后台草稿。用户从采集箱进入编辑时,ERP 使用页头当前平台(首期默认 Ozon),服务端取得或创建“商品 + 当前平台”的发布配置,预选该平台默认门店;用户可改选该平台的另一已授权门店。切换页头平台会进入另一套独立的平台发布配置:A 平台已上架不影响 B 平台的未上架状态。没有任何可用门店时停留在进入编辑的引导页,允许回采集箱但不能编辑平台属性、定价或发布。 + ```text -页头:返回 · 商品标题 · 草稿状态 · 来源 +页头:返回 · 商品标题 · 当前平台 · 目标门店 · 当前平台上架状态 · 来源 ├─ 商品通用信息 -│ ├─ 类目、标题、品牌、型号、主题标签、简介、JSON 富内容 +│ ├─ 类目、标题、品牌、型号、主题标签、简介 │ ├─ 条形码、采买地址、货源备注 │ └─ 当前平台/市场类目的动态属性 ├─ SKU 商品:卡片(默认)/ 紧凑列表 │ └─ N 个 SKU └─ 固定操作栏 - ├─ 左:AI 标题/标签/描述 · AI 套图生成 - └─ 右:取消并关闭 · 保存草稿 · 上架至平台(后续) + ├─ 左:AI 类目 · AI 标题/标签/描述 · AI 套图生成 + └─ 右:取消并关闭 · 保存草稿 · 选择未上架 SKU 并首次上架 ``` ## 2. 保存和校验 页面以编辑草稿工作,不采用字段失焦即写库。保存草稿需原子提交父商品、SKU、属性和媒体顺序;存在未保存修改时关闭页面须二次确认。SKU 定价确认是例外:它会即时持久化该 SKU 的价格与定价快照。 +采集进入采集箱时,商品**型号**和各 SKU 的目标平台**货号(Ozon `offer_id`)**默认都为空,不从来源标题、来源 SKU 或规格名称猜测生成。用户在编辑页填写:型号属于商品通用资料;货号属于当前平台下的每个 SKU。两者允许随草稿保存而暂时为空,但首次上架校验必须全部通过:商品型号非空,且每个待上架 SKU 的货号非空并在目标店铺内唯一。 + +上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。 + 前端根据平台商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。`is_aspect=true` 默认显示在 SKU 区;采购业务确有需要时,用户可把非 aspect 属性(如容量)改为 SKU 属性。类目变更后,失配属性保留为未映射草稿,不能自动进入发布请求。 ## 3. SKU 卡片和列表 -卡片默认展示:序号、SKU 名称、启用状态、最多 3 张图片缩略图(可切换为 5)、视频封面/视频、货号、采购价、售价、划线价、定价按钮、两组规格与变体属性。列表仅是紧凑视图,字段和数据完全相同。 +卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、采购价、售价、划线价、定价按钮、两组规格与变体属性。列表仅是紧凑视图,字段和数据完全相同。货号由用户填写,不自动复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。 SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。 +无论 SPU 或 SKU 在当前平台是否已上架,用户都可以新增 SKU 变体、复制任一既有 SKU 作为新变体、删除 SKU。复制出的 SKU 没有平台发布映射,始终从 `未上架` 开始。删除已上架 SKU 只从 Atlas 商品结构和当前平台的可发布 SKU 集合移除,**不会删除、下架或修改 Ozon 已创建的商品**;删除未上架 SKU 后若当前平台只剩已上架 SKU,SPU 状态变为 `已上架`。 + +删除 SKU 是商品结构的全局编辑,不会跨平台删除任何已创建的平台商品;其他平台下该 SKU 的发布状态和历史映射仍保留在审计记录中。各平台各自仅对当前未删除 SKU 计算 SPU 状态。 + ## 4. 定价弹窗 点击 SKU 的“定价”打开弹窗,显示该 SKU 合并后的包装物流规格,并允许调整采购价、期望加价率、贴单费、物流方式、划线折扣率和汇率。服务端计算预览、尺寸校验和告警;确认后保存不可变快照,并回填 SKU 销售价和划线价。划线价统一按 `划线价 = 销售价 ÷ (1 − 划线折扣率)` 计算,不使用或兼容“划线价倍数”。 @@ -37,6 +47,12 @@ SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联, ## 5. 媒体与 AI -SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。 +SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 15 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。 -AI 文案针对父商品生成候选标题、标签、简介与富内容,用户选择后写入草稿。AI 套图由左侧抽屉承载,必须显式选择目标 SKU;尺寸图一 SKU 一任务,并保存实物规格快照。 +已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。 + +首期素材规则:发布图片仅接受 JPG、PNG、WebP;单文件最大 20 MB,最短边低于 700 px 给出警告且不得作为主图,超过 10,000 px 的边长在上传时拒绝;建议 1:1 或 3:4,允许其他比例但在预览中提示裁切风险。采集候选素材可保留原始文件供编辑选择,实际发布时始终按目标平台的图片数、格式和可访问性校验。Ozon 发布图片必须是可由 Ozon 拉取的 HTTPS 地址。 + +AI 文案针对当前平台的商品信息生成候选标题、标签与简介,用户选择后写入草稿;首期不生成或编辑富内容。AI 套图由左侧抽屉承载,必须显式选择目标 SKU;尺寸图一 SKU 一任务,并保存实物规格快照。 + +AI 类目由用户主动触发:系统将采集标题、规格、参数、图片摘要等证据与当前平台可发布的类目树交给 AI,返回候选类目、置信度和简短依据,用户必须选择确认后才写入当前平台发布配置。来源为 Ozon 的商品优先保留采集到的 Ozon 类目,默认不自动调用 AI,但始终允许用户主动重新推荐;1688、淘宝、天猫等其他来源默认提供“AI 推荐类目”以提升上品速度。AI 不得直接自动落类目或跳过类目属性校验。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index c81e921..8c2c09d 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -1,10 +1,10 @@ # Atlas Seller Kit 交接说明 -> 更新:2026-09-01。此文是新 AI 对话、架构讨论与开发工作的首读入口。 +> 更新:2026-09-02。此文是新 AI 对话、架构讨论与开发工作的首读入口。 ## 1. 项目定位 -这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户将拥有多个门店;每个门店可属于不同平台和国家,因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。 +这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。 当前项目还没有业务代码、数据库、部署或依赖。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件。可选择性复用已经验证过的纯计价公式、采集扩展思路和 AI 任务经验,但必须先适配多租户和云端边界。 @@ -14,18 +14,18 @@ |---|---| | 商品模型 | 每个商品是一个 SPU,且始终至少有一条 SKU;单规格商品使用默认 SKU,多规格商品使用多条 SKU。 | | 平台可售单元 | 一个 SKU 对应一个独立平台 offer;Ozon 首个平台中对应 `/v3/product/import.items[]` 的一项和独立 `offer_id`。 | -| 通用属性 | 类目、标题、品牌、型号、主题标签、简介、JSON 富内容、条形码、采买地址、货源备注及未来商品级属性。 | -| SKU 属性 | SKU 名称、图片、视频封面、视频、货号、采购价、售价、划线价、包装重量和尺寸、商品重量和尺寸、商品颜色、颜色名称等。 | -| 型号/货号 | 型号是父商品通用字段;SKU 共用型号。货号属于 SKU,可由 AI 生成并允许人工编辑。 | +| 通用属性 | 标题、品牌、型号、主题标签、简介、条形码、采买地址、货源备注及未来商品级属性;类目和平台属性属于独立的平台发布配置。首期不做富内容。 | +| SKU 属性 | SKU 名称、图片、货号、采购价、售价、划线价、包装重量和尺寸、商品重量和尺寸、商品颜色、颜色名称等。首期不支持视频。 | +| 型号/货号 | 型号(`model`)是父商品通用字段;`offer_id` 是当前平台下的每个 SKU 字段。采集时两者可为空;首次上架前必须由用户填写,系统不从标题、规格或来源 SKU 自动生成。被成功导入的 `offer_id` 作为历史事实锁定。 | | 规格 | 实物规格与包装物流规格严格分离;尺寸图只读取实物规格,计价/物流/发布只读取包装规格。 | | 继承 | 可继承字段的 `null` 表示该字段没有覆盖父商品默认值,不表示 SKU 不存在、名称为空或商品为单规格。 | | 尺寸图 | 必须逐 SKU 生成,任务保存 SKU ID 和实物规格快照。 | | 定价 | SKU 上点“定价”打开弹窗,以有效包装数据计算;用户确认后回填 SKU 销售价和划线价。 | | 编辑体验 | 新商品 AI 编辑页取代旧商品编辑页;旧试算页不再追加多 SKU 功能。 | -| 采集插件 | 采集 Ozon、1688、淘宝、天猫详情页;未登录只显示登录入口,已登录显示“编辑上架 / 采集商品 / 进入 ERP”。采集记录始终写入当前工作空间的默认店铺。详情见 [`requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md)。 | +| 采集插件 | 采集 Ozon、1688、淘宝、天猫详情页;未登录只显示登录入口,已登录显示“编辑上架 / 采集商品 / 进入 ERP”。采集箱为工作空间共用,采集记录不关联目标平台或门店;用户进入 ERP 编辑时,按页头当前平台(首期默认 Ozon)取得该平台发布配置并预选默认店铺。A/B 平台的上架状态独立。详情见 [`requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md)。 | | 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 | | 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 | -| 发布 | 商品采集、编辑、定价、AI 媒体和发布至 Ozon 后台并上线是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行、轮询结果并设置库存。 | +| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 | ## 3. Ozon 已核对结论 @@ -37,39 +37,51 @@ 1. 使用 PostgreSQL;所有业务行必须受 `workspace_id` 或可安全追溯至工作空间的外键约束。 2. 首期注册后静默创建一个个人工作空间,并只建立一个 `owner` 成员;不展示工作空间、成员、角色或店铺级权限管理。后续有团队协作需求时再扩展。 -3. 平台连接属于门店:平台、国家/站点、默认语言、默认币种、加密后的凭据和能力配置均不得写进商品核心表。 +3. 门店只能绑定一个平台,不能跨平台使用;市场/国家、默认语言、支持语言和支持币种属于平台能力配置,不属于门店。门店保留已确认的默认计价币种与加密凭据;上述信息均不得写进商品核心表。 4. 本地素材使用 MinIO;共享测试和生产素材使用七牛 Kodo,不使用应用服务器本地目录作为持久存储。 -5. AI、生图、导出、采集转存和发布使用 Redis + BullMQ 队列/Worker,任务可重试、可恢复且携带工作空间与 SKU 上下文。 +5. AI、生图、导出、采集转存和发布使用 Redis + BullMQ 队列/Worker,任务可重试、可恢复且携带工作空间与 SKU 上下文。关键可重试写操作使用持久化幂等记录;异步任务与业务事实在同一 PostgreSQL 事务写入 Transactional Outbox,再由 Dispatcher 投递队列。Redis/BullMQ 不作为业务事实唯一来源。 6. 用户界面语言、采集源语言、商品编辑语言和发布目标语言相互独立;文案及属性值需要支持多语言版本。 -7. Ozon Seller 网页内部接口可以在插件中作为显式开启的观察性实验采集;不得保存 Cookie、不得阻塞标准采集、不得替代官方 Seller API 发布或把未验证经营数据视为本店事实。 +7. 一期实现插件中显式开启的 Ozon Seller 网页本地观察:在用户浏览器的已登录 Seller 标签页调用四个已记录内部接口,并仅上传净化结果;不得提取完整、保存或上传 Cookie,不得阻塞标准采集、不得替代官方 Seller API 发布或把未验证经营数据视为本店事实。加密保存 Cookie 仅作为未来二级实验能力预留。 建议的核心层级: ```text User → Workspace → Membership / Role - → Store → Platform connection / market / locale / currency + → Store → Platform connection / default currency → Product (SPU) → ProductVariant (SKU) → attributes / media / pricing snapshots / tasks ``` -## 5. 下一步建议 +## 5. 已冻结的第一阶段 API 契约 -在开始实施前,以下内容需要继续细化并获得确认: +第一阶段 HTTP API 的接口范围已讨论完成,当前状态为**设计冻结、尚未实现**。后续编码以 [`docs/api/`](../api/README.md) 为唯一 HTTP 契约来源;旧 [`product/api.md`](../product/api.md) 仅作历史背景,不能作为实现依据。 -1. 云端多租户、认证、权限与门店模型; -2. PostgreSQL schema、迁移策略和租户隔离策略; -3. 平台适配器接口(先 Ozon,后续可接其他平台); -4. 多语言/市场/币种的数据模型; -5. 对象存储、任务队列、密钥加密和部署方案。 -6. 浏览器采集载荷、采集箱页面、真实多 SKU 解析测试集,以及 Ozon Seller 网页观察实验的字段口径,见 [`requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md)。 +- 覆盖账户认证、平台/门店、水印与素材、定价、浏览器采集/采集箱、商品编辑/首次发布、在线商品读取与同步。 +- 所有接口遵循统一鉴权、工作空间隔离、页头平台上下文、金额字符串、错误格式、`Idempotency-Key`、`If-Match` 和异步 `operation` 约定。 +- 关键异步动作必须在同一事务写入业务记录、`operations`(如需用户可见进度)和 `outbox_events`;Dispatcher 至少一次投递 BullMQ,Worker 必须幂等。 +- DDL 已通过临时隔离 PostgreSQL 实例执行验证;API 契约已完成文档评审。这不表示 NestJS 模块、迁移执行器或 Worker 已实现。 -之后再按 `docs/product/` 的商品编辑页、数据库和 API 契约实现产品功能。 +仍需在对应模块开始编码前完成的 P1/P2 决策和数据模型补丁,集中记录在 [`reviews/implementation-readiness-open-items.md`](../reviews/implementation-readiness-open-items.md)。 -## 6. 文档导航 +## 6. 下一步建议 -- [商品 AI 编辑页总览](../product/README.md) -- [编辑页交互设计](../product/product-ai-editor.md) -- [商品/SKU 数据模型](../product/data-model.md) -- [商品编辑 API 契约](../product/api.md) +下一步进入项目初始化与实现准备,而不是继续扩大 API 范围: + +1. 创建 NestJS 后端、React 前端、迁移工具、Redis/BullMQ 和本地 Docker Compose 骨架。 +2. 以 `001_foundation.sql`、`002_product-domain.sql` 建立可重复执行的迁移,并按数据库角色/RLS 约束验证本地环境。 +3. 在开始商品编辑/在线同步模块前,先关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。 +4. 优先实现账户、平台上下文、门店、素材上传、操作任务、幂等/Outbox Dispatcher 的纵向最小闭环,再接入采集箱和商品编辑。 +5. 为浏览器采集准备真实多 SKU 解析测试集,并继续限定 Ozon Seller 网页观察实验的字段口径,见 [`requirements/collector-browser-extension.md`](../requirements/collector-browser-extension.md)。 + +## 7. 文档导航 + +- [文档总索引](../README.md) +- [第一阶段 HTTP API 索引](../api/README.md) +- [API 统一约定](../api/conventions.md) +- [基础模块数据模型与 DDL](../architecture/foundation-data-model.md) +- [商品领域数据模型与 DDL](../architecture/product-domain-data-model.md) +- [幂等与 Transactional Outbox](../architecture/transactional-outbox-and-idempotency.md) +- [数据库角色与 RLS](../architecture/database-security.md) +- [实现前待决与待落地事项](../reviews/implementation-readiness-open-items.md) - [浏览器采集插件需求与技术方案](../requirements/collector-browser-extension.md) - [技术栈、异步任务与部署方案](../architecture/technology-and-deployment.md) diff --git a/docs/requirements/account-store-watermark.md b/docs/requirements/account-store-watermark.md index 275605e..0fb4eb8 100644 --- a/docs/requirements/account-store-watermark.md +++ b/docs/requirements/account-store-watermark.md @@ -14,18 +14,18 @@ ### 2.1 注册 -注册页字段只有手机号和密码,均为必填: +注册页字段只有邮箱和密码,均为必填: | 字段 | 规则 | |---|---| -| 手机号 | 使用国家区号 + 手机号的标准化格式保存;同一手机号只能注册一个账户。首期注册页默认中国区号,但数据模型和界面需支持后续扩展。 | +| 邮箱 | 去除首尾空格后以不区分大小写的形式保存;同一邮箱只能注册一个账户。首期仅校验基本邮箱格式,不发送验证邮件。 | | 密码 | 由后续安全设计确定最终长度与复杂度;页面需要二次输入密码并提示规则。密码只可安全散列存储,绝不回显。 | -首期注册成功即可登录使用。短信验证和风控验证不在首期流程执行,但要预留“手机号是否已验证”和“注册/登录风控结果”的状态,未来加功能时不改变账户主流程。 +首期注册成功即可登录使用。邮箱验证和注册/登录风控不在首期流程执行,但数据模型预留 `email_verified_at`;后续可增加邮箱验证。手机号、短信验证、短信登录也不在首期范围,未来通过新增可选手机号凭据和验证流程扩展,不影响邮箱账户主流程。 ### 2.2 登录与会话 -用户用手机号和密码登录。登录成功进入默认工作空间与首页;若后续一个账户拥有多个工作空间,再增加切换入口。首期需要提供退出登录;忘记密码、短信登录、第三方登录、设备管理和多因素认证均不在本轮范围。 +用户用邮箱和密码登录。登录成功进入默认工作空间与首页;若后续一个账户拥有多个工作空间,再增加切换入口。首期需要提供退出登录;忘记密码、短信登录、第三方登录、设备管理和多因素认证均不在本轮范围。 ### 2.3 全局平台与当前店铺上下文 @@ -78,9 +78,9 @@ - 店铺名称:只读,不可修改。 - 水印:下拉列表,包含当前账户可用的水印模板,也可以选择“暂不绑定”。 -- 确认后,该店铺后续上架商品时默认使用此模板处理商品图片;已有图片不会被自动重写。 +- 确认后,该店铺保存一个推荐水印模板;已有图片不会被自动重写。首期它不在发布时自动处理商品图片,用户须在商品编辑页对具体图片的“编辑 → 添加水印”中主动选择模板并生成派生图。 -一个店铺首期只可绑定一个默认水印;同一水印模板可以绑定给多个店铺。 +一个店铺首期只可绑定一个推荐水印;同一水印模板可以绑定给多个店铺。后期可将它用作图片弹窗的默认预选、批量添加或自动添加策略,但这些自动行为不属于首期。 ### 3.4 编辑与删除 @@ -133,20 +133,20 @@ “查看效果”使用模板当前配置和固定演示底图打开预览。 -### 4.3 上架时的默认加水印规则 +### 4.3 商品编辑时主动加水印规则 -当用户选择的目标店铺已经绑定水印模板时,点击“上架至 Ozon”后,系统在上传每张商品图片前生成一份带水印的派生图,并把派生图上传给 Ozon;未绑定模板则上传原选图。规则如下: +用户在商品编辑页的已选图片上悬浮,点击“编辑 → 添加水印”,在弹窗选择一个工作空间水印模板。确认后系统对这张图片生成一份带水印的派生图,并以派生图替换此 SKU 当前图片位置。规则如下: -- 商品级图片与每个 SKU 已选图片都适用,最终以实际要提交给 Ozon 的图片集合为准。 -- 原始图片和编辑页已选图片不被改写;水印处理产物是一次独立的、可追溯的上传副本。 -- 上传任务固化水印模板版本、位置、缩放/字体和不透明度;模板后续编辑不影响已上架商品。 -- 用户在具体上架任务中应能查看“将应用的水印模板”;是否允许该次上架临时取消或更换模板,留待发布流程需求确认。 +- 商品级候选图片与每个 SKU 已选图片都可处理;最终仅提交用户明确选中的图片集合。 +- 原始图片不被改写;水印处理产物是独立、可追溯的派生素材,用户可在编辑弹窗恢复使用原图。 +- 派生任务固化水印模板版本、位置、缩放/字体和不透明度;模板后续编辑不影响已生成的派生图。 +- 首期不在上架任务中自动加水印,也不允许发布时临时替换;后续另行支持绑定水印的默认预选、批量添加或自动添加。 ## 5. 本轮待确认事项 1. 商品限额的 Ozon 数据来源与刷新频率仍待平台接口/真实店铺联调确认;在此之前固定显示 `—`。 2. 账户是否从首期就允许多人共享同一工作空间/协作店铺?如果不做,首期仍按“一个账户一个工作空间”使用,但架构会保留扩展空间。 -3. 上架任务是否允许临时取消或替换门店默认水印? +3. 后续“推荐水印”是仅在图片弹窗中预选,还是支持批量/自动生成派生图? ## 6. 非功能性约束 diff --git a/docs/requirements/collection-box-and-online-products.md b/docs/requirements/collection-box-and-online-products.md new file mode 100644 index 0000000..c02d006 --- /dev/null +++ b/docs/requirements/collection-box-and-online-products.md @@ -0,0 +1,132 @@ +# 采集箱与在线商品需求 + +> 状态:已确认核心规则,待补充具体 PostgreSQL 表和 API 契约。采集插件入口和来源多 SKU 规则见 [`collector-browser-extension.md`](./collector-browser-extension.md)。 + +## 1. 领域边界 + +采集箱是工作空间共用的商品工作池:插件的“采集商品”“编辑上架”以及手动创建商品,都先创建或复用一条采集箱记录。采集时没有目标平台和门店。用户进入 ERP 编辑时使用页头当前平台;系统为该商品取得或创建这个平台的发布配置,并预选该平台默认门店。门店可以为这次及后续未上架 SKU 修改,但采集箱商品本身不属于任何门店。 + +**上架成功不会把商品移出采集箱。** 采集箱保留该商品,以便继续查看和编辑 Atlas 草稿;某个已成功提交到平台的 SKU 不能再次提交上架,但同一 SPU 下仍未上架的 SKU 可以首次上架。采集箱状态随**当前页头平台**变化:商品在 A 平台上架后,切换到 B 平台仍是未上架。在线商品是另一份“目标店铺在平台上实际存在什么商品”的同步视图,不能只由 Atlas 的发布记录推断。 + +```text +来源采集 / 手动创建 + → 采集箱(持续保留、可编辑;已成功上架 SKU 不可再次上架) + → 发布任务 / Ozon 当前状态 + → 在线商品(店铺级平台同步视图,含非 Atlas 创建商品) +``` + +## 2. 采集箱列表 + +默认按更新时间倒序显示当前工作空间内所有未删除的采集箱商品;已上架商品仍显示。 + +| 列 | 展示与规则 | +|---|---| +| 复选框 | 用于批量删除。 | +| 图片 | 首张已就绪主图;素材转存中显示占位进度,失败显示占位和提示。 | +| 产品名称 | 来源标题或手动填写标题;尚未填写时显示“未命名商品”。 | +| 采集价格 | 单 SKU 显示来源价格;多 SKU 显示最小值–最大值,始终保留来源币种。它不是目标店铺售价。 | +| SKU 数量 | 来源确认的真实 SKU 数;无可靠组合时是默认 SKU `1`,并带“待确认”提示。 | +| 采集来源 | Ozon、1688、淘宝、天猫或手动创建;这是**来源平台**,不是目标发布平台。 | +| 备注 | 工作空间内管理备注,不发送给平台。 | +| 上架状态 | 按页头当前平台显示 `未上架`、`已部分上架` 或 `已上架`,口径见第 3 节。 | +| 创建时间 | 采集箱记录首次创建时间。 | +| 更新时间 | 采集补全、素材转存、编辑保存、发布任务或同步结果更新的时间。 | +| 操作 | 未上架:`编辑上架`、`删除`;已上架:`编辑`、`删除`。 | + +顶部功能: + +- 按产品标题模糊查询; +- 按采集来源平台筛选; +- 按上架状态筛选; +- `手动创建商品`; +- 容量进度条:`当前有效采集箱商品数 / 1000`; +- `批量删除`、`清空采集箱`。 + +容量按采集箱商品数计算,不按 SKU 数计算。一条采集箱记录即使含多个 SKU,也只计 1 个。由于已上架商品仍保留在采集箱,它同样占用当前 1000 条临时上限;已删除记录不计入。重复采集命中工作空间内同一 `source_platform + source_item_key` 时复用原记录,不增加额度。 + +## 3. 当前平台的上架状态和失败原因 + +列表按当前平台汇总该 SPU 下未删除 SKU 的**首次导入结果**;它不是 Ozon 前台可见性。Ozon 库存由卖家后续在 Ozon 后台设置,`visible`、库存、审核等实际状态在在线商品中查看。主状态有三种: + +| 主状态 | 判定 | +|---|---| +| `未上架` | 当前平台没有任何 SKU 导入成功;包括未选择门店、尚未提交、提交处理中或所有已提交 SKU 均失败。 | +| `已部分上架` | 至少一个 SKU 已导入成功,且仍有未删除 SKU 未导入成功或正在处理。 | +| `已上架` | 当前平台下全部未删除 SKU 均已导入成功。若删除所有未上架 SKU、只留下已上架 SKU,状态随即变为此值。 | + +“未上架”不能只给笼统结果。状态列的第二行或悬浮详情应展示状态说明: + +- `未选择目标平台`; +- `等待发布`; +- `导入处理中` / `审核中`; +- `已导入,库存由 Ozon 后台维护`; +- `审核拒绝:{最近原因}`; +- `上架失败:{最近失败原因}`; +- `已归档或已下架`。 + +失败原因优先取最近 Atlas 发布任务保存的结构化错误;同步后若 Ozon 返回更准确的 `errors`、`status.decline_reasons` 或 `status.state_description`,以 Ozon 同步结果更新展示。处理/审核中不应误标为失败。 + +已成功上架的 SKU 没有“重新上架”或“更新平台商品”路径:编辑仅保存 Atlas 草稿,不向 Ozon 提交接口请求。未上架 SKU 可以在同一 SPU 中继续首次上架。若未来需要修改 Ozon 已有商品,应从在线商品的 SKU 行进入专门的“编辑在线商品”流程,并单独设计可修改字段、审核及发布规则;不能复用采集箱的上架按钮。首次发布仍为每个 SKU 保存独立不可变快照和任务记录。 + +## 4. 编辑、手动创建与删除 + +### 4.1 编辑上架 + +点击后进入 ERP 编辑入口: + +1. 读取页头当前平台(首期默认 Ozon),取得或创建该商品的当前平台发布配置; +2. 服务端预选该工作空间在此平台的默认店铺,用户可在编辑页改为该平台的另一已授权门店;无门店则提示前往店铺管理; +3. 用户可编辑 SKU、媒体、定价、动态类目属性;已成功上架的 SKU 仅能保存 Atlas 侧编辑,未上架 SKU 可被选择并提交首次上架; +4. 发布成功或失败后均返回/保留采集箱记录,状态和原因随任务、同步更新。 + +### 4.2 手动创建 + +“手动创建商品”创建来源为 `manual` 的工作空间级采集箱草稿,并立即打开同一编辑入口。它不因为是手动创建而绕过采集箱、目标平台绑定、发布校验或任务快照。 + +### 4.3 删除与清空 + +- 删除只删除 Atlas 中的采集箱记录及其未被其他记录引用的素材关系;不删除来源站商品。 +- 已上架采集箱记录也允许删除,但二次确认必须说明:**不会删除或下架 Ozon 商品,也不会从在线商品同步列表移除。** +- 删除时取消未执行的素材任务;已转存文件在确认没有任何引用后延迟物理清理。 +- “清空采集箱”清理当前工作空间所有未删除的采集箱记录(包括已上架记录),必须展示数量并二次确认;不影响任何平台商品和在线商品记录。 + +## 5. 在线商品 + +在线商品是店铺级的 Ozon 实际商品镜像:既包含通过 Atlas 发布的商品,也包含卖家此前直接在 Ozon 后台或其他工具创建的商品。它是**平台 + 门店独有**的数据,不能跨平台或跨门店复用;即使某行关联到采集箱 SKU,它仍是 Ozon 店铺中的独立事实记录。**列表粒度为 Ozon `product_id` / `offer_id` 对应的一条商品 SKU 行,不按 Atlas 采集箱 SPU 聚合展示。** 一个采集箱 SPU 有多个 SKU 时,在线商品中应显示为多行;外部同步商品同样按这个粒度展示。 + +### 5.1 Ozon API 能力确认 + +现有 Ozon Seller API 资料已确认以下同步链路: + +1. `POST /v3/product/list`:按 `last_id` 游标分页拉取店铺商品,支持 `ALL`、`VISIBLE`、`INVISIBLE`、`EMPTY_STOCK` 筛选;每项返回 `product_id` 与 `offer_id`,单页最多 1000 条。 +2. `POST /v3/product/info/list`:按 `product_id`、`offer_id` 或 `sku` 批量读取详情,每批最多 100 条;返回名称、价格、图片、属性、库存、`visible`、审核状态、失败/拒绝原因和可见性详情。 + +因此可以使用 `Client-Id + Api-Key` 完成同步,不需要浏览器 Cookie。依据见 [商品列表 API](../integrations/ozon-seller-api/06-product-list.md) 与 [商品信息 API](../integrations/ozon-seller-api/05-product-info.md)。 + +### 5.2 同步任务 + +在线商品页提供“同步 Ozon 商品”入口;后台也可按后续确定的频率自动执行。同步必须是 BullMQ 异步任务: + +```text +创建 store_online_product_sync 任务 + → /v3/product/list 分页获得全部 product_id + offer_id + → /v3/product/info/list 每批最多 100 条补全详情 + → 以 store_id + ozon_product_id(或同店 offer_id)upsert 在线商品 SKU 镜像 + → 将 Atlas SKU 映射到匹配的 Ozon 商品;非 Atlas 商品保持“外部同步”来源 + → 成功完成全量分页后,处理本轮未见项目为疑似已删除/已归档,不直接物理删除 + → 保存同步时间、游标、统计、失败原因和原始响应摘要 +``` + +同步中的并发、分页、重试、限流和异常应由 Worker 控制。同步失败保留上一轮成功镜像,并在在线商品页显示“最近同步失败”;不把失败当作平台商品已下架。 + +### 5.3 与采集箱的关联 + +通过 Atlas 发布的 SKU,使用 `store_id + offer_id` 和发布回填的 Ozon `product_id` 建立稳定映射。在线商品的每一 SKU 行可显示其 Atlas 来源链接;采集箱 SPU 的当前平台上架状态由首次导入映射汇总,**不**由在线商品的当前可见性派生。外部同步得到的 Ozon 商品也可出现在在线商品,但初始没有采集箱记录或 Atlas 商品关联;后续如需纳入编辑,另行设计“认领/导入在线商品”流程。 + +## 6. 待补充 + +1. 采集箱草稿已绑定目标后,是否允许换平台/换店铺及其迁移规则; +2. 在线商品列表的字段、筛选、详情、编辑入口和“认领外部商品”流程; +3. 自动同步频率、手动同步冷却时间、单店并发和限流参数; +4. 采集箱容量 1000 的最终套餐口径及超限提示; +5. 采集箱“首次导入成功”的用户文案,以及在线商品“实际可见/库存”的用户文案;二者必须区分,不能把 `visible=true` 当作采集箱上架状态。 diff --git a/docs/requirements/collector-browser-extension.md b/docs/requirements/collector-browser-extension.md index 77eef01..6ef6019 100644 --- a/docs/requirements/collector-browser-extension.md +++ b/docs/requirements/collector-browser-extension.md @@ -11,17 +11,18 @@ 浏览器插件让用户在来源商品详情页一键把商品导入 Atlas。它必须: 1. 采集商品原始文本、参数、媒体、规格轴和真实 SKU 组合; -2. 把记录写入登录用户当前工作空间的**当前默认店铺**; +2. 把记录写入登录用户当前工作空间共用的采集箱;采集时不关联目标平台或门店; 3. 让用户选择“只进入采集箱”或“导入后直接编辑上架”; 4. 保存来源证据和素材转存任务,确保后续可以修正、重试、审计; -5. 尽可能保留真实多 SKU,不凭名称、图片或笛卡尔积猜测不存在的组合。 +5. 尽可能保留真实多 SKU,不凭名称、图片或笛卡尔积猜测不存在的组合; +6. 一期提供 Ozon Seller 网页增强采集:在用户浏览器的现有登录会话中调用第 7.3 节列出的四个接口,保存经过净化的观察结果。 首期来源站点与旧采集插件相同:Ozon(`ozon.ru`、`ozon.kz`、`ozon.by`)、1688、淘宝、天猫。 ### 1.2 非目标 - 插件内不展示采集详情、商品字段编辑、AI 智能规划、AI 文案、AI 生图、套图生成、图片导出或定价;这些工作均在 Atlas ERP 页面完成。 -- 插件不直接调用 Atlas 的正式发布接口,不直接创建 Ozon offer,也不保存 Ozon Seller Cookie。 +- 插件不直接调用 Atlas 的正式发布接口,不直接创建 Ozon offer,也不提取完整、上传或持久化 Ozon Seller Cookie。 - 插件不以网页上的规格名称、SKU 图片或“所有颜色 × 所有尺码”的笛卡尔积推测真实 SKU。 - Ozon Seller 网页内部接口仅用于可观察的实验性采集,不能成为生产发布、库存或定价的唯一数据源。 @@ -38,10 +39,9 @@ | 状态 | 悬浮窗内容 | 行为 | |---|---|---| | 未登录或令牌失效 | `登录 Atlas ERP` | 新标签打开 Atlas 登录页。完成授权后,插件自动刷新为已登录状态。 | -| 已登录但没有默认店铺 | `前往 ERP 设置默认店铺` | 打开店铺管理页;不允许导入,避免采集记录落到不确定的店铺。 | -| 已登录且存在当前默认店铺 | `编辑上架`、`采集商品`、`进入 ERP` | 按下面的操作规则执行。 | +| 已登录 | `编辑上架`、`采集商品`、`进入 ERP` | 允许采集;采集箱记录只归属工作空间,不要求已有目标店铺。 | -首期不在悬浮窗中选择店铺。服务端从插件授权身份解析工作空间,并使用该工作空间的当前默认店铺;默认店铺切换在 ERP 中完成。 +首期不在悬浮窗中选择平台或店铺。服务端从插件授权身份只解析工作空间,创建未绑定目标平台/门店的采集记录和商品基础草稿。用户进入 Atlas ERP 编辑页时,页面使用页头当前选中的平台;首期页头默认选择 Ozon,服务端取得该平台发布配置并预选默认店铺。若当前平台没有可用门店,允许保留采集箱记录,但不能进入该平台的商品编辑/发布流程,应引导用户在店铺管理中添加门店。 推荐采用扩展专用的短期授权码流程:插件打开 Atlas 登录/授权页,登录页完成后向 `chrome-extension://{extension-id}/...` 回传一次性 code,插件交换短期 access token 与可轮换 refresh token。令牌只放 Chrome 扩展专属存储,后台请求统一携带 `Authorization: Bearer`。不得像参考插件一样直接读取 ERP 网页 localStorage 中的长期 Token。 @@ -49,26 +49,27 @@ | 操作 | 流程 | 成功结果 | |---|---|---| -| 编辑上架 | 扫描当前页 → 上报采集记录 → 创建/复用商品草稿 → 打开编辑页 | 新标签打开 `/products/{productId}/editor`;素材继续后台转存,编辑页显示状态。 | +| 编辑上架 | 扫描当前页 → 上报采集记录 → 创建/复用未绑定目标的商品基础草稿 → 打开 ERP 编辑入口 | 新标签打开 `/collection-items/{collectionItemId}/edit`;ERP 按页头当前平台取得发布配置并预选门店后进入 `/products/{productId}/editor`,素材继续后台转存。 | | 采集商品 | 扫描当前页 → 上报采集记录 | 停留来源页,提示“已进入采集箱”;用户以后从采集箱编辑。 | | 进入 ERP | 不扫描、不上报 | 新标签打开 Atlas ERP 首页。 | -“上报成功”定义为:数据库事务已创建或复用采集记录、商品草稿、SKU 草稿和素材转存任务。它不等待所有图片下载完成;各素材的 `pending/downloading/ready/failed` 状态在采集箱和编辑页可见,失败可单独重试。 +“上报成功”定义为:数据库事务已创建或复用工作空间级采集记录、未绑定目标的商品草稿、SKU 草稿和素材转存任务。它不等待所有图片下载完成;各素材的 `pending/downloading/ready/failed` 状态在采集箱和编辑页可见,失败可单独重试。 ## 3. 端到端流程与状态 ```text 来源商品详情页 - → 插件认证、确认当前默认店铺 + → 插件认证、确认当前工作空间 → 平台采集器(原始证据 + 规范化结果) → 扩展后台统一上报 - → Atlas API:幂等创建采集记录、SPU 草稿、真实 SKU 草稿、素材任务 + → Atlas API:幂等创建工作空间级采集记录、未绑定平台/门店的 SPU 基础草稿、真实 SKU 草稿、素材任务 → Redis/BullMQ Worker:下载、校验、去重、转存到 MinIO/Kodo - → 采集箱 - ├─ 继续编辑:商品编辑页 + → 工作空间共用采集箱(无目标平台/门店;上架后仍保留) + ├─ 当前平台下未上架 SKU:编辑并首次上架;已上架 SKU:仅编辑 Atlas 草稿,不能再次上架 └─ 删除/归档/重试 → 文案、SKU 媒体、定价、AI 生图、发布校验 - → 发布快照 → 官方 Ozon Seller API 导入、轮询、库存设置、上线状态 + → 发布快照 → 官方 Ozon Seller API 导入、轮询;库存由用户在 Ozon 后台设置 + → 在线商品:异步同步店铺全部 Ozon 商品(含非 Atlas 发布商品) ``` 建议的采集状态: @@ -83,7 +84,13 @@ | `failed` | 采集请求或关键记录创建失败;保留可诊断错误。 | | `archived` | 用户不再处理的采集记录。 | -同一默认店铺内,以 `source_platform + source_item_key` 进行幂等。重复“采集商品”应返回已有采集记录;重复“编辑上架”打开同一商品草稿。只有用户在 ERP 中明确选择“重新采集并合并/创建副本”时,才允许覆盖或复制。 +同一工作空间内,以 `source_platform + source_item_key` 进行幂等。重复“采集商品”应返回已有采集记录;重复“编辑上架”打开同一未绑定目标的商品草稿或其已绑定的编辑入口。只有用户在 ERP 中明确选择“重新采集并合并/创建副本”时,才允许覆盖或复制。 + +### 3.1 采集箱与目标店铺的绑定时点 + +采集来源平台与目标发布平台是不同概念:例如从 1688 采集的商品可以在后续发布到 Ozon,也可能未来发布到其他平台。因此采集箱和刚创建的商品草稿只携带 `workspace_id`、来源平台及来源证据,`target_platform_id`、`target_store_id` 均为未设置状态。 + +用户从采集箱点击“编辑”时,ERP 读取页头当前平台。服务端验证该平台存在于当前系统配置中,并取得/创建该商品在这个平台的发布配置,预选该工作空间在该平台的默认店铺;用户可在编辑页改选门店。A 平台与 B 平台的发布配置和 SKU 上架状态相互独立。扩展不能传入、指定或覆盖这个选择。 ## 4. 统一采集数据模型 @@ -243,12 +250,12 @@ interface CollectedVariant { ### 7.1 目的与边界 -毛子 ERP 在公开 Ozon 商品页采集外,还要求浏览器打开已登录的 `seller.ozon.ru`,并通过跨标签消息调用 Seller 网页的内部接口。Atlas 可以在 Ozon 采集插件中实现一个**可选、显式开启、只用于观察结果**的实验模式。 +毛子 ERP 在公开 Ozon 商品页采集外,还要求浏览器打开已登录的 `seller.ozon.ru`,并通过跨标签消息调用 Seller 网页的内部接口。Atlas **一期即实现**第 7.3 节四个接口的浏览器本地观察模式;用户须在插件设置中显式开启。 实验模式目标是把公开页面发现的来源 SKU 与 Seller 网页返回的补充数据并排保存,帮助评估是否值得采用;它不得: - 自动修改 Atlas 商品、价格、包装规格、库存或发布数据; -- 读取、保存、上传或记录 Seller Cookie、`company_id` 以外的会话凭据; +- 提取完整 Cookie、保存/上传 Cookie,或将 Cookie 写入日志、数据库、队列和扩展持久化存储; - 替代官方 `Client-Id + Api-Key` Seller API 的正式发布、库存和状态同步; - 将返回的销量/浏览等数据表述为“当前店铺数据”,除非 Ozon 未来提供可验证的官方契约。 @@ -270,7 +277,9 @@ interface CollectedVariant { → 毛子 ERP 加密后上传自己的后端 ``` -Atlas 只可借鉴“请求必须在已登录 Ozon 同源标签执行”的技术结构。实验结果进入 `source_evidence` 或独立 `seller_enrichment_observations`,保存原始响应的净化副本、字段清单、接口名、时间、当前来源 SKU、解析版本和错误信息。 +Atlas 只可借鉴“请求必须在已登录 Ozon 同源标签执行”的技术结构。四个请求由浏览器扩展在 Seller 同源标签的页面上下文执行,`credentials: include` 仅让浏览器自动附带现有会话 Cookie;Atlas 不取得 Cookie 正文。请求所需的 `company_id` 仅在本次浏览器会话内从已登录 Seller 页面状态或 `sc_company_id` Cookie 取得,不上传为会话凭据、不写入扩展持久化存储。 + +实验结果进入 `source_evidence` 或独立 `seller_enrichment_observations`,保存**净化后的**响应副本、字段清单、接口名、采集时间、当前来源 SKU、解析版本和错误信息。`company_id` 如确有诊断必要,只能保存不可逆摘要或单独受控的店铺匹配标记,不能保存完整 Cookie。 ### 7.3 四个接口与已观察到的用途 @@ -291,40 +300,54 @@ Atlas 只可借鉴“请求必须在已登录 Ozon 同源标签执行”的技 验证方法:使用两个不同店铺账户,对同一个不属于任一店铺的公开 SKU 对比响应;再和当前店铺已上架 SKU 的官方 Seller API 数据对比。只有在口径、权限和稳定性得到确认后,才能决定是否建设独立“选品洞察”功能。 -### 7.5 Atlas 实验流程与安全控制 +### 7.5 一期 Atlas 本地观察流程与安全控制 -1. 用户在插件设置中显式打开“采集 Ozon Seller 网页观察数据(实验)”。默认关闭。 -2. 用户自行在浏览器登录 `seller.ozon.ru`;插件只检测标签页和请求可用性,不显示、不导出、不持久化 Cookie。 -3. 标准公开页面采集先完成。对每一个发现的 `sourceVariantKey`,后台按低并发、有限数量发起四个观察请求。 +1. 用户在插件设置中显式打开“采集 Ozon Seller 网页观察数据(实验)”;默认关闭。该开关和第 7.3 节四接口能力均在一期交付。 +2. 用户自行在浏览器登录 `seller.ozon.ru` 并保留一个有效 Seller 标签页;插件只检测标签页和请求可用性,不显示、不导出、不持久化 Cookie。 +3. 标准公开页面采集先完成。对每一个发现的 `sourceVariantKey`,扩展后台按低并发、有限数量,通过 Seller 标签页依次发起四个观察请求。 4. 回传前清除请求/响应中可能存在的会话字段、用户信息、店铺名称、授权头等无关信息;服务器再次执行结构化白名单与脱敏。 5. 结果写入工作空间隔离的观察记录。采集箱展示“公开页数据 / Seller 网页观察数据”的字段差异,但不会自动覆盖草稿。 6. 请求失败、账号无权、没有 Seller 标签页或内部接口变更时,只记录 `unavailable` 原因;“编辑上架”和“采集商品”仍正常完成。 +### 7.6 Cookie 能力分级与毛子 ERP 调研结论 + +“不持久化 Cookie”不等于“不使用用户浏览器的现有登录态”。浏览器在 Seller 同源 `fetch(..., { credentials: 'include' })` 时会自动附带该站 Cookie;Cookie 不需要、也不应离开浏览器才能完成一期四接口观察。 + +已核对的毛子 ERP 插件行为:它具有 Chrome `cookies` 权限;其本地跨标签请求会读取 `sc_company_id` 并使用 `credentials: 'include'` 调用四个接口;此外,用户点击“绑定 Cookie”后,它会读取完整 Seller Cookie 列表并提交给毛子后端的 `/api.shop/set_cookies`。该后端后续具体调用范围无法仅凭插件产物证明。 + +| 级别 | Cookie 位置与用途 | 能力边界 | Atlas 决策 | +|---|---|---|---| +| **一级:浏览器本地观察** | 用户浏览器管理 Cookie;扩展仅在已登录 Seller 标签页同源发请求,不读取完整 Cookie 值。 | 一期调用第 7.3 节四接口,保存净化结果;用户需保持浏览器 Seller 登录态。 | **一期实现。** | +| 二级:加密保存 Cookie(实验) | 用户显式授权后,浏览器将会话 Cookie 加密上传;云端 Worker 在白名单内调用网页接口。 | 可在用户未打开浏览器时执行已验证的补充查询或定时刷新;不自动获得任何未经验证的新业务能力。 | **预留,不实现。** | +| 三级:Cookie 驱动业务自动化 | 云端以会话身份调用具有写入副作用的网页接口。 | 可能涉及发布、改价、库存、广告等高风险操作;接口稳定性、账号权限、平台规则均未确认。 | **不纳入当前规划。** | + +二级若未来立项,必须逐接口白名单、独立风险评审和用户再次授权;按门店验证 `company_id` 匹配;Cookie 只做信封加密保存,队列仅传 `secret_id`;不得记录明文、不得返回前端,支持立即撤销、过期检测和完整审计。无论任何级别,正式商品发布、库存和状态同步仍以官方 `Client-Id + Api-Key` Seller API 为准。 + ## 8. Atlas API、任务与存储 ### 8.1 插件接口建议 | 接口 | 用途 | |---|---| -| `GET /api/extension/session` | 返回登录状态、工作空间、当前默认店铺是否可用;不返回敏感门店凭据。 | +| `GET /api/extension/session` | 返回登录状态和工作空间是否可用;不返回平台、目标店铺或敏感门店凭据。 | | `POST /api/extension/auth/exchange` | 用一次性授权码交换扩展短期令牌。 | -| `POST /api/extension/materials` | 接收 `CollectedProductPayload`;服务端解析当前身份与默认店铺,实施幂等创建。 | +| `POST /api/extension/materials` | 接收 `CollectedProductPayload`;服务端仅解析当前工作空间,实施幂等创建未绑定目标平台/门店的采集记录和草稿。 | | `GET /api/collection-items/{id}` | 查询采集记录、SKU 发现数、素材转存状态和警告。 | | `POST /api/collection-items/{id}/retry-assets` | 重新投递失败素材。 | -| `POST /api/collection-items/{id}/seller-enrichment-observations` | 仅实验开关启用时接收已净化的 Ozon Seller 网页观察数据。 | +| `POST /api/collection-items/{id}/seller-enrichment-observations` | 一期的本地观察开关启用时,接收已净化的四接口结果;不接收 Cookie。 | -不得让插件在请求体中随意指定 `workspace_id` 或 `store_id`。服务端从令牌确定工作空间,读取该工作空间的当前默认店铺,并在事务中应用 PostgreSQL RLS。 +不得让插件在请求体中随意指定 `workspace_id`、`platform_id` 或 `store_id`。服务端从令牌确定工作空间并在事务中应用 PostgreSQL RLS;目标平台和店铺仅在 ERP 编辑入口按第 3.1 节规则绑定。 ### 8.2 素材转存 - 插件上报来源 URL、Referer origin、MIME/类型、组别、排序和媒体关系;不把二进制图片塞进常规 JSON。 -- Worker 用受控请求下载来源素材,进行 MIME、大小、尺寸、哈希校验,再写入私有 MinIO/Kodo 对象存储。 +- Worker 用受控请求下载来源素材,进行 MIME、大小、尺寸、哈希校验,再写入私有 MinIO/Kodo 对象存储;资产记录保存 `storage_provider + bucket + object_key`,不保存转存后文件的长期访问 URL。 - 下载受来源防盗链、过期 URL 或风控影响而失败时,保留来源 URL 和错误;用户可重新采集或在编辑页手动上传。 - 同一工作空间内用内容哈希和来源规范化 URL 去重,但同一资产可在多个商品/SKU 媒体关系中复用。 ### 8.3 可恢复性 -采集记录、素材任务、Ozon Seller 网页实验请求和未来发布任务都应先写 PostgreSQL,再经 outbox 投递 BullMQ。重试必须有幂等键、次数上限、指数退避和可见错误。浏览器关闭、Worker 重启或 Redis 重建都不能造成已创建商品草稿重复或素材关系丢失。 +采集记录、素材任务和未来发布任务都应先写 PostgreSQL,再经 outbox 投递 BullMQ。第 7.3 节四接口请求必须在用户浏览器的 Seller 同源标签完成,不能投递给云端 Worker;扩展将结果或 `unavailable` 原因提交服务端后再落库。浏览器关闭、Worker 重启或 Redis 重建都不能造成已创建商品草稿重复或素材关系丢失。 ## 9. 旧实现与参考实现的取舍 @@ -348,10 +371,10 @@ Atlas 只可借鉴“请求必须在已登录 Ozon 同源标签执行”的技 ### 10.1 分期 -1. **采集基础**:扩展认证、小浮窗、默认店铺检查、统一载荷、采集箱入库、素材转存、幂等和“编辑上架/采集商品”。 +1. **采集基础与 Seller 本地观察**:扩展认证、小浮窗、工作空间检查、统一载荷、无目标平台/门店的采集箱入库、素材转存、幂等和“编辑上架/采集商品”;同时实现第 7.3 节四接口的跨标签同源调用、净化存储、`unavailable` 回退和字段差异展示。 2. **真实多 SKU**:1688 `skuMapOriginal`、淘宝/天猫 `skuId + propPath`、Ozon 公开 `sku` 变体遍历;完善 SKU 媒体继承与采集箱警告。 -3. **Ozon Seller 网页观察实验**:四个接口的同源调用、净化存储、字段差异观察页与双店铺验证;不接入正式发布链路。 -4. **编辑与发布闭环**:动态属性、AI 文案/媒体、定价快照、发布校验、官方 Ozon Seller API 导入、轮询和库存设置。 +3. **观察数据验证**:用双店铺账户完成字段字典、口径、权限范围和稳定性对照;不接入正式发布链路,也不升级为 Cookie 云端保存。 +4. **编辑与发布闭环**:动态属性、AI 文案/媒体/类目、定价快照、发布校验、官方 Ozon Seller API 导入和轮询;库存由用户在 Ozon 后台设置。 ### 10.2 验收样本矩阵 @@ -366,17 +389,17 @@ Atlas 只可借鉴“请求必须在已登录 Ozon 同源标签执行”的技 | 无 SKU 图 | 真实 SKU 均存在并继承父商品默认图。 | | 个别变体详情/素材失败 | 保留发现的 SKU 和错误;可单独重试。 | | 重复点击采集 | 复用同一采集记录和商品草稿。 | -| 无默认店铺/令牌过期 | 只显示正确的登录或设置引导,不写入数据。 | -| Ozon Seller 实验无权限 | 标准公开采集成功,观察实验单独失败且不阻塞。 | +| 无目标店铺/令牌过期 | 没有 Ozon 默认店铺时仍可采集;进入 Ozon 编辑时提示设置默认店铺。令牌过期时只显示登录引导,不写入数据。 | +| Ozon Seller 本地观察无权限、未登录或无 Seller 标签页 | 标准公开采集成功,四接口观察分别记录 `unavailable` 且不阻塞。 | ## 11. 待确认与后续文档 -本文件已确认插件入口、默认店铺、多 SKU 基本原则和 Ozon Seller 网页观察实验的边界。实施前仍需补充: +本文件已确认插件入口、工作空间共用采集箱、目标平台/门店绑定时点、多 SKU 基本原则和 Ozon Seller 网页观察实验的边界。实施前仍需补充: 1. `CollectedProductPayload` 的 OpenAPI/JSON Schema 与 PostgreSQL 表、迁移和 RLS 细节; -2. 当前默认店铺的服务端选择、切换和删除规则; +2. ERP 页头当前平台、该平台默认店铺的选择、切换和删除规则,以及采集草稿已绑定目标后的换平台策略; 3. 各来源真实页面快照测试集与解析器版本策略; -4. Ozon Seller 网页四接口实验的字段字典、双店铺对照结果、数据保留期和是否继续投入; +4. Ozon Seller 网页四接口的字段字典、双店铺对照结果、数据保留期和是否具备建设独立选品洞察的价值; 5. 采集箱列表、详情、重新采集、合并与归档的页面需求; 6. 正式 Ozon 发布任务的状态机、请求/响应审计与库存策略。 diff --git a/docs/requirements/pricing-tool.md b/docs/requirements/pricing-tool.md index 6b818c8..0aa3ed5 100644 --- a/docs/requirements/pricing-tool.md +++ b/docs/requirements/pricing-tool.md @@ -82,10 +82,10 @@ | 分组 | 字段 | 规则 | |---|---|---| -| 目标市场 | 履约模式、当前平台商品类目 | 当前平台和当前店铺从页头读取;店铺自动带入币种和默认定价方案。平台商品类目和平台佣金类目均强绑定当前平台;履约模式首期预留 FBS/FBO,实际可用项由该平台的佣金规则决定。 | +| 目标市场 | 履约模式、当前平台商品类目 | 当前平台和当前店铺从页头读取;店铺带入默认计价币种和定价方案。市场不是门店字段:首期使用当前平台默认市场 `RU`,未来多市场时由顶部全局上下文选择或使用平台默认值。平台商品类目和平台佣金类目均强绑定当前平台;履约模式首期预留 FBS/FBO,实际可用项由该平台的佣金规则决定。 | | 商品与包裹 | 采购成本、包裹实重、长/宽/高 | 采购成本首期以 CNY 输入;重量以 g、尺寸以 cm 或 mm 输入。体积由三边自动计算,不能只存一个孤立体积值。允许显示并在必要时人工确认体积重。 | | 定价目标 | 期望加价率、划线折扣率、广告费占比、其他占比 | 所有比例必须明确单位为 `%`。期望加价率与划线折扣率的计算口径见第 7 节。 | -| 费用与物流 | 跨境物流商、运输方式、国内运费+代贴单、尾程(Ozon 代理费)、提现费、汇率 | 默认选择 CEL 并同时勾选其可用运输方式;用户可比较全部方式。国内运费+代贴单为固定金额;尾程、提现费为比例;汇率必须显示来源、值和更新时间,允许本次覆盖。 | +| 费用与物流 | 跨境物流商、运输方式、国内运费+代贴单、尾程(Ozon 代理费)、提现费、汇率 | 默认选择 CEL 并同时勾选其可用运输方式;用户可比较全部方式。国内运费+代贴单为固定金额;尾程、提现费为比例;系统汇率首期使用 CBR 的 CNY→RUB 官方参考汇率,必须显示来源、适用日期、值和抓取时间,允许本次覆盖。详见 [`../architecture/exchange-rates.md`](../architecture/exchange-rates.md)。 | “其他”可先作为单一百分比输入,展示名称为“其他(提醒、货损等)”。规则模型必须支持将来拆成多项费用,例如货损、退款准备金、保险、税费,而不改变最终报价结构。 @@ -111,7 +111,7 @@ ### 4.3 缺失与异常 -- 缺少类目佣金、有效运价、汇率、采购成本或有效包裹数据时,不输出建议售价,指出缺失项。 +- 缺少类目佣金、有效运价、系统汇率、采购成本或有效包裹数据时,不输出建议售价,指出缺失项;用户可按权限选择定价方案固定汇率或本次覆盖。若系统 CBR 汇率超过 72 小时未更新,须醒目标记“可能过期”,不得伪装为最新数据。 - 包裹尺寸不适用于某个运输方式、超过重量/尺寸上限或无法得到计费重时,该方式单独标为不可报价,不影响其他方式。 - 临时覆盖的佣金、汇率或运价必须以醒目标记,并且保存结果时固化为快照。 diff --git a/docs/reviews/implementation-readiness-open-items.md b/docs/reviews/implementation-readiness-open-items.md new file mode 100644 index 0000000..ec62ddc --- /dev/null +++ b/docs/reviews/implementation-readiness-open-items.md @@ -0,0 +1,128 @@ +# 实现前待决与待落地事项 + +> 本文记录 API 契约和数据库 DDL 评审后尚未闭环的事项。它不是新的产品需求;除明确标为“已决策”的条目外,实施对应模块前必须先确认或补齐。最后复核:2026-09-02。 + +## 使用规则 + +- **已决策,待落地**:产品或技术方向已确认,但 DDL、接口契约或实现规则尚未同步,编码前补齐即可。 +- **待确认**:会改变用户可见行为或数据语义,不能由开发自行猜测。 +- **实现约束**:已经确认,后续代码、测试和文档必须遵守。 + +## P0:开始实现前必须闭环 + +### 1. 幂等请求与 Transactional Outbox + +**状态:已决策,DDL 与基础架构文档已落地;待 NestJS 实现和集成测试。** + +所有可重试的关键写操作需要幂等保护;所有会触发异步工作的业务写入需要 Transactional Outbox。基础迁移已新增以下两张工作空间隔离表,并已纳入 RLS、清理策略和 Worker 设计: + +- `idempotency_requests`:按用户、工作空间、规范化请求路径和 `Idempotency-Key` 保存请求摘要、处理状态、HTTP 响应摘要、关联资源及过期时间。至少保留 24 小时;建议 48 小时。相同键而请求摘要不同返回 `409 IDEMPOTENCY_KEY_REUSED`。 +- `outbox_events`:在**同一数据库事务**中随业务数据和 `operations` 记录写入待投递事件;独立 Dispatcher 读取、投递 BullMQ 并重试。事件投递必须按“至少一次”设计,消费者必须可重入;不得将 API Key、Cookie、预签名 URL 等秘密写入事件载荷。 + +首批必须接入的场景:店铺创建/凭据验证、上传完成后的媒体处理、采集素材转存、首次上架发布、在线商品同步、AI 生成任务。普通草稿保存可在首期只使用乐观锁。完整设计和验收项见 [`../architecture/transactional-outbox-and-idempotency.md`](../architecture/transactional-outbox-and-idempotency.md)。 + +### 2. `offer_id`(Ozon 货号)策略 + +**状态:已决策,待接口和数据校验落地。** + +- 采集进入采集箱时,型号(`model`)和 `offer_id` 都允许为空。 +- 用户在商品编辑页填写:`model` 是商品通用字段;`offer_id` 是当前平台下、每个 SKU 的字段。 +- 系统**不**从采集标题、规格或来源 SKU 自动生成 `offer_id`。 +- 首次上架前,`model` 必填;每个被选中 SKU 的 `offer_id` 必填,且在目标门店内唯一。 +- 导入成功后,该次平台 SKU 发布记录使用的 `offer_id` 作为历史事实,不允许通过“重新上架”改变;后续编辑在线商品属于另一套平台商品更新能力。 + +`docs/api/product-editor-and-publishing.md`、商品域 DDL 和发布前校验必须以本节为准,不再保留“自动生成可编辑货号”的备选逻辑。 + +## P1:实现对应模块前修正 + +### 1. SKU 与平台发布配置的同商品归属 + +**状态:已决策,待数据库约束设计。** + +`product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。 + +实施时优先选用可维护的复合外键/冗余 `product_id` 加一致性约束;如 PostgreSQL 外键表达能力不足,再使用约束触发器。不得只依赖服务层校验。 + +### 2. 类目切换后未映射属性的持久化 + +**状态:已决策,待数据模型补齐。** + +切换目标平台类目后,旧类目中无法映射的属性值必须作为 Atlas 草稿保留,供用户查看、复用或手动处理,不能只出现在一次 API response 的 warning 中。 + +可选落地方式: + +- 新增 `product_unmapped_attributes`,保存原类目/属性标识、名称快照、原值、原因、保留时间;或 +- 在 `product_platform_profiles` 增加有明确 JSON Schema 的 `unmapped_attributes_draft`。 + +选择后需要同步商品编辑 API 的读取、保存和类目切换响应。 + +### 3. 乐观锁的统一实施 + +**状态:已决策,待 Repository 规则和测试落地。** + +`revision` 不依赖数据库触发器自动递增。所有可编辑资源的更新必须由统一 Repository/数据访问层执行: + +```sql +UPDATE ... +SET ..., revision = revision + 1 +WHERE id = :id AND revision = :expected_revision; +``` + +受影响行数为 `0` 时,服务层读取当前版本摘要并返回 `409 VERSION_CONFLICT`。必须为并发更新和重复提交编写集成测试;禁止由各业务开发者手写、遗漏该规则。 + +### 4. 来源平台、发布目标平台与页头上下文 + +**状态:已决策,待平台能力模型落地。** + +`platforms` 需要显式表达以下能力,不能由名称或零散 JSON 约定猜测: + +- `can_be_source`:可作为采集来源,例如 1688、淘宝、天猫、Ozon; +- `can_be_target`:可作为商品发布目标,例如首期 Ozon; +- `can_be_header_context`:可出现在 ERP 页头平台切换器。 + +页头只展示已启用、且 `can_be_header_context = true` 的目标平台;来源信息仍可保存其平台标识。实现时可以使用三个布尔列,或统一的 `capabilities` JSONB,但后者必须在架构文档中给出固定 JSON Schema、默认值和校验规则。 + +### 5. 在线商品同步中的“未见”与归档 + +**状态:待确认。** + +建议规则如下,待产品确认后写入接口和同步 Worker: + +1. 一次**成功的全量同步**中未见某在线 SKU:仅首次写入 `missing_since`;不归档。 +2. 之后连续第二次成功全量同步仍未见,或 `missing_since` 已超过 7 天:写入 `archived_at`,列表默认隐藏但可筛选查看。 +3. 同步失败、部分同步或被取消:绝不修改 `missing_since`、`archived_at`。 +4. 该 SKU 随后的成功同步再次出现:清空 `missing_since` 与 `archived_at`,恢复正常展示。 + +## P2:契约一致性优化 + +### 1. 删除的同步与异步响应 + +**状态:已决策,待统一约定更新。** + +- 同步软删除成功返回 `204 No Content`。 +- 涉及大量素材/衍生文件清理、需要异步任务的删除或清空操作允许返回 `202 Accepted` 与 `operation`。 + +应将此例外写入 `docs/api/conventions.md`,并在每个异步删除接口明确说明。 + +### 2. 平台市场、语言和币种能力的 JSON Schema + +**状态:待技术设计确认。** + +目前平台 API 返回的 `default_market_code`、`default_locale` 等字段可暂存于 `platforms.capabilities`,但必须定义统一 Schema,至少覆盖:默认市场、可用市场、默认语言、可用语言、可用币种,以及上述来源/目标/页头能力。 + +如平台能力会被频繁查询、筛选或由后台维护,后续可拆分为平台市场/语言/币种关联表;首期不得让各模块自行解释任意 JSON 字段。 + +### 3. 修改门店 Client ID 的边界 + +**状态:待产品确认。** + +当前 API 契约禁止编辑 `Client ID`,这比最初“编辑店铺”需求更严格。二选一: + +- 保持禁止:Client ID 变化视为新增授权,用户新建门店后停用/删除旧门店; +- 允许替换:要求同时提交新的 `Client ID` 与 API Key,服务端原子加密替换、清除验证状态并重新验证,验证失败不得使旧密钥与新 Client ID 形成混合状态。 + +无论选哪种,都不得只允许单独改 `Client ID`。 + +## 关闭记录 + +事项关闭时,在对应小节补充“确认日期、最终决策、影响的 SQL/API/测试文件”。不要直接删除历史结论,以保留设计决策轨迹。