Files
cmautobuy/docs/admin/03-data-model.md
T

47 KiB
Raw Blame History

03 Admin 数据模型

  • 文档状态:基线草案,待数据评审
  • 生产数据库:MySQL 8.4(驱动 go-sql-driver/mysql v1.9.2)
  • 位置:与 Admin 同机的 127.0.0.1:3307,使用独立数据库和最小权限账号
  • 历史数据库:data/admin.db 只作为 SQLite → MySQL 单向迁移来源和回退备份

本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。 没有标注的默认是 [必须]。看不懂的词查 术语表。

1. 设计原则

  • [必须] 金额一律整数,字段名带单位后缀(_cent),禁止 float。
  • [必须] 台币和人民币分开存、不互相换算覆盖。
  • [必须] 时间存带时区 ISO 8601 的 UTC 字符串,页面上转本地时区显示。
  • [必须] 外部来的原始数据(规格原文、货运单 JSON、采集结果 JSON)原样保留, 规范化字段用于查询和显示。解析失败留空,不要猜。
  • [必须] 人工维护的字段不得被导入覆盖(详见 §3.3)。
  • [必须] SQL 一律参数化查询。

2. 建库与迁移

生产 MySQL 使用 schema_migrations 记录追加版本。MySQL DDL 会隐式提交, 因此每条 DDL 必须可重放;整版全部执行、通过表和关键列自检后才记录版本。 连接会话固定 UTC,字符集固定 utf8mb4,业务 ID 使用二进制排序规则保持大小写精确。

下面的 PRAGMA 和 user_version 章节是历史 SQLite 迁移规则,只供单向迁移工具维护, 不得重新接入生产运行时。

2.1 PRAGMA 必须写在 DSN 里

[必须] 三条设置通过连接串传入,不要用 db.Exec("PRAGMA ..."):

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 等,这才是要的行为。

[建议] 同时限制连接数:

db.SetMaxOpenConns(4)
db.SetMaxIdleConns(4)

SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、把 busy_timeout 耗光。 不要设成 1——那样在一个事务里再调用需要连接的代码会死锁。

2.2 迁移

用 PRAGMA user_version 管理顺序迁移。

[必须] 迁移语句一条一执行,不要把多条 SQL 塞进一个字符串—— database/sql 的 Exec 对"一次执行多条语句"的支持因驱动而异, 拆开最稳妥,报错还能精确到第几条。

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

[必须] 加新版本时只能往末尾追加 migrations,不许改动已有元素—— 已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。

2.3 迁移版本历史

版本 做了什么
v1 初始表结构:shopee_products(当时还带着 pdd_data/collect_status 等采集字段)、shopee_skus、syb_orders、sku_mappings(当时主键只有 shopee_sku_id)、tasks、clients、idempotency_keys。
v2 新增 task_claims(领取历史,见 §8)。
v3 把 PDD 采集数据从 shopee_products 拆到独立的 pdd_products(本文档 §4 描述的最终结构);重建 shopee_products,去掉已经搬走的四个字段;重建 sku_mappings,主键改成 (shopee_sku_id, pdd_goods_id)(§6.1 的理由)。
v4 pdd_products 增加可空的 shop_name;老数据保持 NULL。
v5 顺运宝货运单同步(工单 #46):新增 syb_session(会话缓存)、syb_sync_state(同步进度)两张表;syb_orders 增加可空的 product_spec(规格原文)。三条都是新增,v1–v4 一个字节没改。
v6(#50) Admin 网页登录:新增 users 和 web_sessions,只在迁移末尾追加,未改写 v1–v5。
v7(#54) 客户端负责人:新增 client_user_assignments 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。
v8(#59) 顺运宝同步记录:新增 syb_sync_runs 和开始时间倒序索引;未改写 v1–v7。
v9(#151) syb_orders 增加可空 shop_name,从有效 syb_data.stock.shopName 回填历史店铺名;无效 JSON、缺字段和空白值保持 NULL。

生产 MySQL 迁移继续独立追加;v12(#165)新增 task_syb_sources,并把没有任何 有效采集任务的孤立 pdd_products.collect_status='collecting' 回收到 pending。 v13(#172)新增 task_sequences,把存量 tasks.task_id 按类型和创建时间确定性改为 cjN / cgN,同步更新领取历史和顺运宝来源外键。历史 SQLite migrations 保持冻结,不追加 v12/v13 生产表结构。

v3 为什么丢弃旧 sku_mappings 数据(见 #20): 新主键需要 pdd_option_key, 这是 Go 的 service.OptionKey() 用 json.Marshal 算出来的规范化键,SQL 语句 复现不了。硬凑一个键出来有两种后果:算错了会让映射静默失效,需要人工重新匹配一遍; 算的时候恰好和别的规格撞了键,会静默买错东西,且事后查不出来——这正是引入 pdd_goods_id 做主键要防的问题,不能在迁移里重新引入。当时匹配界面还没做, 所以迁移时不可能存在真实映射数据,丢弃的代价很小。丢弃的行数会打进启动日志 (迁移 v3:丢弃了 N 条旧规格映射...),不会静默丢。

[必须] v1 曾经在 #16 里被原地改写(直接把 pdd_products 等结构塞进 v1, 没有新增版本),导致已经建过库、user_version 已经越过 v1 的老机器永远不会 重跑改写后的语句,程序拿着一个和代码对不上的库静默启动,界面点到 PDD 商品页 才报 500。#20 把 v1 恢复成原样、改动挪进新增的 v3,并加了启动时 schema 自检 (缺表直接拒绝启动,见下)作为兜底。

2.4 启动时 schema 自检

[必须] Migrate 成功后,repository.CheckSchema 会检查代码依赖的表是否都在, 缺了就返回错误、拒绝启动,不是打个警告继续跑——静默启动正是 #20 的教训: 错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错 而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。

MySQL 迁移 v3(工单 #88)是追加式迁移:新增 spec_mappings,为 syb_orders 增加 spec_key,为 shopee_products 增加 source。迁移使用稳定 syb_id 游标分批回填,先完整备份旧 sku_mappings 为 sku_mappings_v3_backup, 再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛; 启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 source 取值约束。

MySQL 迁移 v5(工单 #98)为 tasks 追加 execution_mode、live_confirmed_by 和 live_confirmed_at,并增加模式/审计 CHECK 与领取索引。旧任务通过数据库默认值 统一成为 dry_run;每条 DDL 可重放,列、约束和索引全部通过自检后才记录 v5。

MySQL 迁移 v6(工单 #127)为 tasks 追加可空的 created_by_user_id 和创建人列表 索引。存量记录不回填,NULL 明确表示“历史任务”;新建采集和采购任务必须写当前 网页登录用户。迁移与自检可重放,历史 SQLite migrations 保持冻结。

MySQL 迁移 v7(工单 #132)新增 catalog_import_runs 批次摘要表,并为蝦皮商品、 蝦皮 SKU 和 PDD 商品补充来源观测时间。shopee_products.source 新增 api 取值, pdd_products 新增可空来源字段。迁移不改写既有业务数据,每条 DDL 可重放,所有 字段和约束自检通过后才记录 v7。

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,                      -- 蝦皮「主商品貨號」
    source          VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', -- report / syb / api
    source_observed_at VARCHAR(35),              -- 外部来源观测时间,UTC ISO 8601

    -- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
    pdd_goods_url   TEXT,                      -- ★ 人工填写的 PDD 链接原文
    pdd_goods_id    TEXT,                      -- 从 url 解析,指向 pdd_products.goods_id

    created_at      TEXT NOT NULL,
    updated_at      TEXT NOT NULL
);

ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
    CHECK (source IN ('report', 'syb', 'api'));

CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);

[必须] 采集结果和采集状态不在这张表里,它们属于 PDD 商品,见 §4。

source='syb' 表示顺运宝先到、蝦皮报表尚未导入时创建的最小商品骨架; source='api' 表示由商品目录批量接口写入。 后续 Excel 导入同一 goods_id 时必须补全商品信息并把来源提升为 report; 导入不得覆盖人工维护的 PDD 关联。

pdd_goods_id 表示"这个蝦皮商品当前对应哪个 PDD 商品"。 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=人工新增的,导入不得删
    source_observed_at VARCHAR(35),            -- 外部来源观测时间,UTC ISO 8601
    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。第三方脚本负责把不同来源文件归一化, 再按 商品目录接口契约 提交结构化商品、SKU、PDD 和关联。

  • [必须] 整批只做 upsert,不清空表,也不删除批次中缺席的数据。
  • [必须] spec_raw 原样保留;解析失败由上游明确提交 parse_ok=false,Admin 不猜。
  • [必须] 商品更新不覆盖 pdd_goods_url / pdd_goods_id,SKU 更新不覆盖 is_manual。
  • [必须] 较旧 observed_at 不覆盖较新接口数据。
  • 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。
  • 一个批次在单事务中写入并返回新增、更新、未变化和失败统计。

4. pdd_products 拼多多商品

CREATE TABLE pdd_products (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    goods_id       TEXT NOT NULL UNIQUE,       -- 从 PDD 链接解析
    url            TEXT NOT NULL,              -- 操作员填的链接原文
    title          TEXT,                       -- 采集回来,人工核对用
    shop_name      TEXT,                       -- 店铺名;采不到或老数据为 NULL
    skus_json      TEXT,                       -- 采集结果,结构见 §4.2

    collect_status TEXT NOT NULL DEFAULT 'pending'
                    CHECK (collect_status IN (
                        'pending', 'collecting', 'collected', 'failed'
                    )),
    collect_msg    TEXT,                       -- 失败原因
    artifact_ref   TEXT,                       -- 诊断产物位置
    collected_at   TEXT,

    deleted_at     TEXT,                       -- 软删除
    created_at     TEXT NOT NULL,
    updated_at     TEXT NOT NULL
);

CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);

4.1 几个关键决定

为什么 goods_id 不是主键却必须 UNIQUE

主键用了自增 id,那 goods_id 就不再天然防重了。少了 UNIQUE, 同一个 PDD 商品会被存成好几行:采好几遍、映射说不清指向哪一行。

配套规则:[必须] 保存链接时先从 URL 解析出 goods_id,按它查重, 不要按 URL 查重——同一个商品的 URL 有很多写法(带不带分享参数), 按 URL 查会漏掉,照样存重复。解析不出来就报错,让操作员给完整链接。

为什么状态里没有"未填链接"

这张表里有这一行,就说明链接已经填了。"未填链接"是蝦皮侧的状态 (shopee_products.pdd_goods_id 为空)。界面上仍然显示 5 种, 只是数据来源不同,见 01 需求 §6.1。

为什么用软删除

spec_mappings 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。 软删除后界面不再显示,但记录和映射都还在。

[必须] 操作员重新填同一个链接时要能复活(清 deleted_at、状态置回 pending、清空旧采集结果)。不复活的话 goods_id 的 UNIQUE 会让插入失败, 操作员会看到一个莫名其妙的错误。

为什么不存截图

按已定案的 Artifact 策略(Client 契约 §10 待确认 #3), 客户端只报本地引用、不上传文件。截图在客户端那台机器上,Admin 显示不了。 所以存 artifact_ref(形如 client-001:artifacts/PDD-0001/attempt-xxx/), 告诉操作员去哪台机器的哪个目录捞。

采集超时:collecting 怎么才不会永久卡死

客户端离线、崩溃、任务被删——这些都是常态,不是异常。原来的规则是 "只有 pending / failed 才允许建采集任务",而只有客户端提交结果才会 把状态从 collecting 改走。于是上面任何一种情况发生后,这个商品的 collect_status 就永远卡在 collecting,界面上没有任何入口能救回来, 只能改数据库(见 #24)。

修复:collecting 状态超过 model.CollectStaleAfter(15 分钟),或已经找不到 pending/assigned/claimed 采集任务时,也允许重新建采集任务:

-- repository.MarkCollecting,简化版
UPDATE pdd_products
   SET collect_status = 'collecting', updated_at = ?
 WHERE goods_id = ? AND deleted_at IS NULL
   AND ( collect_status IN ('pending', 'failed')
         OR (collect_status = 'collecting' AND
             (updated_at < ? OR NOT EXISTS (有效采集任务))) )

[必须] 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领, 15 分钟足够宽裕。宁可短也不要长——采集是只读操作,多采一次没有任何副作用, 而卡死的代价是这个商品永久报废、只能改数据库救。定义成常量 model.CollectStaleAfter, 不要把 15 分钟当魔数散落在各处判断里。

[必须] 超时判定在读取的这一刻现算(拼进上面这条 UPDATE 的 WHERE 里), 不是后台协程定期扫描把超时的状态改回 pending。本项目已经在并发上栽过两次 (PRAGMA 没作用到连接池、BEGIN DEFERRED 死锁),能不引入并发就不引入; 现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。 同理,没有引入租约(lease)或心跳——那是被明确移除的设计, 见 Client 术语表「为什么没有租约和心跳」一节。

[必须] 时间比较用字符串比较 updated_at < ?,不解析成 time.Time 再比。 这依赖 model.NowISO() 永远产出定宽 UTC 格式(如 2026-08-07T06:10:19Z): 定宽 + 同一时区 + 补零,字符串的字典序才等于时间先后顺序。 谁把它改成带时区偏移的本地时间(比如 +08:00),这个比较会静默失效 ——不会报错,但超时判断全错,见 model.NowISO 的注释。

已知取舍:可能采两次

超时后允许重新建任务,但老任务还留在队列里,客户端上线后可能把新旧两个 任务都领走,同一个商品被采两次。这是可以接受的:

  • 采集是只读操作,没有任何副作用;
  • 后一次的结果覆盖前一次(SetCollectResult 按 goods_id 整体覆盖),数据仍然正确。

[必须] 不要看到"可能采两次"就去加锁或加租约"修"它——那正是被明确移除的设计, 加回来会重新引入本项目已经吃过两次亏的并发复杂度,而这里换来的收益(避免极少数情况下 多采一次)远小于代价。

4.2 skus_json 的结构

由 Client 采集后原样提交,Admin 不做转换:

{
  "schema_version": 1,
  "goods_id": "737116531267",
  "title": "【现货】西装外套三件套",
  "shop_name": "XX旗舰店",
  "price_granularity": "color",
  "captured_at": "2026-08-07T08:00:00Z",
  "dimensions": [
    {"key": "color", "name": "颜色分类"},
    {"key": "size",  "name": "尺码"}
  ],
  "skus": [
    {"options": {"color": "黑色", "size": "M"},
     "price_cent": 1256, "list_price_cent": 1990,
     "price_observed_at": {"color": "黑色", "size": "M"},
     "available": true,  "raw_price": "券后¥12.56"},
    {"options": {"color": "白色", "size": "M"},
     "price_cent": 1256, "available": false, "raw_price": "¥12.56"}
  ]
}

每个字段都对应 Admin 的一个实际用途,没有多余的:

字段 Admin 拿它干什么 不给会怎样
skus[].options 匹配弹窗列出规格供人选 没东西可选,匹配做不了
skus[].price_cent 建采购任务时带出 max_price_cent 价格保护填不了,Client 会拒绝执行
skus[].available 不给缺货规格建任务 白跑一趟,Client 到手机上才发现卖光
goods_id / title 核对"采的是不是要的那个商品" 链接跳转、采错商品时静默存错
shop_name 核对是否来自目标店铺 同标题商品无法区分来源;采不到时允许为空
dimensions 界面按顺序渲染下拉框 Go 的 map 无序,不知道该先显示颜色还是尺码
price_granularity 告诉下游价格是逐 SKU 实测还是按颜色推断 下单价格保护会把推断价误当实测价
price_observed_at 标明读取价格时实际选中的规格组合 无法区分哪一行是实测价
raw_price 价格解析出错时对账 只有数字,出错了没法查

[必须] 几条硬规则:

  • price_cent 是整数分,不是 12.56 也不是 "12.56"。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。
  • price_cent 是实付价(如券后价、折后价),不是划线价;可选的划线价放在 list_price_cent。
  • price_granularity 只能是 color 或 sku。color 表示同颜色各尺码共用一次采样价;老 Client 不传时继续兼容。
  • price_observed_at 必须记录读取价格时实际选中的完整组合。按颜色采样时,同颜色下只有与它完全相同的那一行是实测,其余是推断。
  • 采不到价格时给 null,不要给 0。Admin 遇到 null 当"未知"处理并拒绝建任务,绝不当成 0 元。
  • options 嵌一层,不平铺 color/size。支持任意多个维度,碰到三维商品(颜色/尺码/款式)平铺的结构直接装不下。
  • dimensions 只给 key 和 name,不给 values。values 能从 skus 去重推出来,存两份迟早不一致。

4.3 落库时的两条校验

[必须] Client 提交采集结果时,Admin 必须校验:

  1. 返回的 goods_id 必须等于请求采集的那个。 不等说明链接跳转了或采错商品, 要拒绝(422 COLLECT_GOODS_MISMATCH)并整体回滚。不拦的话,会把 B 的规格价格 存到 A 名下,之后按它下单就是买错东西。
  2. skus 为空要记成 failed,不是 collected。 采到 0 个规格对业务毫无用处 (商品下架、页面改版、解析器没认出来),显示"已采集"会让操作员以为好了, 等建任务时才发现不对。

完整的采集提交原文仍然存进 tasks.result_data,审计链不断。

5. syb_orders 顺运宝货运单

CREATE TABLE syb_orders (
    syb_id          TEXT PRIMARY KEY,          -- 货运单**明细行** ID(顺运宝 details[].id)
    order_no        TEXT NOT NULL,             -- 订单号(顺运宝外层 code,不是 orderCode)
    shop_name       TEXT,                      -- 蝦皮店铺(顺运宝外层 shopName)
    title           TEXT,                      -- 商品标题
    product_spec    TEXT,                      -- 顺运宝规格原文
    spec_key        VARCHAR(191),              -- 规格身份键;空规格保持 NULL
    shopee_goods_id TEXT,                      -- 蝦皮商品 ID(顺运宝 productId,11 位)
    shopee_sku_id   TEXT,                      -- 历史兼容列;新主链路不读写
    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。[必须] 不要把图片二进制存进数据库。
  • shop_name 是顺运宝货运单级事实,同一货运单下的多条商品明细会重复保存同一值; 独立列用于列表展示和检索,完整上游值仍保留在 syb_data.stock.shopName。
  • spec_key 通过 SpecKey(product_spec) 计算:去除首尾空白,把连续空白折叠为一个半角空格, 但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
  • 处理阶段是派生的,不存字段:PDD 规格是否已匹配要按 (shopee_goods_id, spec_key, 当前 pdd_goods_id) 联查 spec_mappings,并确认 pdd_option_key 仍存在于最新 skus_json。
  • shopee_sku_id 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。

MySQL schema v8 将 shopee_skus.sku_id 保留为系统内部记录主键(兼容历史引用), 新增可空且唯一的 shopee_sku_id 保存真实外部 ID,并用唯一 (goods_id, spec_key) 标识无真实 ID 的规格。既有行将真实 ID复制到新列;新记录没有 真实 ID 时只生成内部主键,页面和接口不得把内部值冒充真实 SKU ID。source 和 source_observed_at 用于限制同来源更新,is_manual=1 始终优先保护。 field_sources 与 field_observed_at 是只含颜色、尺码、建议和 SKU 编码的 JSON 来源表;fill_missing 补字段时分别登记,overwrite_same_source 只更新来源属于本次 调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。

MySQL schema v9 为 shopee_products 增加 image_url 和 shopee_shop_name。两者分别 使用 image_source/image_observed_at/image_is_manual 与 shop_name_source/shop_name_observed_at/shop_name_is_manual 记录来源边界。目录接口只 能补空或更新同来源的新观测,空值、跨来源、旧观测和人工字段均不覆盖。该图片是蝦皮 商品主图,与 syb_orders.image_url 的历史货运单观测图片是两个独立概念。

MySQL schema v10(工单 #144)是兼容修复:部分数据库曾在 v8 开发中间状态提前记录 v8/v9,实际缺少 field_sources、field_observed_at 或 v9 图片/店铺人工标记约束。 v10 幂等重放 v8、v9 的逐项建列、空值回填、约束和唯一索引逻辑,再核对两版完整 形状;只有全部通过才记录 v10。它不删除或改写旧迁移记录,也不清空商品、SKU 或 导入记录。

MySQL schema v11(工单 #151)为 syb_orders 增加可空 VARCHAR(500) 的 shop_name,并从合法原始 JSON 的 $.stock.shopName 回填历史数据。迁移可重放, 只填仍为空的列;新同步直接更新该列,不把它写入或覆盖蝦皮商品主数据。

蝦皮详情把 syb_orders 按 (shopee_goods_id, spec_key) 聚合为“顺运宝观测规格”: 订单数按唯一 syb_id 行计数,累计数量求和,最近一行提供历史台币售价和图片。 该读模型不写入 shopee_skus。只有同一商品下恰好一条正式 SKU 的 spec_raw 经 SpecKey() 后相等时才展示关联;零条或多条都不猜测、不写回。重复同步同一个 syb_id 只更新原行,因此不会重复累计。

[必须] 一行对应顺运宝一张货运单的一个商品明细(details[] 的一项), 不是一张货运单——一张货运单可以有多个商品,各占一行,syb_id 用的是 details[].id,不是货运单本身的 id。

[必须] price_twd_cent 一律取 detail/listByStock 接口的值(元)自己 ×100 转分、先四舍五入再转整数;不要用 /am/stock/list 列表接口的金额字段—— 同一响应里不同金额字段的单位不统一,见 08 顺运宝接口 §5.1。

[建议] 收件人姓名/电话/地址不入库,syb_data 落库前已剔除。

5.1 syb_session 顺运宝会话缓存、syb_sync_state 同步进度(v5)

CREATE TABLE syb_session (
    username    TEXT PRIMARY KEY,
    cookies     TEXT NOT NULL,   -- JSON 数组,Cookie 名/值/路径
    expires_at  TEXT NOT NULL,   -- min(JWT exp, 24h)
    updated_at  TEXT NOT NULL
);

CREATE TABLE syb_sync_state (
    id              INTEGER PRIMARY KEY CHECK (id = 1),  -- 只允许一行
    last_synced_at  TEXT,
    updated_at      TEXT NOT NULL
);
  • 只存 Cookie,不存登录 JWT——认证完全靠 Cookie,JWT 从不参与后续请求, 见 08 顺运宝接口 §3.1。
  • syb_sync_state 只允许一行(CHECK (id = 1)),是全局的"上次同步到哪"。
  • [必须] 增量同步从 last_synced_at 对应的日期当天重新拉,不是第二天—— created 筛选粒度是日期,last_synced_at 精确到秒,从第二天拉会漏掉当天 晚些时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏。
  • [必须] 只有一次同步全部成功才更新 last_synced_at;中途失败不更新, 否则下次同步会跳过这段区间,漏掉的单永远补不回来。

5.2 syb_sync_runs 同步记录(v8)

每次从页面发起同步时,先写一条 running 记录,再启动后台任务。记录保存操作 账号、日期范围、开始/完成时间、结果统计、是否推进覆盖游标以及必要的失败原因。 状态只能是 running、succeeded、failed、interrupted;Admin 启动时把上次 进程遗留的 running 记录改成 interrupted。

[必须] 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。 失败原因最多保留 500 个字符,并在页面输出时由模板转义。

6. spec_mappings 顺运宝规格映射

"这个蝦皮商品的这条顺运宝规格 = 当前拼多多商品的那个规格",匹配一次,以后复用。

CREATE TABLE spec_mappings (
    shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    spec_key        VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    pdd_goods_id    VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    pdd_option_key  VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, -- 见 §6.2
    pdd_options     LONGTEXT NOT NULL,        -- 原始 options JSON,显示用
    spec_raw        TEXT NOT NULL,
    mapped_at       VARCHAR(35) NOT NULL,
    mapped_by       VARCHAR(191),
    PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
    KEY idx_spec_mappings_goods (shopee_goods_id),
    KEY idx_spec_mappings_pdd (pdd_goods_id)
);

[必须] 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品; PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。

6.1 为什么主键要带上商品和 pdd_goods_id

PDD 商品下架换代很频繁——A 买不到了就得换 B。

假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码"; 后来 A 下架,换成了 B。如果映射不带 pdd_goods_id,那条旧映射还在, 但它描述的是 A 的规格:

后果
运气好 B 没有"黑色/M码",建任务时找不到会报错,还算安全
运气坏 B 恰好也有"黑色/M码",但完全是另一件衣服 → 静默买错,事后查不出来

把 pdd_goods_id 放进主键后,[必须] 查映射永远带上"当前对应的 PDD 商品":

SELECT ... FROM spec_mappings
 WHERE shopee_goods_id = ?
   AND spec_key = ?
   AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)

换成 B 就自然查不到 A 的映射,界面显示"待匹配"。不需要在换商品时记得去删旧数据 ——靠查询条件天然隔离,忘不了。

附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。

6.2 pdd_option_key 的规范化

采集回来的 PDD 数据里没有 SKU 编号,一个规格只能靠它的 options 组合来认。 而 JSON 对象的键是无序的:

存映射时:{"color":"黑色","size":"M"}
采回来时:{"size":"M","color":"黑色"}

这两个是同一个规格,但字符串不相等。直接比原始 JSON 会匹配不上, 而且是静默失效——不报错,只是查不到,最后表现为"明明匹配过却说待匹配"。

[必须] 所以要有一个规范化函数,service.OptionKey():

OptionKey(map[string]string{"size": "M", "color": "黑色"})
// -> {"color":"黑色","size":"M"}

实现直接用 json.Marshal —— Go 序列化 map 时会按键名排序,正好就是我们要的 规范化,不用自己拼字符串(自己拼容易漏掉值里含分隔符、含引号之类的边界情况)。

[必须] 这个函数只能有一处实现。 存映射用它算 key,查规格也用它算 key, 两边必须逐字节一致。如果 Client 那边也算一份、或者别处再写一个"差不多"的版本, 只要有一点点不同(空格、转义、键序),映射就会静默对不上。 Client 只上报 options 对象,key 一律由 Admin 这一个函数算。

6.3 spec_mapping_decisions 决策审计

本表只追加、不参与业务判断。每次成功保存映射时,与 spec_mappings 在同一事务写入; 没有建议时 suggested_option_key 为 NULL 且 accepted=0。accepted=0 表示未采纳建议或当时无建议。

CREATE TABLE spec_mapping_decisions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    rules_version VARCHAR(32) NOT NULL,
    suggested_option_key VARCHAR(191) COLLATE utf8mb4_bin,
    chosen_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    accepted TINYINT NOT NULL CHECK (accepted IN (0, 1)),
    decided_by VARCHAR(191),
    decided_at VARCHAR(35) NOT NULL,
    KEY idx_decisions_mapping (shopee_goods_id, spec_key, pdd_goods_id)
);

下面的指标只能称为“成功保存决策中的建议采纳率”,不是准确率:

SELECT rules_version, COUNT(*) AS 有建议的决策数, SUM(accepted) AS 采纳数,
       ROUND(SUM(accepted) / COUNT(*) * 100, 1) AS 采纳率
FROM spec_mapping_decisions WHERE suggested_option_key IS NOT NULL GROUP BY rules_version;

SELECT rules_version, COUNT(*) AS 无建议决策数
FROM spec_mapping_decisions WHERE suggested_option_key IS NULL GROUP BY rules_version;

7. tasks 任务

采集任务和采购任务共用一张表,用 task_type 区分。

CREATE TABLE tasks (
    task_id        TEXT PRIMARY KEY,           -- 稳定业务主键:采集 cjN,采购 cgN
    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);

task_id 不是另外的展示别名,而是真实主键。采集和采购分别从 1 开始递增,不共用计数器。分配编号与插入任务必须在同一事务中,以避免 并发重号和创建失败消耗序号。Client 应把这个值当作不透明字符串原样保存。

生产 MySQL v5 在 tasks 追加以下安全字段(历史 SQLite 结构不改):

字段 含义
execution_mode dry_run / live,非空且默认 dry_run;创建后不可修改
live_confirmed_by 提交创建真实任务的正常状态 Admin 用户;演练任务必须为空
live_confirmed_at 真实任务创建操作时间;演练任务必须为空

chk_tasks_execution_mode 和 chk_tasks_live_confirmation 在数据库层阻止非法模式及 缺少创建审计信息的真实任务;idx_tasks_claim_mode 支持按客户端能力领取。字段名保留 历史命名以兼容已发布的 v5 migration。

生产 MySQL v6 再追加任务创建人字段:

字段 含义
created_by_user_id 创建任务的 Admin 用户 ID;存量 NULL 表示“历史任务”

idx_tasks_creator_list(created_by_user_id, updated_at DESC, task_id DESC) 支持采购员 按本人范围分页,以及管理员按创建人筛选。用户只禁用不删除,因此无需把显示名称冗余 写入任务;fk_tasks_created_by 指向 users(user_id),页面通过 users 关联显示用户名。

[必须] 两条硬约束,来自 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.1 task_sequences 任务序列(生产 MySQL v13)

CREATE TABLE task_sequences (
    task_type VARCHAR(20) COLLATE utf8mb4_bin PRIMARY KEY,
    current_value BIGINT NOT NULL DEFAULT 0,
    CHECK (task_type IN ('collect', 'purchase')),
    CHECK (current_value >= 0)
);

表中只有 collect 和 purchase 两行。创建任务时先在当前事务内对对应行 current_value + 1,再读取并组成 cjN 或 cgN;InnoDB 行锁保证并发唯一。

7.2 task_syb_sources 采集任务来源(生产 MySQL v12)

从顺运宝创建采集任务时,同一个 PDD 商品可能对应多个货运单明细。来源使用关联表, 不能压进 tasks.syb_id 单列;后者仍只表示采购任务自身的顺运宝明细。

CREATE TABLE task_syb_sources (
    task_id    VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    syb_id     VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    created_at VARCHAR(35) NOT NULL,
    PRIMARY KEY (task_id, syb_id),
    KEY idx_task_syb_sources_syb (syb_id, task_id),
    FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ON UPDATE CASCADE,
    FOREIGN KEY (syb_id) REFERENCES syb_orders(syb_id) ON DELETE CASCADE
);
  • [必须] 任务和全部来源在同一事务写入;按 PDD 商品去重后仍要保留每条来源。
  • [必须] 采集采购关键字搜索通过本表支持顺运宝订单号和明细 ID。
  • [必须] 删除任务后若同商品已无 pending/assigned/claimed 采集任务,必须在同一事务 把仍为 collecting 的商品回收到 pending;有其他有效任务时不得回收。
  • 历史任务没有可靠来源,不做猜测回填,仍可按任务号和 PDD 商品 ID 查询。

8. task_claims 领取历史

记录"哪台客户端领过哪个任务"。

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),
    FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX idx_task_claims_client ON task_claims(client_id);

生产 MySQL v13 清理了无对应任务的孤儿领取历史,并加入上述外键; ON UPDATE CASCADE 保证主键迁移时领取历史同步改号。

为什么需要这张表: 04 Client 接口实现 §4.1 要求 "只有从未分配给该客户端的任务才返回 403"。 但 tasks.assigned_client 只记当前归属,任务一旦重派给别人, 就查不出原来那台领过了——而契约又明确要求 "已重派仍要接受原客户端提交的结果"。没有这张表,那条规则根本没法判断。

顺带得到一份审计记录:这个任务被哪几台客户端领过。

[必须] 领取成功时写入;提交结果时用它做权限判断。 同一客户端重复领同一任务只更新时间,不报错。

9. 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 分钟。 存成字段会和真实情况不同步。
  • [必须] 设置页使用独立登记接口幂等新增或更新 Client,claim 保留隐式登记作为兼容兜底。
  • [必须] 不设心跳接口;登记、claim、result 和 failure 都刷新 last_seen_at,见 04 §1.1、§3。

10. 数据关系总览

shopee_products ──1:N──→ shopee_skus
      │
      │ pdd_goods_id(当前 PDD 商品,可换)
      ↓
 pdd_products ←─pdd_goods_id─ spec_mappings
                              ↑
syb_orders ─(shopee_goods_id, spec_key)─┘
   (skus_json 里是所有规格和价格)

syb_orders ──创建──→ tasks ──分配──→ clients
                                      ↑
users(采购员)──1:N 当前归属──────────┘

两条关联都可以变,这是有意的:

  • shopee_products.pdd_goods_id:PDD 商品下架换代时改
  • spec_mappings 按 (蝦皮商品, 顺运宝规格键, PDD商品) 存:换了商品自然查不到旧映射

11. 与 Client 数据模型的关系

Admin 使用集中 MySQL 8.4,Client 仍使用各自的本地 SQLite;两者只通过接口交换数据。

概念 Admin 这边 Client 那边
任务编号 tasks.task_id pdd_tasks.remote_task_id
任务状态 7 个(§6) 8 个,是本机执行状态
采集结果 pdd_products.skus_json pdd_tasks.pdd_data

[必须] 两边的状态是两套,不要试图同步。 Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。

12. Admin 用户和 Web Session(v6)

users 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。

CREATE TABLE users (
    user_id             TEXT PRIMARY KEY,
    username            TEXT NOT NULL COLLATE NOCASE UNIQUE,
    password_hash       TEXT NOT NULL,
    role                TEXT NOT NULL CHECK (role IN ('admin', 'purchaser')),
    status              TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
    last_login_at       TEXT,
    password_changed_at TEXT NOT NULL,
    created_at          TEXT NOT NULL,
    updated_at          TEXT NOT NULL
);
  • password_hash 只保存成熟密码哈希算法的结果,绝不保存或记录明文密码。
  • 用户不做物理删除;离职或停用改为 disabled,保留任务和操作记录的可追溯性。
  • 首次初始化必须在 InnoDB 写事务中锁定初始化保护记录并再次确认 users 为空, 并发提交最多一个成功。即使所有账号都被禁用,初始化入口也不能重新开放。
  • 系统不能禁用最后一个有效管理员。

web_sessions 保存网页登录状态。数据库只保存随机 Session Token 的 SHA-256 哈希, Cookie 原文不能进入数据库、日志或页面。

CREATE TABLE web_sessions (
    session_hash TEXT PRIMARY KEY,
    user_id      TEXT NOT NULL,
    expires_at   TEXT NOT NULL,
    created_at   TEXT NOT NULL,
    last_seen_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

CREATE INDEX idx_web_sessions_user ON web_sessions(user_id);
CREATE INDEX idx_web_sessions_expiry ON web_sessions(expires_at);
  • Session 使用固定过期时间,MVP 默认 12 小时,不做复杂刷新令牌。
  • 退出登录、密码重置或账号禁用时,删除该用户对应的 Session 记录。
  • 过期 Session 可以在登录、退出或定期维护时清理,不需要后台常驻线程。

13. client_user_assignments 客户端负责人历史(v7)

clients 是执行任务的软件实例,users 是登录 Admin 的人,两者生命周期不同, 因此保持两张独立实体表,用归属历史表连接,不能合并字段。

CREATE TABLE client_user_assignments (
    assignment_id       TEXT PRIMARY KEY,
    client_id           TEXT NOT NULL,
    user_id             TEXT NOT NULL,
    started_at          TEXT NOT NULL,
    ended_at            TEXT,
    assigned_by_user_id TEXT NOT NULL,
    ended_by_user_id    TEXT,
    end_reason          TEXT CHECK (end_reason IS NULL OR end_reason IN ('unbind', 'transfer')),
    FOREIGN KEY (user_id) REFERENCES users(user_id),
    FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id),
    FOREIGN KEY (ended_by_user_id) REFERENCES users(user_id)
);

CREATE UNIQUE INDEX idx_client_assignment_current
    ON client_user_assignments(client_id) WHERE ended_at IS NULL;
  • ended_at IS NULL 表示当前归属;部分唯一索引保证一台客户端最多一个当前负责人。
  • 首次绑定只新增记录;转交在同一事务结束旧记录并新增记录;解绑只结束旧记录。
  • assigned_by_user_id / ended_by_user_id 都是执行操作的管理员,不是目标采购员。
  • client_id 故意不设指向 clients 的外键:客户端清单允许删除后由同一稳定编号 重新登记,归属和审计历史不能随临时清单记录丢失。
  • 归属记录不参与 Client API 的登记和领取判断,也不更新 tasks.assigned_client。

14. catalog_import_runs 商品目录批次摘要(MySQL v7)

catalog_import_runs 以 (source, batch_id) 为主键,保存请求哈希、处理状态、 新增/更新/冲突计数和错误摘要。它不保存 Bearer Token,也不保存完整请求体。

  • 相同来源、批次号和请求哈希只处理一次;重复提交返回首次结果。
  • 相同来源和批次号携带不同请求哈希时记录冲突并返回 HTTP 409。
  • response_body 只保存可安全重放的结果摘要,不得包含凭据或原始商品数据。
  • source_observed_at 用于拒绝较旧数据覆盖较新数据;缺席记录绝不代表删除。