2026-08-06 15:49:04 +08:00
|
|
|
|
# 03 Admin 数据模型
|
|
|
|
|
|
|
|
|
|
|
|
- 文档状态:基线草案,待数据评审
|
|
|
|
|
|
- 数据库:SQLite(驱动 `modernc.org/sqlite`,纯 Go 免 cgo)
|
|
|
|
|
|
- 位置:`data/admin.db`,见 [02 架构](02-architecture.md) §6
|
|
|
|
|
|
|
|
|
|
|
|
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
|
|
|
|
|
|
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
|
|
|
|
|
|
|
|
|
|
|
|
## 1. 设计原则
|
|
|
|
|
|
|
|
|
|
|
|
- `[必须]` 金额一律整数,字段名带单位后缀(`_cent`),**禁止 float**。
|
|
|
|
|
|
- `[必须]` 台币和人民币**分开存、不互相换算覆盖**。
|
|
|
|
|
|
- `[必须]` 时间存带时区 ISO 8601 的 UTC 字符串,页面上转本地时区显示。
|
|
|
|
|
|
- `[必须]` 外部来的原始数据(规格原文、货运单 JSON、采集结果 JSON)**原样保留**,
|
|
|
|
|
|
规范化字段用于查询和显示。解析失败留空,不要猜。
|
|
|
|
|
|
- `[必须]` **人工维护的字段不得被导入覆盖**(详见 §3.3)。
|
|
|
|
|
|
- `[必须]` SQL 一律参数化查询。
|
|
|
|
|
|
|
|
|
|
|
|
## 2. 建库与迁移
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
### 2.1 PRAGMA 必须写在 DSN 里
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[必须]` 三条设置通过**连接串**传入,**不要**用 `db.Exec("PRAGMA ...")`:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
dsn := "file:" + path +
|
|
|
|
|
|
"?_pragma=busy_timeout(5000)" +
|
|
|
|
|
|
"&_pragma=journal_mode(WAL)" +
|
|
|
|
|
|
"&_pragma=foreign_keys(1)"
|
|
|
|
|
|
db, _ := sql.Open("sqlite", dsn)
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
| 设置 | 作用 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `busy_timeout(5000)` | 拿不到锁时最多等 5 秒,而不是立刻报错 |
|
|
|
|
|
|
| `journal_mode(WAL)` | 读和写可以同时进行,不互相锁死 |
|
|
|
|
|
|
| `foreign_keys(1)` | 打开外键约束(SQLite 默认是**关**的) |
|
2026-08-06 17:04:49 +08:00
|
|
|
|
| `_txlock=immediate` | 事务一开始就拿写锁,见下 |
|
2026-08-06 16:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
**为什么不能用 `db.Exec`:** Go 的 `database/sql` 是一个**连接池**。
|
|
|
|
|
|
`db.Exec("PRAGMA busy_timeout=5000")` 只作用于当时拿到的那一条连接,
|
|
|
|
|
|
池子后来新开的连接**完全没执行过**这些 PRAGMA。
|
|
|
|
|
|
并发写的时候,没有 `busy_timeout` 的那些连接会直接报
|
|
|
|
|
|
`database is locked (SQLITE_BUSY)`,而不是等锁释放。
|
|
|
|
|
|
|
|
|
|
|
|
这个坑在开发时不容易发现——单线程跑一切正常,一并发就炸。
|
|
|
|
|
|
本项目的并发领取测试就是被它绊倒过一次。
|
|
|
|
|
|
|
2026-08-06 17:04:49 +08:00
|
|
|
|
**为什么必须加 `_txlock=immediate`:** Go 的 `db.Begin()` 默认发的是
|
|
|
|
|
|
`BEGIN DEFERRED`——事务开始时**不拿写锁**,等第一次写才去拿。
|
|
|
|
|
|
于是多个事务能同时开始、各自先读,然后同时想升级成写,互相卡死。
|
|
|
|
|
|
这种情况 `busy_timeout` **救不了**,等下去也不会有结果。
|
|
|
|
|
|
|
|
|
|
|
|
实测(6 个并发事务,每个先读后写):
|
|
|
|
|
|
|
|
|
|
|
|
| DSN | 失败数 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| 默认 deferred | **5 / 6** |
|
|
|
|
|
|
| 加 `_txlock=immediate` | **0 / 6** |
|
|
|
|
|
|
|
|
|
|
|
|
加上之后事务一开始就排队拿锁,拿不到就按 `busy_timeout` 等,这才是要的行为。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[建议]` 同时限制连接数:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
db.SetMaxOpenConns(4)
|
|
|
|
|
|
db.SetMaxIdleConns(4)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、把 `busy_timeout` 耗光。
|
|
|
|
|
|
**不要设成 1**——那样在一个事务里再调用需要连接的代码会死锁。
|
|
|
|
|
|
|
|
|
|
|
|
### 2.2 迁移
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
用 `PRAGMA user_version` 管理顺序迁移。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[必须]` 迁移语句**一条一执行**,不要把多条 SQL 塞进一个字符串——
|
|
|
|
|
|
`database/sql` 的 `Exec` 对"一次执行多条语句"的支持因驱动而异,
|
|
|
|
|
|
拆开最稳妥,报错还能精确到第几条。
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
`[必须]` 升级必须支持从所有已发布版本迁移,**不得在启动时删库重建**。
|
|
|
|
|
|
理由:`data/` 在升级时是保留的(见 [02](02-architecture.md) §6),
|
|
|
|
|
|
里面有人工填了几个月的 PDD 链接和 SKU 映射,删掉就没了。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[必须]` 加新版本时**只能往末尾追加** `migrations`,不许改动已有元素——
|
|
|
|
|
|
已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
## 3. 蝦皮数据
|
|
|
|
|
|
|
|
|
|
|
|
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
|
|
|
|
|
|
|
|
|
|
|
|
### 3.1 `shopee_products` 商品级
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE shopee_products (
|
|
|
|
|
|
goods_id TEXT PRIMARY KEY, -- 蝦皮「商品ID」
|
|
|
|
|
|
title TEXT NOT NULL, -- 蝦皮「商品名稱」
|
|
|
|
|
|
shopee_status TEXT, -- 蝦皮「商品當前狀態」
|
|
|
|
|
|
main_sku_code TEXT, -- 蝦皮「主商品貨號」
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
|
|
|
|
|
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
|
|
|
|
|
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
|
|
|
|
|
|
|
|
|
|
|
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
|
|
|
|
|
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
### 3.2 `shopee_skus` SKU 级
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE shopee_skus (
|
|
|
|
|
|
sku_id TEXT PRIMARY KEY, -- 蝦皮「商品規格ID」,实测零重复
|
|
|
|
|
|
goods_id TEXT NOT NULL,
|
|
|
|
|
|
spec_raw TEXT NOT NULL, -- ★ 规格原文,永远保留
|
|
|
|
|
|
color TEXT, -- 解析结果,失败留空
|
|
|
|
|
|
size TEXT,
|
|
|
|
|
|
advice TEXT, -- 建议体重,如「40-50公斤」
|
|
|
|
|
|
parse_ok INTEGER NOT NULL DEFAULT 0, -- 0=解析失败,界面上要标出来
|
|
|
|
|
|
sku_code TEXT, -- 蝦皮「商品選項貨號」
|
|
|
|
|
|
is_manual INTEGER NOT NULL DEFAULT 0, -- 1=人工新增的,导入不得删
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL,
|
|
|
|
|
|
FOREIGN KEY (goods_id) REFERENCES shopee_products(goods_id) ON DELETE CASCADE
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id);
|
|
|
|
|
|
CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.3 Excel 导入规则
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 这一节的每一条都要照做,写错会丢数据。
|
|
|
|
|
|
|
|
|
|
|
|
**第一步:分行**
|
|
|
|
|
|
|
|
|
|
|
|
| 行类型 | 判断方法 | 样本条数 | 导入到 |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| 商品汇总行 | `商品規格ID` 是 `-` 或空 | 5195 | `shopee_products` |
|
|
|
|
|
|
| SKU 行 | `商品規格ID` 是数字 | 6092 | `shopee_skus` |
|
|
|
|
|
|
|
2026-08-06 15:58:09 +08:00
|
|
|
|
拿参考样本(11287 行)导入应得到 **5195 商品 + 6092 SKU**,数字对不上就是解析有问题。
|
|
|
|
|
|
|
|
|
|
|
|
> 样本文件含商业数据,**不在仓库里**,找项目负责人要,放 `raw_data/` 下。
|
|
|
|
|
|
> 自动化测试用 `admin/testdata/` 里的小样本,别读大文件。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
**第二步:按列名找索引,不要写死列号**
|
|
|
|
|
|
|
|
|
|
|
|
报表有 40 列,蝦皮改一次导出格式列号就变。启动时按表头文字定位:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
idx := map[string]int{}
|
|
|
|
|
|
for i, name := range header {
|
|
|
|
|
|
idx[strings.TrimSpace(name)] = i
|
|
|
|
|
|
}
|
|
|
|
|
|
goodsID := row[idx["商品ID"]]
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
找不到必需列时**直接报错停止**,不要用默认值蒙混过去。
|
|
|
|
|
|
|
|
|
|
|
|
**第三步:解析规格原文**
|
|
|
|
|
|
|
|
|
|
|
|
`商品規格` 这一列格式**不统一**,实测两种各占一半:
|
|
|
|
|
|
|
|
|
|
|
|
| 格式 | 占比 | 样例 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| 有【】 | 53.4% | `黑色,M【建議40-50公斤】` |
|
|
|
|
|
|
| 无括号、空格分隔 | 46.6% | `卡其色拼黑色,L 建議50-57.5kg` |
|
|
|
|
|
|
|
|
|
|
|
|
好消息:**逗号数恒为 1**(6092 条无例外),所以"颜色,尺码"这个二分结构是稳的。
|
|
|
|
|
|
|
|
|
|
|
|
规则:
|
|
|
|
|
|
|
|
|
|
|
|
1. 按第一个逗号切开 → 左边是颜色,右边是"尺码 + 可能的建议";
|
|
|
|
|
|
2. 右边尝试提取建议:先找 `【建議...】`,再找 ` 建議...`;
|
|
|
|
|
|
3. 剩下的就是尺码;
|
|
|
|
|
|
4. `[必须]` 任何一步失败都**不要猜**,把 `parse_ok` 置 0,颜色尺码留空,
|
|
|
|
|
|
`spec_raw` 照常保存。界面上把这些行标出来让人工补。
|
|
|
|
|
|
|
|
|
|
|
|
**第四步:upsert,绝不清空**
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code,
|
|
|
|
|
|
created_at, updated_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
|
|
|
|
ON CONFLICT(goods_id) DO UPDATE SET
|
|
|
|
|
|
title = excluded.title,
|
|
|
|
|
|
shopee_status = excluded.shopee_status,
|
|
|
|
|
|
main_sku_code = excluded.main_sku_code,
|
|
|
|
|
|
updated_at = excluded.updated_at;
|
|
|
|
|
|
-- 注意:pdd_goods_url / pdd_data / collect_status 一个都不在这里
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| 别这么做 | 后果 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `DELETE FROM shopee_products` 再导入 | **人工填的 PDD 链接、采集结果全没了** |
|
|
|
|
|
|
| `DO UPDATE SET` 里写 `pdd_goods_url = excluded.pdd_goods_url` | 报表里没这列,会被更新成空 |
|
|
|
|
|
|
| 删掉报表里没出现的 SKU | 人工新增的(`is_manual=1`)会被误删 |
|
|
|
|
|
|
|
|
|
|
|
|
**第五步:返回统计**
|
|
|
|
|
|
|
|
|
|
|
|
导入结束返回 `{商品数, SKU数, 解析失败行号列表}`,页面上显示出来。
|
|
|
|
|
|
**不要静默跳过失败行。**
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 4. `pdd_products` 拼多多商品
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE pdd_products (
|
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
|
goods_id TEXT NOT NULL UNIQUE, -- 从 PDD 链接解析
|
|
|
|
|
|
url TEXT NOT NULL, -- 操作员填的链接原文
|
|
|
|
|
|
title TEXT, -- 采集回来,人工核对用
|
|
|
|
|
|
skus_json TEXT, -- 采集结果,结构见 §4.2
|
|
|
|
|
|
|
|
|
|
|
|
collect_status TEXT NOT NULL DEFAULT 'pending'
|
|
|
|
|
|
CHECK (collect_status IN (
|
|
|
|
|
|
'pending', 'collecting', 'collected', 'failed'
|
|
|
|
|
|
)),
|
|
|
|
|
|
collect_msg TEXT, -- 失败原因
|
|
|
|
|
|
artifact_ref TEXT, -- 诊断产物位置
|
|
|
|
|
|
collected_at TEXT,
|
|
|
|
|
|
|
|
|
|
|
|
deleted_at TEXT, -- 软删除
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 4.1 几个关键决定
|
|
|
|
|
|
|
|
|
|
|
|
**为什么 `goods_id` 不是主键却必须 UNIQUE**
|
|
|
|
|
|
|
|
|
|
|
|
主键用了自增 `id`,那 `goods_id` 就**不再天然防重**了。少了 UNIQUE,
|
|
|
|
|
|
同一个 PDD 商品会被存成好几行:采好几遍、映射说不清指向哪一行。
|
|
|
|
|
|
|
|
|
|
|
|
配套规则:`[必须]` 保存链接时**先从 URL 解析出 `goods_id`,按它查重**,
|
|
|
|
|
|
不要按 URL 查重——同一个商品的 URL 有很多写法(带不带分享参数),
|
|
|
|
|
|
按 URL 查会漏掉,照样存重复。解析不出来就报错,让操作员给完整链接。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么状态里没有"未填链接"**
|
|
|
|
|
|
|
|
|
|
|
|
这张表里有这一行,就说明链接已经填了。"未填链接"是**蝦皮侧**的状态
|
|
|
|
|
|
(`shopee_products.pdd_goods_id` 为空)。界面上仍然显示 5 种,
|
|
|
|
|
|
只是数据来源不同,见 [01 需求](01-requirements.md) §6.1。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么用软删除**
|
|
|
|
|
|
|
|
|
|
|
|
`sku_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
|
|
|
|
|
|
软删除后界面不再显示,但记录和映射都还在。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
|
|
|
|
|
|
`pending`、清空旧采集结果)。不复活的话 `goods_id` 的 UNIQUE 会让插入失败,
|
|
|
|
|
|
操作员会看到一个莫名其妙的错误。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么不存截图**
|
|
|
|
|
|
|
|
|
|
|
|
按已定案的 Artifact 策略([Client 契约](../client/04-admin-api-contract.md) §10 待确认 #3),
|
|
|
|
|
|
客户端**只报本地引用、不上传文件**。截图在客户端那台机器上,Admin 显示不了。
|
|
|
|
|
|
所以存 `artifact_ref`(形如 `client-001:artifacts/PDD-0001/attempt-xxx/`),
|
|
|
|
|
|
告诉操作员去哪台机器的哪个目录捞。
|
|
|
|
|
|
|
|
|
|
|
|
### 4.2 `skus_json` 的结构
|
|
|
|
|
|
|
|
|
|
|
|
由 Client 采集后原样提交,Admin **不做转换**:
|
|
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"goods_id": "737116531267",
|
|
|
|
|
|
"title": "【现货】西装外套三件套",
|
|
|
|
|
|
"captured_at": "2026-08-07T08:00:00Z",
|
|
|
|
|
|
"dimensions": [
|
|
|
|
|
|
{"key": "color", "name": "颜色分类"},
|
|
|
|
|
|
{"key": "size", "name": "尺码"}
|
|
|
|
|
|
],
|
|
|
|
|
|
"skus": [
|
|
|
|
|
|
{"options": {"color": "黑色", "size": "M"},
|
|
|
|
|
|
"price_cent": 1256, "available": true, "raw_price": "¥12.56"},
|
|
|
|
|
|
{"options": {"color": "白色", "size": "M"},
|
|
|
|
|
|
"price_cent": 1256, "available": false, "raw_price": "¥12.56"}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
每个字段都对应 Admin 的一个实际用途,没有多余的:
|
|
|
|
|
|
|
|
|
|
|
|
| 字段 | Admin 拿它干什么 | 不给会怎样 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `skus[].options` | 匹配弹窗列出规格供人选 | 没东西可选,匹配做不了 |
|
|
|
|
|
|
| `skus[].price_cent` | 建采购任务时带出 `max_price_cent` | 价格保护填不了,Client 会拒绝执行 |
|
|
|
|
|
|
| `skus[].available` | 不给缺货规格建任务 | 白跑一趟,Client 到手机上才发现卖光 |
|
|
|
|
|
|
| `goods_id` / `title` | 核对"采的是不是要的那个商品" | 链接跳转、采错商品时静默存错 |
|
|
|
|
|
|
| `dimensions` | 界面按顺序渲染下拉框 | Go 的 map 无序,不知道该先显示颜色还是尺码 |
|
|
|
|
|
|
| `raw_price` | 价格解析出错时对账 | 只有数字,出错了没法查 |
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 几条硬规则:
|
|
|
|
|
|
|
|
|
|
|
|
- **`price_cent` 是整数分**,不是 `12.56` 也不是 `"12.56"`。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。
|
|
|
|
|
|
- **采不到价格时给 `null`,不要给 0**。Admin 遇到 `null` 当"未知"处理并拒绝建任务,绝不当成 0 元。
|
|
|
|
|
|
- **`options` 嵌一层,不平铺 `color`/`size`**。支持任意多个维度,碰到三维商品(颜色/尺码/款式)平铺的结构直接装不下。
|
|
|
|
|
|
- **`dimensions` 只给 `key` 和 `name`,不给 values**。values 能从 `skus` 去重推出来,存两份迟早不一致。
|
|
|
|
|
|
|
|
|
|
|
|
### 4.3 落库时的两条校验
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` Client 提交采集结果时,Admin 必须校验:
|
|
|
|
|
|
|
|
|
|
|
|
1. **返回的 `goods_id` 必须等于请求采集的那个。** 不等说明链接跳转了或采错商品,
|
|
|
|
|
|
要拒绝(`422 COLLECT_GOODS_MISMATCH`)并整体回滚。不拦的话,会把 B 的规格价格
|
|
|
|
|
|
存到 A 名下,之后按它下单就是买错东西。
|
|
|
|
|
|
2. **`skus` 为空要记成 `failed`,不是 `collected`。** 采到 0 个规格对业务毫无用处
|
|
|
|
|
|
(商品下架、页面改版、解析器没认出来),显示"已采集"会让操作员以为好了,
|
|
|
|
|
|
等建任务时才发现不对。
|
|
|
|
|
|
|
|
|
|
|
|
完整的采集提交原文仍然存进 `tasks.result_data`,审计链不断。
|
|
|
|
|
|
|
|
|
|
|
|
## 5. `syb_orders` 顺运宝货运单
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE syb_orders (
|
|
|
|
|
|
syb_id TEXT PRIMARY KEY, -- 货运单 ID
|
|
|
|
|
|
order_no TEXT NOT NULL, -- 订单号
|
|
|
|
|
|
title TEXT, -- 商品标题
|
|
|
|
|
|
shopee_goods_id TEXT, -- 蝦皮商品 ID
|
|
|
|
|
|
shopee_sku_id TEXT, -- 蝦皮规格 ID
|
|
|
|
|
|
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,不存图片本身
|
|
|
|
|
|
syb_data TEXT NOT NULL DEFAULT '{}',-- 完整货运单 JSON,原样保留
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_syb_orders_order ON syb_orders(order_no);
|
|
|
|
|
|
CREATE INDEX idx_syb_orders_goods ON syb_orders(shopee_goods_id);
|
|
|
|
|
|
CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
|
|
|
|
|
|
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
|
|
|
|
|
|
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进 SQLite。
|
|
|
|
|
|
- `shopee_sku_id` **直接对应 `shopee_skus.sku_id`**,编号格式一致,不需要额外转换。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 但**不要加外键约束**。理由:蝦皮报表不是全量目录(样本里平均每商品仅 1.17 个 SKU),
|
|
|
|
|
|
货运单来了而本地查不到这个 SKU 是**常态**。加了外键,同步就会直接失败。
|
|
|
|
|
|
|
|
|
|
|
|
正确做法是软关联:查不到时照常保存货运单,界面上标出"SKU 未收录",
|
|
|
|
|
|
并引导操作员到蝦皮数据模块手动新增(见 [05 界面规范](05-ui-specification.md) §4.4)。
|
|
|
|
|
|
|
|
|
|
|
|
**"编号能对上"和"本地一定查得到"是两回事,别混。**
|
|
|
|
|
|
|
|
|
|
|
|
**"匹配状态"是派生的,不存字段**:`sku_mappings` 里有对应记录就是"已匹配"。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 6. `sku_mappings` 规格映射
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE sku_mappings (
|
2026-08-07 10:27:39 +08:00
|
|
|
|
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),
|
2026-08-06 15:49:04 +08:00
|
|
|
|
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
|
2026-08-07 10:27:39 +08:00
|
|
|
|
CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
### 6.1 为什么主键要带上 `pdd_goods_id`
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
|
|
|
|
|
|
|
|
|
|
|
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
|
|
|
|
|
|
后来 A 下架,换成了 B。如果映射只按 `shopee_sku_id` 存,那条旧映射还在,
|
|
|
|
|
|
但它描述的是 **A 的规格**:
|
|
|
|
|
|
|
|
|
|
|
|
| | 后果 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| 运气好 | B 没有"黑色/M码",建任务时找不到会报错,还算安全 |
|
|
|
|
|
|
| **运气坏** | B 恰好也有"黑色/M码",但完全是另一件衣服 → **静默买错,事后查不出来** |
|
|
|
|
|
|
|
|
|
|
|
|
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
SELECT ... FROM sku_mappings
|
|
|
|
|
|
WHERE shopee_sku_id = ?
|
|
|
|
|
|
AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
换成 B 就自然查不到 A 的映射,界面显示"待匹配"。**不需要在换商品时记得去删旧数据**
|
|
|
|
|
|
——靠查询条件天然隔离,忘不了。
|
|
|
|
|
|
|
|
|
|
|
|
附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
|
|
|
|
|
|
|
|
|
|
|
### 6.2 `pdd_option_key` 的规范化
|
|
|
|
|
|
|
|
|
|
|
|
采集回来的 PDD 数据里**没有 SKU 编号**,一个规格只能靠它的 options 组合来认。
|
|
|
|
|
|
而 JSON 对象的键是无序的:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
存映射时:{"color":"黑色","size":"M"}
|
|
|
|
|
|
采回来时:{"size":"M","color":"黑色"}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
这两个是同一个规格,但字符串不相等。直接比原始 JSON 会匹配不上,
|
|
|
|
|
|
而且是**静默失效**——不报错,只是查不到,最后表现为"明明匹配过却说待匹配"。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 所以要有一个规范化函数,`service.OptionKey()`:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
OptionKey(map[string]string{"size": "M", "color": "黑色"})
|
|
|
|
|
|
// -> {"color":"黑色","size":"M"}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
实现直接用 `json.Marshal` —— Go 序列化 map 时**会按键名排序**,正好就是我们要的
|
|
|
|
|
|
规范化,不用自己拼字符串(自己拼容易漏掉值里含分隔符、含引号之类的边界情况)。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` **这个函数只能有一处实现。** 存映射用它算 key,查规格也用它算 key,
|
|
|
|
|
|
两边必须逐字节一致。如果 Client 那边也算一份、或者别处再写一个"差不多"的版本,
|
|
|
|
|
|
只要有一点点不同(空格、转义、键序),映射就会静默对不上。
|
|
|
|
|
|
**Client 只上报 `options` 对象,key 一律由 Admin 这一个函数算。**
|
|
|
|
|
|
|
|
|
|
|
|
## 7. `tasks` 任务
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
采集任务和采购任务共用一张表,用 `task_type` 区分。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE tasks (
|
|
|
|
|
|
task_id TEXT PRIMARY KEY, -- 给 Client 的稳定编号,如 PDD-20260806-0001
|
|
|
|
|
|
task_type TEXT NOT NULL CHECK (task_type IN ('collect', 'purchase')),
|
|
|
|
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
|
|
|
|
CHECK (status IN ('pending', 'assigned', 'claimed',
|
|
|
|
|
|
'succeeded', 'manual_review',
|
|
|
|
|
|
'failed', 'cancelled')),
|
|
|
|
|
|
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
|
|
|
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
|
|
|
|
|
|
assigned_client TEXT, -- 分配给哪个客户端
|
|
|
|
|
|
claimed_at TEXT,
|
|
|
|
|
|
|
|
|
|
|
|
-- 采购任务才有
|
|
|
|
|
|
syb_id TEXT,
|
|
|
|
|
|
order_no TEXT,
|
|
|
|
|
|
goods_id TEXT, -- 蝦皮商品 ID
|
|
|
|
|
|
shopee_sku_id TEXT,
|
|
|
|
|
|
|
|
|
|
|
|
-- 发给 Client 的执行参数
|
|
|
|
|
|
pdd_goods_url TEXT NOT NULL, -- ★ Client 契约要求必填
|
|
|
|
|
|
pdd_goods_id TEXT,
|
|
|
|
|
|
pdd_options TEXT, -- JSON,采购任务的目标规格
|
|
|
|
|
|
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
|
|
|
|
|
|
max_price_cent INTEGER CHECK (max_price_cent IS NULL OR max_price_cent > 0),
|
|
|
|
|
|
|
|
|
|
|
|
-- 结果
|
|
|
|
|
|
result_data TEXT, -- Client 提交的 pdd_data
|
|
|
|
|
|
error_code TEXT,
|
|
|
|
|
|
error_message TEXT,
|
|
|
|
|
|
finished_at TEXT,
|
|
|
|
|
|
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_tasks_claim ON tasks(assigned_client, status, priority DESC, created_at);
|
|
|
|
|
|
CREATE INDEX idx_tasks_list ON tasks(updated_at DESC, task_id DESC);
|
|
|
|
|
|
CREATE INDEX idx_tasks_order ON tasks(order_no);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4:
|
|
|
|
|
|
|
|
|
|
|
|
1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。
|
|
|
|
|
|
2. **采购任务的 `quantity` 和 `max_price_cent` 都必须有值**,
|
|
|
|
|
|
这是价格保护,没有它 Client 会拒绝执行。
|
|
|
|
|
|
|
|
|
|
|
|
`max_price_cent` 是**人民币分**,默认从 `pdd_data` 里对应 SKU 的价格带出,操作员可改但不能清空。
|
|
|
|
|
|
|
|
|
|
|
|
状态含义见 [01 需求](01-requirements.md) §6.2。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 8. `task_claims` 领取历史
|
2026-08-06 17:04:49 +08:00
|
|
|
|
|
|
|
|
|
|
记录"哪台客户端领过哪个任务"。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE task_claims (
|
|
|
|
|
|
task_id TEXT NOT NULL,
|
|
|
|
|
|
client_id TEXT NOT NULL,
|
|
|
|
|
|
claimed_at TEXT NOT NULL,
|
|
|
|
|
|
PRIMARY KEY (task_id, client_id)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_task_claims_client ON task_claims(client_id);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**为什么需要这张表:** [04 Client 接口实现](04-client-api.md) §4.1 要求
|
|
|
|
|
|
"只有**从未分配给该客户端**的任务才返回 403"。
|
|
|
|
|
|
但 `tasks.assigned_client` 只记**当前**归属,任务一旦重派给别人,
|
|
|
|
|
|
就查不出原来那台领过了——而契约又明确要求
|
|
|
|
|
|
"**已重派仍要接受原客户端提交的结果**"。没有这张表,那条规则根本没法判断。
|
|
|
|
|
|
|
|
|
|
|
|
顺带得到一份审计记录:这个任务被哪几台客户端领过。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 领取成功时写入;提交结果时用它做权限判断。
|
|
|
|
|
|
同一客户端重复领同一任务只更新时间,不报错。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 9. `clients` 客户端
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE clients (
|
|
|
|
|
|
client_id TEXT PRIMARY KEY, -- 序列号,来自 X-Client-Id
|
|
|
|
|
|
name TEXT, -- 客户端上报,可人工改
|
|
|
|
|
|
device_address TEXT,
|
|
|
|
|
|
platform TEXT,
|
|
|
|
|
|
pdd_package TEXT,
|
2026-08-06 17:21:36 +08:00
|
|
|
|
capabilities TEXT, -- 登记或 claim 请求里的 capabilities 原文
|
2026-08-06 15:49:04 +08:00
|
|
|
|
last_seen_at TEXT NOT NULL, -- 每次调接口都刷新
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- `[必须]` **没有 `status` 字段。** 在线状态是**算出来的**:
|
|
|
|
|
|
`last_seen_at` 在 N 分钟内算在线,否则离线。`[建议]` N 默认 10 分钟。
|
|
|
|
|
|
存成字段会和真实情况不同步。
|
2026-08-06 17:21:36 +08:00
|
|
|
|
- `[必须]` 设置页使用独立登记接口幂等新增或更新 Client,claim 保留隐式登记作为兼容兜底。
|
|
|
|
|
|
- `[必须]` 不设心跳接口;登记、claim、result 和 failure 都刷新 `last_seen_at`,见 [04](04-client-api.md) §1.1、§3。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 10. 数据关系总览
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
shopee_products ──1:N──→ shopee_skus
|
|
|
|
|
|
│ │
|
2026-08-07 10:27:39 +08:00
|
|
|
|
│ pdd_goods_id │ shopee_sku_id
|
|
|
|
|
|
│ (当前对应哪个 ↓
|
|
|
|
|
|
│ PDD 商品,可换) sku_mappings ──pdd_goods_id──┐
|
|
|
|
|
|
↓ │
|
|
|
|
|
|
pdd_products ←────────────────────────────────────────┘
|
|
|
|
|
|
(skus_json 里是所有规格和价格)
|
|
|
|
|
|
|
|
|
|
|
|
syb_orders ──创建──→ tasks ──分配──→ clients
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
两条关联都可以变,这是有意的:
|
|
|
|
|
|
|
|
|
|
|
|
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
|
|
|
|
|
|
- `sku_mappings` 按 `(蝦皮SKU, PDD商品)` 存:换了商品自然查不到旧映射
|
|
|
|
|
|
|
|
|
|
|
|
## 11. 与 Client 数据模型的关系
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换数据。
|
|
|
|
|
|
|
|
|
|
|
|
| 概念 | Admin 这边 | Client 那边 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
|
|
|
|
|
|
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
|
2026-08-07 10:27:39 +08:00
|
|
|
|
| 采集结果 | `pdd_products.skus_json` | `pdd_tasks.pdd_data` |
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
`[必须]` **两边的状态是两套,不要试图同步。**
|
|
|
|
|
|
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
|