main.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. // Command dualrun 是 AC-15 双跑 harness 的 CLI 入口。
  2. //
  3. // 用法:
  4. //
  5. // dualrun stage1 --case-set real100 # 真实基线 100 条
  6. // dualrun stage1 --case-set synthetic # 合成边界 6 类 × 5
  7. // dualrun stage1 --all --out report.json --md report.md
  8. // dualrun stage1 --dry-run # 只列 case 不执行
  9. // dualrun stage1 --all --idempotency # 额外做幂等验证
  10. //
  11. // 环境变量(口令只经环境变量,不落盘):
  12. //
  13. // AC15_DB_HOST / AC15_DB_PORT / AC15_DB_NAME / AC15_DB_USER / AC15_DB_PASS
  14. // AC15_LEGACY_REPO legacy 应用根目录(默认 E:/study/php/ysw-finance/yuansiwei-finance)
  15. // AC15_PHP_BIN php 可执行(默认 php)
  16. //
  17. // 退出码:0 = 跑完(无论结论如何);2 = 参数错误;3 = 环境层面失败(守卫/断言/连接)。
  18. package main
  19. import (
  20. "context"
  21. "flag"
  22. "fmt"
  23. "os"
  24. "strings"
  25. "yuansiwei/unified-backend/internal/academic/dualrun"
  26. "yuansiwei/unified-backend/internal/academic/dualrun/stage2"
  27. )
  28. func main() {
  29. if len(os.Args) < 2 {
  30. usage()
  31. os.Exit(2)
  32. }
  33. switch os.Args[1] {
  34. case "stage1":
  35. os.Exit(runStage1(os.Args[2:]))
  36. case "stage2":
  37. os.Exit(runStage2(os.Args[2:]))
  38. case "sentinel":
  39. os.Exit(runSentinel(os.Args[2:]))
  40. case "resultsets":
  41. os.Exit(runResultSets(os.Args[2:]))
  42. case "-h", "--help", "help":
  43. usage()
  44. os.Exit(0)
  45. default:
  46. fmt.Fprintf(os.Stderr, "未知子命令:%s\n\n", os.Args[1])
  47. usage()
  48. os.Exit(2)
  49. }
  50. }
  51. func usage() {
  52. fmt.Fprint(os.Stderr, `dualrun —— AC-15 双跑 harness
  53. 子命令:
  54. stage1 跑课酬链比对(课酬,纯函数)
  55. stage2 跑写 SP 双跑(消费:sp_user_expense_school_expenditure ↔ FundService.Expenditure)
  56. resultsets 跑**只读 SP 的结果集双跑**(账单 / 可退金额 / 课酬详情;不写库,独立分母)
  57. sentinel 跑存量哨兵:全库扫「全局列 <=> Σ校区列」(不挂在 stage2 的 case 里)
  58. stage1 参数:
  59. --case-set string real100 | synthetic | all(默认 all)
  60. --all 等价于 --case-set all
  61. --dry-run 只列 case 不执行
  62. --out string JSON 报告路径(默认 dualrun-stage1.json)
  63. --md string Markdown 报告路径(默认 dualrun-stage1.md)
  64. --idempotency 额外做幂等验证(同 case 连跑两次)
  65. --real-count int 真实基线条数(默认 100)
  66. --per-cat int 合成每类条数(默认 5)
  67. `)
  68. }
  69. func runStage1(args []string) int {
  70. fs := flag.NewFlagSet("stage1", flag.ContinueOnError)
  71. fs.SetOutput(os.Stderr)
  72. caseSet := fs.String("case-set", "all", "real100 | synthetic | all")
  73. all := fs.Bool("all", false, "等价于 --case-set all")
  74. dryRun := fs.Bool("dry-run", false, "只列 case 不执行")
  75. out := fs.String("out", "dualrun-stage1.json", "JSON 报告路径")
  76. md := fs.String("md", "dualrun-stage1.md", "Markdown 报告路径")
  77. idem := fs.Bool("idempotency", false, "幂等验证")
  78. realCount := fs.Int("real-count", 100, "真实基线条数")
  79. perCat := fs.Int("per-cat", 5, "合成每类条数")
  80. if err := fs.Parse(args); err != nil {
  81. return 2
  82. }
  83. if *all {
  84. *caseSet = "all"
  85. }
  86. cfg := dualrun.LoadConfigFromEnv()
  87. // 守卫违反会 panic(验收要求),这里转成清晰的退出码与提示
  88. exit := 0
  89. func() {
  90. defer func() {
  91. if r := recover(); r != nil {
  92. fmt.Fprintf(os.Stderr, "FATAL: %v\n", r)
  93. exit = 3
  94. }
  95. }()
  96. rep, err := dualrun.RunStage1(context.Background(), cfg, dualrun.Options{
  97. CaseSet: *caseSet,
  98. DryRun: *dryRun,
  99. OutJSON: *out,
  100. OutMD: *md,
  101. Idempotency: *idem,
  102. RealCount: *realCount,
  103. PerCat: *perCat,
  104. })
  105. if err != nil {
  106. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  107. exit = 3
  108. return
  109. }
  110. fmt.Printf("cases=%d dryRun=%v\n", len(rep.Cases)+len(rep.DryRunCases), rep.DryRun)
  111. for _, a := range dualrun.AllAttributions {
  112. if n := rep.Summary[a]; n > 0 {
  113. fmt.Printf(" %-24s %d\n", a, n)
  114. }
  115. }
  116. if rep.DryRun {
  117. fmt.Printf("dry-run 清单已写入 %s / %s\n", *out, *md)
  118. } else {
  119. fmt.Printf("报告已写入 %s / %s\n", *out, *md)
  120. }
  121. }()
  122. return exit
  123. }
  124. // caseNameMatches 判断 `-case` 是否命中该 case 名。
  125. //
  126. // 语义:**按名字首段精确匹配**,不是子串。
  127. //
  128. // "-case C1-5" → 命中 "C1-5 清零前置校验·…",**不命中** "C1-5b …"("C1-5b 清零" 不以 "C1-5 " 开头)
  129. // "-case C1-5xx" → 一条都不命中 → FATAL
  130. //
  131. // 为什么不做纯子串:子串会让 "C1-5" 把 "C1-5b" 一起带进来,"case 总数"就错了,
  132. // 正好让 team-lead 定的"hash + 总数 + 通过数三者对齐"失效。
  133. // 为什么不做纯全等:那要用户连中文后缀一起打,实际不可用。
  134. func caseNameMatches(name, only string) bool {
  135. if name == only {
  136. return true
  137. }
  138. return strings.HasPrefix(name, only+" ")
  139. }
  140. // runSentinel 跑存量哨兵(全库扫描,与具体 case 无关)。
  141. func runSentinel(args []string) int {
  142. fs := flag.NewFlagSet("sentinel", flag.ContinueOnError)
  143. fs.SetOutput(os.Stderr)
  144. dbName := fs.String("db", "finance_bk", "扫描的库(finance_bk | yuansiwei_2026)")
  145. if err := fs.Parse(args); err != nil {
  146. return 2
  147. }
  148. repoRoot, err := os.Getwd()
  149. if err != nil {
  150. fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
  151. return 3
  152. }
  153. cfg, err := stage2.LoadConfig(repoRoot)
  154. if err != nil {
  155. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  156. return 3
  157. }
  158. if err := cfg.Guard(); err != nil {
  159. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  160. return 3
  161. }
  162. ok, err := stage2.RunStockSentinels(context.Background(), cfg, *dbName, os.Stdout)
  163. if err != nil {
  164. fmt.Fprintf(os.Stderr, "FATAL: 哨兵执行失败:%v\n", err)
  165. return 3
  166. }
  167. if !ok {
  168. return 1
  169. }
  170. return 0
  171. }
  172. // runStage2 跑写 SP 双跑(第一支:消费)。
  173. func runStage2(args []string) int {
  174. fs := flag.NewFlagSet("stage2", flag.ContinueOnError)
  175. fs.SetOutput(os.Stderr)
  176. only := fs.String("case", "", "只跑指定 case(按名字首段精确匹配,不是子串)")
  177. probe := fs.Bool("probe", false, "跑**探针**(非同构 case,不进 DefaultCases、不构成验收)")
  178. if err := fs.Parse(args); err != nil {
  179. return 2
  180. }
  181. repoRoot, err := os.Getwd()
  182. if err != nil {
  183. fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
  184. return 3
  185. }
  186. cfg, err := stage2.LoadConfig(repoRoot)
  187. if err != nil {
  188. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  189. return 3
  190. }
  191. if err := cfg.Guard(); err != nil {
  192. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  193. return 3
  194. }
  195. fmt.Printf("%s\n\n", cfg.Masked())
  196. ctx := context.Background()
  197. stage2.ResetStats() // 代价计数从这里开始(见 stage2/stats.go)
  198. all := stage2.DefaultCases("")
  199. source := "DefaultCases"
  200. if *probe {
  201. // 探针:非同构 case(第二侧喂不到同构输入)⇒ **不进分母、不算验收**。
  202. // 见 stage2.ProbeCases 的注释。
  203. all = stage2.ProbeCases()
  204. source = "ProbeCases"
  205. }
  206. // 检查 ①:case 列表不能为空。
  207. // 防"case 列表被清空 / build tag 没生效 / DefaultCases 被改坏" —— 那种情况下
  208. // `Summarize` 会返回 `0 == 0 = true`,跑了个寂寞但退出 0。
  209. if len(all) == 0 {
  210. fmt.Fprintf(os.Stderr, "FATAL: %s 长度为 0,没有任何 case 可跑\n", source)
  211. return 3
  212. }
  213. // 检查 ②:`-case` **精确匹配**(不是子串)。
  214. // 子串匹配会让 `-case C1-5` 把 `C1-5b` 一起带进来,"case 总数"就错了。
  215. matched := 0
  216. var results []*stage2.CaseResult
  217. for _, c := range all {
  218. if *only != "" && !caseNameMatches(c.Name, *only) {
  219. continue
  220. }
  221. matched++
  222. r, err := stage2.RunOne(ctx, cfg, c)
  223. if err != nil {
  224. fmt.Fprintf(os.Stderr, "FATAL: case %q 执行失败:%v\n", c.Name, err)
  225. return 3
  226. }
  227. stage2.PrintResult(os.Stdout, r)
  228. results = append(results, r)
  229. }
  230. // 检查 ③:`-case` 必须**恰好**匹配 1 条 —— 匹配不到或匹配到多条都报错退非 0。
  231. // 否则 0 个 case 会以退出码 0 "通过"(`Summarize` 的 `0 == 0` 是 true)。
  232. if *only != "" && matched == 0 {
  233. fmt.Fprintf(os.Stderr, "FATAL: -case %q 没有匹配到任何 case(按名字首段精确匹配,不是子串)\n", *only)
  234. return 2
  235. }
  236. if *only != "" && matched > 1 {
  237. fmt.Fprintf(os.Stderr, "FATAL: -case %q 匹配到 %d 条,定向跑必须恰好 1 条\n", *only, matched)
  238. return 2
  239. }
  240. if *only != "" {
  241. fmt.Printf("定向 case:%s\n", results[0].Case.Name)
  242. }
  243. // 检查 ④:实跑数必须等于"本次应跑数"(**分母收缩**护栏)。
  244. // 防将来有人把 `RunOne` 的 `return 3` 改成 `continue` —— 那时分母会悄悄从 25 变 22,
  245. // 而结论仍是"全 PASS"。分母必须是"应该跑的全部",不是"真正跑完的"。
  246. if len(results) != matched {
  247. fmt.Fprintf(os.Stderr, "FATAL: 分母收缩 —— 应跑 %d 条,实跑 %d 条\n", matched, len(results))
  248. return 3
  249. }
  250. fmt.Printf("分母:%s=%d | 本次应跑=%d | 实跑=%d\n", source, len(all), matched, len(results))
  251. if *only != "" {
  252. fmt.Printf("⚠ 定向跑:仅 1 条,**不构成验收** —— 验收必须全量跑(不带 -case)\n")
  253. }
  254. if *probe {
  255. fmt.Printf("⚠ 探针模式:%s 里的 case **非同构**(第二侧喂不到同构输入),只登记事实、不算验收\n", source)
  256. }
  257. fmt.Println()
  258. // 代价:SQL 执行次数与耗时。它必须和"七个数"一起出现 ——
  259. // 否则"慢了"只能等跑不动才发现,而那时已经分不清是"读得对只是慢"还是"读错了靠重试掩盖"。
  260. fmt.Printf("%s\n", stage2.StatsLine(len(results)))
  261. if ok := stage2.Summarize(os.Stdout, results); !ok {
  262. return 1
  263. }
  264. return 0
  265. }
  266. // runResultSets 跑**只读 SP 的结果集双跑**。
  267. //
  268. // 与 stage2 的区别:那一条比"写后终态"(Δ 断言),这一条比"同一份数据 + 同一组入参
  269. // → 两侧各拿结果集 → 逐列比"。三支 SP 只读、不写库,Δ 恒为 0,用写路径的判据只会假绿。
  270. //
  271. // 入口独立的原因见 stage2/rs_cases.go 抬头:两条链**不同构**,不共用一个分母。
  272. func runResultSets(args []string) int {
  273. fs := flag.NewFlagSet("resultsets", flag.ContinueOnError)
  274. fs.SetOutput(os.Stderr)
  275. if err := fs.Parse(args); err != nil {
  276. return 2
  277. }
  278. repoRoot, err := os.Getwd()
  279. if err != nil {
  280. fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
  281. return 3
  282. }
  283. cfg, err := stage2.LoadConfig(repoRoot)
  284. if err != nil {
  285. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  286. return 3
  287. }
  288. if err := cfg.Guard(); err != nil {
  289. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  290. return 3
  291. }
  292. fmt.Printf("%s\n\n", cfg.Masked())
  293. stage2.ResetStats()
  294. ok, err := stage2.RunResultSetCases(context.Background(), cfg, os.Stdout)
  295. fmt.Printf("%s\n", stage2.StatsLine(len(stage2.ResultSetCases())))
  296. if err != nil {
  297. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  298. return 3
  299. }
  300. if !ok {
  301. return 1
  302. }
  303. return 0
  304. }