report.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. package dualrun
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "sort"
  7. "strings"
  8. )
  9. // WriteJSON 输出机器可读报告。
  10. func WriteJSON(r *Report, path string) error {
  11. b, err := json.MarshalIndent(r, "", " ")
  12. if err != nil {
  13. return err
  14. }
  15. b = append(b, '\n')
  16. return os.WriteFile(path, b, 0o644)
  17. }
  18. // WriteMarkdown 输出人可读报告。
  19. //
  20. // 结构固定:结论摘要(六类计数)→ 环境与守卫 → 用例清单 → 逐条差异表 → 幂等性 → 备注。
  21. func WriteMarkdown(r *Report, path string) error {
  22. var b strings.Builder
  23. b.WriteString("# AC-15 阶段 1 双跑报告(课酬链)\n\n")
  24. fmt.Fprintf(&b, "- 生成时间:`%s`\n", r.GeneratedAt)
  25. fmt.Fprintf(&b, "- 用例集:`%s`|dry-run:`%v`\n", r.CaseSet, r.DryRun)
  26. fmt.Fprintf(&b, "- 冻结时钟:`%d`(%s,整秒)\n", r.FrozenEpoch, r.FrozenAt)
  27. fmt.Fprintf(&b, "- 用例数:%s\n\n", formatCounts(r.CaseCounts))
  28. b.WriteString("## 0. 两侧不对称(务必先读)\n\n")
  29. b.WriteString("| 侧 | 怎么跑 |\n|---|---|\n")
  30. b.WriteString("| **oracle(legacy / PHP)** | 真跑 `settlementExpenses($recordId, $isSupplement)`,事务内执行、`finally` 回滚,读**异常前已写入**的库值(A24:它必抛异常,不能等返回值) |\n")
  31. b.WriteString("| **target(Go)** | 只调纯函数 `computeRecordReward` + `distributeAmount`,输入取自 oracle 侧同一份观测,**不连 yuansiwei_2026、不写库** |\n\n")
  32. b.WriteString("> 这不是「两边各跑一次入库」。对「由输入算出一个金额」的逻辑,纯函数足以判定;\n")
  33. b.WriteString("> 且 target 库没有对应历史数据,真跑只会引入无关变量。\n\n")
  34. b.WriteString("## 1. 守卫与启动断言\n\n| 项 | 值 |\n|---|---|\n")
  35. for _, k := range sortedKeys(r.Guard) {
  36. fmt.Fprintf(&b, "| %s | %s |\n", k, r.Guard[k])
  37. }
  38. for _, k := range sortedKeys(r.Assertions) {
  39. fmt.Fprintf(&b, "| %s | %s |\n", k, r.Assertions[k])
  40. }
  41. b.WriteString("\n")
  42. b.WriteString("## 2. 结论分布(六类,按 case 计)\n\n")
  43. b.WriteString("| 归因 | 条数 |\n|---|---|\n")
  44. total := 0
  45. for _, a := range AllAttributions {
  46. n := r.Summary[a]
  47. total += n
  48. fmt.Fprintf(&b, "| %s | %d |\n", a, n)
  49. }
  50. fmt.Fprintf(&b, "| **合计** | **%d** |\n\n", total)
  51. if r.DryRun {
  52. b.WriteString("## 3. dry-run 用例清单(未执行)\n\n")
  53. fmt.Fprintf(&b, "| caseId | kind | category | recordId | isSupplement | expectTier | 造数 | 期望 |\n|---|---|---|---|---|---|---|---|\n")
  54. for _, c := range r.DryRunCases {
  55. mut := "-"
  56. if len(c.Mutations) > 0 {
  57. mut = fmt.Sprintf("%d 条 SQL", len(c.Mutations))
  58. }
  59. sup := fmt.Sprintf("%v", c.IsSupplement)
  60. if c.BothSupplement {
  61. sup = "false+true"
  62. }
  63. fmt.Fprintf(&b, "| %s | %s | %s | `%s` | %s | %s | %s | %s |\n",
  64. c.ID, c.Kind, c.Category, c.RecordID, sup, c.ExpectTier, mut, c.Expectation)
  65. }
  66. b.WriteString("\n")
  67. for _, c := range r.DryRunCases {
  68. if len(c.Mutations) == 0 {
  69. continue
  70. }
  71. fmt.Fprintf(&b, "- **%s** 造数 SQL:\n", c.ID)
  72. for _, m := range c.Mutations {
  73. fmt.Fprintf(&b, " ```sql\n %s\n ```\n", m)
  74. }
  75. }
  76. b.WriteString("\n")
  77. } else {
  78. b.WriteString("## 3. 逐条差异\n\n")
  79. b.WriteString("| caseId | 表 | 主键 | 列 | legacy | target | 差值 | 归因 |\n|---|---|---|---|---|---|---|---|\n")
  80. for _, cr := range r.Cases {
  81. if len(cr.Rows) == 0 {
  82. fmt.Fprintf(&b, "| %s | - | `%s` | - | - | - | - | %s |\n", cr.CaseID, cr.RecordID, cr.Attribution)
  83. continue
  84. }
  85. for _, row := range cr.Rows {
  86. fmt.Fprintf(&b, "| %s | %s | `%s` | %s | %s | %s | %s | %s |\n",
  87. row.CaseID, row.Table, row.PK, row.Column, row.Legacy, row.Target, row.Delta, row.Attribution)
  88. }
  89. }
  90. b.WriteString("\n")
  91. if len(r.Idempotency) > 0 {
  92. b.WriteString("## 4. 幂等性(同 case 连跑两次)\n\n")
  93. b.WriteString("| caseId | 第一次 | 第二次 | 归因一致 | 差异行一致 |\n|---|---|---|---|---|\n")
  94. allSame := true
  95. for _, ic := range r.Idempotency {
  96. if !ic.Same || !ic.RowsEqual {
  97. allSame = false
  98. }
  99. fmt.Fprintf(&b, "| %s | %s | %s | %v | %v |\n", ic.CaseID, ic.First, ic.Second, ic.Same, ic.RowsEqual)
  100. }
  101. fmt.Fprintf(&b, "\n**幂等结论:%v**\n\n", allSame)
  102. }
  103. }
  104. if len(r.MutationNotes) > 0 {
  105. b.WriteString("## 5. 造数与选样的限制(诚实记录)\n\n")
  106. for _, n := range r.MutationNotes {
  107. fmt.Fprintf(&b, "- %s\n", n)
  108. }
  109. b.WriteString("\n")
  110. }
  111. return os.WriteFile(path, []byte(b.String()), 0o644)
  112. }
  113. func formatCounts(m map[string]int) string {
  114. if len(m) == 0 {
  115. return "-"
  116. }
  117. keys := sortedKeys(m)
  118. var parts []string
  119. for _, k := range keys {
  120. parts = append(parts, fmt.Sprintf("%s=%d", k, m[k]))
  121. }
  122. return strings.Join(parts, ",")
  123. }
  124. func sortedKeys[T any](m map[string]T) []string {
  125. keys := make([]string, 0, len(m))
  126. for k := range m {
  127. keys = append(keys, k)
  128. }
  129. sort.Strings(keys)
  130. return keys
  131. }