补齐 submit_result / submit_failure,Admin 侧的三个接口全部可用, Client 的完整一圈(领取 → 执行 → 提交)现在能走通了。 实现 - 幂等:idempotency_keys 表。同键同内容返回上次的响应且不重复落库, 同键不同内容返回 409。幂等记录与业务写入在**同一事务**, 分开写的话业务成功但幂等没记上,重试会被重复处理 - 无条件接受(契约 §4.1,最容易写错的一条): 任务已取消、已重派给别人,都照样接受结果——客户端中途不查任务状态, 必然会提交"Admin 这边已经不要了"的结果,而它可能真的已经下过单, 这些数据必须留痕 - 采集任务的结果落到商品级 shopee_products.pdd_data 并置 collected; 失败则置 failed 并把原因写进 collect_error,操作员才看得见 - 失败状态映射:retry_wait→assigned,其余同名 - 三个接口都刷新 last_seen_at 新增 task_claims 表(migrations v2) 契约要求"只有从未分配给该客户端的任务才返回 403",但 assigned_client 只记当前归属,重派后就查不出原来那台领过——而契约又要求那种情况必须接受。 没有这张表这条规则根本没法判断。顺带得到一份审计记录。 修复第二个并发 bug:事务必须 BEGIN IMMEDIATE 并发提交报 SQLITE_BUSY。根因是 Go 的 db.Begin() 默认发 BEGIN DEFERRED, 事务开始时不拿写锁,多个事务各自先读再想升级成写就互相卡死, 这种情况 busy_timeout 救不了。DSN 加 _txlock=immediate 后事务一开始 就排队拿锁。实测 6 个并发事务:默认失败 5/6,加参数后 0/6。 已写进 docs/admin/03-data-model.md §2.1。 已验证(Go 1.23.0) - 30 个单元测试全过,并发用例重复 20 次稳定通过 - 端到端:claim 200 → 提交 200 → 重复提交返回完全相同的响应 → 同键不同内容 409 → 没领过的客户端 403 → 任务不存在 404 → 缺 Idempotency-Key 400;库里 task=succeeded、幂等 1 条、领取历史 1 条 说明:Gitea 尚未配置,本次无对应工单号。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
03 Admin 数据模型
- 文档状态:基线草案,待数据评审
- 数据库:SQLite(驱动
modernc.org/sqlite,纯 Go 免 cgo) - 位置:
data/admin.db,见 02 架构 §6
本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。
没有标注的默认是 [必须]。看不懂的词查 术语表。
1. 设计原则
[必须]金额一律整数,字段名带单位后缀(_cent),禁止 float。[必须]台币和人民币分开存、不互相换算覆盖。[必须]时间存带时区 ISO 8601 的 UTC 字符串,页面上转本地时区显示。[必须]外部来的原始数据(规格原文、货运单 JSON、采集结果 JSON)原样保留, 规范化字段用于查询和显示。解析失败留空,不要猜。[必须]人工维护的字段不得被导入覆盖(详见 §3.3)。[必须]SQL 一律参数化查询。
2. 建库与迁移
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,不许改动已有元素——
已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。
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_goods_id TEXT, -- 从 url 解析出来
pdd_data TEXT, -- ★ 采集结果 JSON
collect_status TEXT NOT NULL DEFAULT 'no_link'
CHECK (collect_status IN (
'no_link', 'pending', 'collecting',
'collected', 'failed'
)),
collect_error TEXT,
collected_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_shopee_products_status
ON shopee_products(collect_status);
pdd_data存 Client 采回来的 PDD 商品数据,结构和 Client 侧pdd_data§8.1 一致, 里面有dimensions和skus,匹配弹窗右侧就是从它渲染的。[必须]pdd_data放在商品级,不是订单级。 一个 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. syb_orders 顺运宝货运单
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 界面规范 §4.4)。
"编号能对上"和"本地一定查得到"是两回事,别混。
"匹配状态"是派生的,不存字段:sku_mappings 里有对应记录就是"已匹配"。
5. sku_mappings 规格映射
这张表是"蝦皮的这个规格 = 拼多多的那个规格",匹配一次,以后复用。
CREATE TABLE sku_mappings (
shopee_sku_id TEXT PRIMARY KEY,
goods_id TEXT NOT NULL,
pdd_options TEXT NOT NULL, -- JSON: {"color":"黑色","size":"M码"}
mapped_at TEXT NOT NULL,
mapped_by TEXT,
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
);
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
pdd_options用 JSON 而不是固定的"颜色/尺码"两列, 因为 PDD 商品可能有第三个规格维度 (Client 侧 01 §11 已明确要求按任意维度设计)。[必须]打开匹配弹窗时先查这张表,有记录就自动带出,操作员只需确认。 这是省人工的关键,不要做成每张订单都从头匹配。
6. 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。
7. 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 只记当前归属,任务一旦重派给别人,
就查不出原来那台领过了——而契约又明确要求
"已重派仍要接受原客户端提交的结果"。没有这张表,那条规则根本没法判断。
顺带得到一份审计记录:这个任务被哪几台客户端领过。
[必须] 领取成功时写入;提交结果时用它做权限判断。
同一客户端重复领同一任务只更新时间,不报错。
8. 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 分钟。 存成字段会和真实情况不同步。[必须]注册发生在领取任务时,新序列号新增、已有的更新, 不设单独的注册或心跳接口,见 04 §3。
9. 数据关系总览
shopee_products ──1:N──→ shopee_skus
│ │
│ pdd_data │ 1:1
│ (采集结果) ↓
│ sku_mappings
│ ↑
│ │ 查映射
syb_orders ────────────────────┘
│
└──创建──→ tasks ──分配──→ clients
10. 与 Client 数据模型的关系
Admin 和 Client 各有一个 SQLite,互不相通,只通过接口交换数据。
| 概念 | Admin 这边 | Client 那边 |
|---|---|---|
| 任务编号 | tasks.task_id |
pdd_tasks.remote_task_id |
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
| 采集结果 | shopee_products.pdd_data |
pdd_tasks.pdd_data |
[必须] 两边的状态是两套,不要试图同步。
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。