feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据

This commit is contained in:
R524809
2026-09-18 18:55:16 +08:00
parent fcddb11da0
commit 388429dbd0
58 changed files with 4347 additions and 640 deletions
+1
View File
@@ -10,6 +10,7 @@
| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 |
| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL |
| 图片存储策略 | [`architecture/media-storage-strategy.md`](./architecture/media-storage-strategy.md) | 采集候选图片的按需物化、AI/水印处理与发布素材边界。 |
| 平台商品参考目录 | [`architecture/platform-product-reference-catalog.md`](./architecture/platform-product-reference-catalog.md) | 以平台为边界共享类目、属性和字典的架构与实施状态;门店差异层仅为未来预案。 |
| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 |
| 项目结构 | [`architecture/project-structure.md`](./architecture/project-structure.md) | pnpm monorepo 的目录、依赖关系与本地运行边界。 |
| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 |
+2 -1
View File
@@ -18,6 +18,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.5 部分已实现 | 编辑上下文、完整 SKU 草稿、店铺切换、规格继承、AI 来源素材字段级合并,以及 Ozon 类目树、动态属性和关键词字典缓存同步;媒体、定价、AI 任务与首次上架仍待实现。 |
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
| [`platform-catalog-admin.md`](./platform-catalog-admin.md) | 已实现 | 全局系统管理员授权、平台参考源配置、类目/属性/字典读取、手动同步和任务状态。 |
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
@@ -34,7 +35,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
- 手机号、短信登录、邮箱验证、忘记密码、第三方登录、MFA 与设备管理;
- 团队成员、工作空间切换和角色管理 UI;
- Cookie 授权/保存;
- 平台、货币、字体、物流商、类目、佣金和汇率的后台维护接口。
- 货币、字体、物流商、佣金和汇率的后台维护接口;平台类目维护已经使用独立系统管理员接口实现。
这些事项不会由首期客户端私自调用管理接口;运营数据由受控 seed、migration 或后台任务维护。
+56
View File
@@ -0,0 +1,56 @@
# 平台类目与属性管理 API
> 2026-09-18 新增契约。平台级目录供系统管理员维护;普通商品编辑器现有门店级 API 暂不改变。平台参考目录的归属见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md)。
## 权限与安全
- 本模块接口都要求有效登录会话。除 `GET /api/system/access` 外,必须具有数据库授予且未撤销的 `system_admin` 全局权限;工作空间 `owner/admin` 不等于系统管理员。未授权返回 `403 SYSTEM_ADMIN_REQUIRED`。
- 系统管理员授权只能由受控数据库运维操作授予或撤销,不提供自助提权 HTTP 接口。权限变更在下一次请求时生效;前端菜单只是展示控制。
- 目录、同步来源和任务均按 `platform_id` 分区,不使用客户工作空间的 `workspace_id` 承载平台任务。Ozon `Client-Id` / `Api-Key` 只经 TLS 提交至 API,Key 加密落库,不写日志、Outbox 或响应。
- Wildberries 等其他平台可展示目录空状态;未实现适配器时发起同步返回 `422 PLATFORM_CATALOG_UNSUPPORTED`。
### 首位系统管理员初始化
系统管理员不能通过产品界面自助获得权限。目标用户先完成正常注册,再由数据库运维账号执行以下 SQL;应用运行账号 `atlas_app` 已被禁止修改该表:
```sql
INSERT INTO atlas.system_admin_grants (user_id)
SELECT id FROM atlas.users WHERE lower(email) = lower('管理员邮箱')
ON CONFLICT (user_id) DO UPDATE SET revoked_at = NULL, granted_at = now();
```
撤销权限时设置 `revoked_at = now()`,不要删除用户或改变其工作空间角色。授权和撤销均在下一次请求时生效。
## 接口
`GET /api/system/access`
返回 `{ "data": { "is_system_admin": boolean } }`,供已登录 Web 决定是否展示系统管理入口;服务端权限判断仍以数据库授权为准。
`GET /api/system/platform-catalog/platforms`
返回 `{ "data": [{ "id", "code", "name", "source": { "configured", "client_id_hint", "updated_at" }, "summary": { "category_count", "last_tree_sync_at", "last_error" } }] }`。`client_id_hint` 仅为脱敏末尾字符,不返回 API Key、密文或指纹。
`PUT /api/system/platform-catalog/platforms/{platform_id}/source`
Body:`{ "client_id": "...", "api_key": "..." }`。仅 Ozon 适配器接受当前配置;重复提交同一凭据不产生新版本或重复任务。首次配置或更换凭据时返回脱敏来源状态,并为该平台排队一次类目树同步;更换后旧版本的未完成任务不得用新密钥执行。
`GET /api/system/platform-catalog/platforms/{platform_id}/categories`
返回当前平台的全部树节点 `{ id, parent_id, external_category_id, external_type_id, name, path, is_active, synced_at }`;按父子关系在客户端构建树,目录节点可展开,仅可发布类型叶子用于查看属性。
`GET /api/system/platform-catalog/platforms/{platform_id}/categories/{category_id}/attributes`
返回该类型的属性定义 `{ id, platform_attribute_id, complex_id, name, description, group_name, value_type, is_required, is_aspect, is_collection, max_value_count, dictionary_id, is_active, synced_at, dictionary_value_count }`。`category_id` 不属于所选平台时返回 404。
`GET /api/system/platform-catalog/platforms/{platform_id}/attributes/{attribute_id}/values?q={keyword}&page={number}`
仅字典属性可查;`page` 从 1 开始、每页最多 100,返回 `{ "data": [{ "id", "external_value_id", "value", "info", "picture" }], "meta": { "page", "page_size", "total", "is_complete" } }`。搜索只查 Atlas 缓存;`is_complete=false` 表示已缓存部分候选值,不表示 Ozon 字典只有这些值。
`POST /api/system/platform-catalog/platforms/{platform_id}/syncs`
Headers:`Idempotency-Key`(UUID)必填。Body:`{ "scope": "tree" | "attributes" | "dictionary", "category_id"?: "uuid", "attribute_id"?: "uuid" }`。树同步不带其他 ID;属性同步需要当前平台的可发布 `category_id`;字典同步需要该类型下带字典的 `attribute_id`。同作用域已有 queued/running 任务时返回已有任务,并将本次幂等键持久映射到该任务;同一用户重试不会在原任务完成后意外创建第二个任务。成功排队返回 `202` 与 `{ "data": { "id", "platform_id", "scope", "status", "trigger", "created_at" } }`。同步由 Worker 异步执行,失败保留上次成功缓存。
`GET /api/system/platform-catalog/platforms/{platform_id}/syncs?limit={number}`
返回该平台最近的同步任务(最多 50 条),包括作用域、触发方式、状态、开始/完成时间、数量摘要及脱敏错误。系统管理员可据此查看手动和定时更新结果。
+45 -6
View File
@@ -1,9 +1,11 @@
# 商品编辑与首次上架 API
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和关键词字典仍按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制。M3.2 已实现目标店铺候选与切换语义、父/子 SKU 的实物/包装规格继承、规格轴、SKU 复制和当前上架状态读模型。M3.3/M3.3.1 已实现 AI 来源素材的字段级合并、原子保存、复采变化提示与 AI 输入读模型。M3.4 已实现本地类目候选、动态属性 schema/字典、属性草稿与类目切换后的未映射保留。M3.5 在门店 Ozon 凭据验证成功后同步该门店的中文类目树,编辑器只读取当前目标门店缓存;属性和字典按需同步。媒体选择、定价、AI 任务与发布仍是冻结契约,尚未实现。
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
> 架构迁移说明:2026-09-18 已决定将类目/商品类型树、属性定义和字典候选值迁至[平台级参考目录](../architecture/platform-product-reference-catalog.md),同一平台所有门店共用,不维护门店差异层。本文件以下字段、UUID 和同步端点仍描述**当前已实现的门店级契约**;迁移前不得把它们解释为已经共享。未来调整须先修订本契约、共享类型和测试。
## 1. 进入编辑与读取编辑器
`POST /api/collection-items/{collection_item_id}/editor-contexts`
@@ -130,15 +132,15 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
`GET /api/product-platform-profiles/{profile_id}/attribute-schema`
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 与 `is_aspect`。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
当前 profile 必须已有类目。返回本地同步的字段 schema:`platform_attribute_id`、`complex_id`、名称、`value_type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id`、`is_aspect` 与 `editor_field`。`editor_field` 只按 Ozon 属性 ID 计算,不允许按名称或翻译文本匹配:品牌 `85 → brand`、型号名称 `9048 → model`、类型 `8229 → category`、主题标签 `23171 → tags`、JSON 富内容 `11254 → rich_content`、简介/产品描述 `4191 → description`,其他 ID 返回 `null`。非空字段由编辑器已有控件承载,动态属性区不得重复展示或加载字典;服务端会兼容但忽略旧客户端重复提交的映射属性。可带 `?category_id={uuid}` 预览尚未保存的新类目 schema;该类目仍必须属于当前平台且为活动状态。类别未选返回 `422 CATEGORY_REQUIRED`。
`GET /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}?q={keyword}&cursor={opaque}`
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`;可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`。首期只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
返回本地同步的字典值 `{ items: [{ value_id, value }], next_cursor }`,每页最多 200 项;传入 `next_cursor` 可继续读取,`null` 表示本地缓存已读完。可同样传 `category_id` 读取尚未保存的新类目。字典必须属于当前类目 schema,否则返回 `422 ATTRIBUTE_DICTIONARY_INVALID`;无效游标返回 `400 VALIDATION_FAILED`。接口只读取缓存,不让前端直接持有 Ozon 密钥或直接调用 Ozon。
`GET /api/product-platform-profiles/{profile_id}/category-options?q={keyword}`
返回当前平台的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,不提供同步按钮;Worker 仅使用显式配置的共享 Ozon 缓存源预热该树。
返回当前目标门店的缓存类目树节点,包含本地 UUID、父节点 UUID、平台外部类目/类型 ID、名称、路径和活动状态。选择类目本身不立即写库,随下一次 `editor-draft` 原子保存。缓存为空时客户端显示“准备中”,编辑器不提供同步按钮;当前 Worker 在门店凭据验证成功后用该门店凭据同步树。平台级共享参考源属于待迁移目标,不是本接口的现有行为。
`POST /api/product-platform-profiles/{profile_id}/attribute-schema-syncs?category_id={uuid}`
@@ -146,11 +148,48 @@ Headers:`If-Match`、`Idempotency-Key` 必填。Body:`{ "target_store_id": "
`POST /api/product-platform-profiles/{profile_id}/attribute-dictionaries/{dictionary_id}/syncs?category_id={uuid}&q={keyword}`
按关键词(至少两个字符)创建 `store_attribute_dictionary_sync` operation,Worker 使用该门店的 Ozon 字典搜索接口并 upsert 命中的本地值。不会为打开一个字段而全量下载大字典;前端先读本地缓存,缺少结果时由用户明确选择“从 Ozon 搜索”。
不传 `q` 时创建 `store_attribute_dictionary_sync` operation,Worker 从该门店的 Ozon 字典值接口按游标读取首批最多 1000 项并 upsert 本地缓存;打开字典控件时可按需触发,当前类目下同一字典只自动触发一次,切换类目后重新评估,不在加载类目时同时同步全部字典。传入 `q` 时至少两个字符,Worker 使用 Ozon 字典搜索接口 upsert 命中的值;前端输入关键词后延迟触发,以便从超过首批范围的大字典中继续查找。所有字典属性均由 schema 的 `dictionary_id` 驱动,不根据中文属性名维护固定选项。Ozon 密钥始终留在 Worker。
`GET /api/product-platform-profiles/{profile_id}/validation`
返回 profile 与每个未删除 SKU 的本地首次上架校验:商品型号、类目/属性、标题、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
返回 profile 与每个未删除 SKU 的本地首次上架校验:标题、型号、品牌、类目/属性、目标店铺、用户填写的 offer ID、确认的包装规格、已确认定价快照、图片、格式/数量及平台特有规则。已导入 SKU 单独标记 `not_publishable_again`,而不是当作校验失败。
响应 `data`:
```json
{
"ready": false,
"profile": {
"ready": false,
"issues": [
{
"code": "OZON_ATTRIBUTE_REQUIRED",
"field": "attributes.10001.0",
"message": "请填写最小儿童年龄",
"platform_attribute_id": "10001"
}
]
},
"variants": [
{
"variant_id": "uuid",
"display_name": "红色",
"status": "blocked",
"ready": false,
"issues": [
{
"code": "OZON_ATTRIBUTE_REQUIRED",
"field": "variants.uuid.attributes.10096.0",
"message": "请填写颜色",
"platform_attribute_id": "10096"
}
]
}
]
}
```
`profile.issues` 包含商品级基础信息、类目 schema 和 `is_aspect=false` 的必填属性;每个 SKU 的 `issues` 包含货号、价格、有效包装规格、图片和 `is_aspect=true` 的必填属性。只有 `confirmation_status=confirmed` 且包含非空值的属性满足发布要求。`ready=true` 要求 profile 无问题、至少存在一个未上架 SKU,且全部未上架 SKU 均通过校验。
## 4. SKU 定价确认
+2
View File
@@ -152,6 +152,8 @@ Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与
Headers:`Idempotency-Key` 必填。同步请求 Ozon `POST /v1/description-category/tree` 并写入目标门店缓存;只有完整响应成功落库后才返回 `200` 与类目数量。必须是凭据已验证的 Ozon 门店,否则返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。Ozon 请求或落库失败返回脱敏错误,旧缓存保持不变。此手动操作不经 Worker 或队列,API Key 仅在服务端请求期间短暂存在。
> 此端点是现有门店级能力,不是未来系统管理员同步入口。迁移至[平台级参考目录](../architecture/platform-product-reference-catalog.md)时,须另行修订/废弃该同步语义;管理员操作应按平台排入统一后台任务,不复用本同步 HTTP 路径直接请求 Ozon。
`POST /api/stores/{store_id}/warehouse-syncs`
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
@@ -0,0 +1,46 @@
# 平台级商品参考目录的归属与维护决策
> 决策日期:2026-09-18。状态:平台参考目录、系统管理员维护 API/页面及定时同步链路已经实现;商品编辑器的门店级引用尚未迁移。当前商品编辑契约仍以 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md) 为准。本决策取代 2026-09-10「每个门店完整保存一份类目、属性和字典」的目标设计,不抹去其实现历史。
## 1. 依据与边界
Ozon 的[类目树接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree)请求体仅以 `language` 选择返回语言;[属性接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes)以 `description_category_id + type_id + language` 查询属性;[字典值接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributeValues)还要求 `attribute_id`,并用 `last_value_id` 分页。属性响应的 `category_dependent` 指示字典值是否随类目变化。三个接口都需要卖家 `Client-Id + Api-Key`。
Atlas 本阶段明确采用**平台级参考数据**:同一 `platform_id` 下的所有门店共用一套类目/商品类型树、类型属性定义和字典候选值,不再按门店复制,也**不建立或维护门店差异层**。Ozon 文档未保证所有卖家账号返回完全一致;“同平台门店共用”是本阶段产品假设,而不是 Ozon 官方保证。若实际发现账号差异,再评审下文保留的扩展方案,不提前实现。
## 2. 数据归属
| 数据 | 目标归属与键 | 约束 |
|---|---|---|
| 类目/商品类型树 | `platform_id` 下的一份平台目录,名称按语言保存;Ozon 可发布叶子使用 `description_category_id + type_id` 标识 | 同平台门店读取相同的目录节点、父子关系与 `disabled`;不以名称或单独 `type_id` 判等,也不复制到各门店 |
| 类型属性定义 | `platform_id + 平台类目/类型键 + 属性键 + 语言/版本` | Ozon 的 `is_required`、`is_aspect`、`dictionary_id`、类型和数量限制属于其平台适配数据;其他平台由各自适配器定义外部标识和规则 |
| 字典候选值 | `platform_id + 平台类目/类型键 + 属性键 + 字典键 + 语言 + 值键`;记录完整/部分同步状态 | Ozon 的 `category_dependent=false` 可供后续去重,但不能仅以 `dictionary_id + value_id` 跨类目或跨平台合并 |
| 门店连接与商品/SKU 已填属性 | 原工作空间、门店、商品、profile、SKU | 凭据、上架结果、用户填报值、确认状态和发布快照仍是业务数据,按工作空间隔离;它们不是平台参考目录 |
平台参考表以 `platform_id` 隔离 Ozon、未来的 Wildberries 等平台,只保存对应平台可复用的参考元数据;不存客户凭据、商品、价格或店铺专有数据,也不使用 `store_id` 作为目录分区键。所有门店连接和商品引用仍须满足 [`development-collaboration-contract.md`](../project/development-collaboration-contract.md) 的工作空间外键与 RLS 约束。读 API 依页头平台上下文过滤,不能跨平台混用 ID,也不能据此枚举其他店铺信息。
目标交互上,切换到**同一平台**的另一个门店不应清空已选类目和属性;切换平台则必须重新选择该平台的类目并按新平台 schema 校验。新门店凭据验证只处理该门店连接,不为其复制或重新同步整套平台目录;若平台目录尚未准备好,由平台级任务补齐。当前门店级外键尚未迁移前仍维持现有切换行为。
## 3. 同步方案:自动为主、人工为辅
统一的后台同步服务按 `platform_id` 负责定时任务、首次缺失时的补齐和管理员手动请求。三种触发方式都创建有权限校验、幂等去重的 `platform_catalog_sync_runs + platform_catalog_outbox_events`;这是不带 `workspace_id` 的全局任务事实,不能借用租户 `operations/outbox_events`。Worker 通过平台适配器读取服务端安全存储的参考源凭据并调用对应平台。浏览器、日志、run 和 outbox 载荷不包含 API Key。管理员手动同步不得在 HTTP 请求内直接访问平台或绕过同一套限流、审计与事务发布流程。
1. **同步来源**:每个平台分别配置 Atlas 专用的参考源凭据及适配器;Ozon 首期使用专用参考门店凭据作为技术采集身份,而非普通客户工作空间门店。设置最小授权、密钥轮换、失效告警与备用源,不能未经授权借用客户门店密钥。若暂无专用凭据,保持现有门店级链路,待来源配置完成后迁移。
2. **基线调度**:当前实现为类目树和类型属性成功后 24 小时内不重复、字典成功后 7 天内不重复;调度器每 60 秒扫描,每个平台最多 6 个活动任务。BullMQ 最多尝试 5 次并从 5 秒开始指数退避。当前固定按树、属性、字典排序,在数千类型下会造成字典饥饿;后续必须改为属性/字典配额轮转,并增加近期访问优先级。具体间隔是 Atlas 运营参数,不是 Ozon 文档承诺的 TTL。
3. **管理员页面**:按平台展示参考源状态、树、属性、字典候选值、最后成功时间、失败详情、排队任务和「同步类目树 / 指定类目属性 / 指定字典」操作。手动动作必须受系统管理员权限控制、记录操作者,并返回异步任务;页面不展示密钥明文。当前实现位于 `/system/platform-catalog`,全局系统管理员授权独立于工作空间角色。
4. **读路径**:正常用户只读 Atlas 缓存。首次缺失时可排队补齐并显示准备中;已有数据过期时优先提供上次成功快照并后台更新。不能在每次打开编辑器或下拉框时同步请求 Ozon。大字典搜索按规范化关键词及上述字典作用域共享缓存,并去重并发补齐。
5. **原子性与监控**:P0 按平台及树、类型属性、字典作用域在单事务内 upsert,提交前读取方继续看到旧数据;失败保留上次成功缓存。仅在确认完整分页完成后才处理消失的字典值,部分同步记录 `is_complete=false`。当前尚未实现不可变版本快照、内容哈希、`next_refresh_at`、连续失败告警和异常删减保护,这些属于上线前可靠性加固,不能在文档中误记为已完成。
Ozon 字典分页每次最多 2000 个值;[搜索接口](https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_SearchAttributeValues)要求至少两个字符、每次最多 100 个值。搜索命中只是局部候选,不能证明字典已完整同步。当前平台任务单次最多读取 5 页(10000 项);超过上限会正确保留为部分缓存,但续传游标尚未实现。
## 4. 门店差异层的未来预案(本阶段不实施)
本阶段按同平台所有门店使用相同目录、属性定义和候选值实施:**不创建门店差异表,不按门店同步参考目录,不在读取或发布校验时叠加门店覆盖规则**。门店凭据状态和某次发布的失败原因仍按门店记录,但不据此自动改写平台共享目录。
如果联调或线上证据表明确有账号专属的可售状态、属性或字典差异,再单独提出迁移:可按 `workspace_id + store_id` 建门店覆盖层,或按市场/卖家分组收窄共享范围,并制定差异检测与回退策略。该预案只保留扩展方向,不是当前开发任务或上线前置条件。
## 5. 从现有实现迁移
当前 `022_store_catalog_cache.sql` 的 `store_product_categories`、属性和字典表,以及 profile 的 `store_product_category_id` 外键都是已上线的门店级事实,**本次文档决策不改变它们**。旧 `001_foundation.sql` / `021_product_category_attribute_schema.sql` 虽已有平台级表,但原字典唯一键不足以表达按类目/类型变化的字典;不能直接当成最终系统目录复用。
迁移 `024_platform_catalog_management.sql` 已补齐以 `platform_id` 为作用域的参考来源、候选值和同步任务表,**没有建立门店差异表**;API、Worker 和管理页面已经按该作用域工作。下一步仍需以 Ozon 外部 ID 对照旧门店记录、回填平台目录。草稿/接口迁移应先提供兼容读写与可回滚映射,逐步改为平台目录引用,最后经校验和维护者批准再清理冗余门店缓存。现有商品编辑 HTTP 路径、UUID 含义和门店同步 operation 尚未改变;任何契约变更须先更新 `docs/api/`、共享类型和测试。Wildberries 等新平台应新增平台适配器及自己的外部 ID 映射,不复用 Ozon 的 `description_category_id + type_id` 语义。
@@ -16,6 +16,8 @@
`products` / `product_variants` 只服务采集箱商品,保存来源、采购和跨平台可复用资料。`platform_listings` 则是目标平台、目标门店的实际商品镜像;它不能跨平台或跨门店复用,即使它关联了 Atlas SKU。`product_platform_profiles.market_code` 表示当前平台业务上下文,首期取平台默认 `RU`,不从门店派生。
类目/商品类型、属性定义和字典候选值是待迁移的**平台级**参考数据:同一平台所有门店共用,不跨 Ozon、Wildberries 等平台混用,也不在本阶段维护门店差异层。门店连接与商品/SKU 已填值仍按工作空间隔离。现有 `022_store_catalog_cache.sql` 的门店级缓存和 profile 外键继续有效,目标模型及迁移边界见 [`platform-product-reference-catalog.md`](./platform-product-reference-catalog.md)。
## 2. 关键关系与状态
| 表 | 粒度 | 核心约束 |
@@ -29,7 +29,7 @@
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` |
| language | string | 可选 | 语言代码:`DEFAULT`(默认俄文)、`RU`(俄文)、`EN`(英文)、`TR`(土耳其文)、`ZH_HANS`(简体中文) |
---
@@ -105,13 +105,13 @@
⑤ 选中后保存 description_category_id + type_id
```
### 场景 2:服务端门店缓存
### 场景 2:Atlas 平台级参考缓存(目标架构,未实施)
```
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取完整类目树(Atlas 使用 `ZH_HANS`)
② 递归保存每一层及其父子关系,按门店存入本地类目缓存;不与其他门店混用
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
① 按平台的定时任务用 Atlas 专用参考源凭据拉取完整类目树(Ozon 首期 `ZH_HANS`)
② 原子更新该平台共享目录;同平台门店读取同一份树,不建门店覆盖层
③ 同步失败保留上次成功版本,由后台任务重试并告警
④ 用户选类目时只查 Atlas 缓存,不频繁调 Ozon API
```
---
@@ -213,30 +213,7 @@ function toTreeData(nodes: CategoryNode[]): any[] {
## 缓存策略
### 按门店缓存(Atlas 方案)
```python
# 类目数据的读取凭证属于门店。即使结构通常相近,也必须按 store_id 隔离缓存。
# 验证成功或缓存失效时,由 Worker 用该门店凭证拉取完整树并递归写库。
# 前端只读取该门店的缓存,绝不携带或读取 Client-Id / Api-Key。
async def get_store_category_tree(store_id: str):
cached = await load_store_category_cache(store_id)
if cached and cached.is_valid:
return cached.tree
await enqueue_store_catalog_sync(store_id)
raise CatalogCachePreparingError(store_id)
```
### 按需更新
```python
# 用户在门店管理中手动刷新时,API 同步请求 Ozon 并在成功后原子替换目标门店缓存。
# 请求或落库失败会保留旧缓存并向用户返回错误,不经 Worker 队列。
async def refresh_store_category_tree(store_id: str):
tree = await fetch_category_tree_for_store(store_id)
return await replace_store_category_cache_atomically(store_id, tree)
```
目标方案见 [`platform-product-reference-catalog.md`](../../architecture/platform-product-reference-catalog.md):每个平台分别维护共享目录;定时自动同步与系统管理员手动触发共用异步任务、原子版本切换和失败保留旧版;前端只读 Atlas 缓存。当前运行中的门店级同步仍见下方「Atlas 当前集成」,不能把目标方案当作已经上线。
---
@@ -244,15 +221,15 @@ async def refresh_store_category_tree(store_id: str):
### Q1: 类目树很大吗?
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。完整树在一次响应中以递归 `children` 返回,首次拉取需几秒,后续从门店缓存读取。
**A**: 完整树在一次响应中以递归 `children` 返回;具体节点数和响应大小以实际同步记录为准,后续从 Atlas 缓存读取。
### Q2: 多久更新一次?
**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**。
**A**: Ozon 接口未规定 Atlas 的缓存 TTL。目标架构建议由后台每日检查树,并提供管理员手动排队同步入口;间隔可按运行量调整,见[架构决策](../../architecture/platform-product-reference-catalog.md)。
### Q3: 不同语言的类目树结构一样吗?
**A**: 通常 `description_category_id` / `type_id` 相同,仅 `category_name` / `type_name` 翻译不同。Atlas 统一由 Worker 为每个门店缓存 `ZH_HANS`,前端直接读取该门店缓存,不能为展示语言绕过缓存直接携带门店密钥请求 Ozon。
**A**: 接口支持语言参数,不应未经验证就假定各语言的 ID 和翻译映射完全相同。当前 Worker 为每个门店缓存 `ZH_HANS`;目标是由 Ozon 平台参考源统一缓存该语言,同平台所有门店共用。前端不能携带门店密钥直连 Ozon。
### Q4: `type_id=0` 是什么意思?
@@ -266,6 +243,7 @@ async def refresh_store_category_tree(store_id: str):
- **API**:按当前目标门店返回缓存的扁平节点(包含 `parent_id`);不会将 `Client-Id` 或 `Api-Key` 返回给浏览器。
- **前端**:根据 `parent_id` 重建任意深度的 Cascader;目录可展开,只有可发布的叶子能写入商品草稿。
- **调试**:若需要查看 Ozon 原始响应,应由受权限保护的服务端调试能力获取并脱敏展示;浏览器不得直接用门店密钥请求 Ozon。
- **迁移目标**:平台级共享目录和统一异步管理同步尚未实现;本阶段不维护门店差异层,见[架构决策](../../architecture/platform-product-reference-catalog.md)。
---
@@ -155,7 +155,7 @@
| description_category_id | integer | ✅ 是 | 类目 ID |
| type_id | integer | ✅ 是 | 商品类型 ID |
| language | string | 可选 | 语言代码 |
| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 |
| limit | integer | ✅ 是 | 每页数量,范围 **1–2000**;Atlas 当前首批请求使用 1000 |
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
---
@@ -209,7 +209,7 @@ async def fetch_all_values(attribute_id, category_id, type_id):
"description_category_id": category_id,
"type_id": type_id,
"language": "RU",
"limit": 5000,
"limit": 2000,
"last_value_id": last_id
}
)
@@ -431,48 +431,9 @@ if missing:
## 7. 缓存策略
### 属性列表缓存
Ozon 只定义查询参数和分页规则,不规定 Atlas 的 TTL。2026-09-18 的[架构决策](../../architecture/platform-product-reference-catalog.md)将属性定义和字典候选值设为平台级参考数据:同一平台所有门店共用,本阶段不维护门店差异层;此方案尚未实现。当前代码仍以门店、类目和类型为作用域按需同步,用户从 Atlas 本地缓存读取。
```python
# 按 (category_id, type_id) 缓存
# TTL 7 天(属性变化极少)
from functools import lru_cache
@lru_cache(maxsize=500)
async def get_attributes_cached(category_id: int, type_id: int):
# 先查数据库
cached = await db.query(CategoryAttribute).filter_by(
description_category_id=category_id,
type_id=type_id
).all()
if cached:
return cached
# 未缓存,调 API
attrs = await fetch_category_attributes(category_id, type_id)
# 写入数据库
await save_attributes_to_db(attrs)
return attrs
```
### 字典值缓存(按需)
```python
# 字典值可能很大(数万条),不全量缓存
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search)
async def get_attribute_values(attr_id, category_id, type_id, keyword=None):
if keyword:
# 有关键词 → 搜索接口(limit 100)
return await search_values(attr_id, category_id, type_id, keyword)
else:
# 无关键词 → 拉全量(分页,存数据库)
return await fetch_all_values(attr_id, category_id, type_id)
```
目标同步方式:对活跃 `description_category_id + type_id` 的属性定义定期后台复核;字典候选值至少以类目、类型、属性、字典 ID 和语言限定作用域。`category_dependent=false` 可以作为进一步去重的依据,但在跨账号/跨类目验证前不能只以 `dictionary_id` 合并。小字典可完整分页并标记 `complete`;大字典先保存常用页与搜索命中,并明确标记 `partial`。搜索结果不是全集,不能据此清理未命中的旧值。定时和管理员手动同步必须共用异步任务链路,成功后原子切换版本,失败保留上次成功值。
---
@@ -1,10 +1,10 @@
# Ozon 动态属性与 SKU 填报改造方案
> 状态:待实施设计,记录于 2026-09-18。本文描述目标交互、数据归属和校验边界;在对应 API 契约、共享类型与实现完成更新前,不代表当前接口已经具备这些行为。
> 状态:P0 已于 2026-09-18 实现,P1/P2 待实施。当前已完成必填展示、通用/变体属性分区、宽松草稿保存及服务端逐 SKU 校验;新 SKU 首次保存时关联属性、结构化字典值与发布闭环仍以后续阶段为准。
## 1. 目标与原则
Ozon 商品的必填属性由目标门店、`description_category_id` 与 `type_id` 对应的实时类目 schema 决定。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。
Ozon 商品的必填属性由 `description_category_id` 与 `type_id` 对应的类目 schema 决定;目标架构中,同一 Ozon 平台的所有门店共用该 schema。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。目标参考数据归属见[架构决策](../architecture/platform-product-reference-catalog.md);当前实现仍读门店级缓存。
页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
@@ -65,6 +65,8 @@ SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父
3. 同步成功后重新构建动态属性表单。
4. 页面显示类目路径、商品类型和最后同步时间,并提供失败后的重试入口。
以上描述当前门店级 schema 的获取流程。迁移到平台级参考目录后,直接读取当前平台共享 schema,不叠加门店差异;缺失或过期时由统一后台任务补齐,普通用户不直接触发 Ozon 请求,管理员重试与定时任务共用同一队列。接口字段及草稿外键在 `docs/api/` 更新前不改变。
属性 schema 响应除现有字段外,计划补充:
```ts
@@ -175,6 +177,10 @@ GET /api/product-platform-profiles/{profile_id}/validation
- 保存草稿允许不完整,发布校验阻断缺失项。
- 服务端补齐通用和逐 SKU 必填属性检查。
实现状态:已完成。编辑器会在选定类目后立即加载 schema,商品级必填属性始终显示在「产品属性」,SKU 级必填属性始终显示在对应 SKU 卡片;保存草稿不因缺少必填值失败,`validation` 接口按商品与 SKU 返回结构化缺失项。
类目 schema 中由编辑器已有控件承载的字段通过 `editor_field` 显式绑定,不在动态属性区重复展示,也不重复加载其字典。绑定只允许按 Ozon 属性 ID:品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254`、简介/产品描述 `4191`;禁止使用名称或翻译文本猜测。后续发布适配器必须据此生成 Ozon 所需字段,不能要求用户重复填写。
### P1:SKU 数据结构
- 属性草稿调整为 `common_attributes + variants[].attributes`。
@@ -185,7 +191,7 @@ GET /api/product-platform-profiles/{profile_id}/validation
### P2:发布闭环
- 实现 validation 接口及发布接口复用。
- 发布接口复用现有 validation 校验结果。
- 按 SKU 生成 `/v3/product/import` 的 `items[]`。
- 正确生成普通属性与复杂属性。
- 固化发布快照并将 Ozon 错误回填到 SKU/属性。
+1 -1
View File
@@ -29,7 +29,7 @@
上架状态是“当前平台 + SKU”维度:`未上架`、`处理中`、`上架失败`、`已上架`。SPU 在当前平台的状态由未删除 SKU 汇总为 `未上架`、`已部分上架`、`已上架`。用户可勾选任意未上架 SKU 执行首次上架;已上架 SKU 不显示上架选择,也不发送更新请求。编辑已上架 SKU 时,保存按钮旁必须提示“仅保存 Atlas 草稿,不会更新 Ozon 商品”。
前端根据当前目标门店、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。门店 Ozon 凭据验证成功后,Worker 使用该门店凭据同步中文类目树;缓存为空时编辑器显示准备中状态,用户不能在编辑页发起树同步。选中类目但 schema 缺失时,仍可显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端门店类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。
当前实现中,前端根据目标门店的本地类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。门店 Ozon 凭据验证成功后,Worker 使用该门店凭据同步中文类目树;缓存为空时编辑器显示准备中状态,用户不能在编辑页发起树同步。选中类目但 schema 缺失时,仍可显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端门店类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。[待迁移目标](../architecture/platform-product-reference-catalog.md)是同平台门店共用参考目录,本阶段不维护门店差异层。
类目、标题、型号、品牌以及默认包装重量和默认包装尺寸固定放在「主要信息」区。SKU 默认继承父商品包装规格,仅在包装不同的情况下填写覆盖值。平台动态必填字段统一使用表单标准表现,在 Label 前显示红色 `*`;不追加「Ozon 必填」标签,也不在区块标题显示完成数量。更完整的目标交互、属性值表达、SKU 关联和发布校验改造见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](./ozon-dynamic-attributes-and-sku-form-plan.md)。
+98 -8
View File
@@ -215,7 +215,7 @@
### 1.25 开发进度(2026-09-10):M3.4 类目与动态属性
- 旧迁移 `021_product_category_attribute_schema.sql` 建立了平台级 schema 缓存;最新设计由 `022_store_catalog_cache.sql` 另建门店级类目、schema 与字典缓存,发布草稿只引用门店级数据。
- 旧迁移 `021_product_category_attribute_schema.sql` 建立了平台级 schema 缓存;当时采用 `022_store_catalog_cache.sql` 另建门店级类目、schema 与字典缓存,发布草稿只引用门店级数据。此处记录已实现历史,2026-09-18 的新目标见[平台级参考目录决策](../architecture/platform-product-reference-catalog.md)。
- 新增编辑器接口:类目候选、类目属性 schema 与属性字典值。类目 schema/字典接口支持传入未保存的 `category_id` 预览,因此用户选择新类目后不必先保存一次才能填写字段。
- 编辑器草稿可原子保存 `store_product_category_id`、类目来源与商品/SKU 属性;服务端派生 `platform_type_id`,复核类型、值数量、字典值与 SKU 归属。类目变更或 schema 下线的旧属性会转入 `product_unmapped_attributes`,不会默默丢失或进入新的发布 payload。
- Web 编辑页已提供类目选择、商品/SKU 维度动态字段、字典选择、未映射属性告警;上架店铺/主题标签保持标准 40px 控件高度,父商品包装重量与三项包装尺寸宽度分别为 150px / 120px。
@@ -224,17 +224,18 @@
- 已实现类目树、类目属性 schema、字典值缓存的数据表、Worker 同步逻辑、Transactional Outbox 和读 API;缓存属于门店级发布元数据,不属于某个商品草稿。浏览器、operation 响应与 outbox 事件都不含 API Key。
- 当前旧实现仍把“同步 Ozon 类目”暴露为商品编辑页操作;这已被最新产品决策否定,不能作为完成状态使用。属性 schema 仍是按类目读取/按需同步的现状,后续需单独确认是否也改为启动预热。
- 字典不会全量拉取:字段先读取已有缓存;用户输入至少两个字符且本地无匹配时,现有实现可显式发起 Ozon `/attribute/values/search`,Worker 仅 upsert 返回的命中项。同步失败应保留旧缓存和可读错误状态。
- 字典不会全量拉取:字段先读取已有缓存;首次打开某个字典属性时,按需从 Ozon `/attribute/values` 按游标同步首批最多 1000 项,当前类目下同一字典仅自动触发一次。输入至少两个字符后延迟调用 Ozon `/attribute/values/search`,补充首批以外的候选;本地缓存每页返回 200 项并支持下拉滚动续读。同步失败应保留旧缓存和可读错误状态。
### 1.27 历史状态(2026-09-10):平台级预热方案已废弃
- 原交接要求的“Worker 启动时以指定门店预热一份平台共享类目树”已被产品决策替代:类目、属性和字典缓存按门店隔离,不能再恢复平台级共享缓存或环境变量指定的共享源。
- 原交接要求的“Worker 启动时以指定门店预热一份平台共享类目树”当时被门店级方案替代。该时期要求类目、属性和字典按门店隔离,不恢复平台级共享缓存或以环境变量任意指定客户门店作为共享源。
- 保留的原则是:浏览器、日志、operation 和 outbox 事件均不携带 API Key;编辑器只读缓存,不提供类目树手动同步入口。
- 本节只记录当时决策;2026-09-18 的[平台级参考目录决策](../architecture/platform-product-reference-catalog.md)已取代该目标。现有门店级实现未随文档决策自动迁移。
### 1.28 开发进度(2026-09-10):门店类目同步与来源回填
- Ozon 门店凭据验证成功后,在同一事务中创建门店类目同步 operation 与 outbox 事件;Worker 使用该门店 `Client-Id + Api-Key` 请求中文类目树,并写入 `store_product_categories`。失败只标记类目同步 operation,不会把已验证的凭据改为无效。
- 已删除编辑器的手动类目树同步接口和客户端调用。属性 schema 与字典值仍按当前类目和关键词按需同步,使用原有 Outbox/Worker 链路。
- 已删除编辑器的手动类目树同步接口和客户端调用。属性 schema 与字典值仍按当前类目按需同步;字典支持打开控件时同步首批值和按关键词搜索,均使用原有 Outbox/Worker 链路。
- Cascader 显示完整缓存树路径,但表单与草稿 API 始终保存末级类目 UUID,修复了路径数组不能直接提交到 `editor-draft` 的问题。
- 对 Ozon 来源商品,进入或读取编辑器时会将 `common.categories` 的规范化层级路径与活动缓存节点精确匹配;仅当 profile 未保存类目时写入匹配项并标记 `source_capture`。已有手工选择或此前保存的类目永远不被复采覆盖。API 集成测试覆盖回填和保护规则。
- 已新增 API 集成测试覆盖来源回填和保护规则;仍需在有效 Ozon 门店、真实 Ozon 样本与浏览器页面上执行最终回归。本地 Docker 基础设施未启动时,该集成测试无法实际执行。
@@ -275,6 +276,93 @@ M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目
下一步:在真实采集来源中回归首次按需物化、Worker 下载失败重试与已选图片保存;然后实施定价确认。来源域限流、429/503 延迟重试和跨候选 URL 下载去重属于 Worker 下载器的后续可靠性加固,尚未在本 M3.6 变更中完成。
### 1.32 开发进度(2026-09-18):Ozon 动态属性与 SKU 表单 P0
- 编辑器选择类目后立即加载属性 schema。`is_required=true` 的商品级属性始终展示在「产品属性」,`is_aspect=true` 的必填属性始终展示在对应 SKU 卡片;必填状态仅使用 Label 前的红色 `*`,选填属性仍可折叠。
- 动态属性控件已拆为独立组件,统一处理字典、布尔、数值、文本和多值字段;商品级与 SKU 级属性只负责各自的分区编排。主要信息继续保留类目、标题、型号、品牌和默认包装规格,SKU 展开区可覆盖包装及商品规格。
- 草稿保存不会因 Ozon 必填属性或包装规格为空而失败;已提交的非空属性仍由 API 校验类型、数量、字典归属、类目归属及 `is_aspect` 对应的商品/SKU scope。
- 新增 `GET /api/product-platform-profiles/{profile_id}/validation`,在独立校验模块中返回商品级问题和逐 SKU 问题。已导入 SKU 标为 `not_publishable_again`;必填通用属性和必填变体属性都以当前门店类目 schema 为准。
- 类目 schema 新增 `editor_field` 语义绑定,严格按 Ozon 属性 ID 识别品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254` 和简介/产品描述 `4191`。这些字段由编辑器已有控件承载,动态属性区不再重复展示或预取字典;名称和翻译文本不参与判断。
- Contracts、API 与 Web TypeScript 检查、Web production build 和 `git diff --check` 已通过。API 集成测试已补充通用/逐 SKU 必填缺失和 scope 伪造场景;本轮环境无法连接 PostgreSQL/Redis,因此未实际跑通该集成测试,且未按约定启动 API 或 Worker。
后续 P1/P2 仍包括:新 SKU 通过 `client_key` 在首次保存时关联动态属性、SKU 级条形码、结构化字典值,以及首次发布任务复用 validation 并回填 Ozon 错误。详见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](../product/ozon-dynamic-attributes-and-sku-form-plan.md)。
### 1.33 开发进度与交接(2026-09-18):平台级商品参考目录、系统后台与同步维护
#### 1.33.1 已冻结的数据归属
- 类目/商品类型树、类型属性定义和字典候选值归属 `platform_id`,同一平台的全部门店共用,不再按门店复制。Ozon 与未来 Wildberries 等平台分别使用自己的适配器和外部 ID 语义;本阶段**不创建、不读取门店差异层**。
- 门店连接、卖家凭据、商品/SKU 已填属性、发布状态和错误仍是工作空间业务数据。平台参考目录不保存客户商品、价格、库存或发布事实。
- 这项平台级能力已经独立落地,但商品编辑器仍使用 `022_store_catalog_cache.sql` 的门店级类目 UUID、属性和字典 API。不得直接删除旧表或把新旧 UUID 混用;编辑器迁移必须通过 Ozon 外部 ID 做双读/回填后再切换。
#### 1.33.2 类目树、属性和字典的标识规则
| 参考数据 | Atlas 表与唯一作用域 | Ozon 字段及处理规则 |
|---|---|---|
| 类目/类型树 | `platform_product_categories`;`platform_id + external_category_id + external_type_id` | `external_category_id = description_category_id`,`external_type_id = type_id`。类型节点可能不重复返回类目 ID,映射器会继承最近父类目的 `description_category_id`。纯目录节点使用 `type_id = 0`,只用于树结构,不允许作为建品类型;可发布类型必须同时使用 `description_category_id + type_id`,不能按名称或单独 ID 匹配。`parent_id` 和 `path` 由树同步统一重建,Ozon `disabled` 或本次全量树中消失的节点标为非活动,不物理删除。 |
| 类型属性定义 | `platform_product_category_attributes`;平台 + 类型节点 + `platform_attribute_id + complex_id` | 保存 Ozon 属性 `id`、`attribute_complex_id/complex_id`、名称、说明、分组、类型、`is_required`、`is_aspect`、`is_collection`、`max_value_count`、`dictionary_id` 和 `category_dependent`。属性同步是某一 `description_category_id + type_id` 的完整定义;成功后本次未返回的旧属性标为非活动。 |
| 字典候选值 | `platform_catalog_dictionary_values`;`attribute_id + external_value_id` | 调用字典接口时必须同时传 `attribute_id + description_category_id + type_id`。即使 Ozon 返回相同 `dictionary_id`,也不能只按字典 ID 跨类型合并。当前每页 2000 项,单任务最多读取 5 页;不足 5 页且 `has_next=false` 才记录 `is_complete=true`,只有完整同步才停用本次未返回的旧值。 |
商品编辑器隐藏品牌 `85`、型号名称 `9048`、类型 `8229`、主题标签 `23171`、JSON 富内容 `11254`、简介/产品描述 `4191` 时,仍严格使用 Ozon 属性 ID;名称只用于展示。管理后台是参考数据检查工具,会展示完整属性定义,不应用编辑器的隐藏规则。
#### 1.33.3 数据库和安全边界
迁移 `024_platform_catalog_management.sql` 已新增或扩展以下对象:
- `system_admin_grants`:独立的全局系统管理员授权。工作空间 `owner/admin` 不等于系统管理员;`atlas_app` 被撤销该表的写权限,不能通过普通 HTTP 接口自助提权。
- `platform_catalog_sources`:每个平台一份专用参考源。Ozon `Api-Key` 使用现有 AES-256-GCM 边界加密,随机 nonce 与不可逆 HMAC fingerprint 分开保存;响应只返回脱敏 Client-Id,不回显密钥、密文或 fingerprint。
- `platform_catalog_dictionary_values`:替代旧平台字典表不足以表达类目/属性作用域的问题;旧表暂不删除。
- `platform_catalog_sync_runs`:平台级树、属性和字典任务事实,记录触发方式、状态、来源版本、尝试次数、结果数量和脱敏错误。
- `platform_catalog_sync_requests`:把系统管理员的 `Idempotency-Key` 持久映射到任务;即使手动请求复用了一个已存在的定时任务,重试也不会在原任务完成后创建第二个任务。
- `platform_catalog_outbox_events`:不带 `workspace_id` 的平台级 Transactional Outbox。平台任务不能借用租户 `operations/outbox_events`,避免伪造工作空间归属。
配置来源时,API 在同一事务更新密文和 `revision`,取消旧来源尚未完成的任务,并排队一次树同步。Worker 在领取和提交结果时都会比较 `source_revision`;来源已经更换的旧任务只能取消,不能用新凭据执行或覆盖新数据。API Key 只在 API 加密入口和 Worker 解密调用 Ozon 的短暂内存中出现,不进入浏览器回显、任务载荷或日志。
#### 1.33.4 系统管理员后台
- 独立后台路由为 `/system/platform-catalog`,布局不依赖工作台的门店选择器。普通登录用户可调用 `GET /api/system/access` 获取布尔值;所有管理读写 API 还会在服务端实时校验 `system_admin_grants`,前端隐藏菜单不是权限边界。
- 页面名称为「平台类目与属性」。可选择平台、查看参考源状态和类目树最近成功时间;选择可发布类型后查看属性 ID、必填、SKU 变体、数据类型及已缓存候选值数量;字典在 Drawer 中分页搜索 Atlas 缓存并显示完整/部分状态。
- 「配置同步来源」提交 Client-Id 与 Api-Key。首次配置或实际变更会自动排队树同步;重复提交同一凭据不增加来源版本或重复任务。
- 「同步类目树」「同步当前类型属性」「同步此字典」都只向 API 排队,不在 HTTP 请求中直接访问 Ozon。最近任务表每 5 秒轮询一次;任务由 `queued → running → succeeded/failed/cancelled` 转换,完成后页面重新读取相应缓存。
- 管理 API 契约、首位管理员初始化 SQL 和错误边界见 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md)。目前数据库已经存在一名有效系统管理员授权;此前反馈过用户菜单没有显示入口,数据库授权已确认正确,但刷新/当前 Web 与 API 进程版本下的菜单展示仍需浏览器回归。
#### 1.33.5 手动同步与自动同步的共同链路
```text
系统管理员页面 / 定时调度器
↓ 同一事务创建 run + platform outbox
PlatformCatalogDispatcher(每 2 秒,批量最多 20 条)
↓ 投递 atlas-dispatch / platform_catalog_sync.requested
BullMQ Worker(最多 5 次,5 秒起指数退避)
↓ 使用平台参考源凭据调用 Ozon
事务内 upsert 参考数据并完成 run
↓
管理页面轮询任务并重新读取 Atlas 缓存
```
- 手动接口要求 UUID `Idempotency-Key`。同一平台和 `scope_key` 只允许一个 `queued/running` 任务;已有活动任务时返回并映射到该任务。
- Worker 启动时立即执行一次调度,之后每 60 秒扫描一次。每个平台最多保留 6 个活动任务,优先级依次为树、属性、字典;树和属性成功后 24 小时内不重复,字典成功后 7 天内不重复,任一作用域创建任务后 1 小时内不会被定时器重复排队。
- Ozon 单次 HTTP 超时为 30 秒。401/403 转换为凭据错误;429、5xx、网络错误和响应结构异常交给 BullMQ 重试;其他确定性 4xx 直接记录请求被拒绝。最终失败只更新任务错误,不清空上次成功缓存。
- 树、某类型的属性和某属性的字典分别在单个数据库事务中发布。读取方在事务提交前仍看到旧缓存;P0 尚未实现不可变版本快照、内容哈希和 `next_refresh_at`,当前依赖事务 upsert、`synced_at` 与任务历史保证一致性和可追踪性。
#### 1.33.6 当前实际进度快照
- `024_platform_catalog_management.sql` 已于 2026-09-18 实际应用,系统管理员授权已写入并回读验证。
- Worker 曾因先于 `024` 迁移启动而无法初始化平台 Outbox Dispatcher;迁移后 Worker 已恢复。最新数据库事实显示 Outbox 已被分发、任务 `attempt_count=1`,证明平台 Dispatcher、BullMQ 消费和 Ozon 调用链路当前正在工作。
- 两次 Ozon 类目树任务均已成功,每次映射结果为 7933 条;当前活动节点为 7365 条,差额主要是纯目录/禁用节点。定时属性预热已经开始:检查时 18 个属性任务成功,当前活动属性定义 436 条,单类型返回约 27~50 个属性。
- 字典候选值当前仍为 0,尚未进入有效的初始预热阶段。现有调度固定优先全部属性,再处理字典;在数千个类型下可能导致字典长期排在属性之后,这是已知的 P0 调度公平性问题,不能把“属性同步已开始”误认为字典已经可供全部用户使用。
- 已通过 API/Worker/Web TypeScript 检查、Web production build、3 项系统管理员/参数单元测试、6 项 Ozon 树/属性/字典映射与分页测试及 `git diff --check`。由于功能落地后才执行迁移,尚未新增并运行覆盖真实 PostgreSQL 的平台目录端到端测试;现有成功任务提供了真实 Ozon 链路验证,但不能替代自动化数据库回归。
#### 1.33.7 明确未完成项
1. 调整定时调度公平性:树之后不能等待全部类型属性完成才开始字典;应在属性和字典之间设置配额/轮转,并优先近期使用或管理员选中的类型。
2. 为超过 10000 项的大字典实现可续传游标或分片同步周期。当前任务会正确标记 `is_complete=false`,但下一次仍从头读取,不能持续推进到第 10001 项以后。
3. 将商品编辑器由门店级缓存迁移到平台级目录:按 `description_category_id + type_id` 和属性 ID 回填,先双读/兼容保存,再切换外键和 API,最后才讨论清理 `022` 表。
4. 增加平台目录 PostgreSQL 集成测试,覆盖系统管理员隔离、凭据版本替换、手动幂等复用、定时去重、失败保留旧缓存、完整字典停用旧值及跨平台 ID 拒绝。
5. 在浏览器完成管理员菜单、直达路由、凭据弹窗、任务轮询、类目/属性/字典展示的回归,并确认 API/Worker 进程都使用迁移后的当前代码。
完整架构边界见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md),HTTP 契约见 [`platform-catalog-admin.md`](../api/platform-catalog-admin.md)。
## 2. 已确认产品决策
| 主题 | 决策 |
@@ -293,6 +381,7 @@ M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目
| 采集 SKU | 只有来源给出稳定 SKU/组合证据时创建多个 SKU;不能把规格轴做笛卡尔积。没有专属 SKU 图时,真实 SKU 继承父商品默认图而不复制资产。 |
| 媒体 | 采集主图/详情图是商品候选池;来源明确给出 SKU、或可靠的规格轴值(如颜色)与图片关系时才自动绑定。其余素材由编辑页人工选择。 |
| 发布与在线商品 | 商品采集、编辑、定价、AI 媒体和首次导入至 Ozon 后台是核心闭环。正式发布使用官方 Seller API,按一 SKU 一 import item/offer 执行并轮询结果;首期不传库存或仓库,用户在 Ozon 后台设置库存。已导入 SKU 不可再次上架,未上架 SKU 仍可首次上架;SPU 在当前平台汇总为未上架/已部分上架/已上架。在线商品由异步 Ozon API 同步,按 Ozon 商品 SKU 展示,亦包含非 Atlas 发布商品。 |
| 平台商品参考目录 | `platform_id` 下共享类目/类型树、属性定义和字典候选值;同平台门店共用,本阶段不维护门店差异层。定时同步、系统管理员页面和手动排队已实现;商品编辑器的门店级引用待迁移,见 [`platform-product-reference-catalog.md`](../architecture/platform-product-reference-catalog.md)。 |
## 3. Ozon 已核对结论
@@ -335,13 +424,13 @@ User → Workspace → Membership / Role
下一步建议按以下顺序推进:
> 注意:下面的历史清单仍保留用于追溯;当前执行优先级以 [`1.27 当前进行中`](#127-当前进行中2026-09-10类目树交互改造尚未验收) 和本段补充为准。先完成类目启动同步、树形选择与 Ozon 来源类目自动回填,再进入真实采集回归和后续发布能力。
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台和同步基础链路已经实现;现有门店级商品编辑契约在完成兼容迁移前继续生效。
当前执行顺序:
1. 配置专用 Ozon 类目缓存源,在真实 Ozon 样本和 8100 页面完成类目预热、树形选择及来源回填回归。
2. 完成候选媒体选择与按需物化,并把已选素材纳入 SKU 编辑草稿。
3. 完成定价、AI 任务与候选结果、首次发布和在线商品同步的剩余纵向闭环。
1. 完成平台目录首次预热并修正属性/字典调度公平性和大字典续传;补 PostgreSQL 集成测试与系统管理员浏览器回归。
2. 按 Ozon 外部 ID 建立新旧目录映射和双读,迁移商品编辑器到平台级类目、属性和候选值;在验证前保留门店级缓存与外键。
3. 在真实采集来源中继续回归候选媒体按需物化,然后完成定价、AI 任务、首次发布和在线商品同步的剩余纵向闭环。
### 历史计划(仅供追溯)
@@ -360,6 +449,7 @@ User → Workspace → Membership / Role
- [API 统一约定](../api/conventions.md)
- [基础模块数据模型与 DDL](../architecture/foundation-data-model.md)
- [商品领域数据模型与 DDL](../architecture/product-domain-data-model.md)
- [平台级商品参考目录决策](../architecture/platform-product-reference-catalog.md)
- [幂等与 Transactional Outbox](../architecture/transactional-outbox-and-idempotency.md)
- [数据库角色与 RLS](../architecture/database-security.md)
- [商品采集图片与存储策略](../architecture/media-storage-strategy.md)