main.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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 "-h", "--help", "help":
  39. usage()
  40. os.Exit(0)
  41. default:
  42. fmt.Fprintf(os.Stderr, "未知子命令:%s\n\n", os.Args[1])
  43. usage()
  44. os.Exit(2)
  45. }
  46. }
  47. func usage() {
  48. fmt.Fprint(os.Stderr, `dualrun —— AC-15 双跑 harness
  49. 子命令:
  50. stage1 跑课酬链比对(课酬,纯函数)
  51. stage2 跑写 SP 双跑(消费:sp_user_expense_school_expenditure ↔ FundService.Expenditure)
  52. stage1 参数:
  53. --case-set string real100 | synthetic | all(默认 all)
  54. --all 等价于 --case-set all
  55. --dry-run 只列 case 不执行
  56. --out string JSON 报告路径(默认 dualrun-stage1.json)
  57. --md string Markdown 报告路径(默认 dualrun-stage1.md)
  58. --idempotency 额外做幂等验证(同 case 连跑两次)
  59. --real-count int 真实基线条数(默认 100)
  60. --per-cat int 合成每类条数(默认 5)
  61. `)
  62. }
  63. func runStage1(args []string) int {
  64. fs := flag.NewFlagSet("stage1", flag.ContinueOnError)
  65. fs.SetOutput(os.Stderr)
  66. caseSet := fs.String("case-set", "all", "real100 | synthetic | all")
  67. all := fs.Bool("all", false, "等价于 --case-set all")
  68. dryRun := fs.Bool("dry-run", false, "只列 case 不执行")
  69. out := fs.String("out", "dualrun-stage1.json", "JSON 报告路径")
  70. md := fs.String("md", "dualrun-stage1.md", "Markdown 报告路径")
  71. idem := fs.Bool("idempotency", false, "幂等验证")
  72. realCount := fs.Int("real-count", 100, "真实基线条数")
  73. perCat := fs.Int("per-cat", 5, "合成每类条数")
  74. if err := fs.Parse(args); err != nil {
  75. return 2
  76. }
  77. if *all {
  78. *caseSet = "all"
  79. }
  80. cfg := dualrun.LoadConfigFromEnv()
  81. // 守卫违反会 panic(验收要求),这里转成清晰的退出码与提示
  82. exit := 0
  83. func() {
  84. defer func() {
  85. if r := recover(); r != nil {
  86. fmt.Fprintf(os.Stderr, "FATAL: %v\n", r)
  87. exit = 3
  88. }
  89. }()
  90. rep, err := dualrun.RunStage1(context.Background(), cfg, dualrun.Options{
  91. CaseSet: *caseSet,
  92. DryRun: *dryRun,
  93. OutJSON: *out,
  94. OutMD: *md,
  95. Idempotency: *idem,
  96. RealCount: *realCount,
  97. PerCat: *perCat,
  98. })
  99. if err != nil {
  100. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  101. exit = 3
  102. return
  103. }
  104. fmt.Printf("cases=%d dryRun=%v\n", len(rep.Cases)+len(rep.DryRunCases), rep.DryRun)
  105. for _, a := range dualrun.AllAttributions {
  106. if n := rep.Summary[a]; n > 0 {
  107. fmt.Printf(" %-24s %d\n", a, n)
  108. }
  109. }
  110. if rep.DryRun {
  111. fmt.Printf("dry-run 清单已写入 %s / %s\n", *out, *md)
  112. } else {
  113. fmt.Printf("报告已写入 %s / %s\n", *out, *md)
  114. }
  115. }()
  116. return exit
  117. }
  118. // runStage2 跑写 SP 双跑(第一支:消费)。
  119. func runStage2(args []string) int {
  120. fs := flag.NewFlagSet("stage2", flag.ContinueOnError)
  121. fs.SetOutput(os.Stderr)
  122. only := fs.String("case", "", "只跑指定 case(按名字子串匹配)")
  123. if err := fs.Parse(args); err != nil {
  124. return 2
  125. }
  126. repoRoot, err := os.Getwd()
  127. if err != nil {
  128. fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
  129. return 3
  130. }
  131. cfg, err := stage2.LoadConfig(repoRoot)
  132. if err != nil {
  133. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  134. return 3
  135. }
  136. if err := cfg.Guard(); err != nil {
  137. fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
  138. return 3
  139. }
  140. fmt.Printf("%s\n\n", cfg.Masked())
  141. ctx := context.Background()
  142. var results []*stage2.CaseResult
  143. for _, c := range stage2.DefaultCases("") {
  144. if *only != "" && !strings.Contains(c.Name, *only) {
  145. continue
  146. }
  147. r, err := stage2.RunOne(ctx, cfg, c)
  148. if err != nil {
  149. fmt.Fprintf(os.Stderr, "FATAL: case %q 执行失败:%v\n", c.Name, err)
  150. return 3
  151. }
  152. stage2.PrintResult(os.Stdout, r)
  153. results = append(results, r)
  154. }
  155. if ok := stage2.Summarize(os.Stdout, results); !ok {
  156. return 1
  157. }
  158. return 0
  159. }