# 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 默认是**关**的) | | `_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`,不许改动已有元素—— 已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。 ## 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. `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) ); CREATE INDEX idx_task_claims_client ON task_claims(client_id); ``` **为什么需要这张表:** [04 Client 接口实现](04-client-api.md) §4.1 要求 "只有**从未分配给该客户端**的任务才返回 403"。 但 `tasks.assigned_client` 只记**当前**归属,任务一旦重派给别人, 就查不出原来那台领过了——而契约又明确要求 "**已重派仍要接受原客户端提交的结果**"。没有这张表,那条规则根本没法判断。 顺带得到一份审计记录:这个任务被哪几台客户端领过。 `[必须]` 领取成功时写入;提交结果时用它做权限判断。 同一客户端重复领同一任务只更新时间,不报错。 ## 8. `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。 ## 9. 数据关系总览 ```text 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 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。