Files
cmautobuy/docs/admin/03-data-model.md
T
chengmaandClaude Opus 5 6ae768463f feat: 实现客户端注册与任务领取接口
Admin 的第一个业务功能。选它打头是因为它是穿透所有层的最薄一条竖切
(HTTP → handler/api → service → repository → SQLite → handler/web → 页面),
一个工单把分层模式立起来,后面四个模块照抄;同时它是与 Client 联调的接口,
能解锁另一条并行的工作线。

实现
- POST /tasks/claim:注册 + 领取。注册就在这里做,没有单独的注册接口,
  也没有心跳(理由见 docs/admin/04-client-api.md §3)
- 领取用条件更新 + 检查影响行数防并发,SQLite 没有 SELECT FOR UPDATE
- 客户端列表页:查询、按名称搜索、批量删除
- 在线状态是**算出来的**(last_seen_at 在 10 分钟内),数据库里没有该字段
- CSRF 中间件:双提交 Cookie,手写 82 行不引依赖。
  **只挂页面路由**,/api/v1/client/* 不能加——Client 不是浏览器、没有 Cookie
- 14 个单元测试

修复一个真 bug:PRAGMA 必须写进 DSN
并发领取测试报 database is locked (SQLITE_BUSY)。根因是
PRAGMA busy_timeout 每连接生效,而 database/sql 是连接池——
db.Exec("PRAGMA ...") 只作用于当时那条连接,池子新开的连接没执行过。
单线程正常、一并发就炸。改成 DSN 传参后并发测试跑 20 次全过。
这个坑已写进 docs/admin/03-data-model.md §2.1。

与工单的两处差异
- 去掉 name_is_custom 列后,"人工改的名字不被覆盖"改用更简单的做法:
  ON CONFLICT DO UPDATE SET 里不含 name,即只在首次注册时写入。
  效果相同,零额外字段、零迁移。已同步 04 §3
- 验收项"不向 dry_run 客户端分配真实下单任务"**未实现**:
  tasks 表没有字段标记任务是否需要真实下单。当前真实下单开关默认关闭、
  MVP 全是演练模式,暂不出问题,但开真实下单前必须补该字段,需另开工单

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,并发测试重复 20 次稳定通过
- 端到端:无任务 claim 204;插入任务后 claim 200 且 payload 含
  goods_url/goods_id/options/quantity/max_price_cent、无租约无 Admin 状态;
  重复 claim 204;缺 X-Client-Id 400;POST 无 CSRF token 403;
  列表页两台客户端在线状态与统计正确

说明:Gitea 尚未配置,本次无对应工单号。
submit_result / submit_failure 及其幂等处理留给下一个工单。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:56:18 +08:00

374 lines
15 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 数据模型
- 文档状态:基线草案,待数据评审
- 数据库: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. 建库与迁移
### 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 默认是**关**的) |
**为什么不能用 `db.Exec`:** Go 的 `database/sql` 是一个**连接池**。
`db.Exec("PRAGMA busy_timeout=5000")` 只作用于当时拿到的那一条连接,
池子后来新开的连接**完全没执行过**这些 PRAGMA。
并发写的时候,没有 `busy_timeout` 的那些连接会直接报
`database is locked (SQLITE_BUSY)`,而不是等锁释放。
这个坑在开发时不容易发现——单线程跑一切正常,一并发就炸。
本项目的并发领取测试就是被它绊倒过一次。
`[建议]` 同时限制连接数:
```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`,不许改动已有元素——
已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。
## 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, -- 蝦皮「主商品貨號」
-- 下面三个是我们自己维护的,报表里没有,导入时绝不能覆盖
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`](../client/03-data-model.md) §8.1 一致,
里面有 `dimensions` 和 `skus`,匹配弹窗右侧就是从它渲染的。
- `[必须]` `pdd_data` 放在**商品级**,不是订单级。
一个 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=人工新增的,导入不得删
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 列,蝦皮改一次导出格式列号就变。启动时按表头文字定位:
```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数, 解析失败行号列表}`,页面上显示出来。
**不要静默跳过失败行。**
## 4. `syb_orders` 顺运宝货运单
```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` 里有对应记录就是"已匹配"。
## 5. `sku_mappings` 规格映射
这张表是"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
```sql
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](../client/01-requirements.md) §11 已明确要求按任意维度设计)。
- `[必须]` 打开匹配弹窗时**先查这张表**,有记录就自动带出,操作员只需确认。
这是省人工的关键,不要做成每张订单都从头匹配。
## 6. `tasks` 任务
采集任务和采购任务共用一张表,用 `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。
## 7. `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 分钟。
存成字段会和真实情况不同步。
- `[必须]` 注册发生在**领取任务时**,新序列号新增、已有的更新,
**不设单独的注册或心跳接口**,见 [04](04-client-api.md) §3。
## 8. 数据关系总览
```text
shopee_products ──1:N──→ shopee_skus
│ │
│ pdd_data │ 1:1
│ (采集结果) ↓
│ sku_mappings
│ ↑
│ │ 查映射
syb_orders ────────────────────┘
│
└──创建──→ tasks ──分配──→ clients
```
## 9. 与 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 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。