Files
cmautobuy/docs/admin/03-data-model.md
T

988 lines
52 KiB
Markdown
Raw 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.
# 03 Admin 数据模型
- 文档状态:基线草案,待数据评审
- 生产数据库:MySQL 8.4(驱动 `go-sql-driver/mysql` v1.9.2)
- 位置:与 Admin 同机的 `127.0.0.1:3307`,使用独立数据库和最小权限账号
- 历史数据库:`data/admin.db` 只作为 SQLite → MySQL 单向迁移来源和回退备份
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
## 1. 设计原则
- `[必须]` 金额一律整数,字段名带单位后缀(`_cent`),**禁止 float**。
- `[必须]` 台币和人民币**分开存、不互相换算覆盖**。
- `[必须]` 时间存带时区 ISO 8601 的 UTC 字符串,页面上转本地时区显示。
- `[必须]` 外部来的原始数据(规格原文、货运单 JSON、采集结果 JSON)**原样保留**,
规范化字段用于查询和显示。解析失败留空,不要猜。
- `[必须]` **人工维护的字段不得被导入覆盖**(详见 §3.3)。
- `[必须]` SQL 一律参数化查询。
## 2. 建库与迁移
生产 MySQL 使用 `schema_migrations` 记录追加版本。MySQL DDL 会隐式提交,
因此每条 DDL 必须可重放;整版全部执行、通过表和关键列自检后才记录版本。
连接会话固定 UTC,字符集固定 `utf8mb4`,业务 ID 使用二进制排序规则保持大小写精确。
下面的 PRAGMA 和 `user_version` 章节是历史 SQLite 迁移规则,只供单向迁移工具维护,
不得重新接入生产运行时。
### 2.1 PRAGMA 必须写在 DSN 里
`[必须]` 三条设置通过**连接串**传入,**不要**用 `db.Exec("PRAGMA ...")`:
```go
dsn := "file:" + path +
"?_pragma=busy_timeout(5000)" +
"&_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)"
db, _ := sql.Open("sqlite", dsn)
```
| 设置 | 作用 |
|---|---|
| `busy_timeout(5000)` | 拿不到锁时最多等 5 秒,而不是立刻报错 |
| `journal_mode(WAL)` | 读和写可以同时进行,不互相锁死 |
| `foreign_keys(1)` | 打开外键约束(SQLite 默认是**关**的) |
| `_txlock=immediate` | 事务一开始就拿写锁,见下 |
**为什么不能用 `db.Exec`:** Go 的 `database/sql` 是一个**连接池**。
`db.Exec("PRAGMA busy_timeout=5000")` 只作用于当时拿到的那一条连接,
池子后来新开的连接**完全没执行过**这些 PRAGMA。
并发写的时候,没有 `busy_timeout` 的那些连接会直接报
`database is locked (SQLITE_BUSY)`,而不是等锁释放。
这个坑在开发时不容易发现——单线程跑一切正常,一并发就炸。
本项目的并发领取测试就是被它绊倒过一次。
**为什么必须加 `_txlock=immediate`:** Go 的 `db.Begin()` 默认发的是
`BEGIN DEFERRED`——事务开始时**不拿写锁**,等第一次写才去拿。
于是多个事务能同时开始、各自先读,然后同时想升级成写,互相卡死。
这种情况 `busy_timeout` **救不了**,等下去也不会有结果。
实测(6 个并发事务,每个先读后写):
| DSN | 失败数 |
|---|---|
| 默认 deferred | **5 / 6** |
| 加 `_txlock=immediate` | **0 / 6** |
加上之后事务一开始就排队拿锁,拿不到就按 `busy_timeout` 等,这才是要的行为。
`[建议]` 同时限制连接数:
```go
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(4)
```
SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、把 `busy_timeout` 耗光。
**不要设成 1**——那样在一个事务里再调用需要连接的代码会死锁。
### 2.2 迁移
用 `PRAGMA user_version` 管理顺序迁移。
`[必须]` 迁移语句**一条一执行**,不要把多条 SQL 塞进一个字符串——
`database/sql` 的 `Exec` 对"一次执行多条语句"的支持因驱动而异,
拆开最稳妥,报错还能精确到第几条。
`[必须]` 升级必须支持从所有已发布版本迁移,**不得在启动时删库重建**。
理由:`data/` 在升级时是保留的(见 [02](02-architecture.md) §6),
里面有人工填了几个月的 PDD 链接和 SKU 映射,删掉就没了。
`[必须]` 加新版本时**只能往末尾追加** `migrations`,不许改动已有元素——
已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。
### 2.3 迁移版本历史
| 版本 | 做了什么 |
|---|---|
| v1 | 初始表结构:`shopee_products`(当时还带着 `pdd_data`/`collect_status` 等采集字段)、`shopee_skus`、`syb_orders`、`sku_mappings`(当时主键只有 `shopee_sku_id`)、`tasks`、`clients`、`idempotency_keys`。 |
| v2 | 新增 `task_claims`(领取历史,见 §8)。 |
| v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 |
| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 |
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
| v7(#54) | 客户端负责人:新增 `client_user_assignments` 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。 |
| v8(#59) | 顺运宝同步记录:新增 `syb_sync_runs` 和开始时间倒序索引;未改写 v1–v7。 |
| v9(#151) | `syb_orders` 增加可空 `shop_name`,从有效 `syb_data.stock.shopName` 回填历史店铺名;无效 JSON、缺字段和空白值保持 `NULL`。 |
生产 MySQL 迁移继续独立追加;v12(#165)新增 `task_syb_sources`,并把没有任何
有效采集任务的孤立 `pdd_products.collect_status='collecting'` 回收到 `pending`。
v13(#172)新增 `task_sequences`,把存量 `tasks.task_id` 按类型和创建时间确定性改为
`cjN` / `cgN`,同步更新领取历史和顺运宝来源外键。历史 SQLite migrations
保持冻结,不追加 v12/v13 生产表结构。
v14(#196)新增 `syb_allowed_shops` 全局店铺准入表;`syb_sync_runs` 增加
`accepted_stock_count`、`shop_skipped_count` 和 `shop_filter_hash`。旧同步记录回填为
“接受数=原始货运单数、店铺跳过=0”;历史顺运宝货运单不删除。
v15(#203)把历史 `syb_orders` 中每个蝦皮商品最新的非空店铺和图片,以
`source='syb'` 的字段级低优先级来源补入 `shopee_products`。v16(#204)确定性解析
“颜色,尺码【建议】”格式,并为历史数据补建无真实外部 ID 的低优先级蝦皮 SKU;
不明确格式只保留原文。v17(#205)为蝦皮商品增加 `deleted_at`、
`deleted_by_user_id` 和删除状态索引,删除改为可恢复的软删除。
v18(#208)新增 `shops` 业务店铺和 `shop_channel_aliases` 渠道名称表,并为
`syb_orders`、`shopee_products` 增加可空 `shop_id`。迁移把 v14 旧准入项一对一
转成业务店铺,同时保留 `syb_allowed_shops` 作为回退依据;历史数据只按渠道原始
名称精确回填,无法确认的记录保持未关联。
v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。迁移按“任一旧开关停用
则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
复现不了。硬凑一个键出来有两种后果:算错了会让映射静默失效,需要人工重新匹配一遍;
算的时候恰好和别的规格撞了键,会**静默买错东西,且事后查不出来**——这正是引入
`pdd_goods_id` 做主键要防的问题,不能在迁移里重新引入。当时匹配界面还没做,
所以迁移时不可能存在真实映射数据,丢弃的代价很小。丢弃的行数会打进启动日志
(`迁移 v3:丢弃了 N 条旧规格映射...`),不会静默丢。
`[必须]` v1 曾经在 #16 里被原地改写(直接把 `pdd_products` 等结构塞进 v1,
没有新增版本),导致已经建过库、`user_version` 已经越过 v1 的老机器永远不会
重跑改写后的语句,程序拿着一个和代码对不上的库静默启动,界面点到 PDD 商品页
才报 500。#20 把 v1 恢复成原样、改动挪进新增的 v3,并加了启动时 schema 自检
(缺表直接拒绝启动,见下)作为兜底。
### 2.4 启动时 schema 自检
`[必须]` `Migrate` 成功后,`repository.CheckSchema` 会检查代码依赖的表是否都在,
缺了就返回错误、**拒绝启动**,不是打个警告继续跑——静默启动正是 #20 的教训:
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为 `syb_orders`
增加 `spec_key`,为 `shopee_products` 增加 `source`。迁移使用稳定 `syb_id`
游标分批回填,先完整备份旧 `sku_mappings` 为 `sku_mappings_v3_backup`,
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
MySQL 迁移 v5(工单 #98)为 `tasks` 追加 `execution_mode`、`live_confirmed_by`
和 `live_confirmed_at`,并增加模式/审计 CHECK 与领取索引。旧任务通过数据库默认值
统一成为 `dry_run`;每条 DDL 可重放,列、约束和索引全部通过自检后才记录 v5。
MySQL 迁移 v6(工单 #127)为 `tasks` 追加可空的 `created_by_user_id` 和创建人列表
索引。存量记录不回填,NULL 明确表示“历史任务”;新建采集和采购任务必须写当前
网页登录用户。迁移与自检可重放,历史 SQLite migrations 保持冻结。
MySQL 迁移 v7(工单 #132)新增 `catalog_import_runs` 批次摘要表,并为蝦皮商品、
蝦皮 SKU 和 PDD 商品补充来源观测时间。`shopee_products.source` 新增 `api` 取值,
`pdd_products` 新增可空来源字段。迁移不改写既有业务数据,每条 DDL 可重放,所有
字段和约束自检通过后才记录 v7。
## 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, -- 蝦皮「主商品貨號」
source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', -- report / syb / api
source_observed_at VARCHAR(35), -- 外部来源观测时间,UTC ISO 8601
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
deleted_at VARCHAR(35), -- 软删除时间;非空时默认业务查询不可见
deleted_by_user_id VARCHAR(191), -- 执行删除的管理员
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
CHECK (source IN ('report', 'syb', 'api'));
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
CREATE INDEX idx_shopee_products_deleted ON shopee_products(deleted_at, updated_at, goods_id);
```
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
`source='syb'` 表示顺运宝先到、蝦皮报表尚未导入时创建的最小商品骨架;
`source='api'` 表示由商品目录批量接口写入。
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
导入不得覆盖人工维护的 PDD 关联。
顺运宝同步只把非空店铺和图片补到空字段,或更新同样来自 `syb` 的字段;人工值和
目录接口值不覆盖。后续目录接口可把 `syb` 低优先级值提升为权威来源。软删除商品
不会因顺运宝同步或目录导入自动恢复,SKU、PDD 关联和历史记录全部保留;只能由
管理员显式恢复。存在 `pending/assigned/claimed` 采集或采购任务时禁止软删除。
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
### 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=人工新增的,导入不得删
source_observed_at VARCHAR(35), -- 外部来源观测时间,UTC ISO 8601
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。第三方脚本负责把不同来源文件归一化,
再按 [商品目录接口契约](10-商品目录接入接口.md) 提交结构化商品、SKU、PDD 和关联。
- `[必须]` 整批只做 upsert,不清空表,也不删除批次中缺席的数据。
- `[必须]` `spec_raw` 原样保留。商品目录解析结果由上游提交;SYB 只对明确的
“颜色,尺码【建议】”二维格式做确定性解析,未知尺码、额外维度和不完整括号均不猜。
- `[必须]` 商品更新不覆盖 `pdd_goods_url` / `pdd_goods_id`,SKU 更新不覆盖 `is_manual`。
- `[必须]` 较旧 `observed_at` 不覆盖较新接口数据。
- 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。
- 一个批次在单事务中写入并返回新增、更新、未变化和失败统计。
## 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, -- 采集回来,人工核对用
shop_name TEXT, -- 店铺名;采不到或老数据为 NULL
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。
**为什么用软删除**
`spec_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/`),
告诉操作员去哪台机器的哪个目录捞。
**采集超时:`collecting` 怎么才不会永久卡死**
客户端离线、崩溃、任务被删——这些都是常态,不是异常。原来的规则是
"只有 `pending` / `failed` 才允许建采集任务",而**只有客户端提交结果**才会
把状态从 `collecting` 改走。于是上面任何一种情况发生后,这个商品的
`collect_status` 就永远卡在 `collecting`,界面上没有任何入口能救回来,
只能改数据库(见 #24)。
修复:`collecting` 状态超过 `model.CollectStaleAfter`(15 分钟),或已经找不到
`pending/assigned/claimed` 采集任务时,**也允许**重新建采集任务:
```sql
-- repository.MarkCollecting,简化版
UPDATE pdd_products
SET collect_status = 'collecting', updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL
AND ( collect_status IN ('pending', 'failed')
OR (collect_status = 'collecting' AND
(updated_at < ? OR NOT EXISTS (有效采集任务))) )
```
`[必须]` 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
15 分钟足够宽裕。宁可短也不要长——采集是**只读**操作,多采一次没有任何副作用,
而卡死的代价是这个商品永久报废、只能改数据库救。定义成常量 `model.CollectStaleAfter`,
不要把 15 分钟当魔数散落在各处判断里。
`[必须]` 超时判定在**读取的这一刻现算**(拼进上面这条 UPDATE 的 WHERE 里),
**不是**后台协程定期扫描把超时的状态改回 `pending`。本项目已经在并发上栽过两次
(PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁),能不引入并发就不引入;
现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。
同理,**没有引入租约(lease)或心跳**——那是被明确移除的设计,
见 [Client 术语表](../client/00-glossary.md)「为什么没有租约和心跳」一节。
`[必须]` 时间比较用**字符串比较** `updated_at < ?`,不解析成 `time.Time` 再比。
这依赖 `model.NowISO()` 永远产出定宽 UTC 格式(如 `2026-08-07T06:10:19Z`):
定宽 + 同一时区 + 补零,字符串的字典序才等于时间先后顺序。
谁把它改成带时区偏移的本地时间(比如 `+08:00`),这个比较会**静默失效**
——不会报错,但超时判断全错,见 `model.NowISO` 的注释。
**已知取舍:可能采两次**
超时后允许重新建任务,但**老任务还留在队列里**,客户端上线后可能把新旧两个
任务都领走,同一个商品被采两次。这是可以接受的:
- 采集是只读操作,没有任何副作用;
- 后一次的结果覆盖前一次(`SetCollectResult` 按 `goods_id` 整体覆盖),数据仍然正确。
`[必须]` **不要看到"可能采两次"就去加锁或加租约"修"它**——那正是被明确移除的设计,
加回来会重新引入本项目已经吃过两次亏的并发复杂度,而这里换来的收益(避免极少数情况下
多采一次)远小于代价。
### 4.2 `skus_json` 的结构
由 Client 采集后原样提交,Admin **不做转换**:
```json
{
"schema_version": 1,
"goods_id": "737116531267",
"title": "【现货】西装外套三件套",
"shop_name": "XX旗舰店",
"price_granularity": "color",
"captured_at": "2026-08-07T08:00:00Z",
"dimensions": [
{"key": "color", "name": "颜色分类"},
{"key": "size", "name": "尺码"}
],
"skus": [
{"options": {"color": "黑色", "size": "M"},
"price_cent": 1256, "list_price_cent": 1990,
"price_observed_at": {"color": "黑色", "size": "M"},
"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` | 核对"采的是不是要的那个商品" | 链接跳转、采错商品时静默存错 |
| `shop_name` | 核对是否来自目标店铺 | 同标题商品无法区分来源;采不到时允许为空 |
| `dimensions` | 界面按顺序渲染下拉框 | Go 的 map 无序,不知道该先显示颜色还是尺码 |
| `price_granularity` | 告诉下游价格是逐 SKU 实测还是按颜色推断 | 下单价格保护会把推断价误当实测价 |
| `price_observed_at` | 标明读取价格时实际选中的规格组合 | 无法区分哪一行是实测价 |
| `raw_price` | 价格解析出错时对账 | 只有数字,出错了没法查 |
`[必须]` 几条硬规则:
- **`price_cent` 是整数分**,不是 `12.56` 也不是 `"12.56"`。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。
- **`price_cent` 是实付价**(如券后价、折后价),不是划线价;可选的划线价放在 `list_price_cent`。
- **`price_granularity` 只能是 `color` 或 `sku`**。`color` 表示同颜色各尺码共用一次采样价;老 Client 不传时继续兼容。
- **`price_observed_at` 必须记录读取价格时实际选中的完整组合**。按颜色采样时,同颜色下只有与它完全相同的那一行是实测,其余是推断。
- **采不到价格时给 `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` 顺运宝货运单
```sql
CREATE TABLE syb_orders (
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
shop_name TEXT, -- 蝦皮店铺(顺运宝外层 shopName)
title TEXT, -- 商品标题
product_spec TEXT, -- 顺运宝规格原文
spec_key VARCHAR(191), -- 规格身份键;空规格保持 NULL
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
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,不存图片本身
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。`[必须]` 不要把图片二进制存进数据库。
- `shop_name` 是顺运宝货运单级事实,同一货运单下的多条商品明细会重复保存同一值;
独立列用于列表展示和检索,完整上游值仍保留在 `syb_data.stock.shopName`。
- `spec_key` 通过 `SpecKey(product_spec)` 计算:去除首尾空白,把连续空白折叠为一个半角空格,
但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
- **处理阶段是派生的,不存字段**:PDD 规格是否已匹配要按
`(shopee_goods_id, spec_key, 当前 pdd_goods_id)` 联查 `spec_mappings`,并确认 `pdd_option_key`
仍存在于最新 `skus_json`。
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
MySQL schema v8 将 `shopee_skus.sku_id` 保留为系统内部记录主键(兼容历史引用),
新增可空且唯一的 `shopee_sku_id` 保存真实外部 ID,并用唯一
`(goods_id, spec_key)` 标识无真实 ID 的规格。既有行将真实 ID复制到新列;新记录没有
真实 ID 时只生成内部主键,页面和接口不得把内部值冒充真实 SKU ID。`source` 和
`source_observed_at` 用于限制同来源更新,`is_manual=1` 始终优先保护。
`field_sources` 与 `field_observed_at` 是只含颜色、尺码、建议和 SKU 编码的 JSON
来源表;`fill_missing` 补字段时分别登记,`overwrite_same_source` 只更新来源属于本次
调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。详情页人工修改
颜色、尺码和建议时,把对应字段来源写为 `manual`;即使人工主动清空建议,补空策略
也不得重新填入。人工编辑现有行不把 `is_manual` 改成 1,后者只表示人工新增整条 SKU。
MySQL schema v9 为 `shopee_products` 增加 `image_url` 和 `shopee_shop_name`。两者分别
使用 `image_source/image_observed_at/image_is_manual` 与
`shop_name_source/shop_name_observed_at/shop_name_is_manual` 记录来源边界。目录接口只
能补空或更新同来源的新观测,空值、跨来源、旧观测和人工字段均不覆盖。该图片是蝦皮
商品主图,与 `syb_orders.image_url` 的历史货运单观测图片是两个独立概念。
MySQL schema v10(工单 #144)是兼容修复:部分数据库曾在 v8 开发中间状态提前记录
v8/v9,实际缺少 `field_sources`、`field_observed_at` 或 v9 图片/店铺人工标记约束。
v10 幂等重放 v8、v9 的逐项建列、空值回填、约束和唯一索引逻辑,再核对两版完整
形状;只有全部通过才记录 v10。它不删除或改写旧迁移记录,也不清空商品、SKU 或
导入记录。
MySQL schema v11(工单 #151)为 `syb_orders` 增加可空 `VARCHAR(500)` 的
`shop_name`,并从合法原始 JSON 的 `$.stock.shopName` 回填历史数据。迁移可重放,
只填仍为空的列;新同步直接更新该列。v15(#203)在此基础上把非空店铺和图片作为
`syb` 低优先级字段补入蝦皮商品主数据,但仍不得覆盖人工或目录接口字段。
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
从 v16 起,同步会把格式明确的规格以 `source='syb'` 写入 `shopee_skus`:内部主键
由商品 ID 和 `spec_key` 确定性生成,真实 `shopee_sku_id` 保持 `NULL`。目录接口后到时
按同一规格身份补上真实 ID,并可替换字段来源为权威来源;人工行永不覆盖。不明确格式
仍只存在于观测读模型中,不创建 SKU。重复同步同一个 `syb_id` 只更新原行,因此不会
重复累计。
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
`details[].id`,不是货运单本身的 `id`。
`[必须]` `price_twd_cent` 一律取 `detail/listByStock` 接口的值(元)自己 ×100
转分、先四舍五入再转整数;不要用 `/am/stock/list` 列表接口的金额字段——
同一响应里不同金额字段的单位不统一,见 [08 顺运宝接口](08-顺运宝接口.md) §5.1。
`[建议]` 收件人姓名/电话/地址不入库,`syb_data` 落库前已剔除。
### 5.1 `syb_session` 顺运宝会话缓存、`syb_sync_state` 同步进度(v5)
```sql
CREATE TABLE syb_session (
username TEXT PRIMARY KEY,
cookies TEXT NOT NULL, -- JSON 数组,Cookie 名/值/路径
expires_at TEXT NOT NULL, -- min(JWT exp, 24h)
updated_at TEXT NOT NULL
);
CREATE TABLE syb_sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1), -- 只允许一行
last_synced_at TEXT,
updated_at TEXT NOT NULL
);
```
- 只存 Cookie,不存登录 JWT——认证完全靠 Cookie,JWT 从不参与后续请求,
见 [08 顺运宝接口](08-顺运宝接口.md) §3.1。
- `syb_sync_state` 只允许一行(`CHECK (id = 1)`),是全局的"上次同步到哪"。
- `[必须]` 增量同步从 `last_synced_at` 对应的**日期当天**重新拉,不是第二天——
`created` 筛选粒度是日期,`last_synced_at` 精确到秒,从第二天拉会漏掉当天
晚些时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏。
- `[必须]` 只有一次同步**全部成功**才更新 `last_synced_at`;中途失败不更新,
否则下次同步会跳过这段区间,漏掉的单永远补不回来。
### 5.2 `syb_sync_runs` 同步记录(v8)
每次从页面发起同步时,先写一条 `running` 记录,再启动后台任务。记录保存操作
账号、日期范围、开始/完成时间、结果统计、是否推进覆盖游标以及必要的失败原因。
状态只能是 `running`、`succeeded`、`failed`、`interrupted`;Admin 启动时把上次
进程遗留的 `running` 记录改成 `interrupted`。
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
v14 起,`stock_count` 继续表示通过完整性校验的**原始货运单数**,避免改变旧字段
语义;`accepted_stock_count` 是店铺准入后最终接受的货运单数,
`shop_skipped_count` 是店铺为空、未启用或明细店铺变化而跳过的货运单数。
`shop_filter_hash` 保存同步开始时启用店铺排序后计算的 SHA-256,只用于判断两次同步
是否使用同一份快照,不保存 Cookie、密码或原始响应。
### 5.3 店铺(MySQL v18 / v19)
`shops` 是唯一店铺配置。`display_name` 是页面中的“店铺名称”,同时用于 SYB 同步
准入和蝦皮商品关联;`enabled` 是唯一启停状态。匹配会去除首尾空白,再使用
`utf8mb4_bin` 精确比较,不做模糊、正则、大小写折叠或 AI 猜测。
蝦皮导入和 SYB 写入都保留上游原始店铺名称,并把精确解析出的 `shop_id` 作为派生
关联;未解析成功时 `shop_id` 保持 `NULL`,可从店铺管理的“未登记商品”入口查看。
`shop_channel_aliases` 是 v18 升级兼容表,不再是独立产品概念,也不在页面中编辑;
每个店铺固定保留 `syb`、`shopee` 两条与唯一店铺名称和状态完全相同的派生记录。
`[必须]` 没有任何启用店铺时,同步关闭失败,不请求顺运宝、不推进游标。编辑店铺
名称会在一个事务里重建该店铺的历史精确关联。永久删除只允许已经停用且没有任何
货运单或蝦皮商品引用的店铺。
v14 的 `syb_allowed_shops` 在 v18 后不再作为运行时配置源,但为迁移回退保留,不能
在 v18 / v19 中直接改名或删除。
## 6. `spec_mappings` 顺运宝规格映射
"这个蝦皮商品的这条顺运宝规格 = 当前拼多多商品的那个规格",**匹配一次,以后复用**。
```sql
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)
);
```
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
### 6.1 为什么主键要带上商品和 `pdd_goods_id`
PDD 商品下架换代很频繁——A 买不到了就得换 B。
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
后来 A 下架,换成了 B。如果映射不带 `pdd_goods_id`,那条旧映射还在,
但它描述的是 **A 的规格**:
| | 后果 |
|---|---|
| 运气好 | B 没有"黑色/M码",建任务时找不到会报错,还算安全 |
| **运气坏** | B 恰好也有"黑色/M码",但完全是另一件衣服 → **静默买错,事后查不出来** |
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
```sql
SELECT ... FROM spec_mappings
WHERE shopee_goods_id = ?
AND spec_key = ?
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 这一个函数算。**
### 6.3 `spec_mapping_decisions` 决策审计
本表只追加、不参与业务判断。每次成功保存映射时,与 `spec_mappings` 在同一事务写入;
没有建议时 `suggested_option_key` 为 NULL 且 `accepted=0`。`accepted=0` 表示未采纳建议或当时无建议。
```sql
CREATE TABLE spec_mapping_decisions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
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,
rules_version VARCHAR(32) NOT NULL,
suggested_option_key VARCHAR(191) COLLATE utf8mb4_bin,
chosen_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
accepted TINYINT NOT NULL CHECK (accepted IN (0, 1)),
decided_by VARCHAR(191),
decided_at VARCHAR(35) NOT NULL,
KEY idx_decisions_mapping (shopee_goods_id, spec_key, pdd_goods_id)
);
```
下面的指标只能称为“成功保存决策中的建议采纳率”,不是准确率:
```sql
SELECT rules_version, COUNT(*) AS 有建议的决策数, SUM(accepted) AS 采纳数,
ROUND(SUM(accepted) / COUNT(*) * 100, 1) AS 采纳率
FROM spec_mapping_decisions WHERE suggested_option_key IS NOT NULL GROUP BY rules_version;
SELECT rules_version, COUNT(*) AS 无建议决策数
FROM spec_mapping_decisions WHERE suggested_option_key IS NULL GROUP BY rules_version;
```
## 7. `tasks` 任务
采集任务和采购任务共用一张表,用 `task_type` 区分。
```sql
CREATE TABLE tasks (
task_id TEXT PRIMARY KEY, -- 稳定业务主键:采集 cjN,采购 cgN
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);
```
`task_id` 不是另外的展示别名,而是真实主键。采集和采购分别从 1
开始递增,不共用计数器。分配编号与插入任务必须在同一事务中,以避免
并发重号和创建失败消耗序号。Client 应把这个值当作不透明字符串原样保存。
生产 MySQL v5 在 `tasks` 追加以下安全字段(历史 SQLite 结构不改):
| 字段 | 含义 |
|---|---|
| `execution_mode` | `dry_run` / `live`,非空且默认 `dry_run`;创建后不可修改 |
| `live_confirmed_by` | 提交创建真实任务的正常状态 Admin 用户;演练任务必须为空 |
| `live_confirmed_at` | 真实任务创建操作时间;演练任务必须为空 |
`chk_tasks_execution_mode` 和 `chk_tasks_live_confirmation` 在数据库层阻止非法模式及
缺少创建审计信息的真实任务;`idx_tasks_claim_mode` 支持按客户端能力领取。字段名保留
历史命名以兼容已发布的 v5 migration。
生产 MySQL v6 再追加任务创建人字段:
| 字段 | 含义 |
|---|---|
| `created_by_user_id` | 创建任务的 Admin 用户 ID;存量 NULL 表示“历史任务” |
`idx_tasks_creator_list(created_by_user_id, updated_at DESC, task_id DESC)` 支持采购员
按本人范围分页,以及管理员按创建人筛选。用户只禁用不删除,因此无需把显示名称冗余
写入任务;`fk_tasks_created_by` 指向 `users(user_id)`,页面通过 `users` 关联显示用户名。
`[必须]` 两条硬约束,来自 [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 带出单价上限,操作员可改但不能清空;后端在事务内按顺运宝最新数量计算 `单价上限 × quantity` 后写入本字段,并检查整数溢出。历史任务不回写。
状态含义见 [01 需求](01-requirements.md) §6.2。
### 7.1 `task_sequences` 任务序列(生产 MySQL v13)
```sql
CREATE TABLE task_sequences (
task_type VARCHAR(20) COLLATE utf8mb4_bin PRIMARY KEY,
current_value BIGINT NOT NULL DEFAULT 0,
CHECK (task_type IN ('collect', 'purchase')),
CHECK (current_value >= 0)
);
```
表中只有 `collect` 和 `purchase` 两行。创建任务时先在当前事务内对对应行
`current_value + 1`,再读取并组成 `cjN` 或 `cgN`;InnoDB 行锁保证并发唯一。
### 7.2 `task_syb_sources` 采集任务来源(生产 MySQL v12)
从顺运宝创建采集任务时,同一个 PDD 商品可能对应多个货运单明细。来源使用关联表,
不能压进 `tasks.syb_id` 单列;后者仍只表示采购任务自身的顺运宝明细。
```sql
CREATE TABLE task_syb_sources (
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
syb_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
created_at VARCHAR(35) NOT NULL,
PRIMARY KEY (task_id, syb_id),
KEY idx_task_syb_sources_syb (syb_id, task_id),
FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (syb_id) REFERENCES syb_orders(syb_id) ON DELETE CASCADE
);
```
- `[必须]` 任务和全部来源在同一事务写入;按 PDD 商品去重后仍要保留每条来源。
- `[必须]` 采集采购关键字搜索通过本表支持顺运宝订单号和明细 ID。
- `[必须]` 删除任务后若同商品已无 `pending/assigned/claimed` 采集任务,必须在同一事务
把仍为 `collecting` 的商品回收到 `pending`;有其他有效任务时不得回收。
- 历史任务没有可靠来源,不做猜测回填,仍可按任务号和 PDD 商品 ID 查询。
## 8. `task_claims` 领取历史
记录"哪台客户端领过哪个任务"。
```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),
FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX idx_task_claims_client ON task_claims(client_id);
```
生产 MySQL v13 清理了无对应任务的孤儿领取历史,并加入上述外键;
`ON UPDATE CASCADE` 保证主键迁移时领取历史同步改号。
**为什么需要这张表:** [04 Client 接口实现](04-client-api.md) §4.1 要求
"只有**从未分配给该客户端**的任务才返回 403"。
但 `tasks.assigned_client` 只记**当前**归属,任务一旦重派给别人,
就查不出原来那台领过了——而契约又明确要求
"**已重派仍要接受原客户端提交的结果**"。没有这张表,那条规则根本没法判断。
顺带得到一份审计记录:这个任务被哪几台客户端领过。
`[必须]` 领取成功时写入;提交结果时用它做权限判断。
同一客户端重复领同一任务只更新时间,不报错。
## 9. `clients` 客户端
```sql
CREATE TABLE clients (
client_id TEXT PRIMARY KEY, -- 序列号,来自 X-Client-Id
name TEXT, -- 客户端上报,可人工改
device_address TEXT,
platform TEXT,
pdd_package TEXT,
capabilities TEXT, -- 登记或 claim 请求里的 capabilities 原文
last_seen_at TEXT NOT NULL, -- 每次调接口都刷新
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
- `[必须]` **没有 `status` 字段。** 在线状态是**算出来的**:
`last_seen_at` 在 N 分钟内算在线,否则离线。`[建议]` N 默认 10 分钟。
存成字段会和真实情况不同步。
- `[必须]` 设置页使用独立登记接口幂等新增或更新 Client,claim 保留隐式登记作为兼容兜底。
- `[必须]` 不设心跳接口;登记、claim、result 和 failure 都刷新 `last_seen_at`,见 [04](04-client-api.md) §1.1、§3。
## 10. 数据关系总览
```text
shopee_products ──1:N──→ shopee_skus
│
│ pdd_goods_id(当前 PDD 商品,可换)
↓
pdd_products ←─pdd_goods_id─ spec_mappings
↑
syb_orders ─(shopee_goods_id, spec_key)─┘
(skus_json 里是所有规格和价格)
syb_orders ──创建──→ tasks ──分配──→ clients
↑
users(采购员)──1:N 当前归属──────────┘
```
两条关联都可以变,这是有意的:
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
- `spec_mappings` 按 `(蝦皮商品, 顺运宝规格键, PDD商品)` 存:换了商品自然查不到旧映射
`syb_orders` 不保存 PDD 商品 ID。新货运单通过 `shopee_goods_id` 动态读取
`shopee_products.pdd_goods_id`;只要目标 `pdd_products` 未软删除,就自动复用当前
关联。这样换绑后所有货运单立即看到新关系,也不会留下需要同步更新的第二份 PDD 字段。
## 11. 与 Client 数据模型的关系
Admin 使用集中 MySQL 8.4,Client 仍使用各自的本地 SQLite;两者只通过接口交换数据。
| 概念 | Admin 这边 | Client 那边 |
|---|---|---|
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
| 采集结果 | `pdd_products.skus_json` | `pdd_tasks.pdd_data` |
`[必须]` **两边的状态是两套,不要试图同步。**
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
## 12. Admin 用户和 Web Session(v6)
`users` 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。
```sql
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'purchaser')),
status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
last_login_at TEXT,
password_changed_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
- `password_hash` 只保存成熟密码哈希算法的结果,绝不保存或记录明文密码。
- 用户不做物理删除;离职或停用改为 `disabled`,保留任务和操作记录的可追溯性。
- 首次初始化必须在 InnoDB 写事务中锁定初始化保护记录并再次确认 `users` 为空,
并发提交最多一个成功。即使所有账号都被禁用,初始化入口也不能重新开放。
- 系统不能禁用最后一个有效管理员。
`web_sessions` 保存网页登录状态。数据库只保存随机 Session Token 的 SHA-256 哈希,
Cookie 原文不能进入数据库、日志或页面。
```sql
CREATE TABLE web_sessions (
session_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
CREATE INDEX idx_web_sessions_user ON web_sessions(user_id);
CREATE INDEX idx_web_sessions_expiry ON web_sessions(expires_at);
```
- Session 使用固定过期时间,MVP 默认 12 小时,不做复杂刷新令牌。
- 退出登录、密码重置或账号禁用时,删除该用户对应的 Session 记录。
- 过期 Session 可以在登录、退出或定期维护时清理,不需要后台常驻线程。
## 13. `client_user_assignments` 客户端负责人历史(v7)
`clients` 是执行任务的软件实例,`users` 是登录 Admin 的人,两者生命周期不同,
因此保持两张独立实体表,用归属历史表连接,不能合并字段。
```sql
CREATE TABLE client_user_assignments (
assignment_id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
assigned_by_user_id TEXT NOT NULL,
ended_by_user_id TEXT,
end_reason TEXT CHECK (end_reason IS NULL OR end_reason IN ('unbind', 'transfer')),
FOREIGN KEY (user_id) REFERENCES users(user_id),
FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id),
FOREIGN KEY (ended_by_user_id) REFERENCES users(user_id)
);
CREATE UNIQUE INDEX idx_client_assignment_current
ON client_user_assignments(client_id) WHERE ended_at IS NULL;
```
- `ended_at IS NULL` 表示当前归属;部分唯一索引保证一台客户端最多一个当前负责人。
- 首次绑定只新增记录;转交在同一事务结束旧记录并新增记录;解绑只结束旧记录。
- `assigned_by_user_id` / `ended_by_user_id` 都是执行操作的管理员,不是目标采购员。
- `client_id` 故意不设指向 `clients` 的外键:客户端清单允许删除后由同一稳定编号
重新登记,归属和审计历史不能随临时清单记录丢失。
- 归属记录不参与 Client API 的登记和领取判断,也不更新 `tasks.assigned_client`。
## 14. `catalog_import_runs` 商品目录批次摘要(MySQL v7)
`catalog_import_runs` 以 `(source, batch_id)` 为主键,保存请求哈希、处理状态、
新增/更新/冲突计数和错误摘要。它不保存 Bearer Token,也不保存完整请求体。
- 相同来源、批次号和请求哈希只处理一次;重复提交返回首次结果。
- 相同来源和批次号携带不同请求哈希时记录冲突并返回 HTTP 409。
- `response_body` 只保存可安全重放的结果摘要,不得包含凭据或原始商品数据。
- `source_observed_at` 用于拒绝较旧数据覆盖较新数据;缺席记录绝不代表删除。
## 15. AI 服务商配置(MySQL v20)
`ai_provider_configs` 只保存非敏感配置:服务商名称、HTTPS Base URL、模型、超时、最大
并发、自动写入置信度基点、启用状态和最近连接测试摘要。`active_slot` 是生成列并带唯一
索引,数据库层保证任一时刻最多一个启用项。普通配置或密钥发生变化时,当前服务商会
停用并把测试状态重置为 `pending`,必须重新测试后才能启用。
`ai_provider_audits` 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段
名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的
独立密钥文件。