2
Admin-Data-Model
ila edited this page 2026-08-28 10:57:32 +08:00
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 数据模型

  • 文档状态:基线草案,待数据评审
  • 生产数据库: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 生产表结构。 v14(#196)新增 syb_allowed_shops 全局店铺准入表;syb_sync_runs 增加 accepted_stock_count、shop_skipped_count 和 shop_filter_hash。旧同步记录回填为 “接受数=原始货运单数、店铺跳过=0”;历史顺运宝货运单不删除。 v15(#203)把历史 syb_orders 中每个蝦皮商品最新的非空店铺和图片,以 source='syb' 的字段级低优先级来源补入 shopee_products。v16(#204)确定性解析 “颜色,尺码【建议】”格式,并为历史数据补建无真实外部 ID 的低优先级蝦皮 SKU; 不明确格式只保留原文。v17(#205)为蝦皮商品增加 deleted_at、 deleted_by_user_id 和删除状态索引,删除改为可恢复的软删除。 v18(#208)新增 shops 业务店铺和 shop_channel_aliases 渠道名称表,并为 syb_orders、shopee_products 增加可空 shop_id。迁移把 v14 旧准入项一对一 转成业务店铺,同时保留 syb_allowed_shops 作为回退依据;历史数据只按渠道原始 名称精确回填,无法确认的记录保持未关联。 v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。迁移按“任一旧开关停用 则保持停用”的安全规则合并 v18 状态,重建由 shops 派生的兼容数据和两侧关联。 v26(#254)新增 purchase_spec_resolutions,用一张表保存采购执行中的候选观察、 幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。 v27(#250)为 syb_inner_code_records 增加可空 JSON 字段 remote_items_json,保存 多件商品逐个入库码对应的远端明细、来源和动作状态;仍不拆分新的业务表。 v28(#259)为该表增加可空 source_sku_raw,保留 Excel 原始 SKU,供匹配阶段与 顺运宝 sku/variationSku 做边界去空白后的精确比较。 v29(#259)兼容曾由 v28 中间版本按表默认排序规则创建并记录版本的数据库,把 source_sku_raw 统一规范为 utf8mb4_bin;字段已经正确时迁移不改数据。

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
    deleted_at      VARCHAR(35),               -- 软删除时间;非空时默认业务查询不可见
    deleted_by_user_id VARCHAR(191),            -- 执行删除的管理员

    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);
CREATE INDEX idx_shopee_products_deleted ON shopee_products(deleted_at, updated_at, goods_id);

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

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

顺运宝同步只把非空店铺和图片补到空字段,或更新同样来自 syb 的字段;人工值和 目录接口值不覆盖。后续目录接口可把 syb 低优先级值提升为权威来源。软删除商品 不会因顺运宝同步或目录导入自动恢复,SKU、PDD 关联和历史记录全部保留;只能由 管理员显式恢复。存在 pending/assigned/claimed 采集或采购任务时禁止软删除。

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 原样保留。商品目录解析结果由上游提交;SYB 只对明确的 “颜色,尺码【建议】”二维格式做确定性解析,未知尺码、额外维度和不完整括号均不猜。
  • [必须] 商品更新不覆盖 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 只更新来源属于本次 调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。详情页人工修改 颜色、尺码和建议时,把对应字段来源写为 manual;即使人工主动清空建议,补空策略 也不得重新填入。人工编辑现有行不把 is_manual 改成 1,后者只表示人工新增整条 SKU。

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 回填历史数据。迁移可重放, 只填仍为空的列;新同步直接更新该列。v15(#203)在此基础上把非空店铺和图片作为 syb 低优先级字段补入蝦皮商品主数据,但仍不得覆盖人工或目录接口字段。

蝦皮详情把 syb_orders 按 (shopee_goods_id, spec_key) 聚合为“顺运宝观测规格”: 订单数按唯一 syb_id 行计数,累计数量求和,最近一行提供历史台币售价和图片。 从 v16 起,同步会把格式明确的规格以 source='syb' 写入 shopee_skus:内部主键 由商品 ID 和 spec_key 确定性生成,真实 shopee_sku_id 保持 NULL。目录接口后到时 按同一规格身份补上真实 ID,并可替换字段来源为权威来源;人工行永不覆盖。不明确格式 仍只存在于观测读模型中,不创建 SKU。重复同步同一个 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 个字符,并在页面输出时由模板转义。

v14 起,stock_count 继续表示通过完整性校验的原始货运单数,避免改变旧字段 语义;accepted_stock_count 是店铺准入后最终接受的货运单数, shop_skipped_count 是店铺为空、未启用或明细店铺变化而跳过的货运单数。 shop_filter_hash 保存同步开始时启用店铺排序后计算的 SHA-256,只用于判断两次同步 是否使用同一份快照,不保存 Cookie、密码或原始响应。

5.3 店铺(MySQL v18 / v19)

shops 是唯一店铺配置。display_name 是页面中的“店铺名称”,同时用于 SYB 同步 准入和蝦皮商品关联;enabled 是唯一启停状态。匹配会去除首尾空白,再使用 utf8mb4_bin 精确比较,不做模糊、正则、大小写折叠或 AI 猜测。

蝦皮导入和 SYB 写入都保留上游原始店铺名称,并把精确解析出的 shop_id 作为派生 关联;未解析成功时 shop_id 保持 NULL,可从店铺管理的“未登记商品”入口查看。 shop_channel_aliases 是 v18 升级兼容表,不再是独立产品概念,也不在页面中编辑; 每个店铺固定保留 syb、shopee 两条与唯一店铺名称和状态完全相同的派生记录。

[必须] 没有任何启用店铺时,同步关闭失败,不请求顺运宝、不推进游标。编辑店铺 名称会在一个事务里重建该店铺的历史精确关联。永久删除只允许已经停用且没有任何 货运单或蝦皮商品引用的店铺。

v14 的 syb_allowed_shops 在 v18 后不再作为运行时配置源,但为迁移回退保留,不能 在 v18 / v19 中直接改名或删除。

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),
    source          VARCHAR(16) NOT NULL DEFAULT 'manual', -- manual / rule / ai
    source_provider_id VARCHAR(191),
    source_model    VARCHAR(191),
    confidence_bps  INT,
    source_reason   VARCHAR(500),
    source_version  VARCHAR(64),
    context_version CHAR(64),
    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)
);

v21 增加的来源字段描述当前生效映射。人工在详情页保存时,source 固定改为 manual 并清空 AI 元数据;规则唯一确定的结果使用 rule;通过模型和服务端硬门禁的结果使用 ai。这些字段不改变映射唯一键。PDD 重新采集后如果 pdd_option_key 已不存在,三种 来源都按同一规则判为失效,不能进入采购任务。

[必须] 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 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;

6.4 ai_spec_match_decisions AI 决策审计(MySQL v21)

本表只追加,记录一次 AI 匹配动作使用的上下文版本、规则/提示词版本、服务商和模型快照、 候选短编号到真实选项键的映射、置信度、结论和简短原因。它不保存 API Key、完整提示词、 完整请求/响应、顺运宝订单号、店铺账号、用户资料、地址或 Client 信息。

合格映射和成功决策在同一事务提交。低置信度、伪造候选、模型拒绝、超时和格式错误也 追加脱敏结论,但不写 spec_mappings。人工后来覆盖当前映射时不会删除本表历史。

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 带出单价上限,操作员可改但不能清空;后端在事务内按顺运宝最新数量计算 单价上限 × quantity 后写入本字段,并检查整数溢出。历史任务不回写。

状态含义见 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
                         └──1:N──→ purchase_spec_resolutions
users(采购员)──1:N 当前归属──────────→ clients

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

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

syb_orders 不保存 PDD 商品 ID。新货运单通过 shopee_goods_id 动态读取 shopee_products.pdd_goods_id;只要目标 pdd_products 未软删除,就自动复用当前 关联。这样换绑后所有货运单立即看到新关系,也不会留下需要同步更新的第二份 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
运行时规格解析 purchase_spec_resolutions 候选观察与最终决策 #257 在本地保存请求、幂等键和响应;原任务 payload 不覆盖

[必须] 两边的状态是两套,不要试图同步。 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 用于拒绝较旧数据覆盖较新数据;缺席记录绝不代表删除。

15. AI 服务商配置(MySQL v20)

ai_provider_configs 只保存非敏感配置:服务商名称、HTTP/HTTPS Base URL、模型、超时、最大 并发、自动写入置信度基点、启用状态和最近连接测试摘要。active_slot 是生成列并带唯一 索引,数据库层保证任一时刻最多一个启用项。普通配置或密钥发生变化时,当前服务商会 停用并把测试状态重置为 pending,必须重新测试后才能启用。

ai_provider_audits 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段 名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的 独立密钥文件。

16. AI 规格匹配批次(MySQL v22,批次上限 v31)

ai_match_batches 保存一次批量操作的创建人、状态、计数以及创建时的非敏感服务商配置 快照。状态为 queued/running/partial/succeeded/failed/interrupted,单批最多 200 条。 快照包含服务商名称、Base URL、模型、超时、并发、阈值和配置指纹,但绝不包含 API Key。

MySQL v31 只把 chk_ai_match_batch_total 的范围从 1~100 调整为 1~200,不改变字段、 索引、执行并发或单条匹配规则。迁移读取并核对约束表达式;删除旧约束后即使新增失败, 下次启动也能从约束缺失状态继续补齐,自检通过后才记录版本。

ai_match_batch_items 每条对应一个顺运宝明细,保存上下文版本、同批去重身份、负责人明细、 处理状态、结果来源、选项键、置信度和简短原因。(batch_id, syb_id) 唯一,保证一条明细在 同一批次内只出现一次。相同身份只有负责人调用规则/模型,跟随项读取已保存的共享映射并记为 复用;上下文变化时记为 stale,不得套用旧结果。

批次记录用于进度、故障恢复和操作审计;具体模型决策证据仍只追加到 ai_spec_match_decisions。Admin 重启把未完成明细改为 interrupted 并完成批次计数,已经 成功写入的 spec_mappings 不回滚。

17. syb_inner_code_records 档口入库码记录(MySQL v23,v24 软删除兼容,后台回写 v25,逐件检查点 v27,原始 SKU v28/v29)

档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统 不保存原文件、文件哈希,不再拆批次表或尝试记录表。

  • 业务键为 (business_date, order_number, stall, spec_key)。一个顺运宝商品明细数量大于 1 时,Excel 按件生成的不同入库码按源行顺序使用英文逗号连接,作为该业务记录唯一的 inner_code 目标值;单个码不得包含逗号,聚合值不得超过 128 个字符。
  • 同一业务日期的聚合 inner_code 另设唯一约束;导入事务还会拆分并核对同日现有聚合值, 防止其中任一单件码被分配给另一个业务键。
  • spec_raw 永远保留 Excel 原文,spec_key 只用于确定性匹配; source_duplicate_count 保存合并前的 Excel 源行数,只用于展示和审计,不阻止确定性匹配。
  • stock_id/detail_id 和 syb_*、purchase_*、remote_inner_code 保存最近一次远端 规划快照,不替代 syb_orders,也不修改采购数据。
  • 状态固定为 pending/ready/queued/applying/updated/already_filled/skipped/failed/needs_check。 queued 只表示尚未发送远端请求,applying 才表示已经进入逐条远端处理。重复导入可以 重置尚未完成的记录,但不得覆盖 queued、updated、already_filled、applying 或 needs_check 的远端结果和审计状态。
  • created_by_user_id 记录首次导入账号,applied_by_user_id 记录实际回写账号;时间字段 使用 UTC ISO 8601。用户只停用不物理删除,因此外键不会阻塞账号生命周期。
  • v24 增加 deleted_at、deleted_by_user_id。它们继续保留给 2026-08-28 以前已经软删除的 历史记录;正常列表、统计、匹配和新的回写入口仍只读取未删除记录,不迁移或清理存量。
  • #318 起,页面删除改为事务化物理删除。除 applying 外的可见状态均可删除;事务先核对 全部稳定 ID,再执行带 status<>'applying' 条件的参数化 DELETE 并核对影响行数。任一 记录不存在、状态并发变化或已经进入 applying 时整批回滚。queued 尚未被领取时可删除, 已被领取时会因变为 applying 而拒绝。
  • 物理删除不会撤销顺运宝已经写入的快递单号,也不保留本地匹配、回写或删除审计;重新导入 相同业务键时按新增记录处理。历史软删除记录仍沿用兼容恢复:未完成状态恢复为 pending 并清空旧规划,已完成或结果不确定状态保留远端信息,入库码冲突时拒绝恢复。
  • 同日新版 Excel 可能因商品增删或排序变化重新分配入库码。每次导入仍会核对同日完整旧快照; 只有历史旧快照非空,并且每条记录都已软删除、状态严格为 pending,且不存在匹配、规划、 排队、回写或远端结果字段时,才自动物理删除整日旧快照后导入新版。任一旧记录不满足条件时 不物理删除,继续原有唯一归属检查和幂等 upsert;锁定期间数量变化则整批回滚。
  • v25 增加 apply_batch_id、apply_queued_at 和批次状态索引。批次仍记录在同一业务表, 不新增批次表:提交时所选 ready 记录必须在同一事务内全部转为 queued;后台每次最多 读取 20 条并逐条领取为 applying。页面按 apply_batch_id 聚合当前批次进度。Admin 重启时,queued 恢复为 ready 且不会自动写远端,applying 转为 needs_check。
  • v27 增加 remote_items_json。inner_code 继续保留英文逗号连接的本地聚合值,但远端 不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。 JSON 数组按单件码保存 detail_id/source/title/status 检查点;新增或写入结果未知时立即 转为 needs_check,重启后只读扫描整张货运单,不自动重发请求。
  • #289 复用同一个 JSON 字段保存另一种远端形态:当聚合记录的多个单件码恰好对应多条 已存在、数量均为 1 且规格和 SKU 身份完全相同的空白商品明细时,source 记为 existing_matched,按单件码顺序保存每个现成 detail_id。该规划在 ready 阶段已经 持久化,排队和回写前必须复读全部绑定明细;它不创建占位明细,也不改变数据库结构。
  • v28 增加可空 source_sku_raw VARCHAR(500)。数据库保留 Excel 单元格原文;匹配时仅 去除两端空白后,与规格候选的 sku 或 variationSku 完全相等比较。唯一命中时它是 高优先级确定性证据;重复命中或与档口货号唯一证据冲突时停止自动选择。缺失或零命中 继续使用原档口货号规则,不做去 #、包含匹配或短货号猜测。

18. purchase_spec_resolutions 采购运行时规格解析(MySQL v26)

Client 在同一次采购中选中颜色后,如果任务尺码和 PDD 页面尺码无法精确对应,就通过 Client 接口契约 §7.1 提交当前可购买候选。本表用 一条记录同时保存候选观察、幂等身份和最终决策,不拆观察表、决策表或模型响应表。

CREATE TABLE purchase_spec_resolutions (
    resolution_id          VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
    task_id                VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    attempt_id             VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    client_id              VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    task_version           BIGINT NOT NULL CHECK (task_version > 0),
    pdd_goods_id           VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
    original_options_json  JSON NOT NULL,
    selected_color         VARCHAR(191) NOT NULL,
    target_size            VARCHAR(191) NOT NULL,
    candidates_json        JSON NOT NULL,
    candidate_snapshot_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL,
    request_hash           CHAR(64) COLLATE utf8mb4_bin NOT NULL,
    observed_at            VARCHAR(35) NOT NULL,

    outcome                VARCHAR(16) NOT NULL DEFAULT 'pending',
    decision_source        VARCHAR(16),
    chosen_candidate_id    VARCHAR(16) COLLATE utf8mb4_bin,
    resolved_options_json  JSON,
    confidence_bps         INT,
    reason                 VARCHAR(500),
    provider_id            VARCHAR(191) COLLATE utf8mb4_bin,
    source_model           VARCHAR(191),
    config_fingerprint     CHAR(64) COLLATE utf8mb4_bin,
    rules_version          VARCHAR(32),
    prompt_version         VARCHAR(32),
    created_at             VARCHAR(35) NOT NULL,
    decided_at             VARCHAR(35),

    UNIQUE KEY uq_purchase_spec_resolution_identity
        (task_id, attempt_id, candidate_snapshot_hash),
    KEY idx_purchase_spec_resolution_task
        (task_id, created_at DESC, resolution_id),
    KEY idx_purchase_spec_resolution_outcome
        (outcome, created_at, resolution_id),
    CONSTRAINT chk_purchase_spec_resolution_options CHECK (
        JSON_TYPE(original_options_json) = 'OBJECT'
        AND JSON_LENGTH(original_options_json) BETWEEN 1 AND 16
        AND JSON_TYPE(candidates_json) = 'ARRAY'
        AND JSON_LENGTH(candidates_json) BETWEEN 1 AND 100
        AND (resolved_options_json IS NULL
             OR JSON_TYPE(resolved_options_json) = 'OBJECT')),
    CONSTRAINT chk_purchase_spec_resolution_outcome CHECK (
        outcome IN ('pending', 'matched', 'uncertain', 'rejected', 'failed')),
    CONSTRAINT chk_purchase_spec_resolution_source CHECK (
        decision_source IS NULL OR decision_source IN ('rule', 'ai', 'reused')),
    CONSTRAINT chk_purchase_spec_resolution_confidence CHECK (
        confidence_bps IS NULL OR confidence_bps BETWEEN 0 AND 10000),
    CONSTRAINT chk_purchase_spec_resolution_decision CHECK (
        (outcome = 'pending' AND decided_at IS NULL
         AND decision_source IS NULL AND chosen_candidate_id IS NULL
         AND resolved_options_json IS NULL)
        OR (outcome = 'matched' AND decided_at IS NOT NULL
            AND decision_source IS NOT NULL AND chosen_candidate_id IS NOT NULL
            AND resolved_options_json IS NOT NULL AND reason IS NOT NULL)
        OR (outcome IN ('uncertain', 'rejected', 'failed')
            AND decided_at IS NOT NULL AND chosen_candidate_id IS NULL
            AND resolved_options_json IS NULL AND reason IS NOT NULL))
);
  • (task_id, attempt_id, candidate_snapshot_hash) 是业务唯一身份;request_hash 用于发现 同一身份改了目标尺码、候选原文或其他内容。HTTP 的 idempotency_keys 仍保存可安全 重放的完整响应,两层幂等不能互相替代。
  • pending 是内部恢复状态,不返回给 Client。先用短事务写候选观察,规则或 AI 调用在 事务外执行;最终 outcome 与 idempotency_keys.response_body 在另一个短事务一起提交。 崩溃留下 pending 时,相同请求读取原记录继续恢复,不能新建第二条。
  • matched 必须有来源、候选短编号、原始 options、原因和决定时间;候选必须逐字来自 candidates_json。uncertain/rejected/failed 不得填写候选和 resolved options,避免 Client 把不确定结果当作可点击规格。
  • confidence_bps 可空。NULL 表示规则或复用路径没有可比较分数,不得转换成 0;AI 分数使用 0~10000 基点。服务商、模型、配置指纹和规则/提示版本都是非敏感快照, 表中没有 API Key、完整提示词或完整模型响应。
  • 本表故意不设指向 tasks 或 clients 的外键。现有页面允许硬删除任务和临时 Client 清单记录,规格决策审计不能因此阻塞原流程或被级联删除;Repository 查询始终使用稳定 ID,不把缺少主表记录解释成新的业务状态。
  • 本表不参与任务状态机,不更新 tasks.status/result_data,也不写 pdd_products.skus_json 或长期 spec_mappings。它只回答这一执行尝试在这一候选快照下 可以安全选择哪个原始候选。
  • original_options_json、candidates_json 和 resolved_options_json 只保存接口定义的 颜色/尺码字符串。不得保存原始无障碍 XML、截图、订单号、收货信息、Cookie、Token、 密码或 API Key;时间统一保存为 UTC ISO 8601。

19. 商品级颜色映射与审计(MySQL v30)

product_color_mappings 保存一个蝦皮商品颜色到当前 PDD 商品颜色维度值的人工映射。 主键是 (shopee_goods_id, shopee_color_key, pdd_goods_id):PDD 商品更换后,旧商品映射 仍留库备查,但当前页面和采购链路只读取当前关联商品的映射,避免静默套用。

  • shopee_color_key 只折叠首尾和连续空白,不做同义词、繁简或大小写猜测; shopee_color_raw 保留用于人工核对的来源文字。
  • PDD 颜色维度只能由采集结果中唯一的显式颜色维度确定,目标值只能来自当前 available=true 的完整规格组合。候选价格区间以整数分聚合。
  • context_version 同时包含 PDD 关联、PDD 采集快照、蝦皮颜色集合、当前映射快照和规则版本;保存任一行前 都要在事务中重算,批次内任何上下文过期都会整体拒绝。
  • 当前映射的有效性由最新候选动态判断。PDD 重采集后目标消失时保留旧映射并显示失效, 不自动改成另一个相似颜色。
  • 本表只建立颜色层的人工关系,不直接表示可采购。采购仍必须得到唯一、完整且当前可购买的 spec_mappings 规格组合。

product_color_mapping_audits 是只追加审计表,记录 upsert/clear 的旧值、新值、操作账号、 上下文版本和时间。清除操作只删除当前态,不删除审计;两张表都不保存提示词、模型响应或凭据。

颜色映射辅助完整规格时不新增第三张派生表。唯一结果继续写入既有 spec_mappings: source='rule'、source_version='color-spec-v1',source_reason 记录颜色映射依据, context_version 覆盖顺运宝规格、当前 PDD 快照、颜色目标和规则版本;同时向既有 spec_mapping_decisions 追加成功决策。重复处理同一上下文不重复更新或追加审计。

只有本规则自己生成的映射会在颜色依据缺失、目标失效或剩余维度不唯一时被移除;人工映射 和其他来源的有效完整映射不覆盖、不删除。处理阶段和采购创建仍只认当前 PDD 商品下存在且 选项仍可购买的完整 spec_mappings,颜色表本身不提供可采购状态。