main.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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. if err := fs.Parse(args); err != nil {
  175. return 2
  176. }
  177. repoRoot, err := os.Getwd()
  178. if err != nil {
  179. fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
  180. return 3
  181. }
  182. cfg, err := stage2.LoadConfig(repoRoot)
  183. if err != nil {
  184. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  185. return 3
  186. }
  187. if err := cfg.Guard(); err != nil {
  188. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  189. return 3
  190. }
  191. fmt.Printf("%s\n\n", cfg.Masked())
  192. ctx := context.Background()
  193. all := stage2.DefaultCases("")
  194. // 检查 ①:case 列表不能为空。
  195. // 防"case 列表被清空 / build tag 没生效 / DefaultCases 被改坏" —— 那种情况下
  196. // `Summarize` 会返回 `0 == 0 = true`,跑了个寂寞但退出 0。
  197. if len(all) == 0 {
  198. fmt.Fprintf(os.Stderr, "FATAL: DefaultCases 长度为 0,没有任何 case 可跑\n")
  199. return 3
  200. }
  201. // 检查 ②:`-case` **精确匹配**(不是子串)。
  202. // 子串匹配会让 `-case C1-5` 把 `C1-5b` 一起带进来,"case 总数"就错了。
  203. matched := 0
  204. var results []*stage2.CaseResult
  205. for _, c := range all {
  206. if *only != "" && !caseNameMatches(c.Name, *only) {
  207. continue
  208. }
  209. matched++
  210. r, err := stage2.RunOne(ctx, cfg, c)
  211. if err != nil {
  212. fmt.Fprintf(os.Stderr, "FATAL: case %q 执行失败:%v\n", c.Name, err)
  213. return 3
  214. }
  215. stage2.PrintResult(os.Stdout, r)
  216. results = append(results, r)
  217. }
  218. // 检查 ③:`-case` 必须**恰好**匹配 1 条 —— 匹配不到或匹配到多条都报错退非 0。
  219. // 否则 0 个 case 会以退出码 0 "通过"(`Summarize` 的 `0 == 0` 是 true)。
  220. if *only != "" && matched == 0 {
  221. fmt.Fprintf(os.Stderr, "FATAL: -case %q 没有匹配到任何 case(按名字首段精确匹配,不是子串)\n", *only)
  222. return 2
  223. }
  224. if *only != "" && matched > 1 {
  225. fmt.Fprintf(os.Stderr, "FATAL: -case %q 匹配到 %d 条,定向跑必须恰好 1 条\n", *only, matched)
  226. return 2
  227. }
  228. if *only != "" {
  229. fmt.Printf("定向 case:%s\n", results[0].Case.Name)
  230. }
  231. // 检查 ④:实跑数必须等于"本次应跑数"(**分母收缩**护栏)。
  232. // 防将来有人把 `RunOne` 的 `return 3` 改成 `continue` —— 那时分母会悄悄从 25 变 22,
  233. // 而结论仍是"全 PASS"。分母必须是"应该跑的全部",不是"真正跑完的"。
  234. if len(results) != matched {
  235. fmt.Fprintf(os.Stderr, "FATAL: 分母收缩 —— 应跑 %d 条,实跑 %d 条\n", matched, len(results))
  236. return 3
  237. }
  238. fmt.Printf("分母:DefaultCases=%d | 本次应跑=%d | 实跑=%d\n", len(all), matched, len(results))
  239. if *only != "" {
  240. fmt.Printf("⚠ 定向跑:仅 1 条,**不构成验收** —— 验收必须全量跑(不带 -case)\n")
  241. }
  242. fmt.Println()
  243. if ok := stage2.Summarize(os.Stdout, results); !ok {
  244. return 1
  245. }
  246. return 0
  247. }