report.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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. // DEFECT / ENV-FAILURE 一栏必须能"一键定位到 caseId"(验收要看这一栏)
  52. b.WriteString("## 2.1 DEFECT / ENV-FAILURE 索引(按 caseId 定位)\n\n")
  53. type hit struct{ caseID, attr, where string }
  54. var hits []hit
  55. for _, cr := range r.Cases {
  56. if cr.Attribution != AttrDefect && cr.Attribution != AttrEnvFailure {
  57. continue
  58. }
  59. where := "-"
  60. for _, row := range cr.Rows {
  61. if row.Attribution == AttrDefect || row.Attribution == AttrEnvFailure {
  62. where = fmt.Sprintf("%s.%s(legacy=%s target=%s Δ=%s)", row.Table, row.Column, row.Legacy, row.Target, row.Delta)
  63. break
  64. }
  65. }
  66. if where == "-" && cr.Note != "" {
  67. where = cr.Note
  68. }
  69. hits = append(hits, hit{cr.CaseID, string(cr.Attribution), where})
  70. }
  71. if len(hits) == 0 {
  72. b.WriteString("(无)\n\n")
  73. } else {
  74. b.WriteString("| caseId | 归因 | 位置与数值 |\n|---|---|---|\n")
  75. for _, h := range hits {
  76. fmt.Fprintf(&b, "| **%s** | %s | %s |\n", h.caseID, h.attr, h.where)
  77. }
  78. b.WriteString("\n")
  79. }
  80. if r.DryRun {
  81. b.WriteString("## 3. dry-run 用例清单(未执行)\n\n")
  82. fmt.Fprintf(&b, "| caseId | kind | category | recordId | isSupplement | expectTier | 造数 | 期望 |\n|---|---|---|---|---|---|---|---|\n")
  83. for _, c := range r.DryRunCases {
  84. mut := "-"
  85. if len(c.Mutations) > 0 {
  86. mut = fmt.Sprintf("%d 条 SQL", len(c.Mutations))
  87. }
  88. sup := fmt.Sprintf("%v", c.IsSupplement)
  89. if c.BothSupplement {
  90. sup = "false+true"
  91. }
  92. fmt.Fprintf(&b, "| %s | %s | %s | `%s` | %s | %s | %s | %s |\n",
  93. c.ID, c.Kind, c.Category, c.RecordID, sup, c.ExpectTier, mut, c.Expectation)
  94. }
  95. b.WriteString("\n")
  96. for _, c := range r.DryRunCases {
  97. if len(c.Mutations) == 0 {
  98. continue
  99. }
  100. fmt.Fprintf(&b, "- **%s** 造数 SQL:\n", c.ID)
  101. for _, m := range c.Mutations {
  102. fmt.Fprintf(&b, " ```sql\n %s\n ```\n", m)
  103. }
  104. }
  105. b.WriteString("\n")
  106. } else {
  107. b.WriteString("## 3. 逐条差异\n\n")
  108. b.WriteString("| caseId | 表 | 主键 | 列 | legacy | target | 差值 | 归因 |\n|---|---|---|---|---|---|---|---|\n")
  109. for _, cr := range r.Cases {
  110. if len(cr.Rows) == 0 {
  111. fmt.Fprintf(&b, "| %s | - | `%s` | - | - | - | - | %s |\n", cr.CaseID, cr.RecordID, cr.Attribution)
  112. continue
  113. }
  114. for _, row := range cr.Rows {
  115. fmt.Fprintf(&b, "| %s | %s | `%s` | %s | %s | %s | %s | %s |\n",
  116. row.CaseID, row.Table, row.PK, row.Column, row.Legacy, row.Target, row.Delta, row.Attribution)
  117. }
  118. }
  119. b.WriteString("\n")
  120. if len(r.Idempotency) > 0 {
  121. b.WriteString("## 4. 幂等性(同 case 连跑两次)\n\n")
  122. b.WriteString("| caseId | 第一次 | 第二次 | 归因一致 | 差异行一致 |\n|---|---|---|---|---|\n")
  123. allSame := true
  124. for _, ic := range r.Idempotency {
  125. if !ic.Same || !ic.RowsEqual {
  126. allSame = false
  127. }
  128. fmt.Fprintf(&b, "| %s | %s | %s | %v | %v |\n", ic.CaseID, ic.First, ic.Second, ic.Same, ic.RowsEqual)
  129. }
  130. fmt.Fprintf(&b, "\n**幂等结论:%v**\n\n", allSame)
  131. }
  132. }
  133. if len(r.MutationNotes) > 0 {
  134. b.WriteString("## 5. 造数与选样的限制(诚实记录)\n\n")
  135. for _, n := range r.MutationNotes {
  136. fmt.Fprintf(&b, "- %s\n", n)
  137. }
  138. b.WriteString("\n")
  139. }
  140. return os.WriteFile(path, []byte(b.String()), 0o644)
  141. }
  142. func formatCounts(m map[string]int) string {
  143. if len(m) == 0 {
  144. return "-"
  145. }
  146. keys := sortedKeys(m)
  147. var parts []string
  148. for _, k := range keys {
  149. parts = append(parts, fmt.Sprintf("%s=%d", k, m[k]))
  150. }
  151. return strings.Join(parts, ",")
  152. }
  153. func sortedKeys[T any](m map[string]T) []string {
  154. keys := make([]string, 0, len(m))
  155. for k := range m {
  156. keys = append(keys, k)
  157. }
  158. sort.Strings(keys)
  159. return keys
  160. }