Files

279 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# V2 数据库设计
> 状态:方案设计(待确认)
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md)
> 字段来源:Ozon `ProductAPI_ImportProductsV3` + V1 `docs/contracts/product-json.md` + 采集/发布/店铺维度
---
## 1. 设计原则
1. **商品主表对齐 Ozon 字段**`products` 表按 `ImportProductsV3` 的字段平铺(`name/description/price/offer_id/...`),JSONB 存三类结构:`attributes` / `complex_attributes`Ozon 动态属性)、`raw`(采集原文)、`pricing`(计价结果)。
2. **素材与发布字段分离**:采集来的源图(分组/变体/源站 URL/七牛 URL)放 `product_assets` 表;`products.images` 只存「将提交给 Ozon 的有序公网 URL 数组」。
3. **店铺密钥加密落库**`shops.client_id_enc` / `api_key_enc` 用服务端密钥 AES-GCM 加密,前端永不回显明文。
4. **发布异步化**:发布请求与结果存 `publish_tasks`,商品状态由轮询结果回写。
5. **类目字典可重建**`category_*` 三张表是 Ozon 字典的本地缓存,可随时清空重拉,不作为业务真源。
数据库:**PostgreSQL 16**。ID 统一 `UUID``gen_random_uuid()`)或 Ozon 原生 `BIGINT`(类目/属性 id 用 BIGINT 保持与 Ozon 一致)。时间统一 `timestamptz`
---
## 2. 表结构
### 2.1 `users` —— 用户(预留,MVP 单用户可空置)
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| username | varchar(64) UNIQUE | 登录名 |
| password_hash | varchar(255) | Argon2/bcrypt |
| created_at | timestamptz | |
MVP 用 `APP_TOKEN` 时此表可留空;升级多用户时启用。
### 2.2 `shops` —— Ozon 店铺
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| user_id | UUID FK → users | 归属(MVP 可为空) |
| name | varchar(128) | 店铺显示名 |
| client_id_enc | text | Client-Id 密文 |
| api_key_enc | text | Api-Key 密文 |
| currency_code | varchar(3) DEFAULT 'RUB' | 店铺结算币种(RUB/CNY |
| status | enum('active','invalid','disabled') DEFAULT 'active' | invalid=连通性校验失败 |
| last_checked_at | timestamptz | 最近一次校验时间 |
| created_at / updated_at | timestamptz | |
> **密钥安全**`client_id` / `api_key` 用服务端 `SECRET_KEY` 做 AES-GCM 加密后存 `*_enc`。列表接口只返回 `id/name/currency/status/last_checked_at` 与**打码**的 client_id 后四位,永不返回明文 key。
### 2.3 `products` —— 商品(采集箱 + 编辑 + 发布一体化)
| 列 | 类型 | 说明 | 对应 Ozon 字段 |
|---|---|---|---|
| id | UUID PK | 内部主键 | — |
| user_id | UUID FK → users | 归属(MVP 可空) | — |
| stage | enum | `collected/editing/ready/publishing/published/failed/archived` | — |
| source_platform | varchar(16) | `ozon/1688/taobao` | — |
| source_item_id | varchar(64) | 源平台商品 ID(去重) | — |
| source_url | text | 采集来源 URL | — |
| offer_id | varchar(255) | **自己的货号**(采集恒空,编辑必填) | offer_id |
| ozon_product_id | bigint | 发布成功后回填 | — |
| ozon_sku | bigint | 跟卖(import-by-sku)用,可空 | — |
| name | text | 商品名(俄文,最终) | name |
| description | text | 商品描述(俄文,最终) | description |
| description_category_id | bigint | 类目 | description_category_id |
| type_id | bigint | 商品类型 | type_id |
| price | numeric(20,2) | 销售价 | price |
| old_price | numeric(20,2) | 划线价 | old_price |
| currency_code | varchar(3) DEFAULT 'RUB' | | currency_code |
| vat | varchar(8) DEFAULT '0' | 0 / 0.1 / 0.2 | vat |
| depth / width / height | numeric(12,3) | 尺寸 | depth/width/height |
| dimension_unit | varchar(4) DEFAULT 'mm' | mm / cm | dimension_unit |
| weight | numeric(12,3) | 重量 | weight |
| weight_unit | varchar(4) DEFAULT 'g' | g / kg | weight_unit |
| barcode | varchar(64) | 条码 | barcode |
| images | jsonb | 有序公网 URL(七牛)数组,≤15 | images |
| primary_image | text | 主图 URL | primary_image |
| images360 | jsonb | 360 图 URL 数组 | images360 |
| color_image | text | 营销色图 URL | color_image |
| pdf_list | jsonb | | pdf_list |
| attributes | jsonb | `[{complex_id,id,values:[{dictionary_value_id,value}]}]` | attributes |
| complex_attributes | jsonb | 视频/尺码表等 | complex_attributes |
| promotions | jsonb | | promotions |
| raw | jsonb | 采集原文:`{title,price,params[],desc,sellingPoints,brand,texts[]}` | —(`_raw` |
| pricing | jsonb | 计价结果(见 §3) | —(`_pricing` |
| copy | jsonb | AI 文案结果:`{titles_ru/zh,description_ru/zh,tags_ru/zh,model}` | — |
| fx_rate | numeric(12,4) | 计价时快照的汇率 | — |
| published_at | timestamptz | 发布成功时间 | — |
| created_at / updated_at | timestamptz | | |
索引:
- `(user_id, stage)` —— 采集箱/发布列表主查询
- `(source_platform, source_item_id)` UNIQUE(可空)—— 采集去重
- `offer_id` —— 货号查重
- `ozon_product_id`
### 2.4 `product_assets` —— 采集素材(图片/视频)
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| product_id | UUID FK → products ON DELETE CASCADE | |
| group_key | varchar(16) | `main/sku/detail/video/param` |
| variant_name | varchar(128) | SKU 规格名(俄文原样) |
| sort_order | int | 组内顺序(1 起,对应命名 `main-001` |
| type | varchar(8) | `img/video` |
| source_url | text | 源站原图 URL(溯源) |
| qiniu_url | text | 七牛公网 URL(转存成功后) |
| status | enum('pending','downloading','uploaded','failed') | 转存状态 |
| dedupe_key | varchar(512) | URL 归一化指纹(去重) |
| width / height | int | |
| error | text | 失败原因 |
| created_at | timestamptz | |
索引:`(product_id, group_key, sort_order)`
> **与 `products.images` 的关系**`product_assets` 是「素材库」(编辑期勾选、分组、去重);用户从素材库选出 ≤15 张主图后,按顺序写 `products.images`(七牛 URL)。这两层解耦,跟卖换主图不改素材库。
### 2.5 `product_texts` —— 采集文本(可选,也可并进 raw)
> 一期建议**并进 `products.raw`**JSONB),不必单开表。若后续要按「卖点/参数」检索,再拆此表:
| 列 | 类型 | 说明 |
|---|---|---|
| product_id | UUID FK | |
| kind | varchar(16) | `title/params/selling_point/desc/price/brand` |
| content | text | 文本 |
| pairs | jsonb | `table` 模式的 kv |
### 2.6 `publish_tasks` —— 发布任务
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| product_id | UUID FK → products | |
| shop_id | UUID FK → shops | 发布到哪个店铺 |
| ozon_task_id | bigint | `/v3/product/import` 返回的 task_id |
| status | enum('pending','processing','moderation','imported','failed') | 轮询结果 |
| request_payload | jsonb | 实际发给 Ozon 的 items[0](脱敏后) |
| response | jsonb | `/v1/product/import/info` 原始结果 |
| errors | jsonb | 失败原因数组 |
| created_at / completed_at | timestamptz | |
索引:`(product_id, created_at DESC)``ozon_task_id`
### 2.7 类目字典缓存(三张,可重建)
#### `category_tree`
| 列 | 类型 | 说明 |
|---|---|---|
| description_category_id | bigint PK | 类目 ID |
| parent_id | bigint | 父类目 |
| category_name | varchar(255) | |
| type_id | bigint | 商品类型 ID |
| type_name | varchar(255) | |
| disabled | boolean | 不可建品 |
| level | int | 层级 |
| lang | varchar(8) | DEFAULT/RU/EN/ZH_HANS |
| updated_at | timestamptz | 缓存时间 |
#### `category_attributes`
主键 `(description_category_id, type_id, attribute_id)`
| 列 | 类型 | 说明 |
|---|---|---|
| description_category_id / type_id / attribute_id | bigint | 复合主键 |
| name | varchar(255) | 属性名 |
| description | text | |
| type | varchar(32) | 属性值类型 |
| dictionary_id | bigint | 0=无字典 |
| group_id / group_name | bigint / varchar | 属性分组 |
| is_required | boolean | 必填 |
| is_aspect | boolean | 变体属性(颜色/尺码) |
| is_collection | boolean | 多值 |
| max_value_count | int | |
| attribute_complex_id | bigint | 复杂属性 |
| complex_is_collection | boolean | |
| category_dependent | boolean | 字典值是否依赖类目 |
| lang | varchar(8) | |
| updated_at | timestamptz | |
#### `attribute_values`
| 列 | 类型 | 说明 |
|---|---|---|
| id | bigint | 字典值 ID |
| attribute_id | bigint | |
| description_category_id / type_id | bigint | |
| value | varchar(512) | 字典值文本 |
| picture | text | 值配图 |
| info | text | |
| lang | varchar(8) | |
| updated_at | timestamptz | |
> 字典值可能很大(一个类目数万条),**按需拉取**:用户选了类目+属性后才拉该属性字典,且只缓存用过的属性(见 `ozon-publish.md` §3)。
---
## 3. JSONB 结构约定
### 3.1 `products.raw`(采集原文,对齐 V1 `_raw` + texts
```jsonc
{
"title": "Термокружка детская 316",
"price": "1 290 ₽",
"params": [{ "key": "Материал", "value": "Нержавеющая сталь" }],
"desc": "…",
"sellingPoints": "…",
"brand": "…",
"texts": [ // 插件 texts[] 原样
{ "kind": "params", "content": "…", "pairs": [{ "key": "…", "value": "…" }] }
],
"images": { "main": [...], "sku": [...], "detail": [...], "video": [...] } // 采集快照(可选)
}
```
### 3.2 `products.pricing`(对齐 V1 `_pricing`
```jsonc
{
"purchasePrice": 18.5, // 进货价 ¥
"profitRate": 30, // 净利率 %
"logisticsLevel": "high", // low | high | high2
"weightG": 320,
"dims": { "l": 12, "w": 8, "h": 20 },
"logisticsFee": 0,
"fullCommission": 0,
"totalCost": 0,
"sellingPriceCny": 0,
"sellingPriceRub": 0,
"discountReserve": 50,
"fxRate": 11.8,
"calculatedAt": "…"
}
```
> 计价公式与字段沿用 v1`web/js/app.js`),**只抄不改**,见 `migration.md` §3。`products.price` 最终取 `sellingPriceRub`(预留折扣后售价)。
### 3.3 `products.attributes`(对齐 Ozon
```jsonc
[ { "complex_id": 0, "id": 5076, "values": [ { "dictionary_value_id": 971082156, "value": "Speaker stand" } ] } ]
```
---
## 4. 关系图
```
users 1─n shops 1─n publish_tasks n─1 products
1─n product_assets
1─n product_texts(可选)
products n─1 category_tree(弱关联,仅存 id
```
---
## 5. 迁移(Alembic)约定
- 首个迁移建全部表;后续 schema 变更走 Alembic revision。
- JSONB 字段的 schema 演进靠应用层版本号(`raw.schemaVersion` / `pricing.schemaVersion`)而非 DB 迁移,避免频繁 ALTER。
- `shops.client_id_enc/api_key_enc` 的加密密钥 `SECRET_KEY``.env`,**换环境(本地/腾讯云)需保证一致或做好密文重写**。
---
## 6. 规模预估(单人自用 → 小团队)
| 表 | 量级 | 说明 |
|---|---|---|
| products | 万级 | 每商品数十素材,主表轻 |
| product_assets | 十万级 | 每商品 10~30 图 |
| publish_tasks | 万级 | 每发布一次一条 |
| category_* | 类目数万 / 属性数百万 / 值可能上亿(按需缓存) | 只缓存用过的 |
该量级单机 PostgreSQL 绰绰有余,无需分库分表;`product_assets` 后续可考虑按 product_id 分区或归档。