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
+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`。