Files
cmautobuy/docs/admin/03-data-model.md
T
chengmaandClaude Opus 5 4f920f9d8b docs: 排除蝦皮原始报表,并同步主按钮文案
raw_data 不进 Git
原始报表含逐商品台币销售额等商业数据,进了 Git 就是永久历史。
- .gitignore 排除 raw_data/
- 改掉三处"仓库里有一份样本"的失真表述,改为向项目负责人索取
- 06 §2.1 相应加强:既然大样本不进库,admin/testdata/ 下的脱敏小样本
  就必须提交,否则别人拉下来测试跑不了;并写明脱敏做法

主按钮文案 开始自动获取 → 获取任务 ⇄ 停止获取
只改按钮标签。"自动获取"作为功能名保留(状态栏、Tab 顺序、
协调器开关等处不动),05 §4.1 加了一句说明两者不是一回事。

已知遗留:pdd_ui.py 自身仍不一致——构造时用「获取任务」,
但状态机 485/487 行仍是「开始自动获取」/「停止自动获取」,
会覆盖掉构造时的文字。该文件有未提交改动,本次未触碰,
差异已记入 02 §3.1,需另开工单修。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:58:09 +08:00

336 lines
14 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. 建库与迁移
每次打开连接至少设置:
```sql
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
```
用 `PRAGMA user_version` 管理顺序迁移。
`[必须]` 升级必须支持从所有已发布版本迁移,**不得在启动时删库重建**。
理由:`data/` 在升级时是保留的(见 [02](02-architecture.md) §6),
里面有人工填了几个月的 PDD 链接和 SKU 映射,删掉就没了。
## 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 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。