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 }