feat: 重构顺运宝规格主链路 (#88)

This commit is contained in:
chengma
2026-08-10 12:24:23 +08:00
parent 5bb1258462
commit d1165b9e17
28 changed files with 985 additions and 531 deletions
+53 -47
View File
@@ -128,6 +128,12 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为 `syb_orders`
增加 `spec_key`,为 `shopee_products` 增加 `source`。迁移使用稳定 `syb_id`
游标分批回填,先完整备份旧 `sku_mappings` 为 `sku_mappings_v3_backup`,
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
## 3. 蝦皮数据
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
@@ -140,6 +146,7 @@ CREATE TABLE shopee_products (
title TEXT NOT NULL, -- 蝦皮「商品名稱」
shopee_status TEXT, -- 蝦皮「商品當前狀態」
main_sku_code TEXT, -- 蝦皮「主商品貨號」
source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', -- report / syb
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
@@ -149,11 +156,18 @@ CREATE TABLE shopee_products (
updated_at TEXT NOT NULL
);
ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
CHECK (source IN ('report', 'syb'));
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
```
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
`source='syb'` 表示顺运宝先到、蝦皮报表尚未导入时创建的最小商品骨架。
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
导入不得覆盖人工维护的 PDD 关联。
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
@@ -299,7 +313,7 @@ CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
**为什么用软删除**
`sku_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
`spec_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
软删除后界面不再显示,但记录和映射都还在。
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
@@ -434,9 +448,10 @@ CREATE TABLE syb_orders (
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
title TEXT, -- 商品标题
product_spec TEXT, -- 规格原文,v5 新增,对应 shopee_skus.spec_raw
product_spec TEXT, -- 顺运宝规格原文
spec_key VARCHAR(191), -- 规格身份键;空规格保持 NULL
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
shopee_sku_id TEXT, -- 蝦皮规格 ID,人工/自动匹配的结果
shopee_sku_id TEXT, -- 历史兼容列;新主链路不读写
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
image_url TEXT, -- 存 URL,不存图片本身
@@ -453,24 +468,12 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
- `shopee_sku_id` **直接对应 `shopee_skus.sku_id`**,编号格式一致,不需要额外转换。
`[必须]` 但**不要加外键约束**。理由:蝦皮报表不是全量目录(样本里平均每商品仅 1.17 个 SKU),
货运单来了而本地查不到这个 SKU 是**常态**。加了外键,同步就会直接失败。
正确做法是软关联:查不到时照常保存货运单,界面上标出"SKU 未收录",
并引导操作员到蝦皮数据模块手动新增(见 [05 界面规范](05-ui-specification.md) §4.4)。
**"编号能对上"和"本地一定查得到"是两回事,别混。**
**处理阶段是派生的,不存字段**:`shopee_sku_id` 非空只表示蝦皮规格已确认。
PDD 规格是否已匹配要联查 `sku_mappings`,同时限定 `shopee_sku_id` 和当前
`pdd_goods_id`,并确认 `pdd_option_key` 仍存在于最新 `skus_json`;选项消失后按待匹配处理。
`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是蝦皮规格识别
结果(人工确认或确定性唯一识别产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`,
不含蝦皮規格ID,见 §5.1 下方接口对照)。`repository.UpsertSybOrder` 的
`ON CONFLICT DO UPDATE SET` 里不出现这一列,新建行时才会写它(此时通常是空值)。
- `spec_key` 通过 `SpecKey(product_spec)` 计算:去除首尾空白,把连续空白折叠为一个半角空格,
但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
- **处理阶段是派生的,不存字段**:PDD 规格是否已匹配要按
`(shopee_goods_id, spec_key, 当前 pdd_goods_id)` 联查 `spec_mappings`,并确认 `pdd_option_key`
仍存在于最新 `skus_json`。
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
@@ -518,33 +521,35 @@ CREATE TABLE syb_sync_state (
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
## 6. `sku_mappings` 规格映射
## 6. `spec_mappings` 顺运宝规格映射
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
"这个蝦皮商品的这条顺运宝规格 = 当前拼多多商品的那个规格",**匹配一次,以后复用**。
```sql
CREATE TABLE sku_mappings (
shopee_sku_id TEXT NOT NULL,
pdd_goods_id TEXT NOT NULL, -- ★ 这条映射属于哪个 PDD 商品
pdd_option_key TEXT NOT NULL, -- 规范化的组合键,见 §6.2
pdd_options TEXT NOT NULL, -- 原始 options 对象,显示用
goods_id TEXT NOT NULL, -- 蝦皮商品 ID,方便按商品批量查
mapped_at TEXT NOT NULL,
mapped_by TEXT,
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
CREATE TABLE spec_mappings (
shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
pdd_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, -- 见 §6.2
pdd_options LONGTEXT NOT NULL, -- 原始 options JSON,显示用
spec_raw TEXT NOT NULL,
mapped_at VARCHAR(35) NOT NULL,
mapped_by VARCHAR(191),
PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
KEY idx_spec_mappings_goods (shopee_goods_id),
KEY idx_spec_mappings_pdd (pdd_goods_id)
);
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);
```
### 6.1 为什么主键要带上 `pdd_goods_id`
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
### 6.1 为什么主键要带上商品和 `pdd_goods_id`
PDD 商品下架换代很频繁——A 买不到了就得换 B。
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
后来 A 下架,换成了 B。如果映射只按 `shopee_sku_id` 存,那条旧映射还在,
后来 A 下架,换成了 B。如果映射不带 `pdd_goods_id`,那条旧映射还在,
但它描述的是 **A 的规格**:
| | 后果 |
@@ -555,8 +560,9 @@ PDD 商品下架换代很频繁——A 买不到了就得换 B。
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
```sql
SELECT ... FROM sku_mappings
WHERE shopee_sku_id = ?
SELECT ... FROM spec_mappings
WHERE shopee_goods_id = ?
AND spec_key = ?
AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)
```
@@ -701,12 +707,12 @@ CREATE TABLE clients (
```text
shopee_products ──1:N──→ shopee_skus
│ │
│ pdd_goods_id │ shopee_sku_id
│ (当前对应哪个 ↓
│ PDD 商品,可换) sku_mappings ──pdd_goods_id──┐
↓ │
pdd_products ←────────────────────────────────────────┘
│
│ pdd_goods_id(当前 PDD 商品,可换)
↓
pdd_products ←─pdd_goods_id─ spec_mappings
↑
syb_orders ─(shopee_goods_id, spec_key)─┘
(skus_json 里是所有规格和价格)
syb_orders ──创建──→ tasks ──分配──→ clients
@@ -717,7 +723,7 @@ users(采购员)──1:N 当前归属──────────┘
两条关联都可以变,这是有意的:
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
- `sku_mappings` 按 `(蝦皮SKU, PDD商品)` 存:换了商品自然查不到旧映射
- `spec_mappings` 按 `(蝦皮商品, 顺运宝规格键, PDD商品)` 存:换了商品自然查不到旧映射
## 11. 与 Client 数据模型的关系