2026-08-06 15:49:04 +08:00
|
|
|
|
# 03 Admin 数据模型
|
|
|
|
|
|
|
|
|
|
|
|
- 文档状态:基线草案,待数据评审
|
2026-08-10 01:22:22 +08:00
|
|
|
|
- 生产数据库:MySQL 8.4(驱动 `go-sql-driver/mysql` v1.9.2)
|
|
|
|
|
|
- 位置:与 Admin 同机的 `127.0.0.1:3307`,使用独立数据库和最小权限账号
|
|
|
|
|
|
- 历史数据库:`data/admin.db` 只作为 SQLite → MySQL 单向迁移来源和回退备份
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
|
|
|
|
|
|
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
|
|
|
|
|
|
|
|
|
|
|
|
## 1. 设计原则
|
|
|
|
|
|
|
|
|
|
|
|
- `[必须]` 金额一律整数,字段名带单位后缀(`_cent`),**禁止 float**。
|
|
|
|
|
|
- `[必须]` 台币和人民币**分开存、不互相换算覆盖**。
|
|
|
|
|
|
- `[必须]` 时间存带时区 ISO 8601 的 UTC 字符串,页面上转本地时区显示。
|
|
|
|
|
|
- `[必须]` 外部来的原始数据(规格原文、货运单 JSON、采集结果 JSON)**原样保留**,
|
|
|
|
|
|
规范化字段用于查询和显示。解析失败留空,不要猜。
|
|
|
|
|
|
- `[必须]` **人工维护的字段不得被导入覆盖**(详见 §3.3)。
|
|
|
|
|
|
- `[必须]` SQL 一律参数化查询。
|
|
|
|
|
|
|
|
|
|
|
|
## 2. 建库与迁移
|
|
|
|
|
|
|
2026-08-10 01:22:22 +08:00
|
|
|
|
生产 MySQL 使用 `schema_migrations` 记录追加版本。MySQL DDL 会隐式提交,
|
|
|
|
|
|
因此每条 DDL 必须可重放;整版全部执行、通过表和关键列自检后才记录版本。
|
|
|
|
|
|
连接会话固定 UTC,字符集固定 `utf8mb4`,业务 ID 使用二进制排序规则保持大小写精确。
|
|
|
|
|
|
|
|
|
|
|
|
下面的 PRAGMA 和 `user_version` 章节是历史 SQLite 迁移规则,只供单向迁移工具维护,
|
|
|
|
|
|
不得重新接入生产运行时。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
### 2.1 PRAGMA 必须写在 DSN 里
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[必须]` 三条设置通过**连接串**传入,**不要**用 `db.Exec("PRAGMA ...")`:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
dsn := "file:" + path +
|
|
|
|
|
|
"?_pragma=busy_timeout(5000)" +
|
|
|
|
|
|
"&_pragma=journal_mode(WAL)" +
|
|
|
|
|
|
"&_pragma=foreign_keys(1)"
|
|
|
|
|
|
db, _ := sql.Open("sqlite", dsn)
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
| 设置 | 作用 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `busy_timeout(5000)` | 拿不到锁时最多等 5 秒,而不是立刻报错 |
|
|
|
|
|
|
| `journal_mode(WAL)` | 读和写可以同时进行,不互相锁死 |
|
|
|
|
|
|
| `foreign_keys(1)` | 打开外键约束(SQLite 默认是**关**的) |
|
2026-08-06 17:04:49 +08:00
|
|
|
|
| `_txlock=immediate` | 事务一开始就拿写锁,见下 |
|
2026-08-06 16:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
**为什么不能用 `db.Exec`:** Go 的 `database/sql` 是一个**连接池**。
|
|
|
|
|
|
`db.Exec("PRAGMA busy_timeout=5000")` 只作用于当时拿到的那一条连接,
|
|
|
|
|
|
池子后来新开的连接**完全没执行过**这些 PRAGMA。
|
|
|
|
|
|
并发写的时候,没有 `busy_timeout` 的那些连接会直接报
|
|
|
|
|
|
`database is locked (SQLITE_BUSY)`,而不是等锁释放。
|
|
|
|
|
|
|
|
|
|
|
|
这个坑在开发时不容易发现——单线程跑一切正常,一并发就炸。
|
|
|
|
|
|
本项目的并发领取测试就是被它绊倒过一次。
|
|
|
|
|
|
|
2026-08-06 17:04:49 +08:00
|
|
|
|
**为什么必须加 `_txlock=immediate`:** Go 的 `db.Begin()` 默认发的是
|
|
|
|
|
|
`BEGIN DEFERRED`——事务开始时**不拿写锁**,等第一次写才去拿。
|
|
|
|
|
|
于是多个事务能同时开始、各自先读,然后同时想升级成写,互相卡死。
|
|
|
|
|
|
这种情况 `busy_timeout` **救不了**,等下去也不会有结果。
|
|
|
|
|
|
|
|
|
|
|
|
实测(6 个并发事务,每个先读后写):
|
|
|
|
|
|
|
|
|
|
|
|
| DSN | 失败数 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| 默认 deferred | **5 / 6** |
|
|
|
|
|
|
| 加 `_txlock=immediate` | **0 / 6** |
|
|
|
|
|
|
|
|
|
|
|
|
加上之后事务一开始就排队拿锁,拿不到就按 `busy_timeout` 等,这才是要的行为。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[建议]` 同时限制连接数:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
db.SetMaxOpenConns(4)
|
|
|
|
|
|
db.SetMaxIdleConns(4)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、把 `busy_timeout` 耗光。
|
|
|
|
|
|
**不要设成 1**——那样在一个事务里再调用需要连接的代码会死锁。
|
|
|
|
|
|
|
|
|
|
|
|
### 2.2 迁移
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
用 `PRAGMA user_version` 管理顺序迁移。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[必须]` 迁移语句**一条一执行**,不要把多条 SQL 塞进一个字符串——
|
|
|
|
|
|
`database/sql` 的 `Exec` 对"一次执行多条语句"的支持因驱动而异,
|
|
|
|
|
|
拆开最稳妥,报错还能精确到第几条。
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
`[必须]` 升级必须支持从所有已发布版本迁移,**不得在启动时删库重建**。
|
|
|
|
|
|
理由:`data/` 在升级时是保留的(见 [02](02-architecture.md) §6),
|
|
|
|
|
|
里面有人工填了几个月的 PDD 链接和 SKU 映射,删掉就没了。
|
|
|
|
|
|
|
2026-08-06 16:56:18 +08:00
|
|
|
|
`[必须]` 加新版本时**只能往末尾追加** `migrations`,不许改动已有元素——
|
|
|
|
|
|
已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。
|
|
|
|
|
|
|
2026-08-07 12:23:24 +08:00
|
|
|
|
### 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 的理由)。 |
|
2026-08-07 17:13:25 +08:00
|
|
|
|
| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 |
|
2026-08-09 11:49:13 +08:00
|
|
|
|
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
|
2026-08-09 13:36:41 +08:00
|
|
|
|
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
|
2026-08-09 16:02:25 +08:00
|
|
|
|
| v7(#54) | 客户端负责人:新增 `client_user_assignments` 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。 |
|
2026-08-09 18:22:58 +08:00
|
|
|
|
| v8(#59) | 顺运宝同步记录:新增 `syb_sync_runs` 和开始时间倒序索引;未改写 v1–v7。 |
|
2026-08-11 14:39:13 +08:00
|
|
|
|
| v9(#151) | `syb_orders` 增加可空 `shop_name`,从有效 `syb_data.stock.shopName` 回填历史店铺名;无效 JSON、缺字段和空白值保持 `NULL`。 |
|
2026-08-07 12:23:24 +08:00
|
|
|
|
|
2026-08-11 17:00:33 +08:00
|
|
|
|
生产 MySQL 迁移继续独立追加;v12(#165)新增 `task_syb_sources`,并把没有任何
|
|
|
|
|
|
有效采集任务的孤立 `pdd_products.collect_status='collecting'` 回收到 `pending`。
|
2026-08-12 09:21:21 +08:00
|
|
|
|
v13(#172)新增 `task_sequences`,把存量 `tasks.task_id` 按类型和创建时间确定性改为
|
|
|
|
|
|
`cjN` / `cgN`,同步更新领取历史和顺运宝来源外键。历史 SQLite migrations
|
|
|
|
|
|
保持冻结,不追加 v12/v13 生产表结构。
|
2026-08-12 18:48:31 +08:00
|
|
|
|
v14(#196)新增 `syb_allowed_shops` 全局店铺准入表;`syb_sync_runs` 增加
|
|
|
|
|
|
`accepted_stock_count`、`shop_skipped_count` 和 `shop_filter_hash`。旧同步记录回填为
|
|
|
|
|
|
“接受数=原始货运单数、店铺跳过=0”;历史顺运宝货运单不删除。
|
2026-08-13 10:39:21 +08:00
|
|
|
|
v15(#203)把历史 `syb_orders` 中每个蝦皮商品最新的非空店铺和图片,以
|
|
|
|
|
|
`source='syb'` 的字段级低优先级来源补入 `shopee_products`。v16(#204)确定性解析
|
|
|
|
|
|
“颜色,尺码【建议】”格式,并为历史数据补建无真实外部 ID 的低优先级蝦皮 SKU;
|
|
|
|
|
|
不明确格式只保留原文。v17(#205)为蝦皮商品增加 `deleted_at`、
|
|
|
|
|
|
`deleted_by_user_id` 和删除状态索引,删除改为可恢复的软删除。
|
2026-08-13 11:15:54 +08:00
|
|
|
|
v18(#208)新增 `shops` 业务店铺和 `shop_channel_aliases` 渠道名称表,并为
|
|
|
|
|
|
`syb_orders`、`shopee_products` 增加可空 `shop_id`。迁移把 v14 旧准入项一对一
|
|
|
|
|
|
转成业务店铺,同时保留 `syb_allowed_shops` 作为回退依据;历史数据只按渠道原始
|
|
|
|
|
|
名称精确回填,无法确认的记录保持未关联。
|
2026-08-13 11:47:56 +08:00
|
|
|
|
v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。迁移按“任一旧开关停用
|
|
|
|
|
|
则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。
|
2026-08-17 16:53:11 +08:00
|
|
|
|
v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行中的候选观察、
|
|
|
|
|
|
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
|
2026-08-18 15:18:35 +08:00
|
|
|
|
v27(#250)为 `syb_inner_code_records` 增加可空 JSON 字段 `remote_items_json`,保存
|
|
|
|
|
|
多件商品逐个入库码对应的远端明细、来源和动作状态;仍不拆分新的业务表。
|
2026-08-18 15:25:58 +08:00
|
|
|
|
v28(#259)为该表增加可空 `source_sku_raw`,保留 Excel 原始 SKU,供匹配阶段与
|
|
|
|
|
|
顺运宝 `sku/variationSku` 做边界去空白后的精确比较。
|
2026-08-18 15:37:12 +08:00
|
|
|
|
v29(#259)兼容曾由 v28 中间版本按表默认排序规则创建并记录版本的数据库,把
|
|
|
|
|
|
`source_sku_raw` 统一规范为 `utf8mb4_bin`;字段已经正确时迁移不改数据。
|
2026-08-11 17:00:33 +08:00
|
|
|
|
|
2026-08-07 12:23:24 +08:00
|
|
|
|
**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 的教训:
|
|
|
|
|
|
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
|
|
|
|
|
|
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
|
|
|
|
|
|
|
2026-08-10 12:24:23 +08:00
|
|
|
|
MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为 `syb_orders`
|
|
|
|
|
|
增加 `spec_key`,为 `shopee_products` 增加 `source`。迁移使用稳定 `syb_id`
|
|
|
|
|
|
游标分批回填,先完整备份旧 `sku_mappings` 为 `sku_mappings_v3_backup`,
|
|
|
|
|
|
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
|
|
|
|
|
|
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
|
|
|
|
|
|
|
2026-08-10 15:11:38 +08:00
|
|
|
|
MySQL 迁移 v5(工单 #98)为 `tasks` 追加 `execution_mode`、`live_confirmed_by`
|
|
|
|
|
|
和 `live_confirmed_at`,并增加模式/审计 CHECK 与领取索引。旧任务通过数据库默认值
|
|
|
|
|
|
统一成为 `dry_run`;每条 DDL 可重放,列、约束和索引全部通过自检后才记录 v5。
|
|
|
|
|
|
|
2026-08-10 23:10:28 +08:00
|
|
|
|
MySQL 迁移 v6(工单 #127)为 `tasks` 追加可空的 `created_by_user_id` 和创建人列表
|
|
|
|
|
|
索引。存量记录不回填,NULL 明确表示“历史任务”;新建采集和采购任务必须写当前
|
|
|
|
|
|
网页登录用户。迁移与自检可重放,历史 SQLite migrations 保持冻结。
|
|
|
|
|
|
|
2026-08-11 10:00:07 +08:00
|
|
|
|
MySQL 迁移 v7(工单 #132)新增 `catalog_import_runs` 批次摘要表,并为蝦皮商品、
|
|
|
|
|
|
蝦皮 SKU 和 PDD 商品补充来源观测时间。`shopee_products.source` 新增 `api` 取值,
|
|
|
|
|
|
`pdd_products` 新增可空来源字段。迁移不改写既有业务数据,每条 DDL 可重放,所有
|
|
|
|
|
|
字段和约束自检通过后才记录 v7。
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
## 3. 蝦皮数据
|
|
|
|
|
|
|
|
|
|
|
|
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
|
|
|
|
|
|
|
|
|
|
|
|
### 3.1 `shopee_products` 商品级
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE shopee_products (
|
|
|
|
|
|
goods_id TEXT PRIMARY KEY, -- 蝦皮「商品ID」
|
|
|
|
|
|
title TEXT NOT NULL, -- 蝦皮「商品名稱」
|
|
|
|
|
|
shopee_status TEXT, -- 蝦皮「商品當前狀態」
|
|
|
|
|
|
main_sku_code TEXT, -- 蝦皮「主商品貨號」
|
2026-08-11 10:00:07 +08:00
|
|
|
|
source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', -- report / syb / api
|
|
|
|
|
|
source_observed_at VARCHAR(35), -- 外部来源观测时间,UTC ISO 8601
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
|
|
|
|
|
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
|
|
|
|
|
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
|
2026-08-13 10:39:21 +08:00
|
|
|
|
deleted_at VARCHAR(35), -- 软删除时间;非空时默认业务查询不可见
|
|
|
|
|
|
deleted_by_user_id VARCHAR(191), -- 执行删除的管理员
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-10 12:24:23 +08:00
|
|
|
|
ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
|
2026-08-11 10:00:07 +08:00
|
|
|
|
CHECK (source IN ('report', 'syb', 'api'));
|
2026-08-10 12:24:23 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
2026-08-13 10:39:21 +08:00
|
|
|
|
CREATE INDEX idx_shopee_products_deleted ON shopee_products(deleted_at, updated_at, goods_id);
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
|
|
|
|
|
|
2026-08-11 10:00:07 +08:00
|
|
|
|
`source='syb'` 表示顺运宝先到、蝦皮报表尚未导入时创建的最小商品骨架;
|
|
|
|
|
|
`source='api'` 表示由商品目录批量接口写入。
|
2026-08-10 12:24:23 +08:00
|
|
|
|
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
|
|
|
|
|
|
导入不得覆盖人工维护的 PDD 关联。
|
|
|
|
|
|
|
2026-08-13 10:39:21 +08:00
|
|
|
|
顺运宝同步只把非空店铺和图片补到空字段,或更新同样来自 `syb` 的字段;人工值和
|
|
|
|
|
|
目录接口值不覆盖。后续目录接口可把 `syb` 低优先级值提升为权威来源。软删除商品
|
|
|
|
|
|
不会因顺运宝同步或目录导入自动恢复,SKU、PDD 关联和历史记录全部保留;只能由
|
|
|
|
|
|
管理员显式恢复。存在 `pending/assigned/claimed` 采集或采购任务时禁止软删除。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
|
|
|
|
|
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
### 3.2 `shopee_skus` SKU 级
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE shopee_skus (
|
|
|
|
|
|
sku_id TEXT PRIMARY KEY, -- 蝦皮「商品規格ID」,实测零重复
|
|
|
|
|
|
goods_id TEXT NOT NULL,
|
|
|
|
|
|
spec_raw TEXT NOT NULL, -- ★ 规格原文,永远保留
|
|
|
|
|
|
color TEXT, -- 解析结果,失败留空
|
|
|
|
|
|
size TEXT,
|
|
|
|
|
|
advice TEXT, -- 建议体重,如「40-50公斤」
|
|
|
|
|
|
parse_ok INTEGER NOT NULL DEFAULT 0, -- 0=解析失败,界面上要标出来
|
|
|
|
|
|
sku_code TEXT, -- 蝦皮「商品選項貨號」
|
|
|
|
|
|
is_manual INTEGER NOT NULL DEFAULT 0, -- 1=人工新增的,导入不得删
|
2026-08-11 10:00:07 +08:00
|
|
|
|
source_observed_at VARCHAR(35), -- 外部来源观测时间,UTC ISO 8601
|
2026-08-06 15:49:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-11 10:16:48 +08:00
|
|
|
|
### 3.3 商品目录接口导入规则
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-11 10:16:48 +08:00
|
|
|
|
蝦皮页面不再上传或解析固定格式 Excel。第三方脚本负责把不同来源文件归一化,
|
|
|
|
|
|
再按 [商品目录接口契约](10-商品目录接入接口.md) 提交结构化商品、SKU、PDD 和关联。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-11 10:16:48 +08:00
|
|
|
|
- `[必须]` 整批只做 upsert,不清空表,也不删除批次中缺席的数据。
|
2026-08-13 10:39:21 +08:00
|
|
|
|
- `[必须]` `spec_raw` 原样保留。商品目录解析结果由上游提交;SYB 只对明确的
|
|
|
|
|
|
“颜色,尺码【建议】”二维格式做确定性解析,未知尺码、额外维度和不完整括号均不猜。
|
2026-08-11 10:16:48 +08:00
|
|
|
|
- `[必须]` 商品更新不覆盖 `pdd_goods_url` / `pdd_goods_id`,SKU 更新不覆盖 `is_manual`。
|
|
|
|
|
|
- `[必须]` 较旧 `observed_at` 不覆盖较新接口数据。
|
|
|
|
|
|
- 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。
|
|
|
|
|
|
- 一个批次在单事务中写入并返回新增、更新、未变化和失败统计。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 4. `pdd_products` 拼多多商品
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE pdd_products (
|
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
|
goods_id TEXT NOT NULL UNIQUE, -- 从 PDD 链接解析
|
|
|
|
|
|
url TEXT NOT NULL, -- 操作员填的链接原文
|
|
|
|
|
|
title TEXT, -- 采集回来,人工核对用
|
2026-08-07 17:13:25 +08:00
|
|
|
|
shop_name TEXT, -- 店铺名;采不到或老数据为 NULL
|
2026-08-07 10:27:39 +08:00
|
|
|
|
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 需求](01-requirements.md) §6.1。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么用软删除**
|
|
|
|
|
|
|
2026-08-10 12:24:23 +08:00
|
|
|
|
`spec_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
|
2026-08-07 10:27:39 +08:00
|
|
|
|
软删除后界面不再显示,但记录和映射都还在。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
|
|
|
|
|
|
`pending`、清空旧采集结果)。不复活的话 `goods_id` 的 UNIQUE 会让插入失败,
|
|
|
|
|
|
操作员会看到一个莫名其妙的错误。
|
|
|
|
|
|
|
|
|
|
|
|
**为什么不存截图**
|
|
|
|
|
|
|
|
|
|
|
|
按已定案的 Artifact 策略([Client 契约](../client/04-admin-api-contract.md) §10 待确认 #3),
|
|
|
|
|
|
客户端**只报本地引用、不上传文件**。截图在客户端那台机器上,Admin 显示不了。
|
|
|
|
|
|
所以存 `artifact_ref`(形如 `client-001:artifacts/PDD-0001/attempt-xxx/`),
|
|
|
|
|
|
告诉操作员去哪台机器的哪个目录捞。
|
|
|
|
|
|
|
2026-08-07 14:42:54 +08:00
|
|
|
|
**采集超时:`collecting` 怎么才不会永久卡死**
|
|
|
|
|
|
|
|
|
|
|
|
客户端离线、崩溃、任务被删——这些都是常态,不是异常。原来的规则是
|
|
|
|
|
|
"只有 `pending` / `failed` 才允许建采集任务",而**只有客户端提交结果**才会
|
|
|
|
|
|
把状态从 `collecting` 改走。于是上面任何一种情况发生后,这个商品的
|
|
|
|
|
|
`collect_status` 就永远卡在 `collecting`,界面上没有任何入口能救回来,
|
|
|
|
|
|
只能改数据库(见 #24)。
|
|
|
|
|
|
|
2026-08-11 17:00:33 +08:00
|
|
|
|
修复:`collecting` 状态超过 `model.CollectStaleAfter`(15 分钟),或已经找不到
|
|
|
|
|
|
`pending/assigned/claimed` 采集任务时,**也允许**重新建采集任务:
|
2026-08-07 14:42:54 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
-- 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')
|
2026-08-11 17:00:33 +08:00
|
|
|
|
OR (collect_status = 'collecting' AND
|
|
|
|
|
|
(updated_at < ? OR NOT EXISTS (有效采集任务))) )
|
2026-08-07 14:42:54 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
|
|
|
|
|
|
15 分钟足够宽裕。宁可短也不要长——采集是**只读**操作,多采一次没有任何副作用,
|
|
|
|
|
|
而卡死的代价是这个商品永久报废、只能改数据库救。定义成常量 `model.CollectStaleAfter`,
|
|
|
|
|
|
不要把 15 分钟当魔数散落在各处判断里。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 超时判定在**读取的这一刻现算**(拼进上面这条 UPDATE 的 WHERE 里),
|
|
|
|
|
|
**不是**后台协程定期扫描把超时的状态改回 `pending`。本项目已经在并发上栽过两次
|
|
|
|
|
|
(PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁),能不引入并发就不引入;
|
|
|
|
|
|
现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。
|
|
|
|
|
|
同理,**没有引入租约(lease)或心跳**——那是被明确移除的设计,
|
|
|
|
|
|
见 [Client 术语表](../client/00-glossary.md)「为什么没有租约和心跳」一节。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 时间比较用**字符串比较** `updated_at < ?`,不解析成 `time.Time` 再比。
|
|
|
|
|
|
这依赖 `model.NowISO()` 永远产出定宽 UTC 格式(如 `2026-08-07T06:10:19Z`):
|
|
|
|
|
|
定宽 + 同一时区 + 补零,字符串的字典序才等于时间先后顺序。
|
|
|
|
|
|
谁把它改成带时区偏移的本地时间(比如 `+08:00`),这个比较会**静默失效**
|
|
|
|
|
|
——不会报错,但超时判断全错,见 `model.NowISO` 的注释。
|
|
|
|
|
|
|
|
|
|
|
|
**已知取舍:可能采两次**
|
|
|
|
|
|
|
|
|
|
|
|
超时后允许重新建任务,但**老任务还留在队列里**,客户端上线后可能把新旧两个
|
|
|
|
|
|
任务都领走,同一个商品被采两次。这是可以接受的:
|
|
|
|
|
|
|
|
|
|
|
|
- 采集是只读操作,没有任何副作用;
|
|
|
|
|
|
- 后一次的结果覆盖前一次(`SetCollectResult` 按 `goods_id` 整体覆盖),数据仍然正确。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` **不要看到"可能采两次"就去加锁或加租约"修"它**——那正是被明确移除的设计,
|
|
|
|
|
|
加回来会重新引入本项目已经吃过两次亏的并发复杂度,而这里换来的收益(避免极少数情况下
|
|
|
|
|
|
多采一次)远小于代价。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
### 4.2 `skus_json` 的结构
|
|
|
|
|
|
|
|
|
|
|
|
由 Client 采集后原样提交,Admin **不做转换**:
|
|
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"goods_id": "737116531267",
|
|
|
|
|
|
"title": "【现货】西装外套三件套",
|
2026-08-07 17:13:25 +08:00
|
|
|
|
"shop_name": "XX旗舰店",
|
|
|
|
|
|
"price_granularity": "color",
|
2026-08-07 10:27:39 +08:00
|
|
|
|
"captured_at": "2026-08-07T08:00:00Z",
|
|
|
|
|
|
"dimensions": [
|
|
|
|
|
|
{"key": "color", "name": "颜色分类"},
|
|
|
|
|
|
{"key": "size", "name": "尺码"}
|
|
|
|
|
|
],
|
|
|
|
|
|
"skus": [
|
|
|
|
|
|
{"options": {"color": "黑色", "size": "M"},
|
2026-08-07 17:13:25 +08:00
|
|
|
|
"price_cent": 1256, "list_price_cent": 1990,
|
|
|
|
|
|
"price_observed_at": {"color": "黑色", "size": "M"},
|
|
|
|
|
|
"available": true, "raw_price": "券后¥12.56"},
|
2026-08-07 10:27:39 +08:00
|
|
|
|
{"options": {"color": "白色", "size": "M"},
|
|
|
|
|
|
"price_cent": 1256, "available": false, "raw_price": "¥12.56"}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
每个字段都对应 Admin 的一个实际用途,没有多余的:
|
|
|
|
|
|
|
|
|
|
|
|
| 字段 | Admin 拿它干什么 | 不给会怎样 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `skus[].options` | 匹配弹窗列出规格供人选 | 没东西可选,匹配做不了 |
|
2026-08-12 14:43:18 +08:00
|
|
|
|
| `skus[].price_cent` | 建采购任务时带出单价上限,再乘采购数量生成 `max_price_cent` | 订单总价保护填不了,Client 会拒绝执行 |
|
2026-08-07 10:27:39 +08:00
|
|
|
|
| `skus[].available` | 不给缺货规格建任务 | 白跑一趟,Client 到手机上才发现卖光 |
|
|
|
|
|
|
| `goods_id` / `title` | 核对"采的是不是要的那个商品" | 链接跳转、采错商品时静默存错 |
|
2026-08-07 17:13:25 +08:00
|
|
|
|
| `shop_name` | 核对是否来自目标店铺 | 同标题商品无法区分来源;采不到时允许为空 |
|
2026-08-07 10:27:39 +08:00
|
|
|
|
| `dimensions` | 界面按顺序渲染下拉框 | Go 的 map 无序,不知道该先显示颜色还是尺码 |
|
2026-08-07 17:13:25 +08:00
|
|
|
|
| `price_granularity` | 告诉下游价格是逐 SKU 实测还是按颜色推断 | 下单价格保护会把推断价误当实测价 |
|
|
|
|
|
|
| `price_observed_at` | 标明读取价格时实际选中的规格组合 | 无法区分哪一行是实测价 |
|
2026-08-07 10:27:39 +08:00
|
|
|
|
| `raw_price` | 价格解析出错时对账 | 只有数字,出错了没法查 |
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 几条硬规则:
|
|
|
|
|
|
|
|
|
|
|
|
- **`price_cent` 是整数分**,不是 `12.56` 也不是 `"12.56"`。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。
|
2026-08-07 17:13:25 +08:00
|
|
|
|
- **`price_cent` 是实付价**(如券后价、折后价),不是划线价;可选的划线价放在 `list_price_cent`。
|
|
|
|
|
|
- **`price_granularity` 只能是 `color` 或 `sku`**。`color` 表示同颜色各尺码共用一次采样价;老 Client 不传时继续兼容。
|
|
|
|
|
|
- **`price_observed_at` 必须记录读取价格时实际选中的完整组合**。按颜色采样时,同颜色下只有与它完全相同的那一行是实测,其余是推断。
|
2026-08-07 10:27:39 +08:00
|
|
|
|
- **采不到价格时给 `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` 顺运宝货运单
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE syb_orders (
|
2026-08-09 11:49:13 +08:00
|
|
|
|
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
|
|
|
|
|
|
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
|
2026-08-11 14:39:13 +08:00
|
|
|
|
shop_name TEXT, -- 蝦皮店铺(顺运宝外层 shopName)
|
2026-08-06 15:49:04 +08:00
|
|
|
|
title TEXT, -- 商品标题
|
2026-08-10 12:24:23 +08:00
|
|
|
|
product_spec TEXT, -- 顺运宝规格原文
|
|
|
|
|
|
spec_key VARCHAR(191), -- 规格身份键;空规格保持 NULL
|
2026-08-09 11:49:13 +08:00
|
|
|
|
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
|
2026-08-10 12:24:23 +08:00
|
|
|
|
shopee_sku_id TEXT, -- 历史兼容列;新主链路不读写
|
2026-08-06 15:49:04 +08:00
|
|
|
|
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` 是**台币分**,是蝦皮那边的售价,
|
2026-08-12 14:43:18 +08:00
|
|
|
|
和采购任务的人民币订单总价上限**没有换算关系**,不要互相赋值。
|
2026-08-10 01:22:22 +08:00
|
|
|
|
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
|
2026-08-11 14:39:13 +08:00
|
|
|
|
- `shop_name` 是顺运宝货运单级事实,同一货运单下的多条商品明细会重复保存同一值;
|
|
|
|
|
|
独立列用于列表展示和检索,完整上游值仍保留在 `syb_data.stock.shopName`。
|
2026-08-10 12:24:23 +08:00
|
|
|
|
- `spec_key` 通过 `SpecKey(product_spec)` 计算:去除首尾空白,把连续空白折叠为一个半角空格,
|
|
|
|
|
|
但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
|
|
|
|
|
|
- **处理阶段是派生的,不存字段**:PDD 规格是否已匹配要按
|
|
|
|
|
|
`(shopee_goods_id, spec_key, 当前 pdd_goods_id)` 联查 `spec_mappings`,并确认 `pdd_option_key`
|
|
|
|
|
|
仍存在于最新 `skus_json`。
|
|
|
|
|
|
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
|
2026-08-09 11:49:13 +08:00
|
|
|
|
|
2026-08-11 11:03:18 +08:00
|
|
|
|
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` 只更新来源属于本次
|
2026-08-13 12:14:02 +08:00
|
|
|
|
调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。详情页人工修改
|
|
|
|
|
|
颜色、尺码和建议时,把对应字段来源写为 `manual`;即使人工主动清空建议,补空策略
|
|
|
|
|
|
也不得重新填入。人工编辑现有行不把 `is_manual` 改成 1,后者只表示人工新增整条 SKU。
|
2026-08-11 11:03:18 +08:00
|
|
|
|
|
2026-08-11 11:18:51 +08:00
|
|
|
|
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` 的历史货运单观测图片是两个独立概念。
|
|
|
|
|
|
|
2026-08-11 11:26:40 +08:00
|
|
|
|
MySQL schema v10(工单 #144)是兼容修复:部分数据库曾在 v8 开发中间状态提前记录
|
2026-08-11 11:30:08 +08:00
|
|
|
|
v8/v9,实际缺少 `field_sources`、`field_observed_at` 或 v9 图片/店铺人工标记约束。
|
|
|
|
|
|
v10 幂等重放 v8、v9 的逐项建列、空值回填、约束和唯一索引逻辑,再核对两版完整
|
|
|
|
|
|
形状;只有全部通过才记录 v10。它不删除或改写旧迁移记录,也不清空商品、SKU 或
|
|
|
|
|
|
导入记录。
|
2026-08-11 11:26:40 +08:00
|
|
|
|
|
2026-08-11 14:39:13 +08:00
|
|
|
|
MySQL schema v11(工单 #151)为 `syb_orders` 增加可空 `VARCHAR(500)` 的
|
|
|
|
|
|
`shop_name`,并从合法原始 JSON 的 `$.stock.shopName` 回填历史数据。迁移可重放,
|
2026-08-13 10:39:21 +08:00
|
|
|
|
只填仍为空的列;新同步直接更新该列。v15(#203)在此基础上把非空店铺和图片作为
|
|
|
|
|
|
`syb` 低优先级字段补入蝦皮商品主数据,但仍不得覆盖人工或目录接口字段。
|
2026-08-11 14:39:13 +08:00
|
|
|
|
|
2026-08-11 10:46:11 +08:00
|
|
|
|
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
|
|
|
|
|
|
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
|
2026-08-13 10:39:21 +08:00
|
|
|
|
从 v16 起,同步会把格式明确的规格以 `source='syb'` 写入 `shopee_skus`:内部主键
|
|
|
|
|
|
由商品 ID 和 `spec_key` 确定性生成,真实 `shopee_sku_id` 保持 `NULL`。目录接口后到时
|
|
|
|
|
|
按同一规格身份补上真实 ID,并可替换字段来源为权威来源;人工行永不覆盖。不明确格式
|
|
|
|
|
|
仍只存在于观测读模型中,不创建 SKU。重复同步同一个 `syb_id` 只更新原行,因此不会
|
|
|
|
|
|
重复累计。
|
2026-08-11 10:46:11 +08:00
|
|
|
|
|
2026-08-09 11:49:13 +08:00
|
|
|
|
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
|
|
|
|
|
|
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
|
|
|
|
|
|
`details[].id`,不是货运单本身的 `id`。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` `price_twd_cent` 一律取 `detail/listByStock` 接口的值(元)自己 ×100
|
|
|
|
|
|
转分、先四舍五入再转整数;不要用 `/am/stock/list` 列表接口的金额字段——
|
|
|
|
|
|
同一响应里不同金额字段的单位不统一,见 [08 顺运宝接口](08-顺运宝接口.md) §5.1。
|
|
|
|
|
|
|
|
|
|
|
|
`[建议]` 收件人姓名/电话/地址不入库,`syb_data` 落库前已剔除。
|
|
|
|
|
|
|
|
|
|
|
|
### 5.1 `syb_session` 顺运宝会话缓存、`syb_sync_state` 同步进度(v5)
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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 顺运宝接口](08-顺运宝接口.md) §3.1。
|
|
|
|
|
|
- `syb_sync_state` 只允许一行(`CHECK (id = 1)`),是全局的"上次同步到哪"。
|
|
|
|
|
|
- `[必须]` 增量同步从 `last_synced_at` 对应的**日期当天**重新拉,不是第二天——
|
|
|
|
|
|
`created` 筛选粒度是日期,`last_synced_at` 精确到秒,从第二天拉会漏掉当天
|
|
|
|
|
|
晚些时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏。
|
|
|
|
|
|
- `[必须]` 只有一次同步**全部成功**才更新 `last_synced_at`;中途失败不更新,
|
|
|
|
|
|
否则下次同步会跳过这段区间,漏掉的单永远补不回来。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-09 18:22:58 +08:00
|
|
|
|
### 5.2 `syb_sync_runs` 同步记录(v8)
|
|
|
|
|
|
|
|
|
|
|
|
每次从页面发起同步时,先写一条 `running` 记录,再启动后台任务。记录保存操作
|
|
|
|
|
|
账号、日期范围、开始/完成时间、结果统计、是否推进覆盖游标以及必要的失败原因。
|
|
|
|
|
|
状态只能是 `running`、`succeeded`、`failed`、`interrupted`;Admin 启动时把上次
|
|
|
|
|
|
进程遗留的 `running` 记录改成 `interrupted`。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
|
|
|
|
|
|
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
|
|
|
|
|
|
|
2026-08-12 18:48:31 +08:00
|
|
|
|
v14 起,`stock_count` 继续表示通过完整性校验的**原始货运单数**,避免改变旧字段
|
|
|
|
|
|
语义;`accepted_stock_count` 是店铺准入后最终接受的货运单数,
|
|
|
|
|
|
`shop_skipped_count` 是店铺为空、未启用或明细店铺变化而跳过的货运单数。
|
|
|
|
|
|
`shop_filter_hash` 保存同步开始时启用店铺排序后计算的 SHA-256,只用于判断两次同步
|
|
|
|
|
|
是否使用同一份快照,不保存 Cookie、密码或原始响应。
|
|
|
|
|
|
|
2026-08-13 11:47:56 +08:00
|
|
|
|
### 5.3 店铺(MySQL v18 / v19)
|
2026-08-12 18:48:31 +08:00
|
|
|
|
|
2026-08-13 11:47:56 +08:00
|
|
|
|
`shops` 是唯一店铺配置。`display_name` 是页面中的“店铺名称”,同时用于 SYB 同步
|
|
|
|
|
|
准入和蝦皮商品关联;`enabled` 是唯一启停状态。匹配会去除首尾空白,再使用
|
|
|
|
|
|
`utf8mb4_bin` 精确比较,不做模糊、正则、大小写折叠或 AI 猜测。
|
2026-08-12 18:48:31 +08:00
|
|
|
|
|
2026-08-13 11:47:56 +08:00
|
|
|
|
蝦皮导入和 SYB 写入都保留上游原始店铺名称,并把精确解析出的 `shop_id` 作为派生
|
|
|
|
|
|
关联;未解析成功时 `shop_id` 保持 `NULL`,可从店铺管理的“未登记商品”入口查看。
|
|
|
|
|
|
`shop_channel_aliases` 是 v18 升级兼容表,不再是独立产品概念,也不在页面中编辑;
|
|
|
|
|
|
每个店铺固定保留 `syb`、`shopee` 两条与唯一店铺名称和状态完全相同的派生记录。
|
2026-08-13 11:15:54 +08:00
|
|
|
|
|
2026-08-13 11:47:56 +08:00
|
|
|
|
`[必须]` 没有任何启用店铺时,同步关闭失败,不请求顺运宝、不推进游标。编辑店铺
|
|
|
|
|
|
名称会在一个事务里重建该店铺的历史精确关联。永久删除只允许已经停用且没有任何
|
|
|
|
|
|
货运单或蝦皮商品引用的店铺。
|
2026-08-13 11:15:54 +08:00
|
|
|
|
|
|
|
|
|
|
v14 的 `syb_allowed_shops` 在 v18 后不再作为运行时配置源,但为迁移回退保留,不能
|
2026-08-13 11:47:56 +08:00
|
|
|
|
在 v18 / v19 中直接改名或删除。
|
2026-08-12 18:48:31 +08:00
|
|
|
|
|
2026-08-10 12:24:23 +08:00
|
|
|
|
## 6. `spec_mappings` 顺运宝规格映射
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-10 12:24:23 +08:00
|
|
|
|
"这个蝦皮商品的这条顺运宝规格 = 当前拼多多商品的那个规格",**匹配一次,以后复用**。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
2026-08-10 12:24:23 +08:00
|
|
|
|
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),
|
2026-08-14 10:08:32 +08:00
|
|
|
|
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),
|
2026-08-10 12:24:23 +08:00
|
|
|
|
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)
|
2026-08-06 15:49:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-14 10:08:32 +08:00
|
|
|
|
v21 增加的来源字段描述当前生效映射。人工在详情页保存时,`source` 固定改为 `manual`
|
|
|
|
|
|
并清空 AI 元数据;规则唯一确定的结果使用 `rule`;通过模型和服务端硬门禁的结果使用
|
|
|
|
|
|
`ai`。这些字段不改变映射唯一键。PDD 重新采集后如果 `pdd_option_key` 已不存在,三种
|
|
|
|
|
|
来源都按同一规则判为失效,不能进入采购任务。
|
|
|
|
|
|
|
2026-08-10 12:24:23 +08:00
|
|
|
|
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
|
|
|
|
|
|
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
|
|
|
|
|
|
|
|
|
|
|
|
### 6.1 为什么主键要带上商品和 `pdd_goods_id`
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
|
|
|
|
|
|
|
|
|
|
|
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
|
2026-08-10 12:24:23 +08:00
|
|
|
|
后来 A 下架,换成了 B。如果映射不带 `pdd_goods_id`,那条旧映射还在,
|
2026-08-07 10:27:39 +08:00
|
|
|
|
但它描述的是 **A 的规格**:
|
|
|
|
|
|
|
|
|
|
|
|
| | 后果 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| 运气好 | B 没有"黑色/M码",建任务时找不到会报错,还算安全 |
|
|
|
|
|
|
| **运气坏** | B 恰好也有"黑色/M码",但完全是另一件衣服 → **静默买错,事后查不出来** |
|
|
|
|
|
|
|
|
|
|
|
|
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
2026-08-10 12:24:23 +08:00
|
|
|
|
SELECT ... FROM spec_mappings
|
|
|
|
|
|
WHERE shopee_goods_id = ?
|
|
|
|
|
|
AND spec_key = ?
|
2026-08-07 10:27:39 +08:00
|
|
|
|
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()`:
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
OptionKey(map[string]string{"size": "M", "color": "黑色"})
|
|
|
|
|
|
// -> {"color":"黑色","size":"M"}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
实现直接用 `json.Marshal` —— Go 序列化 map 时**会按键名排序**,正好就是我们要的
|
|
|
|
|
|
规范化,不用自己拼字符串(自己拼容易漏掉值里含分隔符、含引号之类的边界情况)。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` **这个函数只能有一处实现。** 存映射用它算 key,查规格也用它算 key,
|
|
|
|
|
|
两边必须逐字节一致。如果 Client 那边也算一份、或者别处再写一个"差不多"的版本,
|
|
|
|
|
|
只要有一点点不同(空格、转义、键序),映射就会静默对不上。
|
|
|
|
|
|
**Client 只上报 `options` 对象,key 一律由 Admin 这一个函数算。**
|
|
|
|
|
|
|
2026-08-10 12:41:17 +08:00
|
|
|
|
### 6.3 `spec_mapping_decisions` 决策审计
|
|
|
|
|
|
|
|
|
|
|
|
本表只追加、不参与业务判断。每次成功保存映射时,与 `spec_mappings` 在同一事务写入;
|
|
|
|
|
|
没有建议时 `suggested_option_key` 为 NULL 且 `accepted=0`。`accepted=0` 表示未采纳建议或当时无建议。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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)
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
下面的指标只能称为“成功保存决策中的建议采纳率”,不是准确率:
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-14 10:08:32 +08:00
|
|
|
|
### 6.4 `ai_spec_match_decisions` AI 决策审计(MySQL v21)
|
|
|
|
|
|
|
|
|
|
|
|
本表只追加,记录一次 AI 匹配动作使用的上下文版本、规则/提示词版本、服务商和模型快照、
|
|
|
|
|
|
候选短编号到真实选项键的映射、置信度、结论和简短原因。它不保存 API Key、完整提示词、
|
|
|
|
|
|
完整请求/响应、顺运宝订单号、店铺账号、用户资料、地址或 Client 信息。
|
|
|
|
|
|
|
|
|
|
|
|
合格映射和成功决策在同一事务提交。低置信度、伪造候选、模型拒绝、超时和格式错误也
|
|
|
|
|
|
追加脱敏结论,但不写 `spec_mappings`。人工后来覆盖当前映射时不会删除本表历史。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 7. `tasks` 任务
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
采集任务和采购任务共用一张表,用 `task_type` 区分。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE tasks (
|
2026-08-12 09:21:21 +08:00
|
|
|
|
task_id TEXT PRIMARY KEY, -- 稳定业务主键:采集 cjN,采购 cgN
|
2026-08-06 15:49:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-12 09:21:21 +08:00
|
|
|
|
`task_id` 不是另外的展示别名,而是真实主键。采集和采购分别从 1
|
|
|
|
|
|
开始递增,不共用计数器。分配编号与插入任务必须在同一事务中,以避免
|
|
|
|
|
|
并发重号和创建失败消耗序号。Client 应把这个值当作不透明字符串原样保存。
|
|
|
|
|
|
|
2026-08-10 15:11:38 +08:00
|
|
|
|
生产 MySQL v5 在 `tasks` 追加以下安全字段(历史 SQLite 结构不改):
|
|
|
|
|
|
|
|
|
|
|
|
| 字段 | 含义 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `execution_mode` | `dry_run` / `live`,非空且默认 `dry_run`;创建后不可修改 |
|
2026-08-10 18:15:28 +08:00
|
|
|
|
| `live_confirmed_by` | 提交创建真实任务的正常状态 Admin 用户;演练任务必须为空 |
|
|
|
|
|
|
| `live_confirmed_at` | 真实任务创建操作时间;演练任务必须为空 |
|
2026-08-10 15:11:38 +08:00
|
|
|
|
|
|
|
|
|
|
`chk_tasks_execution_mode` 和 `chk_tasks_live_confirmation` 在数据库层阻止非法模式及
|
2026-08-10 18:15:28 +08:00
|
|
|
|
缺少创建审计信息的真实任务;`idx_tasks_claim_mode` 支持按客户端能力领取。字段名保留
|
|
|
|
|
|
历史命名以兼容已发布的 v5 migration。
|
2026-08-10 15:11:38 +08:00
|
|
|
|
|
2026-08-10 23:10:28 +08:00
|
|
|
|
生产 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` 关联显示用户名。
|
|
|
|
|
|
|
2026-08-06 15:49:04 +08:00
|
|
|
|
`[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4:
|
|
|
|
|
|
|
|
|
|
|
|
1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。
|
|
|
|
|
|
2. **采购任务的 `quantity` 和 `max_price_cent` 都必须有值**,
|
|
|
|
|
|
这是价格保护,没有它 Client 会拒绝执行。
|
|
|
|
|
|
|
2026-08-12 14:43:18 +08:00
|
|
|
|
`max_price_cent` 是**人民币订单总价上限(整数分)**。页面默认从 `pdd_data` 对应 SKU 带出单价上限,操作员可改但不能清空;后端在事务内按顺运宝最新数量计算 `单价上限 × quantity` 后写入本字段,并检查整数溢出。历史任务不回写。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
状态含义见 [01 需求](01-requirements.md) §6.2。
|
|
|
|
|
|
|
2026-08-12 09:21:21 +08:00
|
|
|
|
### 7.1 `task_sequences` 任务序列(生产 MySQL v13)
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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)
|
2026-08-11 17:00:33 +08:00
|
|
|
|
|
|
|
|
|
|
从顺运宝创建采集任务时,同一个 PDD 商品可能对应多个货运单明细。来源使用关联表,
|
|
|
|
|
|
不能压进 `tasks.syb_id` 单列;后者仍只表示采购任务自身的顺运宝明细。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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),
|
2026-08-12 09:21:21 +08:00
|
|
|
|
FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
2026-08-11 17:00:33 +08:00
|
|
|
|
FOREIGN KEY (syb_id) REFERENCES syb_orders(syb_id) ON DELETE CASCADE
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- `[必须]` 任务和全部来源在同一事务写入;按 PDD 商品去重后仍要保留每条来源。
|
|
|
|
|
|
- `[必须]` 采集采购关键字搜索通过本表支持顺运宝订单号和明细 ID。
|
|
|
|
|
|
- `[必须]` 删除任务后若同商品已无 `pending/assigned/claimed` 采集任务,必须在同一事务
|
|
|
|
|
|
把仍为 `collecting` 的商品回收到 `pending`;有其他有效任务时不得回收。
|
|
|
|
|
|
- 历史任务没有可靠来源,不做猜测回填,仍可按任务号和 PDD 商品 ID 查询。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 8. `task_claims` 领取历史
|
2026-08-06 17:04:49 +08:00
|
|
|
|
|
|
|
|
|
|
记录"哪台客户端领过哪个任务"。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE task_claims (
|
|
|
|
|
|
task_id TEXT NOT NULL,
|
|
|
|
|
|
client_id TEXT NOT NULL,
|
|
|
|
|
|
claimed_at TEXT NOT NULL,
|
2026-08-12 09:21:21 +08:00
|
|
|
|
PRIMARY KEY (task_id, client_id),
|
|
|
|
|
|
FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE ON UPDATE CASCADE
|
2026-08-06 17:04:49 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE INDEX idx_task_claims_client ON task_claims(client_id);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-12 09:21:21 +08:00
|
|
|
|
生产 MySQL v13 清理了无对应任务的孤儿领取历史,并加入上述外键;
|
|
|
|
|
|
`ON UPDATE CASCADE` 保证主键迁移时领取历史同步改号。
|
|
|
|
|
|
|
2026-08-06 17:04:49 +08:00
|
|
|
|
**为什么需要这张表:** [04 Client 接口实现](04-client-api.md) §4.1 要求
|
|
|
|
|
|
"只有**从未分配给该客户端**的任务才返回 403"。
|
|
|
|
|
|
但 `tasks.assigned_client` 只记**当前**归属,任务一旦重派给别人,
|
|
|
|
|
|
就查不出原来那台领过了——而契约又明确要求
|
|
|
|
|
|
"**已重派仍要接受原客户端提交的结果**"。没有这张表,那条规则根本没法判断。
|
|
|
|
|
|
|
|
|
|
|
|
顺带得到一份审计记录:这个任务被哪几台客户端领过。
|
|
|
|
|
|
|
|
|
|
|
|
`[必须]` 领取成功时写入;提交结果时用它做权限判断。
|
|
|
|
|
|
同一客户端重复领同一任务只更新时间,不报错。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 9. `clients` 客户端
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE clients (
|
|
|
|
|
|
client_id TEXT PRIMARY KEY, -- 序列号,来自 X-Client-Id
|
|
|
|
|
|
name TEXT, -- 客户端上报,可人工改
|
|
|
|
|
|
device_address TEXT,
|
|
|
|
|
|
platform TEXT,
|
|
|
|
|
|
pdd_package TEXT,
|
2026-08-06 17:21:36 +08:00
|
|
|
|
capabilities TEXT, -- 登记或 claim 请求里的 capabilities 原文
|
2026-08-06 15:49:04 +08:00
|
|
|
|
last_seen_at TEXT NOT NULL, -- 每次调接口都刷新
|
|
|
|
|
|
created_at TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- `[必须]` **没有 `status` 字段。** 在线状态是**算出来的**:
|
|
|
|
|
|
`last_seen_at` 在 N 分钟内算在线,否则离线。`[建议]` N 默认 10 分钟。
|
|
|
|
|
|
存成字段会和真实情况不同步。
|
2026-08-06 17:21:36 +08:00
|
|
|
|
- `[必须]` 设置页使用独立登记接口幂等新增或更新 Client,claim 保留隐式登记作为兼容兜底。
|
|
|
|
|
|
- `[必须]` 不设心跳接口;登记、claim、result 和 failure 都刷新 `last_seen_at`,见 [04](04-client-api.md) §1.1、§3。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 10. 数据关系总览
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
shopee_products ──1:N──→ shopee_skus
|
2026-08-10 12:24:23 +08:00
|
|
|
|
│
|
|
|
|
|
|
│ pdd_goods_id(当前 PDD 商品,可换)
|
|
|
|
|
|
↓
|
|
|
|
|
|
pdd_products ←─pdd_goods_id─ spec_mappings
|
|
|
|
|
|
↑
|
|
|
|
|
|
syb_orders ─(shopee_goods_id, spec_key)─┘
|
2026-08-07 10:27:39 +08:00
|
|
|
|
(skus_json 里是所有规格和价格)
|
|
|
|
|
|
|
|
|
|
|
|
syb_orders ──创建──→ tasks ──分配──→ clients
|
2026-08-17 16:53:11 +08:00
|
|
|
|
└──1:N──→ purchase_spec_resolutions
|
|
|
|
|
|
users(采购员)──1:N 当前归属──────────→ clients
|
2026-08-06 15:49:04 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
两条关联都可以变,这是有意的:
|
|
|
|
|
|
|
|
|
|
|
|
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
|
2026-08-10 12:24:23 +08:00
|
|
|
|
- `spec_mappings` 按 `(蝦皮商品, 顺运宝规格键, PDD商品)` 存:换了商品自然查不到旧映射
|
2026-08-07 10:27:39 +08:00
|
|
|
|
|
2026-08-12 15:13:08 +08:00
|
|
|
|
`syb_orders` 不保存 PDD 商品 ID。新货运单通过 `shopee_goods_id` 动态读取
|
|
|
|
|
|
`shopee_products.pdd_goods_id`;只要目标 `pdd_products` 未软删除,就自动复用当前
|
|
|
|
|
|
关联。这样换绑后所有货运单立即看到新关系,也不会留下需要同步更新的第二份 PDD 字段。
|
|
|
|
|
|
|
2026-08-07 10:27:39 +08:00
|
|
|
|
## 11. 与 Client 数据模型的关系
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
2026-08-10 01:22:22 +08:00
|
|
|
|
Admin 使用集中 MySQL 8.4,Client 仍使用各自的本地 SQLite;两者只通过接口交换数据。
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
| 概念 | Admin 这边 | Client 那边 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
|
|
|
|
|
|
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
|
2026-08-07 10:27:39 +08:00
|
|
|
|
| 采集结果 | `pdd_products.skus_json` | `pdd_tasks.pdd_data` |
|
2026-08-17 16:53:11 +08:00
|
|
|
|
| 运行时规格解析 | `purchase_spec_resolutions` 候选观察与最终决策 | #257 在本地保存请求、幂等键和响应;原任务 payload 不覆盖 |
|
2026-08-06 15:49:04 +08:00
|
|
|
|
|
|
|
|
|
|
`[必须]` **两边的状态是两套,不要试图同步。**
|
|
|
|
|
|
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
|
2026-08-09 13:12:03 +08:00
|
|
|
|
|
2026-08-09 13:36:41 +08:00
|
|
|
|
## 12. Admin 用户和 Web Session(v6)
|
2026-08-09 13:12:03 +08:00
|
|
|
|
|
|
|
|
|
|
`users` 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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`,保留任务和操作记录的可追溯性。
|
2026-08-10 01:22:22 +08:00
|
|
|
|
- 首次初始化必须在 InnoDB 写事务中锁定初始化保护记录并再次确认 `users` 为空,
|
2026-08-09 13:12:03 +08:00
|
|
|
|
并发提交最多一个成功。即使所有账号都被禁用,初始化入口也不能重新开放。
|
|
|
|
|
|
- 系统不能禁用最后一个有效管理员。
|
|
|
|
|
|
|
|
|
|
|
|
`web_sessions` 保存网页登录状态。数据库只保存随机 Session Token 的 SHA-256 哈希,
|
|
|
|
|
|
Cookie 原文不能进入数据库、日志或页面。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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 可以在登录、退出或定期维护时清理,不需要后台常驻线程。
|
2026-08-09 16:02:25 +08:00
|
|
|
|
|
|
|
|
|
|
## 13. `client_user_assignments` 客户端负责人历史(v7)
|
|
|
|
|
|
|
|
|
|
|
|
`clients` 是执行任务的软件实例,`users` 是登录 Admin 的人,两者生命周期不同,
|
|
|
|
|
|
因此保持两张独立实体表,用归属历史表连接,不能合并字段。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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`。
|
2026-08-11 10:00:07 +08:00
|
|
|
|
|
|
|
|
|
|
## 14. `catalog_import_runs` 商品目录批次摘要(MySQL v7)
|
|
|
|
|
|
|
|
|
|
|
|
`catalog_import_runs` 以 `(source, batch_id)` 为主键,保存请求哈希、处理状态、
|
|
|
|
|
|
新增/更新/冲突计数和错误摘要。它不保存 Bearer Token,也不保存完整请求体。
|
|
|
|
|
|
|
|
|
|
|
|
- 相同来源、批次号和请求哈希只处理一次;重复提交返回首次结果。
|
|
|
|
|
|
- 相同来源和批次号携带不同请求哈希时记录冲突并返回 HTTP 409。
|
|
|
|
|
|
- `response_body` 只保存可安全重放的结果摘要,不得包含凭据或原始商品数据。
|
|
|
|
|
|
- `source_observed_at` 用于拒绝较旧数据覆盖较新数据;缺席记录绝不代表删除。
|
2026-08-14 09:49:11 +08:00
|
|
|
|
|
|
|
|
|
|
## 15. AI 服务商配置(MySQL v20)
|
|
|
|
|
|
|
2026-08-14 11:15:50 +08:00
|
|
|
|
`ai_provider_configs` 只保存非敏感配置:服务商名称、HTTP/HTTPS Base URL、模型、超时、最大
|
2026-08-14 09:49:11 +08:00
|
|
|
|
并发、自动写入置信度基点、启用状态和最近连接测试摘要。`active_slot` 是生成列并带唯一
|
|
|
|
|
|
索引,数据库层保证任一时刻最多一个启用项。普通配置或密钥发生变化时,当前服务商会
|
|
|
|
|
|
停用并把测试状态重置为 `pending`,必须重新测试后才能启用。
|
|
|
|
|
|
|
|
|
|
|
|
`ai_provider_audits` 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段
|
|
|
|
|
|
名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的
|
|
|
|
|
|
独立密钥文件。
|
2026-08-14 10:30:08 +08:00
|
|
|
|
|
|
|
|
|
|
## 16. AI 规格匹配批次(MySQL v22)
|
|
|
|
|
|
|
|
|
|
|
|
`ai_match_batches` 保存一次批量操作的创建人、状态、计数以及创建时的非敏感服务商配置
|
|
|
|
|
|
快照。状态为 `queued/running/partial/succeeded/failed/interrupted`,单批最多 100 条。
|
|
|
|
|
|
快照包含服务商名称、Base URL、模型、超时、并发、阈值和配置指纹,但绝不包含 API Key。
|
|
|
|
|
|
|
|
|
|
|
|
`ai_match_batch_items` 每条对应一个顺运宝明细,保存上下文版本、同批去重身份、负责人明细、
|
|
|
|
|
|
处理状态、结果来源、选项键、置信度和简短原因。`(batch_id, syb_id)` 唯一,保证一条明细在
|
|
|
|
|
|
同一批次内只出现一次。相同身份只有负责人调用规则/模型,跟随项读取已保存的共享映射并记为
|
|
|
|
|
|
复用;上下文变化时记为 `stale`,不得套用旧结果。
|
|
|
|
|
|
|
|
|
|
|
|
批次记录用于进度、故障恢复和操作审计;具体模型决策证据仍只追加到
|
|
|
|
|
|
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
|
|
|
|
|
成功写入的 `spec_mappings` 不回滚。
|
2026-08-15 08:47:22 +08:00
|
|
|
|
|
2026-08-18 15:37:12 +08:00
|
|
|
|
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25,逐件检查点 v27,原始 SKU v28/v29)
|
2026-08-15 08:47:22 +08:00
|
|
|
|
|
|
|
|
|
|
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
|
|
|
|
|
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
|
|
|
|
|
|
2026-08-15 14:27:31 +08:00
|
|
|
|
- 业务键为 `(business_date, order_number, stall, spec_key)`。一个顺运宝商品明细数量大于 1
|
|
|
|
|
|
时,Excel 按件生成的不同入库码按源行顺序使用英文逗号连接,作为该业务记录唯一的
|
|
|
|
|
|
`inner_code` 目标值;单个码不得包含逗号,聚合值不得超过 128 个字符。
|
|
|
|
|
|
- 同一业务日期的聚合 `inner_code` 另设唯一约束;导入事务还会拆分并核对同日现有聚合值,
|
|
|
|
|
|
防止其中任一单件码被分配给另一个业务键。
|
2026-08-15 08:47:22 +08:00
|
|
|
|
- `spec_raw` 永远保留 Excel 原文,`spec_key` 只用于确定性匹配;
|
2026-08-15 14:27:31 +08:00
|
|
|
|
`source_duplicate_count` 保存合并前的 Excel 源行数,只用于展示和审计,不阻止确定性匹配。
|
2026-08-15 08:47:22 +08:00
|
|
|
|
- `stock_id/detail_id` 和 `syb_*`、`purchase_*`、`remote_inner_code` 保存最近一次远端
|
|
|
|
|
|
规划快照,不替代 `syb_orders`,也不修改采购数据。
|
2026-08-15 17:08:17 +08:00
|
|
|
|
- 状态固定为 `pending/ready/queued/applying/updated/already_filled/skipped/failed/needs_check`。
|
|
|
|
|
|
`queued` 只表示尚未发送远端请求,`applying` 才表示已经进入逐条远端处理。重复导入可以
|
|
|
|
|
|
重置尚未完成的记录,但不得覆盖 `queued`、`updated`、`already_filled`、`applying` 或
|
|
|
|
|
|
`needs_check` 的远端结果和审计状态。
|
2026-08-15 08:47:22 +08:00
|
|
|
|
- `created_by_user_id` 记录首次导入账号,`applied_by_user_id` 记录实际回写账号;时间字段
|
|
|
|
|
|
使用 UTC ISO 8601。用户只停用不物理删除,因此外键不会阻塞账号生命周期。
|
2026-08-15 10:49:28 +08:00
|
|
|
|
- v24 增加 `deleted_at`、`deleted_by_user_id`。正常列表、统计、匹配和新的回写入口只读取
|
|
|
|
|
|
未删除记录;删除不改变业务状态,也不撤销顺运宝远端值。在途 `applying` 的最终结果仍
|
|
|
|
|
|
写回同一条隐藏记录,保证远端结果审计不会丢失。
|
|
|
|
|
|
- 重新导入同一业务键会恢复软删除记录:`pending/ready/skipped/failed` 重置为
|
2026-08-15 17:08:17 +08:00
|
|
|
|
`pending` 并清空旧规划;`queued/applying/updated/already_filled/needs_check` 保留状态和远端
|
2026-08-15 10:49:28 +08:00
|
|
|
|
审计。后一组状态若导入的 `inner_code` 已变化则拒绝恢复,交由人工核对。
|
2026-08-15 17:08:17 +08:00
|
|
|
|
- v25 增加 `apply_batch_id`、`apply_queued_at` 和批次状态索引。批次仍记录在同一业务表,
|
|
|
|
|
|
不新增批次表:提交时所选 `ready` 记录必须在同一事务内全部转为 `queued`;后台每次最多
|
|
|
|
|
|
读取 20 条并逐条领取为 `applying`。页面按 `apply_batch_id` 聚合当前批次进度。Admin
|
|
|
|
|
|
重启时,`queued` 恢复为 `ready` 且不会自动写远端,`applying` 转为 `needs_check`。
|
2026-08-18 15:18:35 +08:00
|
|
|
|
- v27 增加 `remote_items_json`。`inner_code` 继续保留英文逗号连接的本地聚合值,但远端
|
|
|
|
|
|
不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。
|
|
|
|
|
|
JSON 数组按单件码保存 `detail_id/source/title/status` 检查点;新增或写入结果未知时立即
|
|
|
|
|
|
转为 `needs_check`,重启后只读扫描整张货运单,不自动重发请求。
|
2026-08-22 11:46:23 +08:00
|
|
|
|
- #289 复用同一个 JSON 字段保存另一种远端形态:当聚合记录的多个单件码恰好对应多条
|
|
|
|
|
|
已存在、数量均为 1 且规格和 SKU 身份完全相同的空白商品明细时,`source` 记为
|
|
|
|
|
|
`existing_matched`,按单件码顺序保存每个现成 `detail_id`。该规划在 `ready` 阶段已经
|
|
|
|
|
|
持久化,排队和回写前必须复读全部绑定明细;它不创建占位明细,也不改变数据库结构。
|
2026-08-18 15:25:58 +08:00
|
|
|
|
- v28 增加可空 `source_sku_raw VARCHAR(500)`。数据库保留 Excel 单元格原文;匹配时仅
|
|
|
|
|
|
去除两端空白后,与规格候选的 `sku` 或 `variationSku` 完全相等比较。唯一命中时它是
|
|
|
|
|
|
高优先级确定性证据;重复命中或与档口货号唯一证据冲突时停止自动选择。缺失或零命中
|
|
|
|
|
|
继续使用原档口货号规则,不做去 `#`、包含匹配或短货号猜测。
|
2026-08-17 16:53:11 +08:00
|
|
|
|
|
|
|
|
|
|
## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26)
|
|
|
|
|
|
|
|
|
|
|
|
Client 在同一次采购中选中颜色后,如果任务尺码和 PDD 页面尺码无法精确对应,就通过
|
|
|
|
|
|
[Client 接口契约](../client/04-admin-api-contract.md) §7.1 提交当前可购买候选。本表用
|
|
|
|
|
|
**一条记录**同时保存候选观察、幂等身份和最终决策,不拆观察表、决策表或模型响应表。
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
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。
|
2026-08-23 23:55:33 +08:00
|
|
|
|
|
|
|
|
|
|
## 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` 的旧值、新值、操作账号、
|
|
|
|
|
|
上下文版本和时间。清除操作只删除当前态,不删除审计;两张表都不保存提示词、模型响应或凭据。
|
2026-08-24 00:12:36 +08:00
|
|
|
|
|
|
|
|
|
|
颜色映射辅助完整规格时不新增第三张派生表。唯一结果继续写入既有 `spec_mappings`:
|
|
|
|
|
|
`source='rule'`、`source_version='color-spec-v1'`,`source_reason` 记录颜色映射依据,
|
|
|
|
|
|
`context_version` 覆盖顺运宝规格、当前 PDD 快照、颜色目标和规则版本;同时向既有
|
|
|
|
|
|
`spec_mapping_decisions` 追加成功决策。重复处理同一上下文不重复更新或追加审计。
|
|
|
|
|
|
|
|
|
|
|
|
只有本规则自己生成的映射会在颜色依据缺失、目标失效或剩余维度不唯一时被移除;人工映射
|
|
|
|
|
|
和其他来源的有效完整映射不覆盖、不删除。处理阶段和采购创建仍只认当前 PDD 商品下存在且
|
|
|
|
|
|
选项仍可购买的完整 `spec_mappings`,颜色表本身不提供可采购状态。
|