# `cmd/spdump` —— 读旧库定义的常备工具 ```bash go run ./cmd/spdump proc sp_user_expense_baddebt # SHOW CREATE PROCEDURE go run ./cmd/spdump routines -like "%expense%" # 有哪些 routine(先看清单) go run ./cmd/spdump view yuansiwei.t_school_campus # 跨库视图怎么定义的 go run ./cmd/spdump tables -db finance_bk -like "%activity%" go run ./cmd/spdump columns t_campus -db yuansiwei_2026 # 列类型/默认值/注释 go run ./cmd/spdump sql -db legacy_oracle "select max(id) from t_courses_activity" ``` `-db` 默认 `legacy_oracle`;flags 与位置参数**任意顺序**都行。 ## 为什么它必须存在(不是"方便一下") AC-15 阶段 2 用三次实测证明:**「照抄 SP」是不可行的策略** —— 不是因为它难, 是因为旧库里混着**错注释**和**坏 SQL**,照抄会把缺陷一起搬过来。 | # | 事实 | 如果照抄 / 按名字猜会怎样 | |---|---|---| | 1 | `sp_user_expense_baddebt` 的 `COMMENT` 写着 **`用户余额充值`**(从 recharge 复制忘改) | 按注释判断会把**坏账**当成充值 | | 2 | `sp_user_expense_school_spend` 的 INSERT 分支是 `INSERT INTO t_user_expense(userid,spend) VALUES(inputUserid, inputMoney, inputSpendMoney)` —— **2 列配 3 值**,且 `inputMoney` 在该 SP 里根本不存在 | 照抄会把这句坏 SQL 搬进 Go;它只在"用户没有账户行"时触发,线上从不走到,所以测也测不出来 | | 3 | `sp_user_expense_school_transfer_expenditure` **没有默认备注**(SP1/2/3/4 都有 `IF inputRemark='' THEN SET …`) | 用空备注跑双跑,"备注逐字一致"是**空对空**,等于没测 | | 4 | `sp_user_expense.school_cleared_balance` 名字叫「余额清零」,实际清的是**校区**余额,全局只减去同一个 `campusBalance` | 会断言"清零后全局余额 = 0",而它一般不是 0 | ## 行为从哪里读:证据强度排序 | 依据 | 可靠度 | 反例 | |---|---|---| | **SQL 语句**(UPDATE / INSERT / IF 守卫) | 最高 | 无(这是唯一的事实来源) | | **参数签名** | 高 | 无 —— 签名能反推"不可能发生的事":SP11 只有 4 个参数、连 `requestId` 都没有 ⇒ 它**不可能**幂等、**不可能**写流水 | | 自己的 `COMMENT` | **会骗人** | `baddebt` 的注释写着"用户余额充值" | | 表名 / 函数名 | **会骗人** | `t_user_expense` 字面像"全局表",实际两个充值 SP 都写双表(这一条当时把 team-lead 也带偏了) | ⇒ **证据强度:SQL 语句 > 参数签名 > COMMENT > 表名/函数名。** ## 读 SP 正文的五个坑(每一条都真的让我们算错过) | # | 坑 | 错在哪 | 怎么防 | |---|---|---|---| | 1 | **`DECLARE x DECIMAL` 不带精度就是 `DECIMAL(10,0)`** | `sp_user_expense_custom_refund` 的 `_balance` / `_money` / `_badDate` 全是整数,赋值即四舍五入,而且**收敛两次**(`_balance` 一次、`_badDate` 再算一次)。照"变量就是数字"的直觉写期望,会整整差一元。 | 逐个变量看有没有精度参数;把每一层收敛写成一条等式(见 `c2_refund.go` 的 `round0Cents`)。 | | 2 | **连续两个 `IF` 不等于 `if/else`** | `IF(inputMoney>_balance) THEN SET _badDate=…; SET _money=…; END IF;` 之后另起 `IF(_badDate>0) THEN … ELSE 扣 inputMoney END IF;`。差额不足 0.5 时 `_badDate` 收敛成 0 ⇒ **进了第一个 IF 却走 ELSE,扣的是 `inputMoney` 而不是 `_money`**。按 if/else 读会期望扣 1.00 而实测 1.30。 | **任何 SP 出现连续两个 `IF`,都要单独确认它们是否互斥**;不互斥就分别判断,别合成一个二分支。锁在 `TestLegacyRefundExpectBadDateRoundsToZero`。 | | 3 | **`SELECT … INTO` 无行可赋时不改变量** | 变量停在 `DEFAULT 0`,于是"账户行不存在"与"余额真的是 0"在 SP 里**不可区分** —— `custom_refund` 会因此静默 `success=0`,调用方看到的是"成功"。 | 断言"账户行是否存在"要单独读,不能从变量值反推。 | | 4 | **`OR` 的短路能把 NULL 变成"走另一条分支"** | `IF(ISNULL(_balance) OR _balance<=0)`:NULL 时**左边**就成立、右边根本不求值 ⇒ 结果是"拒绝",不是"NULL 参与比较"。 | 遇到 `ISNULL(x) OR y` 一定要想"x 为 NULL 时 y 会不会被求值"。 | | 5 | **同名逻辑出现在多处,执行位置可能不同** | `tmp_user_activity` 那段在 `cleared_balance` 里跑在**写流水之前**、在 `custom_refund` 里跑在**写流水之后**。SQL 逐字相同,事务内顺序不同 ⇒ 失败终态组合不同。 | 同一段 SQL 出现在 N 处就测 N 次;并且各自记下"它在流水之前还是之后"(见 C2-10)。 | ## 安全约束(硬性,不是约定) - 只连 `fixture.allowedDatabases` 白名单里的**本机**库(`OpenReadOnlyDB` 里守); - `sql` 子命令只接受 `SELECT / SHOW / DESC / EXPLAIN / WITH` 开头的**单条**语句(分号后还有内容就拒绝); - 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,服务端也会拒绝本会话的写; - 口令只经 `AC15_*` 环境变量或仓库既有 `config/dev.json` 读取,**不打印、不落盘**。 ## 一个自己踩过的坑(写在这里免得下一个人再踩) `spdump columns t_campus -db yuansiwei_2026` —— 这种"先对象、后库名"的写法, 如果直接用 Go 的 `flag` 包解析,`-db` 会被**静默丢弃**(`flag` 遇到第一个非 flag 参数就停止解析), 于是连到默认库、查不到列、打印 `(0 行)`。 **`(0 行)` 看起来像"这表没有列",是个错误答案,而不是一个错误。** 这类失败比报错危险。 所以 `main.go` 里先做 `splitFlagsAndArgs` 重排,并有单测锁住(`TestSplitFlagsAndArgs`)。