| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174 |
- package dualrun
- import (
- "encoding/json"
- "fmt"
- "os"
- "sort"
- "strings"
- )
- // WriteJSON 输出机器可读报告。
- func WriteJSON(r *Report, path string) error {
- b, err := json.MarshalIndent(r, "", " ")
- if err != nil {
- return err
- }
- b = append(b, '\n')
- return os.WriteFile(path, b, 0o644)
- }
- // WriteMarkdown 输出人可读报告。
- //
- // 结构固定:结论摘要(六类计数)→ 环境与守卫 → 用例清单 → 逐条差异表 → 幂等性 → 备注。
- func WriteMarkdown(r *Report, path string) error {
- var b strings.Builder
- b.WriteString("# AC-15 阶段 1 双跑报告(课酬链)\n\n")
- fmt.Fprintf(&b, "- 生成时间:`%s`\n", r.GeneratedAt)
- fmt.Fprintf(&b, "- 用例集:`%s`|dry-run:`%v`\n", r.CaseSet, r.DryRun)
- fmt.Fprintf(&b, "- 冻结时钟:`%d`(%s,整秒)\n", r.FrozenEpoch, r.FrozenAt)
- fmt.Fprintf(&b, "- 用例数:%s\n\n", formatCounts(r.CaseCounts))
- b.WriteString("## 0. 两侧不对称(务必先读)\n\n")
- b.WriteString("| 侧 | 怎么跑 |\n|---|---|\n")
- b.WriteString("| **oracle(legacy / PHP)** | 真跑 `settlementExpenses($recordId, $isSupplement)`,事务内执行、`finally` 回滚,读**异常前已写入**的库值(A24:它必抛异常,不能等返回值) |\n")
- b.WriteString("| **target(Go)** | 只调纯函数 `computeRecordReward` + `distributeAmount`,输入取自 oracle 侧同一份观测,**不连 yuansiwei_2026、不写库** |\n\n")
- b.WriteString("> 这不是「两边各跑一次入库」。对「由输入算出一个金额」的逻辑,纯函数足以判定;\n")
- b.WriteString("> 且 target 库没有对应历史数据,真跑只会引入无关变量。\n\n")
- b.WriteString("## 1. 守卫与启动断言\n\n| 项 | 值 |\n|---|---|\n")
- for _, k := range sortedKeys(r.Guard) {
- fmt.Fprintf(&b, "| %s | %s |\n", k, r.Guard[k])
- }
- for _, k := range sortedKeys(r.Assertions) {
- fmt.Fprintf(&b, "| %s | %s |\n", k, r.Assertions[k])
- }
- b.WriteString("\n")
- b.WriteString("## 2. 结论分布(六类,按 case 计)\n\n")
- b.WriteString("| 归因 | 条数 |\n|---|---|\n")
- total := 0
- for _, a := range AllAttributions {
- n := r.Summary[a]
- total += n
- fmt.Fprintf(&b, "| %s | %d |\n", a, n)
- }
- fmt.Fprintf(&b, "| **合计** | **%d** |\n\n", total)
- // DEFECT / ENV-FAILURE 一栏必须能"一键定位到 caseId"(验收要看这一栏)
- b.WriteString("## 2.1 DEFECT / ENV-FAILURE 索引(按 caseId 定位)\n\n")
- type hit struct{ caseID, attr, where string }
- var hits []hit
- for _, cr := range r.Cases {
- if cr.Attribution != AttrDefect && cr.Attribution != AttrEnvFailure {
- continue
- }
- where := "-"
- for _, row := range cr.Rows {
- if row.Attribution == AttrDefect || row.Attribution == AttrEnvFailure {
- where = fmt.Sprintf("%s.%s(legacy=%s target=%s Δ=%s)", row.Table, row.Column, row.Legacy, row.Target, row.Delta)
- break
- }
- }
- if where == "-" && cr.Note != "" {
- where = cr.Note
- }
- hits = append(hits, hit{cr.CaseID, string(cr.Attribution), where})
- }
- if len(hits) == 0 {
- b.WriteString("(无)\n\n")
- } else {
- b.WriteString("| caseId | 归因 | 位置与数值 |\n|---|---|---|\n")
- for _, h := range hits {
- fmt.Fprintf(&b, "| **%s** | %s | %s |\n", h.caseID, h.attr, h.where)
- }
- b.WriteString("\n")
- }
- if r.DryRun {
- b.WriteString("## 3. dry-run 用例清单(未执行)\n\n")
- fmt.Fprintf(&b, "| caseId | kind | category | recordId | isSupplement | expectTier | 造数 | 期望 |\n|---|---|---|---|---|---|---|---|\n")
- for _, c := range r.DryRunCases {
- mut := "-"
- if len(c.Mutations) > 0 {
- mut = fmt.Sprintf("%d 条 SQL", len(c.Mutations))
- }
- sup := fmt.Sprintf("%v", c.IsSupplement)
- if c.BothSupplement {
- sup = "false+true"
- }
- fmt.Fprintf(&b, "| %s | %s | %s | `%s` | %s | %s | %s | %s |\n",
- c.ID, c.Kind, c.Category, c.RecordID, sup, c.ExpectTier, mut, c.Expectation)
- }
- b.WriteString("\n")
- for _, c := range r.DryRunCases {
- if len(c.Mutations) == 0 {
- continue
- }
- fmt.Fprintf(&b, "- **%s** 造数 SQL:\n", c.ID)
- for _, m := range c.Mutations {
- fmt.Fprintf(&b, " ```sql\n %s\n ```\n", m)
- }
- }
- b.WriteString("\n")
- } else {
- b.WriteString("## 3. 逐条差异\n\n")
- b.WriteString("| caseId | 表 | 主键 | 列 | legacy | target | 差值 | 归因 |\n|---|---|---|---|---|---|---|---|\n")
- for _, cr := range r.Cases {
- if len(cr.Rows) == 0 {
- fmt.Fprintf(&b, "| %s | - | `%s` | - | - | - | - | %s |\n", cr.CaseID, cr.RecordID, cr.Attribution)
- continue
- }
- for _, row := range cr.Rows {
- fmt.Fprintf(&b, "| %s | %s | `%s` | %s | %s | %s | %s | %s |\n",
- row.CaseID, row.Table, row.PK, row.Column, row.Legacy, row.Target, row.Delta, row.Attribution)
- }
- }
- b.WriteString("\n")
- if len(r.Idempotency) > 0 {
- b.WriteString("## 4. 幂等性(同 case 连跑两次)\n\n")
- b.WriteString("| caseId | 第一次 | 第二次 | 归因一致 | 差异行一致 |\n|---|---|---|---|---|\n")
- allSame := true
- for _, ic := range r.Idempotency {
- if !ic.Same || !ic.RowsEqual {
- allSame = false
- }
- fmt.Fprintf(&b, "| %s | %s | %s | %v | %v |\n", ic.CaseID, ic.First, ic.Second, ic.Same, ic.RowsEqual)
- }
- fmt.Fprintf(&b, "\n**幂等结论:%v**\n\n", allSame)
- }
- }
- if len(r.MutationNotes) > 0 {
- b.WriteString("## 5. 造数与选样的限制(诚实记录)\n\n")
- for _, n := range r.MutationNotes {
- fmt.Fprintf(&b, "- %s\n", n)
- }
- b.WriteString("\n")
- }
- return os.WriteFile(path, []byte(b.String()), 0o644)
- }
- func formatCounts(m map[string]int) string {
- if len(m) == 0 {
- return "-"
- }
- keys := sortedKeys(m)
- var parts []string
- for _, k := range keys {
- parts = append(parts, fmt.Sprintf("%s=%d", k, m[k]))
- }
- return strings.Join(parts, ",")
- }
- func sortedKeys[T any](m map[string]T) []string {
- keys := make([]string, 0, len(m))
- for k := range m {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- return keys
- }
|