Files
cmautobuy/docs/admin/03-data-model.md
T
chengmaandClaude Opus 5 998c06a2bf feat: PDD 商品数据独立成表 (#16)
原来 pdd_data 是 shopee_products 上的一个 JSON 字段,两个蝦皮商品指向
同一个 PDD 链接时会各存一份、各采一次;collect_status 描述的是 PDD 商品的
状态,却挂在蝦皮商品上,两份可能不一致。

更要紧的是 PDD 商品变动频繁(A 下架就得换 B),而 sku_mappings 只按
shopee_sku_id 做键——换商品后旧映射还在,B 恰好有同名规格但完全是另一件货
时会静默买错,事后查不出来。

改动
- 新增 pdd_products 表:id 主键 + goods_id UNIQUE + 4 个状态值(去掉
  no_link,「未填链接」改由 shopee_products.pdd_goods_id 为空表达)+
  软删除可复活
- shopee_products 去掉 pdd_data / collect_status / collect_error /
  collected_at,pdd_goods_id 改为引用
- sku_mappings 主键改为 (shopee_sku_id, pdd_goods_id),新增 pdd_option_key。
  查映射永远带上当前 PDD 商品,换商品后天然查不到旧映射,不需要删数据;
  换回原商品时旧映射直接复用
- 新增 OptionKey():用 json.Marshal 实现(Go 序列化 map 按键名排序,
  天然规范化),不自己拼字符串——规格文字里可能含 = 或 ;。
  存映射和查 SKU 必须用同一个函数,各写一遍会静默算出不同结果
- 采集结果改落 pdd_products,新增两条校验:
  返回的 goods_id 与请求不符 → 整体回滚拒绝(422),不静默存下;
  skus 为空数组 → 置 failed 而非 collected,否则界面显示"已采集"
  但数据毫无用处

实施时超出工单但必要的三处
- TaskExists 重构为 GetTaskInfo:原函数只返回蝦皮 goods_id,
  而采集结果要按 PDD goods_id 落库,不改取不到正确的键
- 复活时一并清空旧采集结果(skus_json / collect_msg / collected_at),
  否则复活后会显示"已采集"但数据是删除前的
- 删除 repository/shopee.go:两个函数签名全变且已迁到 pdd.go,留着是死代码

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,55 个测试
- 端到端补验了工单未覆盖的 HTTP 层:goods_id 不符返回 422
  COLLECT_GOODS_MISMATCH 且整体回滚(skus_json 空、任务仍 claimed、
  幂等记录 0 条);skus 为空返回 200 但状态 failed

遗留
- MarkCollecting / SoftDeletePddProduct 暂无调用方,等界面工单接上
- artifact_ref 存 diagnostics 原始 JSON,未按 client-001:artifacts/... 规范化,
  因 Client 侧尚未定义 diagnostics 结构
- 界面未实现(工单明确排除),四个页面仍为骨架

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:27:39 +08:00

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

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,不许改动已有元素—— 已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。

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 链接原文
    pdd_goods_id    TEXT,                      -- 从 url 解析,指向 pdd_products.goods_id

    created_at      TEXT NOT NULL,
    updated_at      TEXT NOT NULL
);

CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);

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

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=人工新增的,导入不得删
    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 列,蝦皮改一次导出格式列号就变。启动时按表头文字定位:

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. pdd_products 拼多多商品

CREATE TABLE pdd_products (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    goods_id       TEXT NOT NULL UNIQUE,       -- 从 PDD 链接解析
    url            TEXT NOT NULL,              -- 操作员填的链接原文
    title          TEXT,                       -- 采集回来,人工核对用
    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。

为什么用软删除

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

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

为什么不存截图

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

4.2 skus_json 的结构

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

{
  "schema_version": 1,
  "goods_id": "737116531267",
  "title": "【现货】西装外套三件套",
  "captured_at": "2026-08-07T08:00:00Z",
  "dimensions": [
    {"key": "color", "name": "颜色分类"},
    {"key": "size",  "name": "尺码"}
  ],
  "skus": [
    {"options": {"color": "黑色", "size": "M"},
     "price_cent": 1256, "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 核对"采的是不是要的那个商品" 链接跳转、采错商品时静默存错
dimensions 界面按顺序渲染下拉框 Go 的 map 无序,不知道该先显示颜色还是尺码
raw_price 价格解析出错时对账 只有数字,出错了没法查

[必须] 几条硬规则:

  • price_cent 是整数分,不是 12.56 也不是 "12.56"。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。
  • 采不到价格时给 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
    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 里有对应记录就是"已匹配"。

6. sku_mappings 规格映射

"蝦皮的这个规格 = 拼多多的那个规格",匹配一次,以后复用。

CREATE TABLE sku_mappings (
    shopee_sku_id   TEXT NOT NULL,
    pdd_goods_id    TEXT NOT NULL,          -- ★ 这条映射属于哪个 PDD 商品
    pdd_option_key  TEXT NOT NULL,          -- 规范化的组合键,见 §6.2
    pdd_options     TEXT NOT NULL,          -- 原始 options 对象,显示用
    goods_id        TEXT NOT NULL,          -- 蝦皮商品 ID,方便按商品批量查
    mapped_at       TEXT NOT NULL,
    mapped_by       TEXT,
    PRIMARY KEY (shopee_sku_id, pdd_goods_id),
    FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
);

CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
CREATE INDEX idx_sku_mappings_pdd   ON sku_mappings(pdd_goods_id);

6.1 为什么主键要带上 pdd_goods_id

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

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

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

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

SELECT ... FROM sku_mappings
 WHERE shopee_sku_id = ?
   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 这一个函数算。

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

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)
);

CREATE INDEX idx_task_claims_client ON task_claims(client_id);

为什么需要这张表: 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          │ shopee_sku_id
      │ (当前对应哪个         ↓
      │   PDD 商品,可换)  sku_mappings ──pdd_goods_id──┐
      ↓                                                  │
 pdd_products ←────────────────────────────────────────┘
   (skus_json 里是所有规格和价格)

syb_orders ──创建──→ tasks ──分配──→ clients

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

  • shopee_products.pdd_goods_id:PDD 商品下架换代时改
  • sku_mappings 按 (蝦皮SKU, PDD商品) 存:换了商品自然查不到旧映射

11. 与 Client 数据模型的关系

Admin 和 Client 各有一个 SQLite,互不相通,只通过接口交换数据。

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

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