main.go 9.3 KB

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