279 lines
11 KiB
Markdown
279 lines
11 KiB
Markdown
# 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 分区或归档。
|