Q 9e2b0b7dc5 fix(ac15): 形态统计唯一化 —— 12+2+1+2=17 那个数是错的,真值 2+2+1+10=15 14 ore în urmă
..
README.md 9e2b0b7dc5 fix(ac15): 形态统计唯一化 —— 12+2+1+2=17 那个数是错的,真值 2+2+1+10=15 14 ore în urmă
main.go 695308f72c feat(ac15): 把「读旧库定义」固化成常备工具 cmd/spdump(并修掉它自己的一个静默错误) 17 ore în urmă
main_test.go 695308f72c feat(ac15): 把「读旧库定义」固化成常备工具 cmd/spdump(并修掉它自己的一个静默错误) 17 ore în urmă

README.md

cmd/spdump —— 读旧库定义的常备工具

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)。
6 SELECT var = expr 不是赋值,SET var = expr 才是 user_discount_refund_user_activity 里写的是 IF (inputAmount>logDiscountAmountUsed) THEN SELECT inputAmount = logDiscountAmountUsed; END IF; —— 这句只产生一个布尔结果列(比较的返回值),inputAmount 一位都不变。所以"退超就压到流水额"这道钳制从未生效:type=3 代金券把入参覆盖成面额后,SP 直接按面额退,池行 discount_amount_used 退成负数。照"这是钳制"去读,期望值会算错一整笔面额。 见到 SELECT 后面跟 x = … 的形式,先判断 = 左边是不是变量名:是 ⇒ 这是 T-SQL 赋值语法被硬搬过来的死代码,不产生任何效果。spdump 的全文里对 SELECT .* = 单独 grep 一遍,逐处确认。锁在 TestActivityChainVerdictIsSingleLabel 的同批 case DRA-2(Case.LegacyRefundTakesFullFace)。

第 6 条特别值得记一笔:它没有任何症状。SP 不报错、不警告、SELECT 还正常返回一个结果集, 调用方拿到的 success=1 也完全正常 —— 唯一的痕迹是"池行用量变成了负数", 而这只有在写期望值时才看得出来。所以读 SP 不能只读"控制流像什么", 还要读"每一行到底改了哪个变量"。

安全约束(硬性,不是约定)

  • 只连 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)。