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>
336 lines
14 KiB
Markdown
336 lines
14 KiB
Markdown
# 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 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
|