Files
cmautobuy/docs/admin/03-data-model.md
T
chengmaandClaude Opus 5 90379347b4 docs: 建立 Admin 子项目的文档基线
仓库从单子项目变成两个:client(Python + PyQt5 桌面端)和
admin(Go + Gin + HTML 模板 Web 管理端)。两者技术栈完全不同,
规则各自独立,只通过三个 HTTP 接口交互。

新增 admin/AGENTS.md 和 docs/admin/ 共 9 份文档,结构与 docs/client/ 对齐。

关键设计决策(均已与用户确认)
- 四模块:蝦皮数据 / 顺运宝数据 / 采购任务 / 客户端列表,
  统一三段式布局(工具条 / 带勾选的表格 / 状态条)
- 蝦皮数据拆成商品表 + SKU 表:报表本身就是两层结构,
  且 PDD 链接是商品级的,放 SKU 级会重复维护
- pdd_data 存商品级,一个 PDD 商品采一次,所有订单共用
- SKU 映射独立成表且可复用,同一蝦皮 SKU 只需人工匹配一次
- PDD 链接人工填写,采集按钮就放在填链接的编辑弹窗里
- 任务分配给指定客户端;不加心跳,注册在领取时完成,
  在线状态由 last_seen_at 派生
- SQLite 驱动固定 modernc.org/sqlite(纯 Go 免 cgo,go build 直接出 exe)
- 不引入前端框架和 npm 构建,只允许原生 JS 或 htmx

基于样本文件 raw_data/蝦皮数据样本.xlsx 实测得出的约束
- 11287 行 = 5195 商品汇总行 + 6092 SKU 行,导入必须分开处理
- 商品規格ID 零重复,是天然主键
- 规格原文两种格式各占 53.4% / 46.6%,只解析【】会漏掉一半
- 平均每商品仅 1.17 个 SKU,报表不是全量目录,
  因此必须支持手动新增,且货运单外键不能加硬约束

同步更新
- 根 AGENTS.md 开头的指针改为两个子项目对照表
- docs/README.md 重构为双子项目索引
- .gitignore 加 admin/data/、admin.exe、Excel 锁文件

说明:Gitea 尚未配置,本次无对应工单号。
raw_data/ 未提交,含台币销售额等商业数据,待用户决定。

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

13 KiB
Raw Blame History

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. 建库与迁移

每次打开连接至少设置:

PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;

用 PRAGMA user_version 管理顺序迁移。

[必须] 升级必须支持从所有已发布版本迁移,不得在启动时删库重建。 理由:data/ 在升级时是保留的(见 02 §6), 里面有人工填了几个月的 PDD 链接和 SKU 映射,删掉就没了。

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

导入 raw_data/蝦皮数据样本.xlsx 应得到 5195 商品 + 6092 SKU,数字对不上就是解析有问题。

第二步:按列名找索引,不要写死列号

报表有 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 条无例外),所以"颜色,尺码"这个二分结构是稳的。

规则:

  1. 按第一个逗号切开 → 左边是颜色,右边是"尺码 + 可能的建议";
  2. 右边尝试提取建议:先找 【建議...】,再找 建議...;
  3. 剩下的就是尺码;
  4. [必须] 任何一步失败都不要猜,把 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:

  1. pdd_goods_url 不能为空,否则 Client 无法执行(它那边是 NOT NULL)。
  2. 采购任务的 quantity 和 max_price_cent 都必须有值, 这是价格保护,没有它 Client 会拒绝执行。

max_price_cent 是人民币分,默认从 pdd_data 里对应 SKU 的价格带出,操作员可改但不能清空。

状态含义见 01 需求 §6.2。

7. 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。

8. 数据关系总览

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