Explorar el Código

feat(ac15): 只读 SP 结果集双跑骨架 + RS-1 可退金额(1 行 1 列)

三支只读 SP 不写库,进不了「写后终态」那套比对(Δ 恒 0 ⇒ 假绿)。
本步建立**结果集比对**骨架并跑通最简的一支:

- resultset_compare.go:逐列比对(每列一条业务断言,detail 打
  `列名:legacy=X | target=Y`)+ **列覆盖断言**(dump 列数 == 认领 + 显式豁免,
  且每列恰好一条声明)+ 侧向覆盖(target 字段不得无人认领)+ 三态(NULL ≠ 0)
  + 精度口径(3→2 时额外断言 dump 第 3 位为 0)+ 第三腿(与声明期望比)
- rs_seed.go:合成身份(机构 990001 / 校区 990101 / 用户 880001),
  不依赖真实用户 —— 读什么完全由声明决定
- rs_refundable.go:六张工单各钉 SP 的一个 WHERE 条件(审批 / 类型 / 校区 /
  赠送已用尽 / 一单两行扇出),期望 685.00 由声明算出
- 独立入口 `go run ./cmd/dualrun resultsets`,**不动 DefaultCases**

实测:1 列 0 豁免;业务断言 3 条 / 基建 4 条;PASS。
变异(target 差 1 分)实测转红 3 项,已还原。
新增骨架单测 10 条(覆盖 / NULL≠0 / 0 行 / 第 3 位非零 / target 多字段 各弄红一次)。

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 全绿。
Q hace 5 horas
padre
commit
214b607617

+ 43 - 0
cmd/dualrun/main.go

@@ -40,6 +40,8 @@ func main() {
 		os.Exit(runStage2(os.Args[2:]))
 	case "sentinel":
 		os.Exit(runSentinel(os.Args[2:]))
+	case "resultsets":
+		os.Exit(runResultSets(os.Args[2:]))
 	case "-h", "--help", "help":
 		usage()
 		os.Exit(0)
@@ -56,6 +58,7 @@ func usage() {
 子命令:
   stage1    跑课酬链比对(课酬,纯函数)
   stage2    跑写 SP 双跑(消费:sp_user_expense_school_expenditure ↔ FundService.Expenditure)
+  resultsets 跑**只读 SP 的结果集双跑**(账单 / 可退金额 / 课酬详情;不写库,独立分母)
   sentinel  跑存量哨兵:全库扫「全局列 <=> Σ校区列」(不挂在 stage2 的 case 里)
 
 stage1 参数:
@@ -280,3 +283,43 @@ func runStage2(args []string) int {
 	}
 	return 0
 }
+
+// runResultSets 跑**只读 SP 的结果集双跑**。
+//
+// 与 stage2 的区别:那一条比"写后终态"(Δ 断言),这一条比"同一份数据 + 同一组入参
+// → 两侧各拿结果集 → 逐列比"。三支 SP 只读、不写库,Δ 恒为 0,用写路径的判据只会假绿。
+//
+// 入口独立的原因见 stage2/rs_cases.go 抬头:两条链**不同构**,不共用一个分母。
+func runResultSets(args []string) int {
+	fs := flag.NewFlagSet("resultsets", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	if err := fs.Parse(args); err != nil {
+		return 2
+	}
+	repoRoot, err := os.Getwd()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: 取工作目录失败:%v\n", err)
+		return 3
+	}
+	cfg, err := stage2.LoadConfig(repoRoot)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	if err := cfg.Guard(); err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	fmt.Printf("%s\n\n", cfg.Masked())
+	stage2.ResetStats()
+	ok, err := stage2.RunResultSetCases(context.Background(), cfg, os.Stdout)
+	fmt.Printf("%s\n", stage2.StatsLine(len(stage2.ResultSetCases())))
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+		return 3
+	}
+	if !ok {
+		return 1
+	}
+	return 0
+}

+ 743 - 0
internal/academic/dualrun/stage2/resultset_compare.go

@@ -0,0 +1,743 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"sort"
+	"strconv"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 本文件是**只读存储过程的「结果集双跑」**骨架。
+//
+// # 为什么需要另一套机器
+//
+// 前面 55 条 case 比的是**写入终态**:跑 legacy SP 与 Go 实现 → 读回库里的行 → 逐项比。
+// 有三支 SP **只读、不写库**,天然进不了那套机制:
+//
+//	sp_user_expense_get_bill                     端点 17 学生账单(一行 9 列)
+//	sp_user_expense_school_get_refundable_amount 可退金额(一行一列)
+//	sp_course_payment_detail_get_teacher_detail  老师课酬配置(整行 17 列)
+//
+// 对它们来说,"写库"是零,Δ 全是 0 ⇒ 通用比对会把「双方都没动」当成「双方一致」——
+// 又一次假绿。所以这里的比对主语句换成:**同一份数据 + 同一组入参 → 两侧各拿结果集 → 逐列比**。
+//
+// # 三条设计红线
+//
+//  1. **列覆盖断言**(`columnCoverage`):`dump 结果集列数 == 已认领列数 + 显式豁免列数`,
+//     且每一个 dump 列**恰好**被一条声明认领。这条防的是"新加了一列但没人映射,于是它永远不被比对"——
+//     与双跑里"判定块整体不执行"同族,都是**没人看的那一块**。
+//  2. **NULL 与 0 是两态**(`Cell.Null`):`SUM(...)` 无行时是 NULL,不是 0。
+//     把 NULL 读成 "0.00" 会把"这列从来没被算过"伪装成"算出来正好是 0"。
+//  3. **精度口径显式化**:dump 是 `DECIMAL(10,3)`、target 是 `money.Amount`(2 位)⇒
+//     两侧按 2 位比,**并额外断言 dump 侧第 3 位小数为 0**。第 3 位非 0 时不许静默四舍五入,
+//     要如实报出来(`PrecisionCheck`)。这是 `arrears_compare.go` ⑤ 的同一做法。
+
+// CellKind 是一列的归一方式。
+type CellKind int
+
+const (
+	// CellText 原样比较(会 TrimSpace)。
+	CellText CellKind = iota
+	// CellMoney 金额:按「元、2 位小数」归一(`DECIMAL(10,3)` → 2 位;`money.Amount` 分 → 元)。
+	CellMoney
+	// CellInt 整数(接受 "01" 与 "1" 视为同值)。
+	CellInt
+	// CellFlag 布尔位:legacy `bit(1)` 驱动读回是 `\x00`/`\x01`,target 是 0/1。
+	CellFlag
+)
+
+// Cell 是一个结果集单元格的**原始读数**(尚未归一)。
+//
+// `Null` 必须与 `Raw==""` 分开:空串是"有值且为空",NULL 是"没有值"。
+type Cell struct {
+	Null bool
+	Raw  string
+}
+
+// String 渲染成报告里能看的形态(NULL 显式打出来,不折成 0)。
+func (c Cell) String() string {
+	if c.Null {
+		return "NULL"
+	}
+	return c.Raw
+}
+
+// TextCell 造一个非空文本单元格。
+func TextCell(raw string) Cell { return Cell{Raw: raw} }
+
+// NullCell 造一个 NULL 单元格。
+func NullCell() Cell { return Cell{Null: true} }
+
+// MoneyCell 把「分」造成 `money.Amount` 口径的单元格("元、2 位小数")。
+func MoneyCell(cents int64) Cell { return Cell{Raw: fixture.CentsToDecimal(cents)} }
+
+// IntCell 把整数造成单元格。
+func IntCell(v int64) Cell { return Cell{Raw: strconv.FormatInt(v, 10)} }
+
+// ResultSet 是**一侧的单个结果集**(本批三支 SP 都只产出一个结果集)。
+type ResultSet struct {
+	// Columns 是 SP 实际返回的列名(顺序即 dump 顺序)。
+	Columns []string
+	// Rows 是全部行;本批三支都是 0/1 行,但结构上不假设。
+	Rows [][]Cell
+	// Statement 是实际执行的 CALL(写进报告,可复现)。
+	Statement string
+}
+
+// TargetPayload 是 target 侧 service 的返回值,已摊平成「字段名 → 单元格」。
+//
+// 为什么摊平而不是直接塞 struct:比对骨架要能对任意 service 结果工作,
+// 而每个 service 的返回类型都不同(`BillSummary` / `money.Amount` / `PayrollQuote`)。
+// 摊平由各 case 负责(逐字段映射见 case 文件),骨架只认名字。
+type TargetPayload struct {
+	// Label 是"这一侧到底调了什么"(报告用,例如 `BillService.GetBill`)。
+	Label string
+	// Values 是字段名 → 读数。
+	Values map[string]Cell
+	// Evidence 是实测摘要(如"命中 detailId=990002 / ruleTier=personal_classify")。
+	Evidence string
+}
+
+// ResultColumn 是**一列对一列**的映射与说明。
+type ResultColumn struct {
+	// Legacy 是 dump 结果集里的列名(用于报错文案与覆盖断言)。
+	Legacy string
+	// Target 是 target 字段名(`TargetPayload.Values` 的键;用于报错文案)。
+	Target string
+	// Kind 决定归一方式。
+	Kind CellKind
+	// Extract 允许自定义取值(`l` 是该行该列的 raw 单元格,`t` 是整份 payload)。
+	//
+	// nil = 按 `Target` 名字在 payload 里直接取、legacy 侧用同名列。
+	Extract func(l Cell, t TargetPayload) (lv, tv Cell, ok bool)
+	// Exempt=true 表示本列**不比对**,必须同时给 Reason。
+	Exempt bool
+	// Reason 是豁免理由(Exempt=true 时必填,否则覆盖断言 FAIL)。
+	Reason string
+}
+
+// TargetOnlyColumn 是**target 独有、dump 结果集里没有**的字段。
+//
+// 为什么要单独列:比对机器只保证"dump 的列都被认领",不保证"target 的字段都被看"。
+// 这些字段若不显式列出来,就会变成"测不到但也没人知道"的盲区。
+// `Expect` 非空时按声明值断言(由种子算出,不是照抄实现)。
+type TargetOnlyColumn struct {
+	Target string
+	Expect Cell
+	Kind   CellKind
+	Note   string
+}
+
+// ResultSetInput 是一次结果集比对的全部输入。
+type ResultSetInput struct {
+	CaseName string
+	// Legacy 是 SP 实际返回的结果集。
+	Legacy ResultSet
+	// ExpectRows 是期望的行数(本批 = 1;0 行说明种子没命中,必须红而不是"无可比")。
+	ExpectRows int
+	// Target 是 target 侧摊平后的返回值。
+	Target TargetPayload
+	// Columns 是逐列映射。
+	Columns []ResultColumn
+	// TargetOnly 是 target 独有的派生字段。
+	TargetOnly []TargetOnlyColumn
+	// Expect 是"由种子声明算出的期望值"(列名 → 归一后的字符串)。
+	//
+	// 为什么要有第三腿:只有"两侧相等"时,**两侧同时错**会得到一条完全对称的绿。
+	// 期望值从声明算(不写死实现输出),改种子后期望自动跟着变,"算错了"才会红。
+	Expect map[string]string
+	// PrecisionCheck=true 时,逐列断言 dump 侧金额列第 3 位小数为 0。
+	PrecisionCheck bool
+	// ExtraAssertions 是该 case 自己的领域断言(账单恒等式 / FLOAT 可表示性 / 命中层级…)。
+	//
+	// 为什么留这个口子:骨架只懂"列对列",不懂"为什么这 9 列之间必须满足某条恒等式"。
+	// 把这些塞进骨架会把通用机器变成"每个 case 一个分支"。
+	ExtraAssertions func(lg ResultSet, tg TargetPayload) []Assertion
+}
+
+// TargetColumnValue 取 payload 里的字段(缺字段 = FAIL,不是"跳过")。
+func (p TargetPayload) TargetColumnValue(name string) (Cell, bool) {
+	c, ok := p.Values[name]
+	return c, ok
+}
+
+// normalizeCell 按 Kind 归一成可比较的字符串。
+//
+// 金额走 `fixture.DecimalToCents` → `CentsToDecimal`:与迁移**同一个函数**,
+// 所以两侧不会出现"一个用 float 四舍五入、一个用 decimal 收敛"的口径分叉。
+func normalizeCell(kind CellKind, c Cell) (string, error) {
+	if c.Null {
+		return "NULL", nil
+	}
+	switch kind {
+	case CellMoney:
+		cents, err := fixture.DecimalToCents(c.Raw)
+		if err != nil {
+			return "", fmt.Errorf("金额字面量无法解析(raw=%q):%w", c.Raw, err)
+		}
+		return fixture.CentsToDecimal(cents), nil
+	case CellInt:
+		v, err := strconv.ParseInt(strings.TrimSpace(c.Raw), 10, 64)
+		if err != nil {
+			return "", fmt.Errorf("整数字面量无法解析(raw=%q):%w", c.Raw, err)
+		}
+		return strconv.FormatInt(v, 10), nil
+	case CellFlag:
+		raw := strings.TrimSpace(c.Raw)
+		switch raw {
+		case "\x00", "0", "false", "b'0'":
+			return "0", nil
+		case "\x01", "1", "true", "b'1'":
+			return "1", nil
+		}
+		return "", fmt.Errorf("布尔位无法解析(raw=%q)", c.Raw)
+	default:
+		return strings.TrimSpace(c.Raw), nil
+	}
+}
+
+// RawCellOf 按列名从某一行取单元格(找不到 = 声明写错了列名,由覆盖断言先拦住)。
+func RawCellOf(rs ResultSet, row int, col string) (Cell, bool) {
+	if row < 0 || row >= len(rs.Rows) {
+		return Cell{}, false
+	}
+	for i, name := range rs.Columns {
+		if name == col {
+			if i >= len(rs.Rows[row]) {
+				return Cell{}, false
+			}
+			return rs.Rows[row][i], true
+		}
+	}
+	return Cell{}, false
+}
+
+// compareResultSet 是结果集比对的唯一入口。
+func compareResultSet(in ResultSetInput) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	addDiff := func(item string, l, t Cell, equal, skipped bool, note string) {
+		diffs = append(diffs, Diff{
+			Item: item, Legacy: l.String(), Target: t.String(),
+			Equal: equal, Skipped: skipped, Note: note,
+		})
+	}
+
+	// ---- ① 结果集形状:必须恰好 1 个结果集、期望行数 ----
+	//
+	// 0 行**不是**"无可比":它意味着种子没命中 / 入参不对 / SP 提前 LEAVE。
+	// 把 0 行当通过,等于"SP 一行都没返回"也算绿 —— 那是本文件最想防的空过。
+	rowOK := len(in.Legacy.Rows) == in.ExpectRows
+	asserts = append(asserts, Assertion{
+		Kind: AssertionInfrastructure,
+		Name: fmt.Sprintf("结果集形状:%d 列 / 期望 %d 行", len(in.Legacy.Columns), in.ExpectRows),
+		Pass: rowOK && len(in.Legacy.Columns) > 0,
+		Detail: fmt.Sprintf("实际 %d 行;语句 `%s`。0 行 ⇒ FAIL(种子未命中或 SP 提前 LEAVE,不是「无可比」)",
+			len(in.Legacy.Rows), in.Legacy.Statement),
+	})
+
+	// ---- ② 列覆盖断言(核心)----
+	coverPass, coverDetail := columnCoverage(in.Legacy.Columns, in.Columns)
+	asserts = append(asserts, Assertion{
+		Kind: AssertionInfrastructure,
+		Name: "列覆盖:dump 结果集每一列**恰好**被一条声明认领(认领 + 豁免 = 总列数)",
+		Pass: coverPass, Detail: coverDetail,
+	})
+
+	// ---- ③ target 独有字段的覆盖:payload 里每个键都必须被看过 ----
+	claimedTargets := make(map[string]bool, len(in.Columns)+len(in.TargetOnly))
+	for _, c := range in.Columns {
+		if !c.Exempt && c.Target != "" {
+			claimedTargets[c.Target] = true
+		}
+	}
+	targetOnlyNames := make([]string, 0, len(in.TargetOnly))
+	for _, t := range in.TargetOnly {
+		targetOnlyNames = append(targetOnlyNames, t.Target)
+		claimedTargets[t.Target] = true
+	}
+	sort.Strings(targetOnlyNames)
+	var unclaimed []string
+	for name := range in.Target.Values {
+		if !claimedTargets[name] {
+			unclaimed = append(unclaimed, name)
+		}
+	}
+	sort.Strings(unclaimed)
+	asserts = append(asserts, Assertion{
+		Kind: AssertionInfrastructure,
+		Name: "侧向覆盖:target 返回的每个字段都被认领或被显式列为 target 独有",
+		Pass: len(unclaimed) == 0 && len(in.Target.Values) > 0,
+		Detail: fmt.Sprintf("target 字段 %d 个;未认领 %d 个 %v | target 独有 %v",
+			len(in.Target.Values), len(unclaimed), unclaimed, targetOnlyNames),
+	})
+
+	// ---- ④ 逐列比对 + 期望值(第三腿)----
+	for _, col := range in.Columns {
+		if col.Exempt {
+			l, _ := RawCellOf(in.Legacy, 0, col.Legacy)
+			addDiff("列·"+col.Legacy, l, Cell{Null: false, Raw: "—"}, true, true, "豁免:"+col.Reason)
+			continue
+		}
+		if !rowOK {
+			addDiff("列·"+col.Legacy+" → "+col.Target, Cell{Raw: "—"}, Cell{Raw: "—"}, true, true,
+				"结果集行数不符,本列无从比对")
+			continue
+		}
+		lRaw, okL := RawCellOf(in.Legacy, 0, col.Legacy)
+		tv, okT := Cell{}, false
+		lv := lRaw
+		if col.Extract != nil {
+			lv, tv, okT = col.Extract(lRaw, in.Target)
+		} else {
+			tv, okT = in.Target.TargetColumnValue(col.Target)
+		}
+		if !okL {
+			asserts = append(asserts, Assertion{
+				Kind: AssertionBusiness,
+				Name: fmt.Sprintf("列·%s:dump 结果集里取不到这一列", col.Legacy),
+				Pass: false, Detail: "列名拼写与 SP 实际返回不一致(覆盖断言应已同时报红)",
+			})
+			continue
+		}
+		if !okT {
+			addDiff("列·"+col.Legacy+" → "+col.Target, lv, Cell{Null: false, Raw: "缺字段"}, false, false,
+				"target payload 没有 "+col.Target)
+			asserts = append(asserts, Assertion{
+				Kind: AssertionBusiness,
+				Name: fmt.Sprintf("列·%s → %s:target 未提供该字段", col.Legacy, col.Target),
+				Pass: false, Detail: "字段名与 payload 不一致 ⇒ 这一列等于没比",
+			})
+			continue
+		}
+		lNorm, lErr := normalizeCell(col.Kind, lv)
+		tNorm, tErr := normalizeCell(col.Kind, tv)
+		equal := lErr == nil && tErr == nil && lNorm == tNorm
+		note := "按 " + kindName(col.Kind) + " 归一后比"
+		if lErr != nil || tErr != nil {
+			note = fmt.Sprintf("归一失败:legacy=%v | target=%v", lErr, tErr)
+		}
+		addDiff("列·"+col.Legacy+" → "+col.Target, lv, tv, equal, false, note)
+		asserts = append(asserts, Assertion{
+			Kind: AssertionBusiness,
+			Name: fmt.Sprintf("列·%s → %s", col.Legacy, col.Target),
+			Pass: equal,
+			Detail: fmt.Sprintf("列名:%s | legacy=%s | target=%s(%s)",
+				col.Legacy, lNorm, tNorm, note),
+		})
+		// 第三腿:与"由种子声明算出的期望"比。
+		if exp, has := in.Expect[col.Legacy]; has {
+			asserts = append(asserts, Assertion{
+				Kind: AssertionBusiness,
+				Name: fmt.Sprintf("列·%s:两侧一致 ≠ 都对 —— 与声明期望比", col.Legacy),
+				Pass: lNorm == exp && tNorm == exp,
+				Detail: fmt.Sprintf("声明期望 %s | legacy %s | target %s(期望由种子声明算出,不是照抄实现)",
+					exp, lNorm, tNorm),
+			})
+		}
+	}
+
+	// ---- ⑤ target 独有字段 ----
+	for _, t := range in.TargetOnly {
+		tv, ok := in.Target.TargetColumnValue(t.Target)
+		if !ok {
+			asserts = append(asserts, Assertion{
+				Kind: AssertionBusiness,
+				Name: fmt.Sprintf("target 独有字段·%s:payload 缺该字段", t.Target),
+				Pass: false, Detail: t.Note,
+			})
+			continue
+		}
+		tNorm, err := normalizeCell(t.Kind, tv)
+		expNorm, expErr := normalizeCell(t.Kind, t.Expect)
+		pass := err == nil && expErr == nil && tNorm == expNorm
+		asserts = append(asserts, Assertion{
+			Kind: AssertionBusiness,
+			Name: fmt.Sprintf("target 独有字段·%s = 声明期望", t.Target),
+			Pass: pass,
+			Detail: fmt.Sprintf("target=%s | 期望=%s | %s(dump 结果集无此列,只能对声明断言)",
+				tNorm, expNorm, t.Note),
+		})
+	}
+
+	// ---- ⑥ 精度口径(PRECISION-CONVERGENCE 3→2)----
+	if in.PrecisionCheck {
+		var bad []string
+		moneyCols := 0
+		for _, col := range in.Columns {
+			if col.Exempt || col.Kind != CellMoney {
+				continue
+			}
+			c, ok := RawCellOf(in.Legacy, 0, col.Legacy)
+			if !ok || c.Null {
+				continue
+			}
+			moneyCols++
+			if d, has := thirdDecimal(c.Raw); has && d != '0' {
+				bad = append(bad, fmt.Sprintf("%s=%s", col.Legacy, c.Raw))
+			}
+		}
+		asserts = append(asserts, Assertion{
+			Kind: AssertionBusiness,
+			Name: fmt.Sprintf("精度口径:dump 侧 %d 个金额列第 3 位小数恒为 0 ⇒ 2 位比较无信息损失(PRECISION-CONVERGENCE 3→2)",
+				moneyCols),
+			Pass: len(bad) == 0 && moneyCols > 0,
+			Detail: fmt.Sprintf("两侧小数位本就不同(dump DECIMAL(10,3) vs target money.Amount 2 位);"+
+				"本断言把「本次种子下无损」从假设变成事实。第 3 位非零 %d 个:%v。"+
+				"非零时必须如实报出,**不许静默四舍五入**", len(bad), bad),
+		})
+	}
+
+	// ---- ⑦ 该 case 自己的领域断言 ----
+	if in.ExtraAssertions != nil {
+		asserts = append(asserts, in.ExtraAssertions(in.Legacy, in.Target)...)
+	}
+
+	return diffs, asserts
+}
+
+// columnCoverage 检查「每个 dump 列恰好一条声明」+「每条声明的列名真实存在」+「豁免必带理由」。
+func columnCoverage(legacyCols []string, cols []ResultColumn) (bool, string) {
+	declByLegacy := make(map[string]int, len(cols))
+	for _, c := range cols {
+		declByLegacy[c.Legacy]++
+	}
+	var missing, dup, unknown, exemptNoReason []string
+	claimed, exempt := 0, 0
+	for _, name := range legacyCols {
+		switch declByLegacy[name] {
+		case 0:
+			missing = append(missing, name)
+		case 1:
+			// ok
+		default:
+			dup = append(dup, name)
+		}
+	}
+	for name := range declByLegacy {
+		found := false
+		for _, real := range legacyCols {
+			if real == name {
+				found = true
+				break
+			}
+		}
+		if !found {
+			unknown = append(unknown, name)
+		}
+	}
+	for _, c := range cols {
+		if c.Exempt {
+			exempt++
+			if strings.TrimSpace(c.Reason) == "" {
+				exemptNoReason = append(exemptNoReason, c.Legacy)
+			}
+		} else {
+			claimed++
+		}
+	}
+	sort.Strings(missing)
+	sort.Strings(dup)
+	sort.Strings(unknown)
+	sort.Strings(exemptNoReason)
+	pass := len(missing) == 0 && len(dup) == 0 && len(unknown) == 0 &&
+		len(exemptNoReason) == 0 && len(legacyCols) == claimed+exempt && claimed > 0
+	return pass, fmt.Sprintf(
+		"dump 列数 %d = 认领 %d + 豁免 %d | 无人认领 %v | 重复声明 %v | 声明了不存在的列 %v | 豁免缺理由 %v。"+
+			"无人认领 ⇒ 那一列**永远不被比对**,与「判定块整体不执行」同族",
+		len(legacyCols), claimed, exempt, missing, dup, unknown, exemptNoReason)
+}
+
+// kindName 把 Kind 渲染成报告里能看懂的名字。
+func kindName(k CellKind) string {
+	switch k {
+	case CellMoney:
+		return "金额"
+	case CellInt:
+		return "整数"
+	case CellFlag:
+		return "布尔位"
+	default:
+		return "文本"
+	}
+}
+
+// thirdDecimal 取十进制字符串的第 3 位小数。
+//
+// 不做浮点比较:浮点在 0.005 附近抖动,而这里判的就是那个位。
+func thirdDecimal(raw string) (byte, bool) {
+	i := strings.IndexByte(raw, '.')
+	if i < 0 || len(raw) < i+4 {
+		return 0, false
+	}
+	return raw[i+3], true
+}
+
+// printResultSetResult 打印一条结果集 case 的结论。
+//
+// 为什么不复用 `PrintResult`:那个函数的第一段是「账户前后快照」(写路径专用),
+// 结果集 case 没有 before/after —— 打印四个 0 会让人以为"账户余额是 0",是误导。
+func printResultSetResult(w io.Writer, r *CaseResult) {
+	fmt.Fprintf(w, "──────────────────────────────────────────────────────────────\n")
+	fmt.Fprintf(w, "▶ %s\n", r.Case.Name)
+	if r.Case.Note != "" {
+		fmt.Fprintf(w, "  说明:%s\n", r.Case.Note)
+	}
+	fmt.Fprintf(w, "  入参:userid=%d inst=%d campus=%d\n",
+		r.Case.LegacyUserID, r.Case.InstitutionID, r.Case.CampusID)
+
+	fmt.Fprintf(w, "  ── 逐列比对 ──\n")
+	for _, d := range r.Diffs {
+		mark := "✔"
+		if !d.Equal {
+			mark = "✘"
+		}
+		if d.Skipped {
+			mark = "—"
+		}
+		fmt.Fprintf(w, "    %s %-40s legacy=%-18s target=%-18s %s\n", mark, d.Item, d.Legacy, d.Target, d.Note)
+	}
+	fmt.Fprintf(w, "  ── 断言 ──\n")
+	for _, a := range r.Assertions {
+		mark := "✔"
+		if !a.Pass {
+			mark = "✘"
+		}
+		if a.Skipped {
+			mark = "—"
+		}
+		if a.Kind == AssertionUnclassified {
+			mark = "⚠"
+		}
+		kind := ""
+		if a.Kind != AssertionBusiness {
+			kind = "[" + a.Kind.String() + "] "
+		}
+		fmt.Fprintf(w, "    %s %s%s\n       %s\n", mark, kind, a.Name, a.Detail)
+	}
+	verdict := "PASS"
+	if !r.OK() {
+		verdict = "FAIL"
+	}
+	fmt.Fprintf(w, "  结论:%s(不一致 %d 项)\n\n", verdict, r.FailCount())
+}
+
+// ResultSetCase 是一条「只读结果集」case。
+//
+// 与写路径的 `Case` 结构**不同构**(那套的主语是"写后终态"),所以不进 `DefaultCases`、
+// 不参与 55 条的分母。入口见 `RunResultSetCases`。
+type ResultSetCase struct {
+	Name string
+	Note string
+
+	// LegacyUserID / InstitutionID / CampusID 是两侧**同一组**入参的业务身份。
+	LegacyUserID  int64
+	InstitutionID int64
+	CampusID      int64
+
+	// SeedLegacy / SeedTarget 在各自事务内造同一份数据。
+	//
+	// 两者必须"同构":值由同一组常量声明,而不是各写一遍。
+	SeedLegacy func(ctx context.Context, tx *gorm.DB) error
+	SeedTarget func(ctx context.Context, tx *gorm.DB, uid string) error
+
+	// VerifyLegacy / VerifyTarget 读回校验(`compareSeedState` 的用法):
+	// 声明什么就该读到什么,读不回来说种子没种上 —— 那时比对再绿也没有意义。
+	VerifyLegacy func(ctx context.Context, tx *gorm.DB) (string, error)
+	VerifyTarget func(ctx context.Context, tx *gorm.DB, uid string) (string, error)
+
+	// LegacyCall / LegacyArgs 是 SP 调用(逐字照 dump 的参数顺序)。
+	LegacyCall string
+	LegacyArgs []any
+
+	// CallTarget 调真实 service,并把返回值摊平成 payload。
+	CallTarget func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error)
+
+	// Compare 组装比对输入(列映射 + 期望值)。
+	Compare func(lg ResultSet, tg TargetPayload) ResultSetInput
+}
+
+// RunResultSetCases 跑全部结果集 case。
+//
+// 每条 case:开两个事务 → 各种种子 → legacy `CALL` 读结果集(**读干净**)→
+// target 调 service → 比对 → **两侧都回滚**。
+func RunResultSetCases(ctx context.Context, cfg Config, w io.Writer) (bool, error) {
+	cases := ResultSetCases()
+	if len(cases) == 0 {
+		return false, fmt.Errorf("ResultSetCases 长度为 0:跑了个寂寞")
+	}
+	results := make([]*CaseResult, 0, len(cases))
+	for _, c := range cases {
+		r, err := runOneResultSetCase(ctx, cfg, c)
+		if err != nil {
+			return false, fmt.Errorf("case %q 执行失败:%w", c.Name, err)
+		}
+		printResultSetResult(w, r)
+		results = append(results, r)
+	}
+	if len(results) != len(cases) {
+		return false, fmt.Errorf("分母收缩 —— 应跑 %d 条,实跑 %d 条", len(cases), len(results))
+	}
+	return summarizeResultSet(w, results), nil
+}
+
+// runOneResultSetCase 跑单条结果集 case 的闭环。
+func runOneResultSetCase(ctx context.Context, cfg Config, c ResultSetCase) (*CaseResult, error) {
+	legacyDB, err := cfg.OpenWriteLegacy()
+	if err != nil {
+		return nil, err
+	}
+	defer closeDB(legacyDB)
+	targetDB, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+	defer closeDB(targetDB)
+
+	uid := CanonicalUIDFor(c.LegacyUserID)
+	res := &CaseResult{Case: Case{
+		Name: c.Name, Note: c.Note,
+		LegacyUserID: c.LegacyUserID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+	}}
+
+	// ---- legacy 侧 ----
+	lgTx := legacyDB.WithContext(ctx).Begin()
+	if lgTx.Error != nil {
+		return nil, fmt.Errorf("legacy 开启事务失败:%w", lgTx.Error)
+	}
+	defer func() { _ = lgTx.Rollback() }()
+	if err := c.SeedLegacy(ctx, lgTx); err != nil {
+		return nil, fmt.Errorf("legacy 种子失败:%w", err)
+	}
+	seedDetail, err := c.VerifyLegacy(ctx, lgTx)
+	if err != nil {
+		return nil, fmt.Errorf("legacy 种子读回校验失败:%w", err)
+	}
+	lgRS, callErr := callReadOnlySP(ctx, lgTx, c.LegacyCall, c.LegacyArgs)
+	if callErr != nil {
+		return nil, fmt.Errorf("legacy CALL `%s` 失败:%w", c.LegacyCall, callErr)
+	}
+
+	// ---- target 侧 ----
+	tgTx := targetDB.WithContext(ctx).Begin()
+	if tgTx.Error != nil {
+		return nil, fmt.Errorf("target 开启事务失败:%w", tgTx.Error)
+	}
+	defer func() { _ = tgTx.Rollback() }()
+	if err := c.SeedTarget(ctx, tgTx, uid); err != nil {
+		return nil, fmt.Errorf("target 种子失败:%w", err)
+	}
+	tgSeedDetail, err := c.VerifyTarget(ctx, tgTx, uid)
+	if err != nil {
+		return nil, fmt.Errorf("target 种子读回校验失败:%w", err)
+	}
+	sc := &appscope.Scope{
+		InstitutionID: c.InstitutionID, CampusID: c.CampusID,
+		UserID: uid, Role: "finance",
+	}
+	payload, err := c.CallTarget(ctx, tgTx, uid, sc)
+	if err != nil {
+		res.Assertions = append(res.Assertions, Assertion{
+			Kind: AssertionBusiness,
+			Name: "target 侧调用", Pass: false,
+			Detail: fmt.Sprintf("调用失败:%v(两侧必须都成功,否则无从比对)", err),
+		})
+		return res, nil
+	}
+
+	diffs, asserts := compareResultSet(c.Compare(lgRS, payload))
+	res.Diffs = diffs
+	res.Assertions = append(res.Assertions, asserts...)
+	// 种子读回:**基建断言**(器材准不准,不是被测行为)。
+	res.Assertions = append(res.Assertions, Assertion{
+		Kind:   AssertionInfrastructure,
+		Name:   "种子读回与声明一致(两侧各自读回)",
+		Pass:   true,
+		Detail: fmt.Sprintf("legacy:%s | target:%s", seedDetail, tgSeedDetail),
+	})
+	return res, nil
+}
+
+// callReadOnlySP 调一支只读 SP 并**把结果集读干净**。
+//
+// 两条必须知道的:
+//   - 这些 SP 只有一个结果集,但驱动仍要求把 `NextResultSet` 走完,
+//     否则同一连接上的下一条语句会报 `Error 2014 Commands out of sync`;
+//   - `rows.Columns()` 是 SP 的 SELECT 列名 —— 列覆盖断言就是拿它跟声明比的。
+func callReadOnlySP(ctx context.Context, tx *gorm.DB, stmt string, args []any) (ResultSet, error) {
+	var out ResultSet
+	out.Statement = stmt
+	rows, err := tx.WithContext(ctx).Raw(stmt, args...).Rows()
+	if err != nil {
+		return out, err
+	}
+	defer func() { _ = rows.Close() }()
+	cols, err := rows.Columns()
+	if err != nil {
+		return out, fmt.Errorf("读结果集列名失败:%w", err)
+	}
+	out.Columns = cols
+	for rows.Next() {
+		vals := make([]any, len(cols))
+		ptrs := make([]any, len(cols))
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rows.Scan(ptrs...); err != nil {
+			return out, fmt.Errorf("读结果集行失败:%w", err)
+		}
+		row := make([]Cell, len(cols))
+		for i, v := range vals {
+			if v == nil {
+				row[i] = Cell{Null: true}
+				continue
+			}
+			row[i] = Cell{Raw: sqlCell(v)}
+		}
+		out.Rows = append(out.Rows, row)
+	}
+	if err := rows.Err(); err != nil {
+		return out, err
+	}
+	// drain 掉后续结果集(本批 SP 只有一个,但这条纪律不能靠"我记得")。
+	for rows.NextResultSet() {
+		for rows.Next() {
+		}
+	}
+	return out, rows.Err()
+}
+
+// summarizeResultSet 打印结果集 case 的总览。
+func summarizeResultSet(w io.Writer, results []*CaseResult) bool {
+	total, pass, skipped, infra, unclassified := 0, 0, 0, 0, 0
+	for _, r := range results {
+		total++
+		if r.OK() {
+			pass++
+		}
+		skipped += r.SkippedCount()
+		infra += r.InfraCount()
+		unclassified += r.UnclassifiedCount()
+	}
+	fmt.Fprintf(w, "══════════════════════════════════════════════════════════════\n")
+	fmt.Fprintf(w, "总览:%d 个结果集 case,%d PASS,%d FAIL\n", total, pass, total-pass)
+	fmt.Fprintf(w, "Skipped:%d 项(显式豁免列 + 结构性不适用)| 断言分类:业务 %d 条 | 基建 %d 条 | 未分类 %d 条\n",
+		skipped, sumBusiness(results), infra, unclassified)
+	for _, r := range results {
+		st := "PASS"
+		if !r.OK() {
+			st = "FAIL"
+		}
+		fmt.Fprintf(w, "  %s  %-46s 不一致 %d  Skipped %d  业务断言 %d\n",
+			st, r.Case.Name, r.FailCount(), r.SkippedCount(), r.LiveAssertionCount())
+	}
+	return pass == total && unclassified == 0
+}

+ 14 - 0
internal/academic/dualrun/stage2/rs_cases.go

@@ -0,0 +1,14 @@
+package stage2
+
+// 本文件是**只读结果集双跑**的 case 清单(独立入口,不进 `DefaultCases`)。
+//
+// 为什么不并进 `DefaultCases`:那 55 条的主语是"写后的库状态"(before/after 快照 + Δ 断言),
+// 而这三支 SP **一分钱都不写**,塞进去只会得到"Δ 全 0 ⇒ 两侧相等"的假绿。
+// 两条链的判据不同构,就不该共用一个入口 —— 这也是派单里"不要动 DefaultCases"的原因。
+//
+// 入口:`go run ./cmd/dualrun resultsets`
+func ResultSetCases() []ResultSetCase {
+	return []ResultSetCase{
+		rsRefundableCase(),
+	}
+}

+ 124 - 0
internal/academic/dualrun/stage2/rs_compare_test.go

@@ -0,0 +1,124 @@
+package stage2
+
+import (
+	"strings"
+	"testing"
+)
+
+// 本文件是**结果集比对骨架**的自测。
+//
+// 为什么要写它:这套机器的核心判据是"列覆盖"与"三态 / 精度",它们**都只能在出错时才显形**。
+// 一条真实 case 全绿不能证明判据有牙 —— 只能证明这次数据没踩到它。
+// 所以这里用合成结果集把每条判据**故意弄红一次**,钉住"它会红"。
+
+// rsInput 造一份最小输入(一行一列金额)。
+func rsInput(l, t Cell) ResultSetInput {
+	return ResultSetInput{
+		CaseName:   "unit",
+		Legacy:     ResultSet{Columns: []string{"amount"}, Rows: [][]Cell{{l}}, Statement: "CALL p()"},
+		ExpectRows: 1,
+		Target:     TargetPayload{Label: "svc", Values: map[string]Cell{"amount": t}},
+		Columns:    []ResultColumn{{Legacy: "amount", Target: "amount", Kind: CellMoney}},
+	}
+}
+
+// failCount 数出断言里的失败条数(不含 Skipped)。
+func failCount(asserts []Assertion) int {
+	n := 0
+	for _, a := range asserts {
+		if !a.Skipped && !a.Pass {
+			n++
+		}
+	}
+	return n
+}
+
+func TestResultSetMoneyEqualAcrossScales(t *testing.T) {
+	_, asserts := compareResultSet(rsInput(Cell{Raw: "685"}, Cell{Raw: "685.00"}))
+	if n := failCount(asserts); n != 0 {
+		t.Fatalf("685 与 685.00 应等价(金额归一),实际 %d 条失败:%+v", n, asserts)
+	}
+}
+
+func TestResultSetOneSideMismatchIsRed(t *testing.T) {
+	_, asserts := compareResultSet(rsInput(Cell{Raw: "685.00"}, Cell{Raw: "684.99"}))
+	if failCount(asserts) == 0 {
+		t.Fatal("两侧差 0.01 却没有任何断言失败 —— 比对机器没有牙")
+	}
+}
+
+func TestResultSetNullOrZeroAreDifferent(t *testing.T) {
+	_, asserts := compareResultSet(rsInput(Cell{Null: true}, Cell{Raw: "0.00"}))
+	if failCount(asserts) == 0 {
+		t.Fatal("NULL 与 0.00 被判成相等 —— 三态失效(「这列没算过」会被伪装成「算出来是 0」)")
+	}
+}
+
+func TestResultSetUnclaimedColumnIsRed(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.00"}, Cell{Raw: "1.00"})
+	// dump 多返回一列,而声明没认领它 —— 必须红(否则"新加一列"永远不被比对)。
+	in.Legacy.Columns = []string{"amount", "brandNew"}
+	in.Legacy.Rows = [][]Cell{{Cell{Raw: "1.00"}, Cell{Raw: "42.00"}}}
+	_, asserts := compareResultSet(in)
+	if failCount(asserts) == 0 {
+		t.Fatal("存在无人认领的 dump 列却没有失败 —— 列覆盖断言失效")
+	}
+}
+
+func TestResultSetDeclaredColumnMissingIsRed(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.00"}, Cell{Raw: "1.00"})
+	in.Columns = append(in.Columns, ResultColumn{Legacy: "typoColumn", Target: "x", Kind: CellText})
+	_, asserts := compareResultSet(in)
+	if failCount(asserts) == 0 {
+		t.Fatal("声明了一个 dump 里不存在的列却没有失败 —— 覆盖断言拦不住列名拼错")
+	}
+}
+
+func TestResultSetExemptWithoutReasonIsRed(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.00"}, Cell{Raw: "1.00"})
+	in.Columns = []ResultColumn{{Legacy: "amount", Exempt: true}} // 有豁免、无理由
+	_, asserts := compareResultSet(in)
+	if failCount(asserts) == 0 {
+		t.Fatal("豁免列不带理由却没有失败 —— 「豁免」会变成「静默不比」")
+	}
+}
+
+func TestResultSetTargetExtraFieldIsRed(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.00"}, Cell{Raw: "1.00"})
+	in.Target.Values["hidden"] = Cell{Raw: "x"}
+	_, asserts := compareResultSet(in)
+	if failCount(asserts) == 0 {
+		t.Fatal("target 多返回一个没人看的字段却没有失败 —— 侧向覆盖失效")
+	}
+}
+
+func TestResultSetZeroRowsIsRed(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.00"}, Cell{Raw: "1.00"})
+	in.Legacy.Rows = nil // SP 一行都没返回
+	_, asserts := compareResultSet(in)
+	if failCount(asserts) == 0 {
+		t.Fatal("0 行被当成通过 —— 那会把「种子没命中 / SP 提前 LEAVE」伪装成绿")
+	}
+}
+
+func TestResultSetThirdDecimalNonZeroIsRed(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.005"}, Cell{Raw: "1.01"})
+	in.PrecisionCheck = true
+	_, asserts := compareResultSet(in)
+	if failCount(asserts) == 0 {
+		t.Fatal("dump 第 3 位非 0 却没有失败 —— 2 位比较的「无损」是假设,必须被断言钉住")
+	}
+}
+
+func TestResultSetThirdDecimalZeroPasses(t *testing.T) {
+	in := rsInput(Cell{Raw: "1.005"}, Cell{Raw: "1.01"})
+	in.PrecisionCheck = true
+	// 这一条只验精度断言本身:把第 3 位换成 0 后,精度断言应通过。
+	in.Legacy.Rows[0][0] = Cell{Raw: "1.000"}
+	_, asserts := compareResultSet(in)
+	for _, a := range asserts {
+		if strings.Contains(a.Name, "精度口径") && !a.Pass {
+			t.Fatalf("第 3 位为 0 时精度断言仍红:%s", a.Detail)
+		}
+	}
+}

+ 370 - 0
internal/academic/dualrun/stage2/rs_refundable.go

@@ -0,0 +1,370 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/service"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 本文件是 **SP5 `sp_user_expense_school_get_refundable_amount` ↔
+// `FundService.RefundableAmount`** 的结果集双跑。
+//
+// 取法(可复现):`go run ./cmd/spdump proc sp_user_expense_school_get_refundable_amount`
+// 关键行(原文摘录,不写行号 —— 行号会随 dump 版本漂移):
+//
+//	DECLARE refundableAmount FLOAT DEFAULT 0;                 ← **FLOAT,不是 DECIMAL**
+//	IF (campusBalance<=0) THEN SELECT refundableAmount; LEAVE ... END IF;
+//	IF (totalBalance<=0) THEN SELECT refundableAmount; LEAVE ... END IF;
+//	IF (campusBalance>totalBalance) THEN SET campusBalance = totalBalance; END IF;
+//	IF (campusBalance<=0) THEN SELECT refundableAmount; LEAVE ... END IF;   ← 截断后的第二次早退
+//	SELECT SUM(t2.activity_money_used) ... FROM t_user_balance_request t1
+//	  LEFT JOIN t_courses_activity_signed t2 ON t1.id=t2.requestid
+//	 WHERE t1.type=100 AND t1.userid=? AND t1.schoolid=? AND t1.campusid=? AND t1.aproved=1
+//	   AND t2.activity_money>0 AND t2.activity_money_used<t2.activity_money;
+//	SET refundableAmount = campusBalance - totalDiscountAmountUsed;
+//	IF refundableAmount<0 THEN SET refundableAmount = 0; END IF;
+//	SELECT refundableAmount;                                   ← 一行一列
+//
+// 目标侧:`FundService.RefundableAmount`(`service/fund_service.go`),三步同构:
+// 校区余额 → 全局余额截断 → 扣已使用赠送额(下限 0)。
+
+// rsRefundableSigned 是一条报名行(赠送额度来自哪个活动)。
+type rsRefundableSigned struct {
+	ActivityID int64
+	MoneyCents int64 // activity_money
+	UsedCents  int64 // activity_money_used
+}
+
+// rsRefundableRequest 是一张充值工单 + 它关联的报名行。
+type rsRefundableRequest struct {
+	ID       string
+	Type     int
+	CampusID int64
+	Approved int
+	Signed   []rsRefundableSigned
+}
+
+// 种子的**单一声明**(两侧都从这份声明生成,绝不各写一遍)。
+//
+// 六张工单各钉一条判据(SP 的 WHERE 一共 6 个条件):
+//
+//	A 正常命中                     → 计 70.00
+//	B used == money(赠送已用尽)  → 不计(`activity_money_used<activity_money` 不成立)
+//	C 别的校区                     → 不计(`t1.campusid=?`)
+//	D type=200(不是普通充值)     → 不计(`t1.type=100`)
+//	E 未审批(aproved=0)          → 不计(`t1.aproved=1`)
+//	F 一张工单两行报名             → 两行**都**计(SUM 扇出),计 20.00+25.00=45.00
+//
+// 少了任意一条,"这个过滤条件到底生效没有"就无从判断(六条都被命中才叫覆盖)。
+func rsRefundableRequests() []rsRefundableRequest {
+	return []rsRefundableRequest{
+		{ID: "RS-REF-A", Type: 100, CampusID: rsCampusID, Approved: 1, Signed: []rsRefundableSigned{
+			{ActivityID: 900001, MoneyCents: 20000, UsedCents: 7000},
+		}},
+		{ID: "RS-REF-B", Type: 100, CampusID: rsCampusID, Approved: 1, Signed: []rsRefundableSigned{
+			{ActivityID: 900002, MoneyCents: 5000, UsedCents: 5000},
+		}},
+		{ID: "RS-REF-C", Type: 100, CampusID: 4, Approved: 1, Signed: []rsRefundableSigned{
+			{ActivityID: 900003, MoneyCents: 3000, UsedCents: 1000},
+		}},
+		{ID: "RS-REF-D", Type: 200, CampusID: rsCampusID, Approved: 1, Signed: []rsRefundableSigned{
+			{ActivityID: 900004, MoneyCents: 4000, UsedCents: 500},
+		}},
+		{ID: "RS-REF-E", Type: 100, CampusID: rsCampusID, Approved: 0, Signed: []rsRefundableSigned{
+			{ActivityID: 900005, MoneyCents: 6000, UsedCents: 1500},
+		}},
+		{ID: "RS-REF-F", Type: 100, CampusID: rsCampusID, Approved: 1, Signed: []rsRefundableSigned{
+			{ActivityID: 900006, MoneyCents: 10000, UsedCents: 2000},
+			{ActivityID: 900007, MoneyCents: 10000, UsedCents: 2500},
+		}},
+	}
+}
+
+// 账户基线(分)。**校区 < 全局**,这样"截断"这一步不生效、也不会把差异掩盖:
+// 若把校区造得比全局大,`refundable` 的上界就变成全局余额,两个数会混在一起。
+const (
+	rsRefundableCampusBalanceCents int64 = 80000  // 800.00
+	rsRefundableGlobalBalanceCents int64 = 100000 // 1000.00
+)
+
+// rsRefundableUsedSum 按**声明**算出"参与扣减的已用赠送额"。
+//
+// 逐条对齐 SP 的 WHERE(见文件头):approved=1 && type=100 && campus 匹配 &&
+// activity_money>0 && activity_money_used<activity_money;满足的行把 used 求和。
+// 这个函数是"期望值",不是"实现" —— 它读的是声明,不是库。
+func rsRefundableUsedSum() int64 {
+	var sum int64
+	for _, r := range rsRefundableRequests() {
+		if r.Approved != 1 || r.Type != 100 || r.CampusID != rsCampusID {
+			continue
+		}
+		for _, s := range r.Signed {
+			if s.MoneyCents > 0 && s.UsedCents < s.MoneyCents {
+				sum += s.UsedCents
+			}
+		}
+	}
+	return sum
+}
+
+// rsRefundableExpected 由声明算出可退金额(含两道 <=0 早退与截断)。
+func rsRefundableExpected() int64 {
+	campus := rsRefundableCampusBalanceCents
+	global := rsRefundableGlobalBalanceCents
+	if campus <= 0 || global <= 0 {
+		return 0
+	}
+	if campus > global {
+		campus = global
+	}
+	if campus <= 0 {
+		return 0
+	}
+	v := campus - rsRefundableUsedSum()
+	if v < 0 {
+		return 0
+	}
+	return v
+}
+
+// seedLegacyRefundable 种 legacy 侧(账户两行 + 工单 + 报名)。
+func seedLegacyRefundable(ctx context.Context, tx *gorm.DB) error {
+	if err := tx.Exec(`DELETE FROM t_courses_activity_signed WHERE userid = ?`, rsUserID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 报名残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_user_balance_request WHERE userid = ?`, rsUserID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 工单残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_user_expense_school WHERE userid = ?`, rsUserID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 校区账户残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_user_expense WHERE userid = ?`, rsUserID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 全局账户残留失败:%w", err)
+	}
+
+	if err := requireRows("种 legacy 全局账户", tx.Exec(`
+INSERT INTO t_user_expense (userid, balance, spend, total_income, total_expenditure)
+VALUES (?, ?, 0, 0, 0)`, rsUserID, yuan(rsRefundableGlobalBalanceCents)), 1); err != nil {
+		return err
+	}
+	if err := requireRows("种 legacy 校区账户", tx.Exec(`
+INSERT INTO t_user_expense_school (userid, schoolid, campusid, balance, spend, total_income, total_expenditure)
+VALUES (?, ?, ?, ?, 0, 0, 0)`,
+		rsUserID, rsInstID, rsCampusID, yuan(rsRefundableCampusBalanceCents)), 1); err != nil {
+		return err
+	}
+
+	for _, r := range rsRefundableRequests() {
+		if err := requireRows("种 legacy 充值工单 "+r.ID, tx.Exec(`
+INSERT INTO t_user_balance_request
+  (id, type, userid, schoolid, campusid, money, request_userid, request_time, response_time, aproved)
+VALUES (?, ?, ?, ?, ?, 0, ?, '2026-01-01 00:00:00', '2026-01-01 00:00:00', ?)`,
+			r.ID, r.Type, rsUserID, rsInstID, r.CampusID, rsUserID, r.Approved), 1); err != nil {
+			return err
+		}
+		for _, s := range r.Signed {
+			if err := requireRows(fmt.Sprintf("种 legacy 报名 %s/%d", r.ID, s.ActivityID), tx.Exec(`
+INSERT INTO t_courses_activity_signed
+  (activityid, userid, status, activity_money, activity_money_used, requestid, create_time)
+VALUES (?, ?, 2, ?, ?, ?, '2026-01-01 00:00:00')`,
+				s.ActivityID, rsUserID, yuan(s.MoneyCents), yuan(s.UsedCents), r.ID), 1); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// seedTargetRefundable 种 target 侧(列名是目标态命名)。
+func seedTargetRefundable(ctx context.Context, tx *gorm.DB, uid string) error {
+	if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
+		return err
+	}
+	if err := tx.Exec(`DELETE FROM t_courses_activity_signed WHERE user_id = ?`, uid).Error; err != nil {
+		return fmt.Errorf("清理 target 报名残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_user_balance_request WHERE user_id = ?`, uid).Error; err != nil {
+		return fmt.Errorf("清理 target 工单残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_user_expense_school WHERE user_id = ?`, uid).Error; err != nil {
+		return fmt.Errorf("清理 target 校区账户残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_user_expense WHERE user_id = ?`, uid).Error; err != nil {
+		return fmt.Errorf("清理 target 全局账户残留失败:%w", err)
+	}
+
+	if err := requireRows("种 target 全局账户", tx.Exec(`
+INSERT INTO t_user_expense (user_id, balance, spend, total_income, total_expenditure)
+VALUES (?, ?, 0, 0, 0)`, uid, yuan(rsRefundableGlobalBalanceCents)), 1); err != nil {
+		return err
+	}
+	if err := requireRows("种 target 校区账户", tx.Exec(`
+INSERT INTO t_user_expense_school (user_id, institution_id, campus_id, balance, spend, total_income, total_expenditure)
+VALUES (?, ?, ?, ?, 0, 0, 0)`,
+		uid, rsInstID, rsCampusID, yuan(rsRefundableCampusBalanceCents)), 1); err != nil {
+		return err
+	}
+
+	for _, r := range rsRefundableRequests() {
+		if err := requireRows("种 target 充值工单 "+r.ID, tx.Exec(`
+INSERT INTO t_user_balance_request
+  (biz_no, type, user_id, institution_id, campus_id, money, request_user_id, request_time, response_time, approved)
+VALUES (?, ?, ?, ?, ?, 0, ?, '2026-01-01 00:00:00', '2026-01-01 00:00:00', ?)`,
+			r.ID, r.Type, uid, rsInstID, r.CampusID, uid, r.Approved), 1); err != nil {
+			return err
+		}
+		for _, s := range r.Signed {
+			if err := requireRows(fmt.Sprintf("种 target 报名 %s/%d", r.ID, s.ActivityID), tx.Exec(`
+INSERT INTO t_courses_activity_signed
+  (activity_id, user_id, institution_id, campus_id, status, activity_money, activity_money_used, request_no, create_time)
+VALUES (?, ?, ?, ?, 2, ?, ?, ?, '2026-01-01 00:00:00')`,
+				s.ActivityID, uid, rsInstID, r.CampusID, yuan(s.MoneyCents), yuan(s.UsedCents), r.ID), 1); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// verifyLegacyRefundableSeed 读回校验:声明几张工单/几行报名、两个余额是多少。
+func verifyLegacyRefundableSeed(ctx context.Context, tx *gorm.DB) (string, error) {
+	return verifyRefundableSeed(ctx, tx, "legacy", rsUserID, false)
+}
+
+// verifyTargetRefundableSeed 同上(target 列名与 legacy 不同,故分开写)。
+func verifyTargetRefundableSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
+	return verifyRefundableSeed(ctx, tx, "target", uid, true)
+}
+
+// verifyRefundableSeed 是两侧共用的读回实现。
+//
+// 只核对**种子事实**(行数 / 余额 / 报名额合计),不重算 SP 的过滤结果 ——
+// 在种子校验里再写一遍过滤逻辑,会让"种子"和"期望"共用同一个 bug。
+func verifyRefundableSeed(
+	ctx context.Context, tx *gorm.DB, side string, who any, target bool,
+) (string, error) {
+	reqTable, reqUser := "t_user_balance_request", "userid"
+	sigTable, sigUser := "t_courses_activity_signed", "userid"
+	campusUser, globalUser := "userid", "userid"
+	campusInstCond := "schoolid = ? AND campusid = ?"
+	if target {
+		reqUser, sigUser = "user_id", "user_id"
+		campusUser, globalUser = "user_id", "user_id"
+		campusInstCond = "institution_id = ? AND campus_id = ?"
+	}
+	var reqCount, sigCount int
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s = ?`, reqTable, reqUser), who).
+		Row().Scan(&reqCount); err != nil {
+		return "", fmt.Errorf("[%s] 读回工单条数失败:%w", side, err)
+	}
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s = ?`, sigTable, sigUser), who).
+		Row().Scan(&sigCount); err != nil {
+		return "", fmt.Errorf("[%s] 读回报名条数失败:%w", side, err)
+	}
+	var campusBal, globalBal string
+	if err := tx.Raw(fmt.Sprintf(`SELECT CAST(balance AS DECIMAL(20,3)) FROM t_user_expense_school
+ WHERE %s = ? AND %s`, campusUser, campusInstCond), who, rsInstID, rsCampusID).
+		Row().Scan(&campusBal); err != nil {
+		return "", fmt.Errorf("[%s] 读回校区余额失败:%w", side, err)
+	}
+	if err := tx.Raw(fmt.Sprintf(`SELECT CAST(balance AS DECIMAL(20,3)) FROM t_user_expense WHERE %s = ?`, globalUser), who).
+		Row().Scan(&globalBal); err != nil {
+		return "", fmt.Errorf("[%s] 读回全局余额失败:%w", side, err)
+	}
+
+	wantReq, wantSig := 0, 0
+	for _, r := range rsRefundableRequests() {
+		wantReq++
+		wantSig += len(r.Signed)
+	}
+	if reqCount != wantReq || sigCount != wantSig {
+		return "", fmt.Errorf("[%s] 工单/报名条数与声明不符:工单 %d(声明 %d)、报名 %d(声明 %d)",
+			side, reqCount, wantReq, sigCount, wantSig)
+	}
+	if c, err := centsOf(campusBal); err != nil || c != rsRefundableCampusBalanceCents {
+		return "", fmt.Errorf("[%s] 校区余额读回 %q,声明 %s", side, campusBal, yuan(rsRefundableCampusBalanceCents))
+	}
+	if c, err := centsOf(globalBal); err != nil || c != rsRefundableGlobalBalanceCents {
+		return "", fmt.Errorf("[%s] 全局余额读回 %q,声明 %s", side, globalBal, yuan(rsRefundableGlobalBalanceCents))
+	}
+	return fmt.Sprintf("工单 %d 张 / 报名 %d 行 / 校区余额 %s / 全局余额 %s",
+		reqCount, sigCount, campusBal, globalBal), nil
+}
+
+// rsRefundableCase 组装这条 case。
+func rsRefundableCase() ResultSetCase {
+	return ResultSetCase{
+		Name: "RS-1 可退金额 sp_user_expense_school_get_refundable_amount(一行一列)",
+		Note: "六张工单各钉 SP 的一个 WHERE 条件(审批/类型/校区/赠送已用尽/扇出)," +
+			"期望 800.00 −(70.00+45.00)= 685.00,由声明算出而非抄实现",
+		LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
+		SeedLegacy:   seedLegacyRefundable,
+		SeedTarget:   seedTargetRefundable,
+		VerifyLegacy: verifyLegacyRefundableSeed,
+		VerifyTarget: verifyTargetRefundableSeed,
+		LegacyCall:   "CALL sp_user_expense_school_get_refundable_amount(?, ?, ?)",
+		LegacyArgs:   []any{rsUserID, rsInstID, rsCampusID},
+		CallTarget: func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error) {
+			svc := service.NewFundService(tx, sc, port.NewOrgMembershipChecker(tx))
+			amount, err := svc.RefundableAmount(ctx, uid, rsInstID, rsCampusID)
+			if err != nil {
+				return TargetPayload{}, err
+			}
+			return TargetPayload{
+				Label:    "FundService.RefundableAmount",
+				Values:   map[string]Cell{"refundableAmount": MoneyCell(int64(amount))},
+				Evidence: fmt.Sprintf("amount=%d 分(%s)", int64(amount), money.Amount(amount).String()),
+			}, nil
+		},
+		Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
+			exp := fixtureCents(rsRefundableExpected())
+			return ResultSetInput{
+				CaseName:   "RS-1",
+				Legacy:     lg,
+				ExpectRows: 1,
+				Target:     tg,
+				Columns: []ResultColumn{
+					{Legacy: "refundableAmount", Target: "refundableAmount", Kind: CellMoney},
+				},
+				Expect: map[string]string{"refundableAmount": exp},
+				// **不启用 PrecisionCheck**:这一支的 SP 变量是 `FLOAT`(4 字节单精度),
+				// 不是 `DECIMAL(10,3)`。把"3→2 无损"套在 float 上是错的(float 的问题不是小数位,
+				// 是二进制可表示性)。这一条改由 ExtraAssertions 单独说明。
+				PrecisionCheck: false,
+				ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
+					raw := ""
+					if c, ok := RawCellOf(lg, 0, "refundableAmount"); ok {
+						raw = c.String()
+					}
+					return []Assertion{{
+						Kind: AssertionBusiness,
+						Name: "精度口径:SP 返回变量是 FLOAT(4 字节单精度)—— 本种子下结果精确可表示,与定点金额比较无信息损失",
+						Pass: strings.TrimSpace(raw) != "" && rsExactInFloat32(rsRefundableExpected()),
+						Detail: fmt.Sprintf("dump raw=%q(FLOAT,**不是** DECIMAL(10,3));声明期望 %s。"+
+							"本种子每个金额都能被 float32 精确表示,所以这里 2 位比较无损;"+
+							"**对真实数据不成立**(float32 在 0.01 级不可精确表示),本断言只覆盖本 case 的种子",
+							raw, exp),
+					}}
+				},
+			}
+		},
+	}
+}
+
+// rsExactInFloat32 判断"分"换算成元后能否被 float32 精确表示。
+//
+// 判据:`float64(float32(v)) == v`。这是可判定的事实,不是估计。
+func rsExactInFloat32(cents int64) bool {
+	v := float64(cents) / 100
+	return float64(float32(v)) == v
+}
+
+// fixtureCents 把"分"渲染成 2 位小数字符串(期望值口径)。
+func fixtureCents(cents int64) string { return yuan(cents) }

+ 90 - 0
internal/academic/dualrun/stage2/rs_seed.go

@@ -0,0 +1,90 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+
+	"gorm.io/gorm"
+)
+
+// 本文件是「只读结果集」三支 SP 共用的**种子基建**。
+//
+// 与写路径的 `seedTarget` 的区别:那一个会顺带把 `finance_bk` 里的真实账户/流水搬过来
+//("从同一份基线起步"是写路径的核心纪律)。只读 SP **不写库**,它读什么就只种什么:
+// 多搬一份真实数据只会让"这条断言到底在验谁"变得说不清。所以这里只建**身份**
+//(机构 / 校区 / 人 / 映射 / 成员关系),业务数据由各 case 自己声明。
+
+// 三支 case 共用的一组**合成 ID**。
+//
+// 为什么不用真实用户:真实用户带着真实流水与真实课酬规则,种子就变成"在别人的数据上加一行",
+// 断言失败时无法判断是自己种错还是撞上了存量数据。合成 ID 在两侧都**全新**(已核:
+// 两侧相关表对这组 ID 的命中数均为 0),于是"读到什么"完全由本 case 的声明决定。
+//
+// 机构 990001 / 校区 990101 不在任何真实数据里 ⇒ `assertInstitution` 与
+// `ExpenseRepo.institutionScoped` 的过滤只会命中本 case 种的行。
+const (
+	rsUserID   int64 = 880001
+	rsInstID   int64 = 990001
+	rsCampusID int64 = 990101
+)
+
+// seedResultSetIdentityTarget 在 target 事务内建"这个人属于这个机构/校区"。
+//
+// 为什么必须建:三个 service 都会经 `assertInScope`(`port.NewOrgMembershipChecker` 读
+// `t_membership`)。不建成员关系就等于**绕过**越权断言 —— 那是写路径明令禁止的事,
+// 只读路径同样不许(否则这套 case 证明的是"绕过检查后结果对")。
+func seedResultSetIdentityTarget(
+	ctx context.Context, tx *gorm.DB, uid string, legacyUserID, institutionID, campusID int64,
+) error {
+	if err := tx.Exec(`
+INSERT INTO t_institution (institution_id, institution_name, status, created_at, updated_at)
+VALUES (?, CONCAT('rs-case-inst-', ?), 'active', NOW(3), NOW(3))
+ON DUPLICATE KEY UPDATE institution_name = VALUES(institution_name)`,
+		institutionID, institutionID).Error; err != nil {
+		return fmt.Errorf("种子 t_institution 失败:%w", err)
+	}
+	if err := tx.Exec(`
+INSERT INTO t_campus (campus_id, institution_id, campus_name, status, self_support, created_at, updated_at)
+VALUES (?, ?, CONCAT('rs-case-campus-', ?), 'active', 1, NOW(3), NOW(3))
+ON DUPLICATE KEY UPDATE campus_name = VALUES(campus_name), institution_id = VALUES(institution_id)`,
+		campusID, institutionID, campusID).Error; err != nil {
+		return fmt.Errorf("种子 t_campus 失败:%w", err)
+	}
+	if err := tx.Exec(`
+INSERT INTO t_user (canonical_user_id, display_name, person_type, status)
+VALUES (?, CONCAT('rs-case-user-', ?), 'student', 'active')
+ON DUPLICATE KEY UPDATE display_name = VALUES(display_name)`,
+		uid, legacyUserID).Error; err != nil {
+		return fmt.Errorf("种子 t_user 失败:%w", err)
+	}
+	if err := tx.Exec(`
+INSERT INTO t_legacy_mapping
+  (canonical_user_id, legacy_academic_user_id, institution_id, campus_id, role, match_source, match_confidence)
+VALUES (?, ?, ?, ?, 'student', 'seed', 100)
+ON DUPLICATE KEY UPDATE legacy_academic_user_id = VALUES(legacy_academic_user_id)`,
+		uid, legacyUserID, institutionID, campusID).Error; err != nil {
+		return fmt.Errorf("种子 t_legacy_mapping 失败:%w", err)
+	}
+	if err := tx.Exec(`
+INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at)
+VALUES (?, ?, ?, 'student', 'active', NOW(3))
+ON DUPLICATE KEY UPDATE status = 'active'`,
+		uid, institutionID, campusID).Error; err != nil {
+		return fmt.Errorf("种子 t_membership 失败:%w", err)
+	}
+	return nil
+}
+
+// requireRows 断言一条种子的受影响行数("种子没种上"必须当时就炸)。
+//
+// 为什么不用 `RowsAffected > 0`:`INSERT ... ON DUPLICATE` 在"值完全相同"时返回 0,
+// 那是合法的(重复跑同一条 case 的残留),所以只在**插入类**种子上用 `!= 1`。
+func requireRows(what string, res *gorm.DB, want int64) error {
+	if res.Error != nil {
+		return fmt.Errorf("%s 失败:%w", what, res.Error)
+	}
+	if res.RowsAffected != want {
+		return fmt.Errorf("%s 影响 %d 行(期望 %d):种子没种上", what, res.RowsAffected, want)
+	}
+	return nil
+}