39 KiB
03 Admin 数据模型
- 文档状态:基线草案,待数据评审
- 生产数据库:MySQL 8.4(驱动
go-sql-driver/mysqlv1.9.2) - 位置:与 Admin 同机的
127.0.0.1:3307,使用独立数据库和最小权限账号 - 历史数据库:
data/admin.db只作为 SQLite → MySQL 单向迁移来源和回退备份
本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。
没有标注的默认是 [必须]。看不懂的词查 术语表。
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 ..."):
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 等,这才是要的行为。
[建议] 同时限制连接数:
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(4)
SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、把 busy_timeout 耗光。
不要设成 1——那样在一个事务里再调用需要连接的代码会死锁。
2.2 迁移
用 PRAGMA user_version 管理顺序迁移。
[必须] 迁移语句一条一执行,不要把多条 SQL 塞进一个字符串——
database/sql 的 Exec 对"一次执行多条语句"的支持因驱动而异,
拆开最稳妥,报错还能精确到第几条。
[必须] 升级必须支持从所有已发布版本迁移,不得在启动时删库重建。
理由:data/ 在升级时是保留的(见 02 §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。 |
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 的教训:
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
3. 蝦皮数据
蝦皮报表一个文件里混了两层数据,所以拆成两张表。
3.1 shopee_products 商品级
CREATE TABLE shopee_products (
goods_id TEXT PRIMARY KEY, -- 蝦皮「商品ID」
title TEXT NOT NULL, -- 蝦皮「商品名稱」
shopee_status TEXT, -- 蝦皮「商品當前狀態」
main_sku_code TEXT, -- 蝦皮「主商品貨號」
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
[必须] 采集结果和采集状态不在这张表里,它们属于 PDD 商品,见 §4。
pdd_goods_id 表示"这个蝦皮商品当前对应哪个 PDD 商品"。
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
3.2 shopee_skus SKU 级
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 |
拿参考样本(11287 行)导入应得到 5195 商品 + 6092 SKU,数字对不上就是解析有问题。
样本文件含商业数据,不在仓库里,找项目负责人要,放
raw_data/下。 自动化测试用admin/testdata/里的小样本,别读大文件。
第二步:按列名找索引,不要写死列号
报表有 40 列,蝦皮改一次导出格式列号就变。启动时按表头文字定位:
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 条无例外),所以"颜色,尺码"这个二分结构是稳的。
规则:
- 按第一个逗号切开 → 左边是颜色,右边是"尺码 + 可能的建议";
- 右边尝试提取建议:先找
【建議...】,再找建議...; - 剩下的就是尺码;
[必须]任何一步失败都不要猜,把parse_ok置 0,颜色尺码留空,spec_raw照常保存。界面上把这些行标出来让人工补。
第四步:upsert,绝不清空
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数, 解析失败行号列表},页面上显示出来。
不要静默跳过失败行。
4. pdd_products 拼多多商品
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 需求 §6.1。
为什么用软删除
sku_mappings 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
软删除后界面不再显示,但记录和映射都还在。
[必须] 操作员重新填同一个链接时要能复活(清 deleted_at、状态置回
pending、清空旧采集结果)。不复活的话 goods_id 的 UNIQUE 会让插入失败,
操作员会看到一个莫名其妙的错误。
为什么不存截图
按已定案的 Artifact 策略(Client 契约 §10 待确认 #3),
客户端只报本地引用、不上传文件。截图在客户端那台机器上,Admin 显示不了。
所以存 artifact_ref(形如 client-001:artifacts/PDD-0001/attempt-xxx/),
告诉操作员去哪台机器的哪个目录捞。
采集超时:collecting 怎么才不会永久卡死
客户端离线、崩溃、任务被删——这些都是常态,不是异常。原来的规则是
"只有 pending / failed 才允许建采集任务",而只有客户端提交结果才会
把状态从 collecting 改走。于是上面任何一种情况发生后,这个商品的
collect_status 就永远卡在 collecting,界面上没有任何入口能救回来,
只能改数据库(见 #24)。
修复:collecting 状态超过 model.CollectStaleAfter(15 分钟)也允许
重新建采集任务:
-- 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 < ?) )
[必须] 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
15 分钟足够宽裕。宁可短也不要长——采集是只读操作,多采一次没有任何副作用,
而卡死的代价是这个商品永久报废、只能改数据库救。定义成常量 model.CollectStaleAfter,
不要把 15 分钟当魔数散落在各处判断里。
[必须] 超时判定在读取的这一刻现算(拼进上面这条 UPDATE 的 WHERE 里),
不是后台协程定期扫描把超时的状态改回 pending。本项目已经在并发上栽过两次
(PRAGMA 没作用到连接池、BEGIN DEFERRED 死锁),能不引入并发就不引入;
现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。
同理,没有引入租约(lease)或心跳——那是被明确移除的设计,
见 Client 术语表「为什么没有租约和心跳」一节。
[必须] 时间比较用字符串比较 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 不做转换:
{
"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 必须校验:
- 返回的
goods_id必须等于请求采集的那个。 不等说明链接跳转了或采错商品, 要拒绝(422 COLLECT_GOODS_MISMATCH)并整体回滚。不拦的话,会把 B 的规格价格 存到 A 名下,之后按它下单就是买错东西。 skus为空要记成failed,不是collected。 采到 0 个规格对业务毫无用处 (商品下架、页面改版、解析器没认出来),显示"已采集"会让操作员以为好了, 等建任务时才发现不对。
完整的采集提交原文仍然存进 tasks.result_data,审计链不断。
5. syb_orders 顺运宝货运单
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
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
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。[必须]不要把图片二进制存进数据库。shopee_sku_id直接对应shopee_skus.sku_id,编号格式一致,不需要额外转换。
[必须] 但不要加外键约束。理由:蝦皮报表不是全量目录(样本里平均每商品仅 1.17 个 SKU),
货运单来了而本地查不到这个 SKU 是常态。加了外键,同步就会直接失败。
正确做法是软关联:查不到时照常保存货运单,界面上标出"SKU 未收录", 并引导操作员到蝦皮数据模块手动新增(见 05 界面规范 §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 里不出现这一列,新建行时才会写它(此时通常是空值)。
[必须] 一行对应顺运宝一张货运单的一个商品明细(details[] 的一项),
不是一张货运单——一张货运单可以有多个商品,各占一行,syb_id 用的是
details[].id,不是货运单本身的 id。
[必须] price_twd_cent 一律取 detail/listByStock 接口的值(元)自己 ×100
转分、先四舍五入再转整数;不要用 /am/stock/list 列表接口的金额字段——
同一响应里不同金额字段的单位不统一,见 08 顺运宝接口 §5.1。
[建议] 收件人姓名/电话/地址不入库,syb_data 落库前已剔除。
5.1 syb_session 顺运宝会话缓存、syb_sync_state 同步进度(v5)
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 顺运宝接口 §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 个字符,并在页面输出时由模板转义。
6. sku_mappings 规格映射
"蝦皮的这个规格 = 拼多多的那个规格",匹配一次,以后复用。
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 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
PDD 商品下架换代很频繁——A 买不到了就得换 B。
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
后来 A 下架,换成了 B。如果映射只按 shopee_sku_id 存,那条旧映射还在,
但它描述的是 A 的规格:
| 后果 | |
|---|---|
| 运气好 | B 没有"黑色/M码",建任务时找不到会报错,还算安全 |
| 运气坏 | B 恰好也有"黑色/M码",但完全是另一件衣服 → 静默买错,事后查不出来 |
把 pdd_goods_id 放进主键后,[必须] 查映射永远带上"当前对应的 PDD 商品":
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():
OptionKey(map[string]string{"size": "M", "color": "黑色"})
// -> {"color":"黑色","size":"M"}
实现直接用 json.Marshal —— Go 序列化 map 时会按键名排序,正好就是我们要的
规范化,不用自己拼字符串(自己拼容易漏掉值里含分隔符、含引号之类的边界情况)。
[必须] 这个函数只能有一处实现。 存映射用它算 key,查规格也用它算 key,
两边必须逐字节一致。如果 Client 那边也算一份、或者别处再写一个"差不多"的版本,
只要有一点点不同(空格、转义、键序),映射就会静默对不上。
Client 只上报 options 对象,key 一律由 Admin 这一个函数算。
7. tasks 任务
采集任务和采购任务共用一张表,用 task_type 区分。
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 契约 §4:
pdd_goods_url不能为空,否则 Client 无法执行(它那边是NOT NULL)。- 采购任务的
quantity和max_price_cent都必须有值, 这是价格保护,没有它 Client 会拒绝执行。
max_price_cent 是人民币分,默认从 pdd_data 里对应 SKU 的价格带出,操作员可改但不能清空。
状态含义见 01 需求 §6.2。
8. task_claims 领取历史
记录"哪台客户端领过哪个任务"。
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 接口实现 §4.1 要求
"只有从未分配给该客户端的任务才返回 403"。
但 tasks.assigned_client 只记当前归属,任务一旦重派给别人,
就查不出原来那台领过了——而契约又明确要求
"已重派仍要接受原客户端提交的结果"。没有这张表,那条规则根本没法判断。
顺带得到一份审计记录:这个任务被哪几台客户端领过。
[必须] 领取成功时写入;提交结果时用它做权限判断。
同一客户端重复领同一任务只更新时间,不报错。
9. clients 客户端
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 §1.1、§3。
10. 数据关系总览
shopee_products ──1:N──→ shopee_skus
│ │
│ pdd_goods_id │ shopee_sku_id
│ (当前对应哪个 ↓
│ PDD 商品,可换) sku_mappings ──pdd_goods_id──┐
↓ │
pdd_products ←────────────────────────────────────────┘
(skus_json 里是所有规格和价格)
syb_orders ──创建──→ tasks ──分配──→ clients
↑
users(采购员)──1:N 当前归属──────────┘
两条关联都可以变,这是有意的:
shopee_products.pdd_goods_id:PDD 商品下架换代时改sku_mappings按(蝦皮SKU, 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 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。
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 原文不能进入数据库、日志或页面。
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 的人,两者生命周期不同,
因此保持两张独立实体表,用归属历史表连接,不能合并字段。
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。