Admin:顺运宝主链路重构——规格映射改键,蝦皮报表降级为参考数据 #88

Closed
opened 2026-08-10 10:32:21 +08:00 by ila · 10 comments
Owner

基本信息

  • 类型:重构(数据模型 + 主流程,含数据库结构变更)
  • 父级大工单:#14 / 所属 MVP:#15
  • 设计:架构角色。[必须] 实施中发现实质性问题先停下来更新工单并重新审核,
    不要照着已知缺陷落地,也不要自行改设计(CLAUDE.md §5/§7.5)
  • 关联:#16(映射按 PDD 商品隔离的由来)、#20(迁移只追加的教训)、
    #38(ParseSpec)、#46(syb 同步)
  • 替代关系:落地时关闭 #67、#69,见文末「与 #67/#68/#69 的关系」

本工单已按 Codex 全栈评审(#88 评论)逐条修订。评审提出的 10 条中
9 条已采纳,第 4 条(旧映射转换)采纳并追加了命中验证要求。

要解决什么

用线上真实数据量出来的结论

syb_orders 明细总数                    5334
蝦皮报表里有该商品                      186   (3%)
规格原文能精确对上报表某个 SKU          102   (2%)

当前状态机的前两关是「未找到蝦皮商品」「待确认蝦皮规格」——
97% 的真实订单���死在第一关。蝦皮那份 Excel 是「最佳表現商品」报表,
只含有销售成绩的商品,与每天实际来的订单几乎不相交。

不止状态机:AssociateSybPdd / CreateSybPddCollectTask / SaveSybMapping /
validatePurchaseRequest 都有
if context.Order.ShopeeSKUID == "" { return "请先确认蝦皮规格" }——
连填 PDD 链接、发采集任务都被这道闸挡着。

根因

把蝦皮 Excel 当成了主干。但顺运宝明细自带采购需要的一切:

syb 明细:  shopee_goods_id + product_spec("白色,L【建議50-60公斤】") + 标题 + 数量 + 价格
要的是:    pdd_goods_id + PDD 的哪个规格组合(pdd_option_key)

中间绕道 shopee_sku_id 只为给映射一个键,而这个键 98% 不存在。

做什么 / 不做什么

做:

  1. 映射换键:新表 spec_mappings,键 = (蝦皮商品ID, 规格键, PDD商品ID)
  2. shopee_products 增加 source 字段;syb 同步补建骨架
  3. 删掉全部 ShopeeSKUID == "" 闸门;状态机重整
  4. syb_orders 加 spec_key;存量回填
  5. 旧 sku_mappings 转换到新表并验证,再 DROP
  6. 文档同步(含作废 05 §4.4「手动新增 SKU」——写明被本工单作废及原因,不要删段落)

不做(明确排除):

  • 不做匹配建议/预选(规则或 LLM)——#90
  • 不改采集链路(PDD 页、采集任务、Client 提交、超时回退)
  • 不改变蝦皮 Excel 的文件解析和既有字段覆盖规则;仅增加 source='report' 的来源提升,且继续保护人工维护的 PDD 关联字段
  • 不动 admin/repository/sqlite_to_mysql.go(一次性历史导入工具,项目已切 MySQL)
  • 不动 admin/repository/db.go 里那套 SQLite 迁移(已无生产调用方)
  • 不改 Client 侧任何代码

已确认的方案

① SpecKey() 放中立包,不能放 service

[必须] 放 admin/spec 这样的中立包(无数据库依赖)。

理由:现有依赖方向是 service → repository。SpecKey 要在
repository/mysql_db.go 的迁移回填里调用,repository 再导入 service
就是 Go import cycle,直接编译不过。repository、service、#90 共同调用中立包。

// SpecKey 把规格原文规范化成映射身份键:TrimSpace 后把连续空白折叠成单个空格。
// 不做更多加工——键的职责是"同一文本恒等",不是"语义等价"。
// 语义对应由人工匹配决定,存的就是这个键。
func SpecKey(raw string) (string, error)

[必须] 存映射和查映射必须调同一个函数(同 OptionKey,#16):
各写一遍会静默算出不同结果,映射命中率悄悄归零且不报错。

[必须] 键用原文规范化,不用 ParseSpec 的解析结果。ParseSpec 有
0.3% 失败率(括号不配对���脏数据),用解析结果做键这些行就永远建不了映射。
ParseSpec 只用于展示颜色/尺码,和 #90 的匹配建议。

[必须] 超过数据库列宽上限(191 字符)时返回错误,绝不静默截断。
截断会让两个不同规格产生同一个键 → 买错货。实测当前最长 39 字符,
但不能依赖"现在没有"。

② 空规格必须阻断,不能生成空键

实测:product_spec 为空 4 条,涉及 3 个商品——有一个商品占 2 条。

[必须] 空规格的 spec_key 保持 NULL,不要用空字符串。
空字符串会让同一商品的多条未知规格共用同一个映射,
这正是 #16 引入 pdd_goods_id 做主键要防的那类问题,不能在这里重新引入。

[必须] 这类明细进入阶段「采购数据异常:顺运宝未提供规格」,
禁止保存映射、禁止创建采购任务。

③ 新表 spec_mappings(MySQL)

CREATE TABLE IF NOT EXISTS 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,
    pdd_options     LONGTEXT NOT NULL,
    spec_raw        TEXT NOT NULL,
    mapped_at       VARCHAR(35) NOT NULL,
    mapped_by       VARCHAR(191),
    PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
    KEY idx_spec_mappings_goods (shopee_goods_id),
    KEY idx_spec_mappings_pdd (pdd_goods_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

[必须] 主键必须含 pdd_goods_id——#16 立的安全性质:PDD 商品从 A 换成 B
后旧映射天然查不到,换回 A 时直接复用。少了它,B 恰好有同名规格时会静默买错货。

[必须] 三列都用 VARCHAR(191) COLLATE utf8mb4_bin。utf8mb4_bin 保证
「藍」「蓝」、大小写不同的键不会被数据库判等——归一化是 SpecKey() 的职责,
交给 collation 做会让 Go 算出的键和库里认的键不一致。

[注意] 索引键长余量很薄:191 × 4 字节 × 3 列 = 2292,InnoDB DYNAMIC
上限 3072,余 780。任何一列改宽都会直接撞上限建表失败。
要加长请先算这笔账,不要「顺手把 191 改成 255」。

[必须] 不加外键。旧表 FK 指向 shopee_skus(sku_id),新键不再依赖那张表——
蝦皮报表里没有的商品也要能建映射,这正是本工单的目的。

④ shopee_products 增加来源字段

[必须] 必须有可靠的来源标识:

ALTER TABLE shopee_products
  ADD COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report',
  ADD CONSTRAINT chk_shopee_products_source CHECK (source IN ('report','syb'));

[必须] 必须有数据库层的 CHECK,不能只靠代码自觉。页面和 Go 代码里
只写两个值,挡不住以后的脚本、迁移或人工 SQL 写进拼错的值——
而一个拼错的 source 会让那批商品的来源永久判错,且不报错。
MySQL 8.0.16+ 强制执行 CHECK。

[建议] 不加索引。本工单只显示来源、不做按来源筛选,约 8 千行全表扫可接受。
将来真要筛选再加。

—— 取值 report(Excel 导入)/ syb(同步补建的骨架)。

—— [必须] 不能用「有没有 SKU 行」推断来源。实测 5195 个报表商品里
3909 个(75%)本来就没有任何 SKU,这个推断完全不成立。
(初版工单曾据此判断"不需要来源列",已证伪。)

—— [必须] 迁移时存量全部置 report(它们确实来自 Excel),
骨架补建时置 syb,Excel 导入时提升为 report。

—— [必须] Excel 导入更新 source 时绝不能覆盖 pdd_goods_url /
pdd_goods_id
(#38 已有的白名单规则不变,只是白名单里加上 source)。

—— [建议] 蝦皮页显示来源或支持按来源筛选,避免骨架行被当成完整报表商品。

⑤ syb 同步补建蝦皮商品骨架

在 UpsertSybOrder 的同一事务里:

INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at)
VALUES (?, ?, 'syb', ?, ?)
ON DUPLICATE KEY UPDATE goods_id = goods_id;

[必须] 不要用 INSERT IGNORE。它不只忽略主键重复,还会把截断、
类型错误等真实数据问题降级成警告,静默写进坏数据。用上面这种
明确的 no-op upsert:重复商品什么都不做,其余错误照常抛出。

[必须] 绝不覆盖已有行的标题。Excel 报表的标题是权威数据,
骨架只在商品完全缺失时垫底。方向永远是 Excel 覆盖骨架、骨架不覆盖 Excel。

[必须] 事务语义一致性优先:骨架创建失败 → 本条订单事务失败、
本次同步不推进 last_synced_at
。不允许"个别骨架失败但订单照写"
留下半套上下文。(初版工单同时写了"同事务"和"个别失败不阻塞",
自相矛盾,以本条为准。)

⑥ 迁移:MySQL v3,只追加,且必须可重放

[注意] 本项目已切 MySQL 8。main.go 走 OpenMySQL + MigrateMySQL,
版本记在 schema_migrations 表,当前 mysqlSchemaVersion = 2。
db.go 里那套 SQLite Migrate/PRAGMA user_version 已无生产调用方。

[必须] 在 admin/repository/mysql_db.go 追加 mysqlSchemaV3,
mysqlSchemaV1 / V2 一个字节都不许改。已建库的机器版本已越过它们,
改了永远不会重跑,程序会拿着对不上的库启动——#20 在 SQLite 上踩过三次。

[必须] 自检通过才记版本(照 v1/v2 已有写法)。中途失败不记版本,
下次重跑,而不是留下"版本说升级了、表其实没建好"的库。

v3 必须真正可重放。 MySQL 的 DDL 会隐式提交,进程在
「DDL 已执行、版本未记录」之间死掉时,重跑不能炸:

步骤 幂等做法
加列(spec_key / source) 先查 information_schema.columns,已存在则跳过
建 spec_mappings CREATE TABLE IF NOT EXISTS
回填 spec_key 只处理 spec_key IS NULL 的行
补建骨架 no-op upsert,天然幂等
旧映射转换 upsert 进 spec_mappings,天然幂等
DROP sku_mappings 先查 information_schema.tables,不存在则跳过

[必须] 回填按稳定主键游标分批(每批 500 行,WHERE syb_id > ? ORDER BY syb_id LIMIT 500),
不要用 OFFSET——边写边翻页时 OFFSET 会漏行。
5334 行放一个大事务会长时间持锁,失败还得全量重来。

[必须] 测试要证明在三个断点重跑都能收敛:
① DDL 完成、回填未开始;② 回填到一半;③ 回填完成、版本未记录。

[必须] 迁移测试必须覆盖 v2 → v3 升级,不是只测全新库建到 v3。
只测全新库的话回填逻辑一行都跑不到——而回填正是这一版的主要内容。

⑦ MySQL 专用自检清单

[必须] 现在 requiredTables 是 SQLite/MySQL 共享清单,固定含
sku_mappings。v3 DROP 旧表后,CheckMySQLSchema 会直接失败;
而改共享清单又会破坏冻结的 SQLite 历史结构。

在 mysql_db.go 建 MySQL 专用的必需表/列清单:

  • 排除 sku_mappings
  • 加入 spec_mappings
  • 列级自检加 syb_orders.spec_key、shopee_products.source
  • SQLite 侧的 requiredTables 保持不动

⑧ 旧映射:转换 + 验证,不直接丢弃

[必须] 不要在设计里写死旧映射的条数。 实施时数量还会变
(架构角色查本地 SQLite 得 2 条、Codex 查线上 MySQL 得 1 条——
本地 SQLite 是一次性导入的历史文件,不是生产库,不能当权威数据源)。

迁移前统计 N = SELECT COUNT(*) FROM sku_mappings,并保证恒等���:

转换且命中 + 转换但当前无命中 + 无法转换  =  N

转换路径:sku_mappings JOIN shopee_skus → goods_id + spec_raw → SpecKey(spec_raw) → spec_mappings

[必须] 转换后必须逐条验证该键在 syb_orders.spec_key 里真能命中。

理由:转换产出的键来自蝦皮 Excel 的 spec_raw,而运行时要匹配的是
顺运宝的 product_spec。两者来源不同,繁简/空格可能有差异——
这次凑巧一致,不代表以后一致。不验证的话会写进一批永远不生效的死映射,
而且没人知道。

[必须] 分三类报数并打进启动日志:转换且命中 N 条 / 转换但当前无命中 M 条 /
无法转换 K 条
(shopee_skus 里查不到对应行)。后两类不要静默丢弃,
要能从日志看出具体是哪些。

[必须] 无法转换的行在 DROP 前必须先备份——DROP 不可逆。备份只能是
root-only 文件或迁移前的 MySQL 逻辑备份 / 受限备份表。

[必须] 完整内容绝不能进普通运行日志。 启动日志只记三类数量和
必要的非敏感业务 ID;完整的 pdd_options、规格原文不得输出到
systemd 日志、Gitea 工单或 docs/task 归档。
本项目实际会把日志贴进工单排查问题,完整业务数据进日志就是外流。

⑨ 状态机与筛选 SQL 一起改

删掉「未找到蝦皮商品」「待确认蝦皮规格」两个阶段;新增
「采购数据异常:顺运宝未提供规格」(见②)。

[必须] 不只是删 sybStageFor 的两个 case。repository/syb.go 的
sybOrderFilterClause 仍含 shopee_sku_id 条件,且高级阶段会全量读取
再在 Go 里筛选
。必须同步重写:阶段常量、下拉选项、SQL 筛选条件、
高级阶段测试。否则列表显示的状态和筛选出来的数量对不上。

[必须] AssociateSybPdd / CreateSybPddCollectTask / SaveSybMapping /
validatePurchaseRequest 里的 ShopeeSKUID == "" 检查全部删除,
且 grep -rn '请先确认蝦皮规格' admin/ 必须为 0。

[必须] syb_orders.shopee_sku_id 列保留不删(迁移只追加),
但不再写入、不再作为任何条件;界面上「确认蝦皮规格」入口整个移除。

⑩ 匹配与采购的读写路径

  • 映射命中:LEFT JOIN spec_mappings sm ON sm.shopee_goods_id = o.shopee_goods_id AND sm.spec_key = o.spec_key AND sm.pdd_goods_id = <���前关联的 PDD 商品>
  • SaveSybMapping:入参不变,内部改取 (goods_id, spec_key, spec_raw) 落新表
  • CreatePurchaseTasks:校验链里"有映射"改查新表,其余不动
  • 同一 (商品, 规格) 的第二条订单自动命中映射,直接进「可创建采购任务」
    ——本工单的核心收益,必须有测试钉住

实施前补充(2026-08-10,Codex 第三轮复核)

本节修正正文中仍可能导致迁移静默带病或验收口径漂移的内容;与前文冲突时以本节为准。

A. MySQL 自检必须验证“结构形状”,不能只查表/列存在

CheckMySQLSchema 除必需表、列外,还必须从 information_schema 验证:

  • spec_mappings 主键列及顺序严格为 (shopee_goods_id, spec_key, pdd_goods_id);
  • 三个身份列均为 VARCHAR(191) 且使用 binary collation;
  • shopee_products.source 为非空、默认 report,并存在且执行 source IN ('report','syb') 的 CHECK;
  • syb_orders.spec_key 可空、长度 191、使用 binary collation。

CREATE TABLE IF NOT EXISTS 遇到同名但错误结构不会修复,因此只检查“存在”不足以保护采购身份键。

B. 加列与加约束分别幂等

迁移不得因 source 列已存在就跳过 CHECK。字段、默认值、可空性和 CHECK 约束分别检查、分别补齐;测试增加“字段存在但约束缺失”���中断状态,并证明重跑收敛。结构自检未通过不得记录 v3。

C. 线上数量只是迁移前快照

正文中的“4 条空规格”等数字只作为 2026-08-10 的诊断快照。实施和验收必须先动态记录 N,再按不变量核对,不得把固定数量写进断言。骨架商品数、旧映射数同理以迁移前查询结果为准。

D. 页面与权限回归口径

管理员按六个业务页面验收:/shopee、/pdd、/syb、/tasks、/clients、/users。采购员按权限验收其可访问页面,并确认用户管理仍被拒绝;不再使用含义不清的“五个页面均 200”。

预计修改文件

文件 改什么
admin/spec/speckey.go + _test.go 新建中立包:SpecKey()
admin/repository/mysql_db.go mysqlSchemaV3 + 可重放迁移 + 回填/骨架/转换;MySQL 专用自检清单
admin/repository/mysql_db_integration_test.go v2→v3 升级 + 三断点重跑 + 回填测试
admin/repository/mapping.go 改查/写 spec_mappings
admin/repository/shopee.go upsert 白名单加 source
admin/repository/syb.go UpsertSybOrder 写 spec_key + 骨架;SybOrderContext JOIN;sybOrderFilterClause 阶段条件
admin/model/model.go SpecMapping 结构;ShopeeProduct.Source
admin/service/syb.go 状态机重整;视图组装
admin/service/purchase_workflow.go SaveSybMapping / validatePurchaseRequest 去闸门换键
admin/service/shopee_pdd.go AssociateSybPdd / CreateSybPddCollectTask 去闸门
admin/handler/web/others.go + admin/templates/syb/*、shopee/* 移除「确认蝦皮规格」入口;阶段文案;来源显示
docs/admin/01/03/05 流程、表结构、界面;作废 05 §4.4 手动新增 SKU(写明被本工单作废及原因,不要删段落)

验收标准

主链路(核心)

  • 蝦皮报表里没有的商品,其订单能走完全程:关联 PDD → 建采集任务 →(模拟采集结果)→ 匹配 → 创建采购任务
  • 同一 (商品, 规格) 的第二条明细自动命中映射,阶段直接为「可创建采购任务」
  • PDD 从 A 换到 B 后旧映射不命中;换回 A 后不需要重新匹配
  • grep -rn '请先确认蝦皮规格' admin/ 为 0
  • 阶段筛选的每个阶段计数与逐行实时推导一致(筛选 SQL 和 sybStageFor 不脱节)

SpecKey

  • 在中立包,repository 和 service 都能调用且不产生 import cycle(能编译即证明)
  • 存与查走同一函数(测试:原文带多余空白/前后空格仍命中)
  • ParseSpec 解析失败的规格(如 紅色,3XL建議80-90公斤】)照样能建映射并命中
  • 超过 191 字符返回错误,不截断

空规格

  • 实施前动态记录空规格数量;迁移后全部空规格的 spec_key 为 NULL,不是空字符串(不依赖当前线上快照数量)
  • 这些明细阶段为「采购数据异常:顺运宝未提供规格」
  • 禁止为它们保存映射、创建采购任务(有测试)
  • 验收 SQL:所有非空 product_spec 的 spec_key 均非空

来源字段

  • 迁移后存量商品 source='report'
  • syb 骨架补建的商品 source='syb'
  • Excel 导入把 syb 骨架提升为 report,且不覆盖 pdd_goods_url/pdd_goods_id/标题以外的人工字段
  • 蝦皮页可区分来源

迁移

  • mysqlSchemaV3 是追加的,V1/V2 逐字未动
  • v2 → v3 升级测试通过(不是只测全新库)
  • 三个断点重跑均收敛:DDL 完成/回填一半/回填完成未记版本
  • 回填用主键游标分批,不用 OFFSET
  • MySQL 专用自检清单生效;SQLite 侧 requiredTables 未动
  • 自检失败时不记录版本(有测试)

旧映射

  • 迁移前旧映射总数 N 与三类处理结果之和一致(不断言固定数量)
  • 可转换的记录转换到 spec_mappings 且逐条验证命中
  • 分三类报数进启动日志:命中 / 转换但无命中 / 无法转换
  • 完整备份为 root-only;日志 / 工单 / 归档里不含完整业务内容
  • source 写入非 report/syb 的值时被 MySQL CHECK 拒绝(有集成测试)
  • #14/#15 在实施前已加入 #88 并标注 #67/#69 替代关系

其他

  • 采集链路回归:PDD 页建任务 → claim → 提交,全部照旧
  • 管理员访问 /shopee、/pdd、/syb、/tasks、/clients、/users 六个页面均正常;采购员可访问其授权页面且不能进入用户管理
  • GOTOOLCHAIN=go1.23.0 下 go vet / gofmt -l . / go test ./... 全过

怎么验证

cd D:\chengma\cmautobuy\admin
$env:GOTOOLCHAIN="go1.23.0"
go vet ./...; gofmt -l .; go test ./... -count=1
Remove-Item Env:GOTOOLCHAIN

端到端(用数据库备份跑,不要在正式库上试):

  1. 迁移后:
    SELECT COUNT(*) FROM syb_orders WHERE (product_spec IS NOT NULL AND TRIM(product_spec)<>'') AND (spec_key IS NULL OR spec_key='') → 0
    SELECT COUNT(*) FROM syb_orders WHERE TRIM(COALESCE(product_spec,''))='' AND spec_key IS NOT NULL → 0
    SELECT COUNT(*) FROM syb_orders o WHERE NOT EXISTS (SELECT 1 FROM shopee_products p WHERE p.goods_id=o.shopee_goods_id) → 0
    SELECT source, COUNT(*) FROM shopee_products GROUP BY source → report ≈5195,syb ≈2936
  2. /syb 阶段筛选里不再有「未找到蝦皮商品」「待确认蝦皮规格」
  3. 挑一条报表里没有的商品的明细:关联 PDD → 建采集任务成功(不再提示"请先确认蝦皮规格")
  4. curl 模拟采集结果提交 → 该明细进「规格待匹配」→ 弹窗选规格保存
  5. 同商品同规格的另一条明细 → 应已是「可创建采购任务」
  6. 创建采购任务成功

交付报告必须包含第 1、3、5 步的实际输出,以及三断点重跑的测试输出。

风险和回退

风险 应对
SpecKey 两处实现不一致,命中率静默归零 中立包单一函数 + 空白变体测试
空规格共键 → 买错货 NULL 阻断 + 禁止保存映射,已列验收项
骨架覆盖 Excel 标题 no-op upsert + 专项测试
回填漏行,97% 依旧卡死 主键游标分批 + 全量 SQL 断言
转换出永远不生效的死映射 逐条验证命中并分类报数
迁移中断后无法重跑 三断点收敛测试
又一次原地改迁移 只追加已列验收项

[必须] 回退不是只跑 git revert。 「版本高于程序支持所以拒绝启动」
只防止旧代码误跑,不是业务回退。实施前必须准备好:

  • 应用二进制备份
  • MySQL 逻辑备份(mysqldump)及恢复命令
  • 旧映射转换报告
  • 停机窗口

迁移未通过时恢复数据库快照 + 旧二进制,而不是只 git revert。

与 #67 / #68 / #69 的关系

三张单都已实现,就是现在跑着的代码。本工单只推翻其中一部分,
关闭时必须说清哪半作废、哪半仍然有效,否则会被当整块清掉。

处置 说明
#67 建立顺运宝采购处理状态并识别蝦皮 SKU 落地时关闭 核心「自动确认/手动选择蝦皮 SKU」正是本工单要删的那一步。但「处理阶段 + 阶段筛选 + 同步不覆盖已确认字段」这套框架仍然有效,本工单是在它之上重整阶段,不是推翻框架
#68 统一蝦皮关联 PDD 与采集入口 保持 open,不关 「两个入口调用同一个 Service」「换 PDD 需确认」「采集任务复用幂等/超时规则」全部继续有效。本工单只删掉它的一个前置条件——「蝦皮 SKU 已确认时才提供入口」
#69 实现可复用规格映射与采购任务创建 落地时关闭 映射部分(sku_mappings、查询同时限定蝦皮 SKU 与 PDD)被本工单替代。但采购任务创建的六条校验、批量选客户端、逐行价格上限、重复提交保护全部继续有效,本工单不动它们

父工单同步的时点

[必须] 分两步,时点不同:

何时 做什么
用户确认本版设计后、开始改代码前 把 #88 加入 #14/#15 子工单清单;在清单里标明 #67/#69 由 #88 替代、#68 保留
#88 实施并验收通过后 才关闭 #67/#69,并在各自评论里贴上面那张「哪半作废哪半有效」的表

索引必须先更新——否则实施方照着过期的父工单清单干活,会以为 #67/#69
仍然是要遵守的设计基线。

## 基本信息 - 类型:重构(数据模型 + 主流程,含数据库结构变更) - 父级大工单:#14 / 所属 MVP:#15 - 设计:架构角色。`[必须]` 实施中**发现实质性问题先停下来更新工单并重新审核**, 不要照着已知缺陷落地,也不要自行改设计(CLAUDE.md §5/§7.5) - 关联:#16(映射按 PDD 商品隔离的由来)、#20(迁移只追加的教训)、 #38(`ParseSpec`)、#46(syb 同步) - **替代关系**:落地时关闭 #67、#69,见文末「与 #67/#68/#69 的关系」 > 本工单已按 Codex 全栈评审(#88 评论)逐条修订。评审提出的 10 条中 > 9 条已采纳,第 4 条(旧映射转换)采纳并追加了命中验证要求。 ## 要解决什么 ### 用线上真实数据量出来的结论 ```text syb_orders 明细总数 5334 蝦皮报表里有该商品 186 (3%) 规格原文能精确对上报表某个 SKU 102 (2%) ``` 当前状态机的前两关是「未找到蝦皮商品」「待确认蝦皮规格」—— **97% 的真实订单���死在第一关**。蝦皮那份 Excel 是「最佳表現商品」报表, 只含有销售成绩的商品,与每天实际来的订单几乎不相交。 不止状态机:`AssociateSybPdd` / `CreateSybPddCollectTask` / `SaveSybMapping` / `validatePurchaseRequest` 都有 `if context.Order.ShopeeSKUID == "" { return "请先确认蝦皮规格" }`—— 连**填 PDD 链接、发采集任务**都被这道闸挡着。 ### 根因 把蝦皮 Excel 当成了主干。但顺运宝明细自带采购需要的一切: ```text syb 明细: shopee_goods_id + product_spec("白色,L【建議50-60公斤】") + 标题 + 数量 + 价格 要的是: pdd_goods_id + PDD 的哪个规格组合(pdd_option_key) ``` 中间绕道 `shopee_sku_id` 只为给映射一个键,而这个键 **98% 不存在**。 ## 做什么 / 不做什么 做: 1. 映射换键:新表 `spec_mappings`,键 = (蝦皮商品ID, 规格键, PDD商品ID) 2. `shopee_products` 增加 `source` 字段;syb 同步补建骨架 3. 删掉全部 `ShopeeSKUID == ""` 闸门;状态机重整 4. `syb_orders` 加 `spec_key`;存量回填 5. 旧 `sku_mappings` **转换**到新表并验证,再 DROP 6. 文档同步(含作废 `05` §4.4「手动新增 SKU」——写明被本工单作废及原因,**不要删段落**) 不做(明确排除): - **不做匹配建议/预选**(规则或 LLM)——#90 - 不改采集链路(PDD 页、采集任务、Client 提交、超时回退) - 不改变蝦皮 Excel 的文件解析和既有字段覆盖规则;仅增加 `source='report'` 的来源提升,且继续保护人工维护的 PDD 关联字段 - **不动 `admin/repository/sqlite_to_mysql.go`**(一次性历史导入工具,项目已切 MySQL) - 不动 `admin/repository/db.go` 里那套 SQLite 迁移(已无生产调用方) - 不改 Client 侧任何代码 ## 已确认的方案 ### ① `SpecKey()` 放中立包,不能放 service `[必须]` **放 `admin/spec` 这样的中立包**(无数据库依赖)。 理由:现有依赖方向是 service → repository。`SpecKey` 要在 `repository/mysql_db.go` 的迁移回填里调用,repository 再导入 service 就是 **Go import cycle,直接编译不过**。repository、service、#90 共同调用中立包。 ```go // SpecKey 把规格原文规范化成映射身份键:TrimSpace 后把连续空白折叠成单个空格。 // 不做更多加工——键的职责是"同一文本恒等",不是"语义等价"。 // 语义对应由人工匹配决定,存的就是这个键。 func SpecKey(raw string) (string, error) ``` `[必须]` **存映射和查映射必须调同一个函数**(同 `OptionKey`,#16): 各写一遍会静默算出不同结果,映射命中率悄悄归零且不报错。 `[必须]` **键用原文规范化,不用 `ParseSpec` 的解析结果**。`ParseSpec` 有 0.3% 失败率(括号不配对���脏数据),用解析结果做键这些行就永远建不了映射。 `ParseSpec` 只用于**展示**颜色/尺码,和 #90 的匹配建议。 `[必须]` **超过数据库列宽上限(191 字符)时返回错误,绝不静默截断**。 截断会让两个不同规格产生同一个键 → 买错货。实测当前最长 39 字符, 但不能依赖"现在没有"。 ### ② 空规格必须阻断,不能生成空键 实测:`product_spec` 为空 **4 条,涉及 3 个商品**——**有一个商品占 2 条**。 `[必须]` 空规格的 `spec_key` 保持 **NULL**,不要用空字符串。 空字符串会让同一商品的多条未知规格**共用同一个映射**, 这正是 #16 引入 `pdd_goods_id` 做主键要防的那类问题,不能在这里重新引入。 `[必须]` 这类明细进入阶段「**采购数据异常:顺运宝未提供规格**」, **禁止保存映射、禁止创建采购任务**。 ### ③ 新表 `spec_mappings`(MySQL) ```sql CREATE TABLE IF NOT EXISTS 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, pdd_options LONGTEXT NOT NULL, spec_raw TEXT NOT NULL, mapped_at VARCHAR(35) NOT NULL, mapped_by VARCHAR(191), PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id), KEY idx_spec_mappings_goods (shopee_goods_id), KEY idx_spec_mappings_pdd (pdd_goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ``` `[必须]` **主键必须含 `pdd_goods_id`**——#16 立的安全性质:PDD 商品从 A 换成 B 后旧映射天然查不到,换回 A 时直接复用。少了它,B 恰好有同名规格时会静默买错货。 `[必须]` **三列都用 `VARCHAR(191) COLLATE utf8mb4_bin`**。`utf8mb4_bin` 保证 「藍」「蓝」、大小写不同的键不会被数据库判等——归一化是 `SpecKey()` 的职责, 交给 collation 做会让 Go 算出的键和库里认的键不一致。 `[注意]` **索引键长余量很薄**:`191 × 4 字节 × 3 列 = 2292`,InnoDB DYNAMIC 上限 **3072**,余 780。**任何一列改宽都会直接撞上限建表失败**。 要加长请先算这笔账,不要「顺手把 191 改成 255」。 `[必须]` **不加外键**。旧表 FK 指向 `shopee_skus(sku_id)`,新键不再依赖那张表—— 蝦皮报表里没有的商品也要能建映射,这正是本工单的目的。 ### ④ `shopee_products` 增加来源字段 `[必须]` 必须有可靠的来源标识: ```sql ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', ADD CONSTRAINT chk_shopee_products_source CHECK (source IN ('report','syb')); ``` `[必须]` **必须有数据库层的 `CHECK`**,不能只靠代码自觉。页面和 Go 代码里 只写两个值,挡不住以后的脚本、迁移或人工 SQL 写进拼错的值—— 而一个拼错的 `source` 会让那批商品的来源永久判错,且不报错。 MySQL 8.0.16+ 强制执行 `CHECK`。 `[建议]` **不加索引**。本工单只显示来源、不做按来源筛选,约 8 千行全表扫可接受。 将来真要筛选再加。 —— 取值 `report`(Excel 导入)/ `syb`(同步补建的骨架)。 —— `[必须]` **不能用「有没有 SKU 行」推断来源**。实测 5195 个报表商品里 **3909 个(75%)本来就没有任何 SKU**,这个推断完全不成立。 (初版工单曾据此判断"不需要来源列",已证伪。) —— `[必须]` 迁移时存量全部置 `report`(它们确实来自 Excel), 骨架补建时置 `syb`,Excel 导入时**提升为 `report`**。 —— `[必须]` Excel 导入更新 `source` 时**绝不能覆盖 `pdd_goods_url` / `pdd_goods_id`**(#38 已有的白名单规则不变,只是白名单里加上 `source`)。 —— `[建议]` 蝦皮页显示来源或支持按来源筛选,避免骨架行被当成完整报表商品。 ### ⑤ syb 同步补建蝦皮商品骨架 在 `UpsertSybOrder` 的**同一事务**里: ```sql INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at) VALUES (?, ?, 'syb', ?, ?) ON DUPLICATE KEY UPDATE goods_id = goods_id; ``` `[必须]` **不要用 `INSERT IGNORE`**。它不只忽略主键重复,还会把截断、 类型错误等真实数据问题降级成警告,静默写进坏数据。用上面这种 **明确的 no-op upsert**:重复商品什么都不做,其余错误照常抛出。 `[必须]` **绝不覆盖已有行的标题**。Excel 报表的标题是权威数据, 骨架只在商品完全缺失时垫底。方向永远是 Excel 覆盖骨架、骨架不覆盖 Excel。 `[必须]` **事务语义一致性优先**:骨架创建失败 → **本条订单事务失败、 本次同步不推进 `last_synced_at`**。不允许"个别骨架失败但订单照写" 留下半套上下文。(初版工单同时写了"同事务"和"个别失败不阻塞", 自相矛盾,以本条为准。) ### ⑥ 迁移:MySQL v3,只追加,且必须可重放 `[注意]` 本项目已切 MySQL 8。`main.go` 走 `OpenMySQL` + `MigrateMySQL`, 版本记在 `schema_migrations` 表,当前 `mysqlSchemaVersion = 2`。 `db.go` 里那套 SQLite `Migrate`/`PRAGMA user_version` **已无生产调用方**。 `[必须]` 在 `admin/repository/mysql_db.go` 追加 `mysqlSchemaV3`, **`mysqlSchemaV1` / `V2` 一个字节都不许改**。已建库的机器版本已越过它们, 改了永远不会重跑,程序会拿着对不上的库启动——#20 在 SQLite 上踩过三次。 `[必须]` **自检通过才记版本**(照 v1/v2 已有写法)。中途失败不记版本, 下次重跑,而不是留下"版本说升级了、表其实没建好"的库。 **v3 必须真正可重放。** MySQL 的 DDL 会隐式提交,进程在 「DDL 已执行、版本未记录」之间死掉时,重跑不能炸: | 步骤 | 幂等做法 | |---|---| | 加列(`spec_key` / `source`) | 先查 `information_schema.columns`,已存在则跳过 | | 建 `spec_mappings` | `CREATE TABLE IF NOT EXISTS` | | 回填 `spec_key` | **只处理 `spec_key IS NULL` 的行** | | 补建骨架 | no-op upsert,天然幂等 | | 旧映射转换 | upsert 进 `spec_mappings`,天然幂等 | | DROP `sku_mappings` | 先查 `information_schema.tables`,不存在则跳过 | `[必须]` **回填按稳定主键游标分批**(每批 500 行,`WHERE syb_id > ? ORDER BY syb_id LIMIT 500`), **不要用 `OFFSET`**——边写边翻页时 OFFSET 会漏行。 5334 行放一个大事务会长时间持锁,失败还得全量重来。 `[必须]` 测试要证明**在三个断点重跑都能收敛**: ① DDL 完成、回填未开始;② 回填到一半;③ 回填完成、版本未记录。 `[必须]` **迁移测试必须覆盖 v2 → v3 升级**,不是只测全新库建到 v3。 只测全新库的话回填逻辑一行都跑不到——而回填正是这一版的主要内容。 ### ⑦ MySQL 专用自检清单 `[必须]` 现在 `requiredTables` 是 SQLite/MySQL **共享**清单,固定含 `sku_mappings`。v3 DROP 旧表后,`CheckMySQLSchema` 会直接失败; 而改共享清单又会破坏冻结的 SQLite 历史结构。 在 `mysql_db.go` 建 **MySQL 专用**的必需表/列清单: - 排除 `sku_mappings` - 加入 `spec_mappings` - 列级自检加 `syb_orders.spec_key`、`shopee_products.source` - **SQLite 侧的 `requiredTables` 保持不动** ### ⑧ 旧映射:转换 + 验证,不直接丢弃 `[必须]` **不要在设计里写死旧映射的条数。** 实施时数量还会变 (架构角色查本地 SQLite 得 2 条、Codex 查线上 MySQL 得 1 条—— 本地 SQLite 是一次性导入的历史文件,**不是生产库**,不能当权威数据源)。 迁移前统计 `N = SELECT COUNT(*) FROM sku_mappings`,并保证恒等���: ```text 转换且命中 + 转换但当前无命中 + 无法转换 = N ``` 转换路径:`sku_mappings JOIN shopee_skus → goods_id + spec_raw → SpecKey(spec_raw) → spec_mappings` `[必须]` **转换后必须逐条验证该键在 `syb_orders.spec_key` 里真能命中**。 理由:转换产出的键来自**蝦皮 Excel 的 `spec_raw`**,而运行时要匹配的是 **顺运宝的 `product_spec`**。两者来源不同,繁简/空格可能有差异—— 这次凑巧一致,不代表以后一致。不验证的话会写进一批**永远不生效的死映射**, 而且没人知道。 `[必须]` 分三类报数并打进启动日志:**转换且命中 N 条 / 转换但当前无命中 M 条 / 无法转换 K 条**(`shopee_skus` 里查不到对应行)。后两类**不要静默丢弃**, 要能从日志看出具体是哪些。 `[必须]` 无法转换的行在 DROP 前必须先备份——DROP 不可逆。备份只能是 **root-only 文件**或**迁移前的 MySQL 逻辑备份 / 受限备份表**。 `[必须]` **完整内容绝不能进普通运行日志。** 启动日志只记三类**数量**和 必要的非敏感业务 ID;完整的 `pdd_options`、规格原文不得输出到 systemd 日志、Gitea 工单或 `docs/task` 归档。 本项目实际会把日志贴进工单排查问题,完整业务数据进日志就是外流。 ### ⑨ 状态机与筛选 SQL 一起改 删掉「未找到蝦皮商品」「待确认蝦皮规格」两个阶段;新增 「采购数据异常:顺运宝未提供规格」(见②)。 `[必须]` **不只是删 `sybStageFor` 的两个 case**。`repository/syb.go` 的 `sybOrderFilterClause` 仍含 `shopee_sku_id` 条件,且高级阶段会**全量读取 再在 Go 里筛选**。必须同步重写:阶段常量、下拉选项、SQL 筛选条件、 高级阶段测试。否则列表显示的状态和筛选出来的数量对不上。 `[必须]` `AssociateSybPdd` / `CreateSybPddCollectTask` / `SaveSybMapping` / `validatePurchaseRequest` 里的 `ShopeeSKUID == ""` 检查**全部删除**, 且 `grep -rn '请先确认蝦皮规格' admin/` 必须为 0。 `[必须]` `syb_orders.shopee_sku_id` 列**保留不删**(迁移只追加), 但不再写入、不再作为任何条件;界面上「确认蝦皮规格」入口整个移除。 ### ⑩ 匹配与采购的读写路径 - 映射命中:`LEFT JOIN spec_mappings sm ON sm.shopee_goods_id = o.shopee_goods_id AND sm.spec_key = o.spec_key AND sm.pdd_goods_id = <���前关联的 PDD 商品>` - `SaveSybMapping`:入参不变,内部改取 (goods_id, spec_key, spec_raw) 落新表 - `CreatePurchaseTasks`:校验链里"有映射"改查新表,其余不动 - **同一 (商品, 规格) 的第二条订单自动命中映射,直接进「可创建采购任务」** ——本工单的核心收益,必须有测试钉住 ## 实施前补充(2026-08-10,Codex 第三轮复核) 本节修正正文中仍可能导致迁移静默带病或验收口径漂移的内容;与前文冲突时以本节为准。 ### A. MySQL 自检必须验证“结构形状”,不能只查表/列存在 `CheckMySQLSchema` 除必需表、列外,还必须从 `information_schema` 验证: - `spec_mappings` 主键列及顺序严格为 `(shopee_goods_id, spec_key, pdd_goods_id)`; - 三个身份列均为 `VARCHAR(191)` 且使用 binary collation; - `shopee_products.source` 为非空、默认 `report`,并存在且执行 `source IN ('report','syb')` 的 CHECK; - `syb_orders.spec_key` 可空、长度 191、使用 binary collation。 `CREATE TABLE IF NOT EXISTS` 遇到同名但错误结构不会修复,因此只检查“存在”不足以保护采购身份键。 ### B. 加列与加约束分别幂等 迁移不得因 `source` 列已存在就跳过 CHECK。字段、默认值、可空性和 CHECK 约束分别检查、分别补齐;测试增加“字段存在但约束缺失”���中断状态,并证明重跑收敛。结构自检未通过不得记录 v3。 ### C. 线上数量只是迁移前快照 正文中的“4 条空规格”等数字只作为 2026-08-10 的诊断快照。实施和验收必须先动态记录 N,再按不变量核对,不得把固定数量写进断言。骨架商品数、旧映射数同理以迁移前查询结果为准。 ### D. 页面与权限回归口径 管理员按六个业务页面验收:`/shopee`、`/pdd`、`/syb`、`/tasks`、`/clients`、`/users`。采购员按权限验收其可访问页面,并确认用户管理仍被拒绝;不再使用含义不清的“五个页面均 200”。 ## 预计修改文件 | 文件 | 改什么 | |---|---| | `admin/spec/speckey.go` + `_test.go` | 新建中立包:`SpecKey()` | | `admin/repository/mysql_db.go` | `mysqlSchemaV3` + 可重放迁移 + 回填/骨架/转换;MySQL 专用自检清单 | | `admin/repository/mysql_db_integration_test.go` | v2→v3 升级 + 三断点重跑 + 回填测试 | | `admin/repository/mapping.go` | 改查/写 `spec_mappings` | | `admin/repository/shopee.go` | upsert 白名单加 `source` | | `admin/repository/syb.go` | `UpsertSybOrder` 写 `spec_key` + 骨架;`SybOrderContext` JOIN;**`sybOrderFilterClause` 阶段条件** | | `admin/model/model.go` | `SpecMapping` 结构;`ShopeeProduct.Source` | | `admin/service/syb.go` | 状态机重整;视图组装 | | `admin/service/purchase_workflow.go` | `SaveSybMapping` / `validatePurchaseRequest` 去闸门换键 | | `admin/service/shopee_pdd.go` | `AssociateSybPdd` / `CreateSybPddCollectTask` 去闸门 | | `admin/handler/web/others.go` + `admin/templates/syb/*`、`shopee/*` | 移除「确认蝦皮规格」入口;阶段文案;来源显示 | | `docs/admin/01/03/05` | 流程、表结构、界面;**作废 `05` §4.4 手动新增 SKU(写明被本工单作废及原因,不要删段落)** | ## 验收标准 **主链路(核心)** - [x] **蝦皮报表里没有的商品**,其订单能走完全程:关联 PDD → 建采集任务 →(模拟采集结果)→ 匹配 → 创建采购任务 - [x] 同一 (商品, 规格) 的第二条明细**自动命中映射**,阶段直接为「可创建采购任务」 - [x] PDD 从 A 换到 B 后旧映射不命中;**换回 A 后不需要重新匹配** - [x] `grep -rn '请先确认蝦皮规格' admin/` 为 **0** - [x] 阶段筛选的**每个阶段计数与逐行实时推导一致**(筛选 SQL 和 `sybStageFor` 不脱节) **SpecKey** - [x] 在**中立包**,repository 和 service 都能调用且**不产生 import cycle**(能编译即证明) - [x] 存与查走同一函数(测试:原文带多余空白/前后空格仍命中) - [x] `ParseSpec` 解析失败的规格(如 `紅色,3XL建議80-90公斤】`)**照样能建映射并命中** - [x] 超过 191 字符**返回错误**,不截断 **空规格** - [x] 实施前动态记录空规格数量;迁移后全部空规格的 `spec_key` 为 **NULL**,不是空字符串(不依赖当前线上快照数量) - [x] 这些明细阶段为「采购数据异常:顺运宝未提供规格」 - [x] **禁止**为它们保存映射、创建采购任务(有测试) - [x] 验收 SQL:**所有非空 `product_spec` 的 `spec_key` 均非空** **来源字段** - [x] 迁移后存量商品 `source='report'` - [x] syb 骨架补建的商品 `source='syb'` - [x] Excel 导入把 `syb` 骨架**提升为 `report`**,且**不覆盖** `pdd_goods_url`/`pdd_goods_id`/标题以外的人工字段 - [x] 蝦皮页可区分来源 **迁移** - [x] `mysqlSchemaV3` 是追加的,`V1`/`V2` 逐字未动 - [x] **v2 → v3 升级测试通过**(不是只测全新库) - [x] **三个断点重跑均收敛**:DDL 完成/回填一半/回填完成未记版本 - [x] 回填用主键游标分批,**不用 OFFSET** - [x] MySQL 专用自检清单生效;SQLite 侧 `requiredTables` 未动 - [x] 自检失败时**不记录版本**(有测试) **旧映射** - [x] **迁移前旧映射总数 N 与三类处理结果之和一致**(不断言固定数量) - [x] 可转换的记录**转换到 `spec_mappings` 且逐条验证命中** - [x] 分三类报数进启动日志:命中 / 转换但无命中 / 无法转换 - [x] **完整备份为 root-only**;日志 / 工单 / 归档里不含完整业务内容 - [x] `source` 写入非 `report`/`syb` 的值时**被 MySQL CHECK 拒绝**(有集成测试) - [x] **#14/#15 在实施前**已加入 #88 并标注 #67/#69 替代关系 **其他** - [x] 采集链路回归:PDD 页建任务 → claim → 提交,全部照旧 - [x] 管理员访问 `/shopee`、`/pdd`、`/syb`、`/tasks`、`/clients`、`/users` 六个页面均正常;采购员可访问其授权页面且不能进入用户管理 - [x] `GOTOOLCHAIN=go1.23.0` 下 `go vet` / `gofmt -l .` / `go test ./...` 全过 ## 怎么验证 ```powershell cd D:\chengma\cmautobuy\admin $env:GOTOOLCHAIN="go1.23.0" go vet ./...; gofmt -l .; go test ./... -count=1 Remove-Item Env:GOTOOLCHAIN ``` 端到端(**用数据库备份跑,不要在正式库上试**): 1. 迁移后: `SELECT COUNT(*) FROM syb_orders WHERE (product_spec IS NOT NULL AND TRIM(product_spec)<>'') AND (spec_key IS NULL OR spec_key='')` → **0** `SELECT COUNT(*) FROM syb_orders WHERE TRIM(COALESCE(product_spec,''))='' AND spec_key IS NOT NULL` → **0** `SELECT COUNT(*) FROM syb_orders o WHERE NOT EXISTS (SELECT 1 FROM shopee_products p WHERE p.goods_id=o.shopee_goods_id)` → **0** `SELECT source, COUNT(*) FROM shopee_products GROUP BY source` → report ≈5195,syb ≈2936 2. `/syb` 阶段筛选里不再有「未找到蝦皮商品」「待确认蝦皮规格」 3. 挑一条报表里没有的商品的明细:关联 PDD → 建采集任务成功(不再提示"请先确认蝦皮规格") 4. curl 模拟采集结果提交 → 该明细进「规格待匹配」→ 弹窗选规格保存 5. 同商品同规格的另一条明细 → 应已是「可创建采购任务」 6. 创建采购任务成功 交付报告必须包含第 1、3、5 步的实际输出,以及三断点重跑的测试输出。 ## 风险和回退 | 风险 | 应对 | |---|---| | SpecKey 两处实现不一致,命中率静默归零 | 中立包单一函数 + 空白变体测试 | | **空规格共键 → 买错货** | NULL 阻断 + 禁止保存映射,已列验收项 | | 骨架覆盖 Excel 标题 | no-op upsert + 专项测试 | | 回填漏行,97% 依旧卡死 | 主键游标分批 + 全量 SQL 断言 | | 转换出永远不生效的死映射 | 逐条验证命中并分类报数 | | 迁移中断后无法重跑 | 三断点收敛测试 | | 又一次原地改迁移 | 只追加已列验收项 | `[必须]` **回退不是只跑 `git revert`。** 「版本高于程序支持所以拒绝启动」 只防止旧代码误跑,不是业务回退。实施前必须准备好: - 应用二进制备份 - **MySQL 逻辑备份**(`mysqldump`)及恢复命令 - 旧映射转换报告 - 停机窗口 迁移未通过时**恢复数据库快照 + 旧二进制**,而不是只 `git revert`。 ## 与 #67 / #68 / #69 的关系 三张单**都已实现**,就是现在跑着的代码。本工单只推翻其中一部分, 关闭时必须说清哪半作废、哪半仍然有效,否则会被当整块清掉。 | | 处置 | 说明 | |---|---|---| | **#67** 建立顺运宝采购处理状态并识别蝦皮 SKU | **落地时关闭** | 核心「自动确认/手动选择蝦皮 SKU」正是本工单要删的那一步。**但「处理阶段 + 阶段筛选 + 同步不覆盖已确认字段」这套框架仍然有效**,本工单是在它之上重整阶段,不是推翻框架 | | **#68** 统一蝦皮关联 PDD 与采集入口 | **保持 open,不关** | 「两个入口调用同一个 Service」「换 PDD 需确认」「采集任务复用幂等/超时规则」**全部继续有效**。本工单只删掉它的一个前置条件——「蝦皮 SKU 已确认时才提供入口」 | | **#69** 实现可复用规格映射与采购任务创建 | **落地时关闭** | 映射部分(`sku_mappings`、查询同时限定蝦皮 SKU 与 PDD)被本工单替代。**但采购任务创建的六条校验、批量选客户端、逐行价格上限、重复提交保护全部继续有效**,本工单不动它们 | ### 父工单同步的时点 `[必须]` **分两步,时点不同**: | 何时 | 做什么 | |---|---| | **用户确认本版设计后、开始改代码前** | 把 #88 加入 #14/#15 子工单清单;在清单里标明 **#67/#69 由 #88 替代、#68 保留** | | **#88 实施并验收通过后** | 才关闭 #67/#69,并在各自评论里贴上面那张「哪半作废哪半有效」的表 | 索引必须先更新——否则实施方照着过期的父工单清单干活,会以为 #67/#69 仍然是要遵守的设计基线。
Author
Owner

工单已改写:迁移方案从 SQLite 换成 MySQL 8

初版的迁移设计是对着 SQLite 写的(PRAGMA user_version、sqlite_master、ON CONFLICT DO NOTHING、#20 的三起点收敛测试)。这是错的——本项目已经切到 MySQL 8:

  • main.go 走 repository.OpenMySQL + repository.MigrateMySQL
  • db.go 里那套 SQLite Migrate / schemaVersion 已无生产调用方,只服务历史测试和一次性导入工具
  • 版本号记在 schema_migrations 表,不是 PRAGMA user_version
  • 当前 mysqlSchemaVersion = 2

已改写的部分:

初版(错) 现在
迁移位置 db.go 新增一版 mysql_db.go 追加 mysqlSchemaV3
建表 DDL SQLite TEXT VARCHAR(191) COLLATE utf8mb4_bin + InnoDB/utf8mb4
骨架补建 ON CONFLICT DO NOTHING INSERT IGNORE(前者在 MySQL 上直接报错)
迁移测试 三起点收敛 v2 → v3 升级测试
sqlite_to_mysql.go 允许改列清单 完全不动

另外补了三条 MySQL 特有的约束:

  1. 主键三列用 utf8mb4_bin —— 保证「藍」和「蓝」不会被 collation 判为相等。归一化是 SpecKey() 的职责,交给数据库做会让 Go 算出的键和库里认的键不一致。
  2. 自检通过才记版本 —— 照 v1/v2 已有写法。中途失败不记版本,下次重跑,而不是留下「版本说升级了、表其实没建好」的库。
  3. 回填分批提交(每 500 行一个事务)—— 5334 行放一个大事务会长时间持锁,失败还得全量重来。

[必须] 迁移测试要覆盖 v2 → v3 升级,不是只测全新库建到 v3。只测全新库的话回填逻辑一行都跑不到,而回填正是这一版的主要内容。

## 工单已改写:迁移方案从 SQLite 换成 MySQL 8 初版的迁移设计是对着 SQLite 写的(`PRAGMA user_version`、`sqlite_master`、`ON CONFLICT DO NOTHING`、#20 的三起点收敛测试)。**这是错的**——本项目已经切到 MySQL 8: - `main.go` 走 `repository.OpenMySQL` + `repository.MigrateMySQL` - `db.go` 里那套 SQLite `Migrate` / `schemaVersion` **已无生产调用方**,只服务历史测试和一次性导入工具 - 版本号记在 `schema_migrations` 表,不是 `PRAGMA user_version` - 当前 `mysqlSchemaVersion = 2` 已改写的部分: | | 初版(错) | 现在 | |---|---|---| | 迁移位置 | `db.go` 新增一版 | **`mysql_db.go` 追加 `mysqlSchemaV3`** | | 建表 DDL | SQLite `TEXT` | **`VARCHAR(191) COLLATE utf8mb4_bin` + InnoDB/utf8mb4** | | 骨架补建 | `ON CONFLICT DO NOTHING` | **`INSERT IGNORE`**(前者在 MySQL 上直接报错) | | 迁移测试 | 三起点收敛 | **v2 → v3 升级测试** | | `sqlite_to_mysql.go` | 允许改列清单 | **完全不动** | 另外补了三条 MySQL 特有的约束: 1. **主键三列用 `utf8mb4_bin`** —— 保证「藍」和「蓝」不会被 collation 判为相等。归一化是 `SpecKey()` 的职责,交给数据库做会让 Go 算出的键和库里认的键不一致。 2. **自检通过才记版本** —— 照 v1/v2 已有写法。中途失败不记版本,下次重跑,而不是留下「版本说升级了、表其实没建好」的库。 3. **回填分批提交**(每 500 行一个事务)—— 5334 行放一个大事务会长时间持锁,失败还得全量重来。 `[必须]` 迁移测试要覆盖 **v2 → v3 升级**,不是只测全新库建到 v3。只测全新库的话回填逻辑一行都跑不到,而回填正是这一版的主要内容。
Author
Owner

Codex 全栈评审:待 Claude Code 审核的修订建议

本评论基于当前代码和线上 MySQL 真实分布,尚未替代工单正文。请 Claude Code 逐项审核;确认后再改写方案和验收标准,当前不要直接实施。

总体结论与数据依据

主方向合理:顺运宝原文已有 shopee_goods_id + product_spec,蝦皮报表覆盖率不足,不应继续作为 PDD 关联和规格匹配的强制前置条件。映射改为 (shopee_goods_id, spec_key, pdd_goods_id) 能解除主链路阻塞,并保留“换 PDD 后不误用旧映射”的安全性质。

线上只读核对:

  • syb_orders 5,334 条;5,148 条找不到 shopee_products,涉及 2,936 个商品。
  • 只有 2 条已确认 shopee_sku_id,当前 sku_mappings 为 1 条,不是正文写的 2 条。
  • product_spec 为空 4 条,涉及 3 个商品,其中一个商品有多条空规格。
  • 规格原文最长 39 字,当前没有超过 191 字的行。
  • 现有 5,195 个蝦皮商品中有 3,909 个没有任何 SKU,因此“有没有 SKU 行”不能可靠推断是不是 SYB 骨架。

必须修���

1. SpecKey() 的包位置会造成循环依赖

正文把 SpecKey() 放在 admin/service,同时要求 admin/repository/mysql_db.go 在迁移中调用。当前依赖方向是 service → repository,repository 再导入 service 会形成 Go import cycle。

建议放入无数据库依赖的中立包,例如 admin/spec;repository、service 和后续 #90 共同调用。SpecKey 只负责身份键,匹配建议的繁简/颜色/尺码归一化应是另一个函数,不能改变身份键语义。

2. 空规格必须单独阻断,不能强行生成非空键

若空规格统一使用空字符串,同商品的多条未知规格会共享映射,存在买错货风险。建议:

  • 空规格的 spec_key 保持 NULL;
  • 状态进入“采购数据异常:顺运宝未提供规格”;
  • 禁止保存映射和创建采购任务;
  • 验收 SQL 改为“所有非空 product_spec 的 spec_key 均非空”,并单独核对空规格仍被安全阻断。

VARCHAR(191) 对当前数据足够,但 SpecKey 仍应显式拒绝超过数据库上限的键,绝不能静默截断。

3. 必须有可靠的商品来源标识

迁移会补约 2,936 个商品骨架,而当前已有 3,909 个报表商品本来就没有 SKU,无法按 SKU 是否存在判断来源。

建议给 shopee_products 增加明确来源字段(例如 source=report|syb,或等价事实字段)。SYB 只建 syb 骨架,Excel 导入时更新为 report。蝦皮页面至少显示来源或支持筛选,避免骨架行被误认为完整报表商品。

4. 旧映射应转换,不应直接丢弃

当前线上已有 1 条映射,实施时数量还可能增加。旧数据可通过:

sku_mappings JOIN shopee_skus → goods_id + spec_raw → SpecKey(spec_raw) → spec_mappings

完成转换。建议先迁移并逐条核对,再 DROP 旧表;无法转换的行要留在 root-only 迁移备份并给出数量,不能只打一条丢弃日志。

5. v3 必须真正可重放

ALTER TABLE ADD COLUMN、DROP TABLE、分批回填在 DDL 已提交但版本尚未记录时可能无法重跑。应明确:

  • 加列前查 information_schema.columns;
  • 删除前检查表存在;
  • 回填只处理 spec_key IS NULL,按稳定主键游标分批,不用 OFFSET;
  • 骨架、旧映射转换均幂等 upsert;
  • 在“DDL 完成 / 部分回填 / 回填完成但版本未记”三个断点重跑都能收敛。

6. MySQL 自检不能继续直接复用 SQLite 的 requiredTables

当前共享清单固定包含 sku_mappings。v3 DROP 旧表后如果只加入 spec_mappings,CheckMySQLSchema 会失败;直接改共享清单又会破坏冻结的 SQLite 历史结构。

建议在 mysql_db.go 建 MySQL 专用必需表/列清单:排除 sku_mappings、加入 spec_mappings、来源字段和 syb_orders.spec_key;SQLite 清单保持不动。

7. 不建议使用宽泛的 INSERT IGNORE

INSERT IGNORE 不只忽略主键重复,还可能把截断等数据错误降级为警告。建议使用明确的 no-op upsert:

INSERT INTO shopee_products (...)
VALUES (...)
ON DUPLICATE KEY UPDATE goods_id = VALUES(goods_id)

除重复商品外的数据库错误应让当前同步失败且不推进游标。

8. 事务语义需要统一

正文同时要求“骨架与订单同事务”和“个别骨架失败不阻塞订单”,两者矛盾。建议一致性优先:骨架创建失败时本条订单事务失败,本次同步不推进游标,不允许静默留下半套上下文。

9. 阶段筛选 SQL 也必须改

不仅要删 sybStageFor 的两个 case,repository/syb.go 的 sybOrderFilterClause 仍含 shopee_sku_id 条件,高级阶段还会全量读取再在 Go 中筛选。必须同步重写阶段常量、下拉选项、SQL 条件和高级阶段测试,否则列表状态与筛选数量会不一致。

10. 补齐范围和验收

预计修改文件还应包含 model 新映射结构、中立 spec 包���测试。建议新增验收:

  • v2 → v3 三个中断点重跑均收敛;
  • 旧映射成功转换且选项仍有效;
  • 4 条空规格保持采购阻断;
  • 报表商品与 SYB 骨架来源可区分,Excel 导入能把骨架提升为 report 且不覆盖 PDD 关联;
  • 每个阶段筛选数与逐行实时推导结果一致;
  • PDD 重采后旧 option key 不存在时,映射仍按现有安全逻辑失效。

回退方案需要加强

“git revert 后因 schema 版本过高拒绝启动”只是防止旧代码误跑,不是业务回退。实施前应记录应用备份、MySQL 逻辑备份、旧映射转换报告、恢复命令和停机窗口。迁移未通过时恢复数据库快照和旧二进制,而不是只执行 git revert。

工单关系

#88 实质上重写 #67~#69 的核心设计。审核通过后,应在 #14/#15 中加入 #88,并明确 #67/#68/#69 哪些被替代、哪些继续有效。原文“实施方不得改设计”建议改成“实质性变化先更新工单并重新审核”,与仓库 AGENTS.md 一致。

## Codex 全栈评审:待 Claude Code 审核的修订建议 > 本评论基于当前代码和线上 MySQL 真实分布,尚未替代工单正文。请 Claude Code 逐项审核;确认后再改写方案和验收标准,当前不要直接实施。 ### 总体结论与数据依据 主方向合理:顺运宝原文已有 `shopee_goods_id + product_spec`,蝦皮报表覆盖率不足,不应继续作为 PDD 关联和规格匹配的强制前置条件。映射改为 `(shopee_goods_id, spec_key, pdd_goods_id)` 能解除主链路阻塞,并保留“换 PDD 后不误用旧映射”的安全性质。 线上只读核对: - `syb_orders` 5,334 条;5,148 条找不到 `shopee_products`,涉及 2,936 个商品。 - 只有 2 条已确认 `shopee_sku_id`,当前 `sku_mappings` 为 1 条,不是正文写的 2 条。 - `product_spec` 为空 4 条,涉及 3 个商品,其中一个商品有多条空规格。 - 规格原文最长 39 字,当前没有超过 191 字的行。 - 现有 5,195 个蝦皮商品中有 3,909 个没有任何 SKU,因此“有没有 SKU 行”不能可靠推断是不是 SYB 骨架。 ### 必须修��� #### 1. `SpecKey()` 的包位置会造成循环依赖 正文把 `SpecKey()` 放在 `admin/service`,同时要求 `admin/repository/mysql_db.go` 在迁移中调用。当前依赖方向是 service → repository,repository 再导入 service 会形成 Go import cycle。 建议放入无数据库依赖的中立包,例如 `admin/spec`;repository、service 和后续 #90 共同调用。`SpecKey` 只负责身份键,匹配建议的繁简/颜色/尺码归一化应是另一个函数,不能改变身份键语义。 #### 2. 空规格必须单独阻断,不能强行生成非空键 若空规格统一使用空字符串,同商品的多条未知规格会共享映射,存在买错货风险。建议: - 空规格的 `spec_key` 保持 `NULL`; - 状态进入“采购数据异常:顺运宝未提供规格”; - 禁止保存映射和创建采购任务; - 验收 SQL 改为“所有非空 `product_spec` 的 `spec_key` 均非空”,并单独核对空规格仍被安全阻断。 `VARCHAR(191)` 对当前数据足够,但 `SpecKey` 仍应显式拒绝超过数据库上限的键,绝不能静默截断。 #### 3. 必须有可靠的商品来源标识 迁移会补约 2,936 个商品骨架,而当前已有 3,909 个报表商品本来就没有 SKU,无法按 SKU 是否存在判断来源。 建议给 `shopee_products` 增加明确来源字段(例如 `source=report|syb`,或等价事实字段)。SYB 只建 `syb` 骨架,Excel 导入时更新为 `report`。蝦皮页面至少显示来源或支持筛选,避免骨架行被误认为完整报表商品。 #### 4. 旧映射应转换,不应直接丢弃 当前线上已有 1 条映射,实施时数量还可能增加。旧数据可通过: `sku_mappings JOIN shopee_skus → goods_id + spec_raw → SpecKey(spec_raw) → spec_mappings` 完成转换。建议先迁移并逐条核对,再 DROP 旧表;无法转换的行要留在 root-only 迁移备份并给出数量,不能只打一条丢弃日志。 #### 5. v3 必须真正可重放 `ALTER TABLE ADD COLUMN`、`DROP TABLE`、分批回填在 DDL 已提交但版本尚未记录时可能无法重跑。应明确: - 加列前查 `information_schema.columns`; - 删除前检查表存在; - 回填只处理 `spec_key IS NULL`,按稳定主键游标分批,不用 OFFSET; - 骨架、旧映射转换均幂等 upsert; - 在“DDL 完成 / 部分回填 / 回填完成但版本未记”三个断点重跑都能收敛。 #### 6. MySQL 自检不能继续直接复用 SQLite 的 `requiredTables` 当前共享清单固定包含 `sku_mappings`。v3 DROP 旧表后如果只加入 `spec_mappings`,`CheckMySQLSchema` 会失败;直接改共享清单又会破坏冻结的 SQLite 历史结构。 建议在 `mysql_db.go` 建 MySQL 专用必需表/列清单:排除 `sku_mappings`、加入 `spec_mappings`、来源字段和 `syb_orders.spec_key`;SQLite 清单保持不动。 #### 7. 不建议使用宽泛的 `INSERT IGNORE` `INSERT IGNORE` 不只忽略主键重复,还可能把截断等数据错误降级为警告。建议使用明确的 no-op upsert: ```sql INSERT INTO shopee_products (...) VALUES (...) ON DUPLICATE KEY UPDATE goods_id = VALUES(goods_id) ``` 除重复商品外的数据库错误应让当前同步失败且不推进游标。 #### 8. 事务语义需要统一 正文同时要求“骨架与订单同事务”和“个别骨架失败不阻塞订单”,两者矛盾。建议一致性优先:骨架创建失败时本条订单事务失败,本次同步不推进游标,不允许静默留下半套上下文。 #### 9. 阶段筛选 SQL 也必须改 不仅要删 `sybStageFor` 的两个 case,`repository/syb.go` 的 `sybOrderFilterClause` 仍含 `shopee_sku_id` 条件,高级阶段还会全量读取再在 Go 中筛选。必须同步重写阶段常量、下拉选项、SQL 条件和高级阶段测试,否则列表状态与筛选数量会不一致。 #### 10. 补齐范围和验收 预计修改文件还应包含 model 新映射结构、中立 spec 包���测试。建议新增验收: - v2 → v3 三个中断点重跑均收敛; - 旧映射成功转换且选项仍有效; - 4 条空规格保持采购阻断; - 报表商品与 SYB 骨架来源可区分,Excel 导入能把骨架提升为 report 且不覆盖 PDD 关联; - 每个阶段筛选数与逐行实时推导结果一致; - PDD 重采后旧 option key 不存在时,映射仍按现有安全逻辑失效。 ### 回退方案需要加强 “git revert 后因 schema 版本过高拒绝启动”只是防止旧代码误跑,不是业务回退。实施前应记录应用备份、MySQL 逻辑备份、旧映射转换报告、恢复命令和停机窗口。迁移未通过时恢复数据库快照和旧二进制,而不是只执行 git revert。 ### 工单关系 #88 实质上重写 #67~#69 的核心设计。审核通过后,应在 #14/#15 中加入 #88,并明确 #67/#68/#69 哪些被替代、哪些继续有效。原文“实施方不得改设计”建议改成“实质性变化先更新工单并重新审核”,与仓库 AGENTS.md 一致。
Author
Owner

评审已逐条审核:9 条采纳,1 条采纳并加限定。正文已重写

先谢一句:这份评审抓到了两个会让实施直接撞墙的硬错误,第 1 条和第 6 条不改的话动手就编译不过 / 启动自检失败。

已核对的事实

评审所述 实测
空规格 4 条 / 3 个商品 4 条 / 3 个 ✅ 且有一个商品占 2 条,共键确会买错货
3909 个报表商品没有 SKU 3909 / 5195 = 75% ✅ 直接推翻了正文原来的判断
规格最长 39 字,无超 191 39 / 0 条 ✅
sku_mappings 1 条 实际 2 条 ⚠️ 小出入,不影响结论

逐条结论

# 结论 说明
1 SpecKey 循环依赖 采纳 service→repository 是既有方向,repository 反向导入是 import cycle,编译不过。已改为中立包 admin/spec
2 空规格 NULL 阻断 采纳 实测有商品占 2 条空规格,共键正是 #16 要防的那类问题
3 来源标识 采纳 正文原写「可从有没有 SKU 行推断」,75% 报表商品本来就没 SKU,该判断已证伪。加 source 字段
4 旧映射转换 采纳并加限定 见下
5 v3 可重放 采纳 MySQL DDL 隐式提交,已补三个断点的幂等做法和收敛测试
6 MySQL 专用自检清单 采纳 共享 requiredTables 含 sku_mappings,DROP 后自检必炸。这条我完全没想到
7 不用 INSERT IGNORE 采纳 它会把截断/类型错误降级成警告。改用明确的 no-op upsert
8 事务语义统一 采纳 正文确实自相矛盾,已按一致性优先改写
9 阶段筛选 SQL 采纳 sybOrderFilterClause 仍含 shopee_sku_id,只删 sybStageFor 的 case 会让显示与筛选数量对不上
10 补齐范围和验收 采纳 已补 model、中立包测试及各项验收
回退方案 采纳 已改为要求 MySQL 逻辑备份 + 恢复命令 + 停机窗口,git revert 只防旧代码误跑
「不得改设计」措辞 采纳 改为「发现实质性问题先停下来更新工单并重新审核」,与 CLAUDE.md §5/§7.5 一致

第 4 条的限定:转换要验证命中

我核了那 2 条的可转换性,都能转:

240909165136 → 灰色,3XL 67.5-72.5公斤    在 syb_orders 精确命中 1 条
315692635436 → 黑色,XL【68-82.5kg】      在 syb_orders 精确命中 1 条

但转换链有个评审没提的风险:产出的键来自蝦皮 Excel 的 spec_raw,
而运行时要匹配的是顺运宝的 product_spec。两者来源不同,繁简/空格可能有差异——
这次凑巧一致,不代表以后一致。

所以采纳转换,但加一条硬要求:转换后逐条验证该键在 syb_orders.spec_key 里
真能命中,分三类报数(命中 / 转换但当前无命中 / 无法转换)。
不验证会写进一批永远不生效的死映射,而且没人知道。

评审没提、我补的一条

索引键长余量很薄:VARCHAR(191) × 4 字节 × 3 列 = 2292,
InnoDB DYNAMIC 上限 3072,余 780。任何一列改宽都会直接撞上限建表失败。
已写进正文,免得以后有人「顺手把 191 改成 255」。

工单关系已写进正文

三张单都已实现,各有一半仍然有效,关闭时不能笼统说"被替代":

  • #67 落地时关闭——「自动确认/手动选择蝦皮 SKU」作废,但处理阶段 + 阶段筛选 + 同步不覆盖已确认字段这套框架仍有效
  • #68 保持 open——只删掉「蝦皮 SKU 已确认才提供入口」这个前置,其余全部继续有效
  • #69 落地时关闭——映射部分作废,但采购任务的六条校验、批量选客户端、逐行价格上限、重复提交保护全部继续有效

关闭时会把这张表贴进评论,并在 #14/#15 加入 #88。

## 评审已逐条审核:9 条采纳,1 条采纳并加限定。正文已重写 先谢一句:这份评审抓到了**两个会让实施直接撞墙的硬错误**,第 1 条和第 6 条不改的话动手就编译不过 / 启动自检失败。 ### 已核对的事实 | 评审所述 | 实测 | | |---|---|---| | 空规格 4 条 / 3 个商品 | 4 条 / 3 个 | ✅ 且**有一个商品占 2 条**,共键确会买错货 | | 3909 个报表商品没有 SKU | 3909 / 5195 = **75%** | ✅ **直接推翻了正文原来的判断** | | 规格最长 39 字,无超 191 | 39 / 0 条 | ✅ | | `sku_mappings` 1 条 | 实际 **2 条** | ⚠️ 小出入,不影响结论 | ### 逐条结论 | # | 结论 | 说明 | |---|---|---| | 1 SpecKey 循环依赖 | **采纳** | service→repository 是既有方向,repository 反向导入是 import cycle,编译不过。已改为中立包 `admin/spec` | | 2 空规格 NULL 阻断 | **采纳** | 实测有商品占 2 条空规格,共键正是 #16 要防的那类问题 | | 3 来源标识 | **采纳** | 正文原写「可从有没有 SKU 行推断」,75% 报表商品本来就没 SKU,**该判断已证伪**。加 `source` 字段 | | 4 旧映射转换 | **采纳并加限定** | 见下 | | 5 v3 可重放 | **采纳** | MySQL DDL 隐式提交,已补三个断点的幂等做法和收敛测试 | | 6 MySQL 专用自检清单 | **采纳** | 共享 `requiredTables` 含 `sku_mappings`,DROP 后自检必炸。**这条我完全没想到** | | 7 不用 `INSERT IGNORE` | **采纳** | 它会把截断/类型错误降级成警告。改用明确的 no-op upsert | | 8 事务语义统一 | **采纳** | 正文确实自相矛盾,已按一致性优先改写 | | 9 阶段筛选 SQL | **采纳** | `sybOrderFilterClause` 仍含 `shopee_sku_id`,只删 `sybStageFor` 的 case 会让显示与筛选数量对不上 | | 10 补齐范围和验收 | **采纳** | 已补 model、中立包测试及各项验收 | | 回退方案 | **采纳** | 已改为要求 MySQL 逻辑备份 + 恢复命令 + 停机窗口,`git revert` 只防旧代码误跑 | | 「不得改设计」措辞 | **采纳** | 改为「发现实质性问题先停下来更新工单并重新审核」,与 CLAUDE.md §5/§7.5 一致 | ### 第 4 条的限定:转换要验证命中 我核了那 2 条的可转换性,**都能转**: ``` 240909165136 → 灰色,3XL 67.5-72.5公斤 在 syb_orders 精确命中 1 条 315692635436 → 黑色,XL【68-82.5kg】 在 syb_orders 精确命中 1 条 ``` 但转换链有个评审没提的风险:产出的键来自**蝦皮 Excel 的 `spec_raw`**, 而运行时要匹配的是**顺运宝的 `product_spec`**。两者来源不同,繁简/空格可能有差异—— 这次凑巧一致,不代表以后一致。 所以采纳转换,但**加一条硬要求**:转换后逐条验证该键在 `syb_orders.spec_key` 里 真能命中,分三类报数(命中 / 转换但当前无命中 / 无法转换)。 不验证会写进一批**永远不生效的死映射**,而且没人知道。 ### 评审没提、我补的一条 **索引键长余量很薄**:`VARCHAR(191) × 4 字节 × 3 列 = 2292`, InnoDB DYNAMIC 上限 **3072**,余 780。**任何一列改宽都会直接撞上限建表失败**。 已写进正文,免得以后有人「顺手把 191 改成 255」。 ### 工单关系已写进正文 三张单**都已实现**,各有一半仍然有效,关闭时不能笼统说"被替代": - **#67 落地时关闭**——「自动确认/手动选择蝦皮 SKU」作废,但**处理阶段 + 阶段筛选 + 同步不覆盖已确认字段**这套框架仍有效 - **#68 保持 open**——只删掉「蝦皮 SKU 已确认才提供入口」这个前置,其余全部继续有效 - **#69 落地时关闭**——映射部分作废,但**采购任务的六条校验、批量选客户端、逐行价格上限、重复提交保护全部继续有效** 关闭时会把这张表贴进评论,并在 #14/#15 加入 #88。
Author
Owner

Codex 第二轮评审:整体可实施,仍需完成 4 项收口

Claude Code 已充分吸收第一轮评审,架构方向和迁移安全性基本达到实施标准。本评论只列剩余修订,不重复已经解决的内容;请审核后更新正文,再由用户确认。

结论

#88 的核心方案合理:中立 SpecKey、空规格 NULL 阻断、商品来源、旧映射转换、v3 可重放、MySQL 专用自检、no-op upsert、事务一致性、阶段筛选和完整回退均已写清。完成下面 4 项后可以实施。

1. 旧映射数量不得写死为 2 条

2026-08-10 再次只读查询线上 MySQL:

sku_mappings                 1
可 JOIN shopee_skus 转换      1
规格键能命中 syb_orders       1

正文中的“实测 2 条”和验收标准“2 条旧映射”已经与线上状态不一致,实施前数量还可能继续变化。

建议统一改为动态 N:

  • 迁移前统计 N;
  • 转换且命中 + 转换但无命中 + 无法转换 = N;
  • 对所有可转换记录做幂等 upsert;
  • 交付报告记录实施时的实际 N,不在设计中固定具体数量或商品 ID。

单元/集成测试可以使用 2~3 条 fixture 覆盖三种分类,但生产验收不能断言固定为 2。

2. source 增加数据库 CHECK 约束

DDL 建议明确加入:

CHECK (source IN ('report', 'syb'))

并在 MySQL 集成测试中插入未知值,断言数据库拒绝。页面和代码只写两个值并不能阻止以后脚本、迁移或人工 SQL 写入拼写错误。

如果蝦皮页按来源筛选,建议给 source 加普通索引;如果本工单只显示来源、不筛选,则无需为约 8 千行提前加索引。

3. 旧映射完整备份不得进入普通运行日志

启动日志只记录三类数量和必要的非敏感业务 ID。完整 pdd_options、规格原文等备份应保存为:

  • root-only 文件;或
  • 迁移前 MySQL 逻辑备份/受限备份表。

不得把完整映射内容输出到 systemd 日志、Gitea 或 docs/task。正文“导出到日志或临时表”建议删除“日志”选项,明确 root-only 可恢复备份。

4. 父工单同步应发生在实施前

用户确认本版设计后、开始改代码前:

  • 把 #88 加入 #14/#15 子工单清单;
  • 标明 #67/#69 由 #88 替代,#68 保留;
  • 关闭 #67/#69 的动作仍放在 #88 实施并验收完成后。

这符合根 AGENTS.md 的范围变更和父工单同步流程。

建议补充的验收项

  • 迁移前旧映射总数 N 与三类处理结果之和一致,不依赖固定数量
  • source 写入非 report/syb 值时 MySQL CHECK 拒绝
  • 完整旧映射备份为 root-only,日志/工单/归档不含完整业务内容
  • #14/#15 在实施前完成 #88 索引和替代关系更新
## Codex 第二轮评审:整体可实施,仍需完成 4 项收口 > Claude Code 已充分吸收第一轮评审,架构方向和迁移安全性基本达到实施标准。本评论只列剩余修订,不重复已经解决的内容;请审核后更新正文,再由用户确认。 ### 结论 #88 的核心方案合理:中立 `SpecKey`、空规格 NULL 阻断、商品来源、旧映射转换、v3 可重放、MySQL 专用自检、no-op upsert、事务一致性、阶段筛选和完整回退均已写清。完成下面 4 项后可以实施。 ### 1. 旧映射数量不得写死为 2 条 2026-08-10 再次只读查询线上 MySQL: ```text sku_mappings 1 可 JOIN shopee_skus 转换 1 规格键能命中 syb_orders 1 ``` 正文中的“实测 2 条”和验收标准“2 条旧映射”已经与线上状态不一致,实施前数量还可能继续变化。 建议统一改为动态 N: - 迁移前统计 `N`; - `转换且命中 + 转换但无命中 + 无法转换 = N`; - 对所有可转换记录做幂等 upsert; - 交付报告记录实施时的实际 N,不在设计中固定具体数量或商品 ID。 单元/集成测试可以使用 2~3 条 fixture 覆盖三种分类,但生产验收不能断言固定为 2。 ### 2. `source` 增加数据库 CHECK 约束 DDL 建议明确加入: ```sql CHECK (source IN ('report', 'syb')) ``` 并在 MySQL 集成测试中插入未知值,断言数据库拒绝。页面和代码只写两个值并不能阻止以后脚本、迁移或人工 SQL 写入拼写错误。 如果蝦皮页按来源筛选,建议给 `source` 加普通索引;如果本工单只显示来源、不筛选,则无需为约 8 千行提前加索引。 ### 3. 旧映射完整备份不得进入普通运行日志 启动日志只记录三类数量和必要的非敏感业务 ID。完整 `pdd_options`、规格原文等备份应保存为: - root-only 文件;或 - 迁移前 MySQL 逻辑备份/受限备份表。 不得把完整映射内容输出到 systemd 日志、Gitea 或 `docs/task`。正文“导出到日志或临时表”建议删除“日志”选项,明确 root-only 可恢复备份。 ### 4. 父工单同步应发生在实施前 用户确认本版设计后、开始改代码前: - 把 #88 加入 #14/#15 子工单清单; - 标明 #67/#69 由 #88 替代,#68 保留; - 关闭 #67/#69 的动作仍放在 #88 实施并验收完成后。 这符合根 `AGENTS.md` 的范围变更和父工单同步流程。 ### 建议补充的验收项 - [ ] 迁移前旧映射总数 N 与三类处理结果之和一致,不依赖固定数量 - [ ] `source` 写入非 `report/syb` 值时 MySQL CHECK 拒绝 - [ ] 完整旧映射备份为 root-only,日志/工单/归档不含完整业务内容 - [ ] #14/#15 在实施前完成 #88 索引和替代关系更新
Author
Owner

第二轮 4 条全部采纳,正文已更新。可以开工

逐条

1. 旧映射数量不写死 —— 采纳,而且暴露了我一个方法论错误。

我说"实测 2 条"查的是 admin/data/admin.db,那是一次性导入的历史 SQLite 文件,不是生产库。生产早已是 MySQL。我没有权限读 config.yaml(含密码),无法独立验证线上是 1 条——但这不影响结论:在验收标准里写死任何具体数字本身就是错的,实施时还会变。

正文已改为动态 N 加恒等式 转换且命中 + 转换但无命中 + 无法转换 = N,验收断言恒等式而非数字,测试用 2~3 条 fixture 覆盖三种分类。

其余数字(5334 / 5148 / 3909 / 4 条空规格)你独立核过且与我一致,仍然有效。

2. source 加 CHECK —— 采纳。已加 CHECK (source IN ('report','syb')) + 集成测试断言未知值被拒。不加索引(本工单只显示不筛选,8 千行不值得)。

3. 完整备份不进普通日志 —— 采纳。已删掉「日志」选项,改为 root-only 文件或迁移前 MySQL 逻辑备份;日志只留三类数量和非敏感 ID。理由你说得对,而且这个项目实际会把日志贴进工单排查问题。

4. 父工单实施前同步 —— 采纳并已执行:

  • #14 / #15 清单已加入 #88 和 Admin:规格匹配建议(规则层排序,人工确认) (#90)
  • 三条已标注:#67 核心设计被替代(阶段框架仍有效)、#68 保留有效、#69 映射部分被替代(采购校验仍有效)
  • #14 / #15 正文里那段描述旧设计的话(「确定性识别蝦皮 SKU」「按蝦皮 SKU 隔离的映射」)也一并改了——只改清单不改正文,父工单会自相矛盾
  • 关闭 #67/#69 仍留到 #88 验收之后

正文里也把这个两步时点写成表格固定下来了。

结论

两轮共 14 条,全部落地。可以开工。

## 第二轮 4 条全部采纳,正文已更新。**可以开工** ### 逐条 **1. 旧映射数量不写死** —— 采纳,**而且暴露了我一个方法论错误**。 我说"实测 2 条"查的是 `admin/data/admin.db`,那是**一次性导入的历史 SQLite 文件,不是生产库**。生产早已是 MySQL。我没有权限读 `config.yaml`(含密码),**无法独立验证线上是 1 条**——但这不影响结论:**在验收标准里写死任何具体数字本身就是错的**,实施时还会变。 正文已改为动态 `N` 加恒等式 `转换且命中 + 转换但无命中 + 无法转换 = N`,验收断言恒等式而非数字,测试用 2~3 条 fixture 覆盖三种分类。 其余数字(5334 / 5148 / 3909 / 4 条空规格)你独立核过且与我一致,仍然有效。 **2. `source` 加 CHECK** —— 采纳。已加 `CHECK (source IN ('report','syb'))` + 集成测试断言未知值被拒。**不加索引**(本工单只显示不筛选,8 千行不值得)。 **3. 完整备份不进普通日志** —— 采纳。已删掉「日志」选项,改为 root-only 文件或迁移前 MySQL 逻辑备份;日志只留三类数量和非敏感 ID。理由你说得对,而且这个项目**实际会把日志贴进工单排查问题**。 **4. 父工单实施前同步** —— 采纳并**已执行**: - #14 / #15 清单已加入 #88 和 #90 - 三条已标注:**#67 核心设计被替代(阶段框架仍有效)**、**#68 保留有效**、**#69 映射部分被替代(采购校验仍有效)** - #14 / #15 正文里那段描述旧设计的话(「确定性识别蝦皮 SKU」「按蝦皮 SKU 隔离的映射」)**也一并改了**——只改清单不改正文,父工单会自相矛盾 - **关闭 #67/#69 仍留到 #88 验收之后** 正文里也把这个两步时点写成表格固定下来了。 ### 结论 两轮共 14 条,全部落地。可以开工。
Author
Owner

开始实施(2026-08-10)

用户已确认第三轮补充方案,工单正文已更新,状态进入进行中。

实施顺序:先完成 MySQL v3 与规格主键主链路,再做页面/文档和完整回归。开始前已检查工作区:存在与本工单无关的 Client、图片及部署目录改动,实施中将保留并避开,不混入 #88 提交。

## 开始实施(2026-08-10) 用户已确认第三轮补充方案,工单正文已更新,状态进入**进行中**。 实施顺序:先完成 MySQL v3 与规格主键主链路,再做页面/文档和完整回归。开始前已检查工作区:存在与本工单无关的 Client、图片及部署目录改动,实施中将保留并避开,不混入 #88 提交。
Author
Owner

????:????,???

????:d1165b9 feat: ?????????? (#88)

???:

  • ???? admin/spec.SpecKey,?????? (shopee_goods_id, spec_key, pdd_goods_id);?????????????
  • MySQL ?? v3:???????????????????????????????;SQLite ?????????????
  • ???? SKU ?????????;?????????? PDD??????????????
  • ?????? report/syb ??,Excel ????????????????? PDD ???
  • ???/?????????????????????;01/03/05 ????????

????:

  • GOTOOLCHAIN=go1.23.0 go vet ./...:???
  • gofmt -l .:????
  • GOTOOLCHAIN=go1.23.0 go test ./... -count=1:???
  • ?? MySQL 8 ?????:????v2?v3??? DDL/???????? source ??????????????source CHECK ????
  • ????? MySQL ??:??????????????????????????????????PDD A?B?A???/??????????
  • ??:??????? 200;????????? 200,/users ? 403?
  • rg "????????" admin:0 ??

???? autobuy ????? v3;?????,????????????????????? open,???????

## ????:????,??? ????:`d1165b9 feat: ?????????? (#88)` ???: - ???? `admin/spec.SpecKey`,?????? `(shopee_goods_id, spec_key, pdd_goods_id)`;????????????? - MySQL ?? v3:???????????????????????????????;SQLite ????????????? - ???? SKU ?????????;?????????? PDD?????????????? - ?????? `report/syb` ??,Excel ????????????????? PDD ??? - ???/?????????????????????;01/03/05 ???????? ????: - `GOTOOLCHAIN=go1.23.0 go vet ./...`:??? - `gofmt -l .`:???? - `GOTOOLCHAIN=go1.23.0 go test ./... -count=1`:??? - ?? MySQL 8 ?????:????v2?v3??? DDL/???????? source ??????????????source CHECK ???? - ????? MySQL ??:??????????????????????????????????PDD A?B?A???/?????????? - ??:??????? 200;????????? 200,`/users` ? 403? - `rg "????????" admin`:0 ?? ???? `autobuy` ????? v3;?????,????????????????????? open,???????
Author
Owner

???????:docs/task/88-??????????.md

????:c24fac9 docs: ???? #88

?????????;?????????? #88,?????????? #67/#69?

???????:`docs/task/88-??????????.md` ????:`c24fac9 docs: ???? #88` ?????????;?????????? #88,?????????? #67/#69?
Author
Owner

主链路代码已随发布版 5a77bde 上线。线上 Admin 当前 active,仅监听回环地址,公网登录与重定向正常,近期无 warning/error;关键顺运宝、蝦皮、PDD 和规格映射数据计数未发现异常。生产备份和完整验证记录见 #90。

主链路代码已随发布版 `5a77bde` 上线。线上 Admin 当前 active,仅监听回环地址,公网登录与重定向正常,近期无 warning/error;关键顺运宝、蝦皮、PDD 和规格映射数据计数未发现异常。生产备份和完整验证记录见 #90。
Author
Owner

用户已明确验收通过。验收清单已回填,本工单关闭。

用户已明确验收通过。验收清单已回填,本工单关闭。
ila closed this issue 2026-08-10 23:23:38 +08:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: chengma/cmautobuy#88