# 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 分区或归档。