Răsfoiți Sursa

feat(ac15): 写 SP 双跑第一支(消费)——两侧真写库 + 双回滚 + 零残留

范围:sp_user_expense_school_expenditure ↔ service.FundService.Expenditure(端点 4)。
这一批第一次两侧都真的写库:构造 case → legacy CALL SP(事务内)→ target 跑 Go service(事务内)
→ 各自读回 → 逐项比对 → 双回滚 → 断言零残留。

一、scope 怎么构造(没有绕过 assertInScope)
- tx := targetDB.Begin(),再把 **tx 本身**传给 NewFundService:
  TxRunner.WithinTx 判 InTx(t.DB) 已处于事务 → 直接透传(不再 BEGIN),service 的写入落在我的事务里
- scope = &appscope.Scope{InstitutionID: case.Inst, CampusID: case.Campus, UserID: uid, Role: "finance"}
- member = port.NewOrgMembershipChecker(tx) —— **真的**读 t_membership,case 里真的插了一行
  (user,63317,5,'student','active');没有 nil checker、没有 SuperAdmin、没改 service 签名

二、基线同源
- legacy 读 legacy_oracle 天然值;target 用 INSERT…SELECT FROM finance_bk 种进去
- 两侧都用 ROUND(CAST(x AS DECIMAL(20,3)),2) + 同一个 fixture.DecimalToCents 解析
- 调用前硬断言「两侧基线逐分相等」(C1/C2 实测两边都是 bal=100.00 spend=105023.35 …)

三、三个 case(全 PASS)
- C1 正常消费(type=201,30.00):Δbalance 两侧都 -30.00;各 1 条流水;money 逐分相等
- C2 幂等(同一 requestId 跑 2 次):target 第 2 次 repeated=true 只入账 1 次(-30.00);
  legacy SP 无幂等键,真的重复入账 2 次(-60.00,2 条流水)
- C3 R15 余额不足(余额 10.00 消费 100.00,type=200):两侧都成功、都扣成 -90.00,
  spend/total_expenditure 都 +100.00

四、Δ 比对不是"两侧相等"而是"各自等于自己的期望"
C2 里两侧本来就不应相等(legacy 重复入账是已知差异)。改成 expectedDelta(typ, amount, runs)
把每侧应有行为显式写出来,偏离任一侧的预期都会红;容差常量为 0(单测锁死)。

五、三处已确认的行为差异(设计内 Go 增强,不是缺陷)
- 幂等:SP 无 request_id 概念会重复入账;Go 用 uk_request_type 命中即 repeated
- type 越界:SP 把 type<200/>299 静默改 200;Go 白名单校验非法即 2002
- balance_after:SP 无此列;Go 写操作后全局余额快照

六、变异 4 组(全部已还原)
- M1a target 多扣 1 分 → 红 5 项(Δbalance -30.00 vs -30.01、流水 money …)
- M1b 在 M1a 基础上把 Δ 断言放宽 1 分 → 变绿(证明放宽会掩盖真实 1 分误差,容差必须 0)
- M2 把 target 的 Rollback 改成 Commit → 红:target 残留账户行=true 流水=1
- M3 把 C3 预期改成"应报 3101" → 红:反证 R15(不校验余额)是真的

七、残留
- target 全部回滚:t_user_expense / _log / _membership / _campus / _institution / _user 全 0
- legacy 回到事务前天然状态(对照用事务外独立连接,不能用事务内基线 —— R15 会覆盖基线)
- M2 变异提交进去的 1 行残留已手工清理,复核全 0

新增 internal/academic/dualrun/stage2/(6 个文件 1305 行 + README)+ cmd/dualrun stage2 子命令。
Q 7 ore în urmă
părinte
comite
dfb32b7153

+ 51 - 2
cmd/dualrun/main.go

@@ -22,8 +22,10 @@ import (
 	"flag"
 	"fmt"
 	"os"
+	"strings"
 
 	"yuansiwei/unified-backend/internal/academic/dualrun"
+	"yuansiwei/unified-backend/internal/academic/dualrun/stage2"
 )
 
 func main() {
@@ -34,6 +36,8 @@ func main() {
 	switch os.Args[1] {
 	case "stage1":
 		os.Exit(runStage1(os.Args[2:]))
+	case "stage2":
+		os.Exit(runStage2(os.Args[2:]))
 	case "-h", "--help", "help":
 		usage()
 		os.Exit(0)
@@ -45,10 +49,11 @@ func main() {
 }
 
 func usage() {
-	fmt.Fprint(os.Stderr, `dualrun —— AC-15 双跑 harness(阶段 1:课酬链)
+	fmt.Fprint(os.Stderr, `dualrun —— AC-15 双跑 harness
 
 子命令:
-  stage1    跑课酬链比对
+  stage1    跑课酬链比对(课酬,纯函数)
+  stage2    跑写 SP 双跑(消费:sp_user_expense_school_expenditure ↔ FundService.Expenditure)
 
 stage1 参数:
   --case-set string   real100 | synthetic | all(默认 all)
@@ -119,3 +124,47 @@ func runStage1(args []string) int {
 	}()
 	return exit
 }
+
+// runStage2 跑写 SP 双跑(第一支:消费)。
+func runStage2(args []string) int {
+	fs := flag.NewFlagSet("stage2", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	only := fs.String("case", "", "只跑指定 case(按名字子串匹配)")
+	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())
+
+	ctx := context.Background()
+	var results []*stage2.CaseResult
+	for _, c := range stage2.DefaultCases("") {
+		if *only != "" && !strings.Contains(c.Name, *only) {
+			continue
+		}
+		r, err := stage2.RunOne(ctx, cfg, c)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "FATAL: case %q 执行失败:%v\n", c.Name, err)
+			return 3
+		}
+		stage2.PrintResult(os.Stdout, r)
+		results = append(results, r)
+	}
+	if ok := stage2.Summarize(os.Stdout, results); !ok {
+		return 1
+	}
+	return 0
+}

+ 98 - 0
internal/academic/dualrun/stage2/README.md

@@ -0,0 +1,98 @@
+# `internal/academic/dualrun/stage2` —— 写 SP 双跑(第一支:消费)
+
+`sp_user_expense_school_expenditure` ↔ `service.FundService.Expenditure`。
+
+这是**第一次两侧都真的写库**的双跑:
+
+```
+构造 case → legacy 侧 CALL SP(事务内)→ target 侧跑 Go service(事务内)
+→ 各自读回写入后的状态 → 逐项比对 → 双方回滚 → 断言零残留
+```
+
+```bash
+go run ./cmd/dualrun stage2              # 跑全部 3 个 case
+go run ./cmd/dualrun stage2 --case C1    # 只跑某个 case(名字子串匹配)
+```
+
+---
+
+## 1. scope 怎么构造的(没有绕过 `assertInScope`)
+
+```go
+tx := targetDB.Begin()                       // ① 我自己持有事务
+defer tx.Rollback()
+
+// ② 把 **tx 本身** 传给 service:TxRunner.WithinTx 开头判 InTx(t.DB) → 已处于事务中就直接透传(不再 BEGIN),
+//    所以 service 的写入落在我的事务里,我可以读完再回滚。
+sc := &appscope.Scope{
+    InstitutionID: c.InstitutionID,          // 63317
+    CampusID:      c.CampusID,               // 5
+    UserID:        canonicalUID,             // 实际操作人(本 case 里 = 消费者本人)
+    Role:          "finance",
+}
+svc := service.NewFundService(tx, sc, port.NewOrgMembershipChecker(tx))
+out, err := svc.Expenditure(ctx, service.ExpenditureInput{...})
+```
+
+三条说明:
+
+1. **checker 是真的**:`port.NewOrgMembershipChecker(tx)` 读 `t_membership`,case 里真的插了一行
+   `(user, 63317, 5, 'student', 'active')`。没有 `nil` checker、没有 `SuperAdmin=true`、没有改 service 签名。
+2. **`Scope` 不是"造一个能过的"**:它的 `InstitutionID/CampusID` 就是 case 的业务参数;如果 case 的机构/校区
+   与该用户的成员关系不匹配,`assertInScope` 会返回 1004 —— 那正是要验证的东西,不回避。
+3. **操作人 = 消费者本人**:`OperatorID` 也传 `canonicalUID`。真实场景里操作人是财务/教务,
+   但 `assertInScope` 校验的是**被扣款用户**的成员关系,与本 case 目标无关,所以仍用同一 ULID 以少插一行数据。
+
+## 2. 两侧怎么保证"同一份基线"
+
+| 侧 | 来源 | 口径 |
+|---|---|---|
+|legacy|`legacy_oracle`(finance_bk 的完整副本)天然值|读成 `ROUND(CAST(x AS DECIMAL(20,3)),2)` 再按分解析|
+|target|`INSERT INTO … SELECT … FROM finance_bk.…`|SQL 里就写 `ROUND(CAST(x AS DECIMAL(20,3)),2)`|
+
+两侧都用**同一个** `fixture.DecimalToCents` 把十进制串转成分,所以"分"不会各算各的。
+调用前有硬断言「**两侧基线逐分相等**」,不等直接红(那说明映射有问题,不是双跑问题)。
+
+## 3. 三个 case 与各自的期望
+
+| case | 入参 | 期望 |
+|---|---|---|
+|**C1** 正常消费|type=201,30.00,调用 1 次|两侧都成功;Δbalance 都 −30.00;各 1 条流水|
+|**C2** 幂等|type=201,30.00,同一 requestId **调用 2 次**|target:第 2 次 `repeated=true`、只入账 1 次;legacy:**SP 没有幂等键,会重复入账 2 次**|
+|**C3** R15 余额不足|type=200,余额置 10.00,消费 100.00|两侧都**成功**且都扣成 −90.00(SP 不校验余额)|
+
+**为什么 Δ 比对不是"两侧相等"而是"各自等于自己的期望"**:
+C2 里两侧本来就**不应该**相等。写成"两侧相等"只能得到 FAIL,而它其实是设计内的已知差异。
+所以 `expectedDelta(typ, amount, runs)` 把每侧应有的行为显式写出来,再拿实测去对 —— 偏离任一侧的预期都会红。
+
+## 4. 三处**已确认的行为差异**(不是缺陷,是设计内的 Go 增强)
+
+| 项 | legacy SP | Go `FundService.Expenditure` | 依据 |
+|---|---|---|---|
+|**幂等**|无 `request_id` 概念,重复提交**重复入账**|`request_id + type` 唯一键,命中即 `repeated=true` 且不入账|设计 §1.5 |
+|**`type` 越界**|`type<200 或 >299` **静默改成 200**(把"传错"记成"历史扣费")|白名单 101/104/105/200/201/204/205,非法 → **2002**|设计 §3.1 #3 |
+|**`balance_after`**|无此列|写"操作后**全局**账户余额"快照|设计 §10.2 |
+
+前两条在报告里都单独列了 diff/断言,C2 用实测把"legacy 重复入账 2 次 / target 1 次"钉死。
+
+## 5. 回滚与残留
+
+- legacy:`tx.Begin()` → 可选基线覆盖 → `CALL` → 读回 → `Rollback`
+- target:`tx.Begin()` → 种基线(org/人/成员关系/两份账户)→ service → 读回 → `Rollback`
+- 残留检查用**新连接**、在事务**之外**读:
+  - target 的种子行必须全部消失(`t_user_expense` / `_log` / `t_membership` 计数为 0)
+  - legacy 的余额必须回到**事务之前**的天然状态(不能拿事务内的基线比 —— R15 用例会把基线覆盖成 10.00)
+
+> `t_user_expense_log.id` 是自增,回滚后**不会回退**;重跑时 id 会跳号。断言里一律不用固定 id。
+
+## 6. 变异实验(3 组,都已还原)
+
+|#|改错什么|结果|
+|---|---|---|
+|**M1a**|target 侧多扣 1 分(`AmountCents + 1`)|**红 5 项**:Δbalance `-30.00 vs -30.01`、Δtotal_expenditure、流水 money|
+|**M1b**|在 M1a 基础上把 Δ 断言放宽 1 分|**变绿**(`PASS`)—— 证明"放宽 1 分"会掩盖真实的 1 分误差,所以容差必须是 0|
+|**M2**|把 target 侧 `Rollback` 改成 `Commit`|**红**:`target 残留账户行=true 流水=1`(回滚断言抓住)|
+|**M3**|把 C3 的预期改成"应当报错 3101"|**红**:`首次调用结果与预期一致` 断言失败 —— 反证 R15(不校验余额)是真的|
+
+> M2 的说明:种子 SQL 每次都把账户 `UPDATE` 回 finance_bk 基线,所以"第二次跑基线不等"这个症状不会出现;
+> 真正抓住它的是**残留断言**(并且在截图里能看到 target 侧多出 1 条已提交的流水)。

+ 171 - 0
internal/academic/dualrun/stage2/case.go

@@ -0,0 +1,171 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// 本文件是 case 定义与两侧快照的数据形状。
+
+// Case 是一个双跑用例。
+type Case struct {
+	Name string
+
+	// 业务输入(两侧完全一致的入参)
+	LegacyUserID  int64
+	InstitutionID int64
+	CampusID      int64
+	AmountCents   int64  // 消费金额(分)
+	Type          int32  // 消费类型(白名单 101/104/105/200/201/204/205)
+	RequestID     string // 幂等请求号(legacy 落在 logid,target 落在 request_id + log_id)
+	Remark        string // 空 → 两侧各自落默认备注(应都为"历史扣费")
+
+	// Baseline 非 nil 时,两侧在调用前**各自 UPDATE 成这份基线**(R15 用)。
+	// 语义:这是同一个基线,不是"各自造一个" —— 设置后仍会断言两侧逐分相等。
+	Baseline *Baseline
+
+	// RunTimes 调用次数(2 = 幂等用例:同一 requestId 连跑两次)。
+	RunTimes int
+
+	// ExpectTargetSuccess 期望 target 成功(R15 场景 = true)。
+	ExpectTargetSuccess bool
+	// ExpectLegacySuccess 期望 legacy 成功(SP 无余额校验 → 恒 true)。
+	ExpectLegacySuccess bool
+
+	// Note 报告里的说明。
+	Note string
+}
+
+// Baseline 是一条账户基线(单位:分)。
+type Baseline struct {
+	GlobalBalance     int64
+	GlobalSpend       int64
+	GlobalIncome      int64
+	GlobalExpenditure int64
+	CampusBalance     int64
+	CampusSpend       int64
+	CampusIncome      int64
+	CampusExpenditure int64
+}
+
+// Account 是一个账户行的快照(单位:分)。
+//
+// legacy 侧是 float(10,3) → 读成 DECIMAL(20,3) 再按分取整(与迁移同一口径);
+// target 侧是 decimal(12,2) → 直接读。
+type Account struct {
+	Balance     int64
+	Spend       int64
+	Income      int64
+	Expenditure int64
+}
+
+// LogRow 是一条流水的快照(两侧字段对齐后)。
+type LogRow struct {
+	ID                 int64
+	Type               int32
+	Money              int64  // 分(legacy 的 money 是负数 = 支出方向)
+	LogID              string // legacy logid / target log_id
+	RequestID          string // target 独有(legacy 无此列)
+	BalanceAfter       *int64 // target 独有(legacy 无此列)
+	Remark             string
+	CreateUserLegacyID int64  // legacy create_userid(int)
+	CreateUserUID      string // target create_user_id(ULID)
+	CreateTime         time.Time
+}
+
+// SideSnapshot 是一侧「调用前 / 调用后」的完整状态。
+type SideSnapshot struct {
+	Global Account
+	Campus Account
+	Logs   []LogRow // 只含本 case 产生的流水
+}
+
+// SideResult 是一侧的运行结果。
+type SideResult struct {
+	Side          string // legacy / target
+	Before        SideSnapshot
+	After         SideSnapshot
+	Calls         []CallOutcome
+	Err           error
+	BaselineEqual bool
+}
+
+// CallOutcome 是一次调用的返回。
+type CallOutcome struct {
+	Success  bool
+	Repeated bool
+	Balance  int64 // 响应里的余额(分)
+	LogID    int64
+	ErrMsg   string
+}
+
+// Diff 是一条比对结论。
+type Diff struct {
+	Item   string
+	Legacy string
+	Target string
+	Equal  bool
+	Note   string
+}
+
+// CaseResult 是一个 case 的完整双跑结论。
+type CaseResult struct {
+	Case       Case
+	Diffs      []Diff
+	Assertions []Assertion
+	Legacy     *SideResult
+	Target     *SideResult
+	Residue    ResidueReport
+}
+
+// Assertion 是一条语义断言。
+type Assertion struct {
+	Name   string
+	Pass   bool
+	Detail string
+}
+
+// ResidueReport 是「回滚后有没有残留」的检查结果。
+type ResidueReport struct {
+	LegacyGlobalExist bool
+	TargetGlobalExist bool
+	LegacyLogCount    int64
+	TargetLogCount    int64
+	Pass              bool
+	Detail            string
+}
+
+// FailCount 统计不一致项 + 失败断言。
+func (r *CaseResult) FailCount() int {
+	n := 0
+	for _, d := range r.Diffs {
+		if !d.Equal {
+			n++
+		}
+	}
+	for _, a := range r.Assertions {
+		if !a.Pass {
+			n++
+		}
+	}
+	return n
+}
+
+// OK 表示该 case 通过。
+func (r *CaseResult) OK() bool { return r.FailCount() == 0 }
+
+// toleranceCents 是 Δ 比对的容差。
+//
+// **必须为 0**:迁移口径把 float 3 位收敛到 2 位,但每个 case 的 Δ 是"同一笔金额在两侧的增量",
+// 两侧都按 2 位运算,增量必须逐分相同。任何非零容差都是在掩盖误差。
+const toleranceCents int64 = 0
+
+// 编译期占位,避免 gorm/context 未使用(本文件只放类型)。
+var (
+	_ = context.Background
+	_ = gorm.ErrRecordNotFound
+	_ = fmt.Sprintf
+)

+ 160 - 0
internal/academic/dualrun/stage2/config.go

@@ -0,0 +1,160 @@
+// Package stage2 是 AC-15 阶段 2 的**写 SP 双跑**(第一支:消费
+// `sp_user_expense_school_expenditure` ↔ `service.FundService.Expenditure`)。
+//
+// 与前几批的根本差别:这一批**两侧都真的写库**,然后再回滚。
+//
+//	构造 case → legacy 侧 CALL SP(事务内)→ target 侧跑 Go service(事务内)
+//	→ 各自读回写入后的状态 → 逐项比对 → 双方回滚 → 断言零残留
+//
+// 三条不可妥协的纪律:
+//
+//  1. **不绕过 `assertInScope`**:target 侧装配**真实的** `port.NewOrgMembershipChecker`,
+//     并在 case 里真的插一行 `t_membership`。不构造超管 scope、不传 nil checker。
+//  2. **两侧从同一份基线起步**:legacy 侧读 `legacy_oracle`(finance_bk 的完整副本),
+//     target 侧用 `INSERT ... SELECT FROM finance_bk` + 同一套 ROUND 口径种进去,
+//     并在调用前**显式断言**基线逐分相等(不等就说明映射有问题,直接红)。
+//  3. **双方都必须回滚**:断言跑完后两边表回到初始状态(残留 = 0)。
+package stage2
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"regexp"
+	"strconv"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// Config 是双跑的两条连接。
+type Config struct {
+	Host string
+	Port int
+	User string
+	Pass string
+
+	TargetDB string // yuansiwei_2026(写)
+	LegacyDB string // legacy_oracle(写:SP 调用在事务内,结束即回滚)
+	SourceDB string // finance_bk(只读:基线取样 + 种子数据来源)
+}
+
+var allowedHosts = map[string]bool{"127.0.0.1": true, "localhost": true, "::1": true}
+
+// Guard 校验 host 与库名(防"连错实例后结果看起来正常")。
+func (c Config) Guard() error {
+	if !allowedHosts[c.Host] {
+		return fmt.Errorf("数据源守卫失败:host %q 不在允许清单内", c.Host)
+	}
+	if c.TargetDB != "yuansiwei_2026" {
+		return fmt.Errorf("数据源守卫失败:target 必须是 yuansiwei_2026,当前 %q", c.TargetDB)
+	}
+	if c.LegacyDB != "legacy_oracle" {
+		return fmt.Errorf("数据源守卫失败:写活 legacy 只允许 legacy_oracle,当前 %q", c.LegacyDB)
+	}
+	if c.SourceDB != "finance_bk" {
+		return fmt.Errorf("数据源守卫失败:只读基线源必须是 finance_bk,当前 %q", c.SourceDB)
+	}
+	if c.Pass == "" {
+		return fmt.Errorf("目标库口令不可得(AC15_DB_PASS 或 config/dev.json)")
+	}
+	return nil
+}
+
+// Masked 返回不含口令的连接描述(日志/报告用)。
+func (c Config) Masked() string {
+	return fmt.Sprintf("target=%s  legacy(w)=%s  source(r)=%s  @%s:%d",
+		c.TargetDB, c.LegacyDB, c.SourceDB, c.Host, c.Port)
+}
+
+type dsnFile struct {
+	DB struct {
+		DSN string `json:"dsn"`
+	} `json:"db"`
+}
+
+var dsnPattern = regexp.MustCompile(`^([^:]+):([^@]+)@tcp\(([^:]+):(\d+)\)/([^?]+)`)
+
+// LoadConfig 读连接参数(环境变量优先,其次仓库内 config/dev.json 的 db.dsn)。
+//
+// 口令只经环境变量或既有配置文件读取,不打印、不落盘。
+func LoadConfig(repoRoot string) (Config, error) {
+	cfg := Config{
+		Host:     envOr("AC15_DB_HOST", "127.0.0.1"),
+		Port:     envIntOr("AC15_DB_PORT", 3306),
+		User:     envOr("AC15_DB_USER", "dev"),
+		Pass:     os.Getenv("AC15_DB_PASS"),
+		TargetDB: envOr("AC15_TARGET_DB", "yuansiwei_2026"),
+		LegacyDB: envOr("AC15_WRITE_LEGACY_DB", "legacy_oracle"),
+		SourceDB: envOr("AC15_LEGACY_DB", "finance_bk"),
+	}
+	if cfg.Pass == "" || os.Getenv("AC15_DB_USER") == "" {
+		raw, err := os.ReadFile(repoRoot + "/config/dev.json")
+		if err == nil {
+			var f dsnFile
+			if json.Unmarshal(raw, &f) == nil {
+				if m := dsnPattern.FindStringSubmatch(f.DB.DSN); m != nil {
+					if cfg.Pass == "" {
+						cfg.Pass = m[2]
+					}
+					if os.Getenv("AC15_DB_USER") == "" {
+						cfg.User = m[1]
+					}
+				}
+			}
+		}
+	}
+	if cfg.Pass == "" {
+		return Config{}, fmt.Errorf("口令不可得:请设置 AC15_DB_PASS 或提供 config/dev.json 的 db.dsn")
+	}
+	return cfg, nil
+}
+
+func envOr(k, def string) string {
+	if v := os.Getenv(k); v != "" {
+		return v
+	}
+	return def
+}
+
+func envIntOr(k string, def int) int {
+	if v := os.Getenv(k); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n > 0 {
+			return n
+		}
+	}
+	return def
+}
+
+func (c Config) dsn(dbName string) string {
+	return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
+		c.User, c.Pass, c.Host, c.Port, dbName)
+}
+
+// open 打开一个 gorm 句柄(单连接,避免事务跨连接池失效)。
+func (c Config) open(dbName string) (*gorm.DB, error) {
+	gdb, err := gorm.Open(mysql.Open(c.dsn(dbName)), &gorm.Config{
+		Logger:               logger.Default.LogMode(logger.Silent),
+		DisableAutomaticPing: false,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("连接 %s 失败:%w", dbName, err)
+	}
+	sqlDB, err := gdb.DB()
+	if err != nil {
+		return nil, err
+	}
+	sqlDB.SetMaxOpenConns(1)
+	sqlDB.SetMaxIdleConns(1)
+	return gdb, nil
+}
+
+// OpenTarget 打开目标库(yuansiwei_2026)。
+func (c Config) OpenTarget() (*gorm.DB, error) { return c.open(c.TargetDB) }
+
+// OpenWriteLegacy 打开可写 legacy(legacy_oracle):SP 调用包在事务里,结束必回滚。
+func (c Config) OpenWriteLegacy() (*gorm.DB, error) { return c.open(c.LegacyDB) }
+
+// OpenSource 打开只读基线源(finance_bk)。
+func (c Config) OpenSource() (*gorm.DB, error) { return c.open(c.SourceDB) }

+ 202 - 0
internal/academic/dualrun/stage2/legacy.go

@@ -0,0 +1,202 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
+)
+
+// 本文件是**legacy 侧**:在 legacy_oracle 的事务里 CALL SP、读回、回滚。
+//
+// 为什么直接 CALL 而不是像阶段 1 那样起 PHP 进程:
+//   - 阶段 1 跑的是 PHP 应用层的结算函数,必须进 PHP;
+//   - 这里跑的就是一条存储过程,客户端无关;且 `START TRANSACTION → CALL → 读回 → ROLLBACK`
+//     正是设计文档给的模板,直接在 Go 里做反而更贴近"SP 本身的行为"。
+//
+// 金额读取口径:用 `ROUND(CAST(x AS DECIMAL(20,3)), 2)` 转成 2 位十进制串,再按分解析
+// —— 与迁移口径**同一个函数**(`fixture.DecimalToCents`),所以两侧的"分"不会因为
+// 一个用 float 四舍五入、一个用 decimal 收敛而各算各的。
+
+// legacySide 是 legacy 侧的运行器。
+type legacySide struct {
+	db *gorm.DB
+}
+
+func newLegacySide(db *gorm.DB) *legacySide { return &legacySide{db: db} }
+
+// callSQL 是 SP 的调用语句(8 个入参,顺序与 dump 一致)。
+const callSQL = "CALL sp_user_expense_school_expenditure(?, ?, ?, ?, ?, ?, ?, ?)"
+
+// Run 在事务内跑 case,读回状态后回滚。
+func (s *legacySide) Run(ctx context.Context, c Case) (*SideResult, error) {
+	res := &SideResult{Side: "legacy"}
+	tx := s.db.WithContext(ctx).Begin()
+	if tx.Error != nil {
+		return nil, fmt.Errorf("legacy 开启事务失败:%w", tx.Error)
+	}
+	defer func() { _ = tx.Rollback() }() // 无论如何都回滚(res 里已把读回值取出)
+
+	if c.Baseline != nil {
+		if err := applyBaselineLegacy(ctx, tx, c); err != nil {
+			return nil, err
+		}
+	}
+
+	before, maxLogID, err := snapshotLegacy(ctx, tx, c)
+	if err != nil {
+		return nil, err
+	}
+	res.Before = before
+
+	// 按 dump 口径把入参规整成 SP 会收到的值(type<200 / >299 → 200 是 SP 内部行为,这里不改,
+	// 让 SP 自己做,才能验证"SP 会静默改写 type"这件事)。
+	for i := 0; i < maxInt(1, c.RunTimes); i++ {
+		err := tx.Exec(callSQL,
+			c.LegacyUserID, c.InstitutionID, c.CampusID,
+			float64(c.AmountCents)/100.0, // SP 的第 4 参是 FLOAT(金额元)
+			c.Type, c.RequestID, c.Remark, c.LegacyUserID,
+		).Error
+		out := CallOutcome{Success: err == nil}
+		if err != nil {
+			out.ErrMsg = err.Error()
+		}
+		res.Calls = append(res.Calls, out)
+		if err != nil {
+			// SP 抛错后事务可能已不可用,直接读回会失败;保留错误并继续读尽力而为
+			break
+		}
+	}
+
+	after, _, err := snapshotLegacy(ctx, tx, c)
+	if err != nil {
+		return nil, err
+	}
+	after.Logs = filterLogs(after.Logs, maxLogID)
+	res.After = after
+
+	// 读回 SP 之后的余额(响应体语义:target 返回校区余额;legacy 没有返回值,这里用快照补)
+	for i := range res.Calls {
+		res.Calls[i].Balance = after.Campus.Balance
+	}
+	return res, nil
+}
+
+// applyBaselineLegacy 把两侧基线强制设置成同一份(R15 用)。
+func applyBaselineLegacy(ctx context.Context, tx *gorm.DB, c Case) error {
+	b := c.Baseline
+	if err := tx.Exec(`
+UPDATE t_user_expense SET balance=?, spend=?, total_income=?, total_expenditure=?
+ WHERE userid=?`,
+		yuan(b.GlobalBalance), yuan(b.GlobalSpend), yuan(b.GlobalIncome), yuan(b.GlobalExpenditure),
+		c.LegacyUserID).Error; err != nil {
+		return fmt.Errorf("legacy 设置全局基线失败:%w", err)
+	}
+	if err := tx.Exec(`
+UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expenditure=?
+ WHERE userid=? AND schoolid=? AND campusid=?`,
+		yuan(b.CampusBalance), yuan(b.CampusSpend), yuan(b.CampusIncome), yuan(b.CampusExpenditure),
+		c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
+		return fmt.Errorf("legacy 设置校区基线失败:%w", err)
+	}
+	return nil
+}
+
+// snapshotLegacy 读 legacy 侧当前状态(全局 + 校区 + 该用户的全部流水)。
+func snapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (SideSnapshot, int64, error) {
+	var out SideSnapshot
+
+	var g struct {
+		Balance, Spend, Income, Expenditure string
+	}
+	err := tx.Raw(`
+SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).
+		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure)
+	if err != nil {
+		return out, 0, fmt.Errorf("读 legacy 全局账户失败:%w", err)
+	}
+	gs, err := centsOf(g.Balance)
+	if err != nil {
+		return out, 0, err
+	}
+	gsp, _ := centsOf(g.Spend)
+	gi, _ := centsOf(g.Income)
+	ge, _ := centsOf(g.Expenditure)
+	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge}
+
+	var sc struct {
+		Balance, Spend, Income, Expenditure string
+	}
+	err = tx.Raw(`
+SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2))
+  FROM t_user_expense_school WHERE userid = ? AND schoolid = ? AND campusid = ?`,
+		c.LegacyUserID, c.InstitutionID, c.CampusID).
+		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure)
+	if err != nil {
+		return out, 0, fmt.Errorf("读 legacy 校区账户失败:%w", err)
+	}
+	cs, _ := centsOf(sc.Balance)
+	csp, _ := centsOf(sc.Spend)
+	ci, _ := centsOf(sc.Income)
+	ce, _ := centsOf(sc.Expenditure)
+	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce}
+
+	rows, err := tx.Raw(`
+SELECT id, IFNULL(type,0), CAST(ROUND(CAST(IFNULL(money,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       IFNULL(logid,''), IFNULL(create_userid,0), IFNULL(remark,''), create_time
+  FROM t_user_expense_log WHERE userid = ? ORDER BY id`, c.LegacyUserID).Rows()
+	if err != nil {
+		return out, 0, fmt.Errorf("读 legacy 流水失败:%w", err)
+	}
+	defer rows.Close()
+	var maxID int64
+	for rows.Next() {
+		var l LogRow
+		var money string
+		var ct time.Time
+		if err := rows.Scan(&l.ID, &l.Type, &money, &l.LogID, &l.CreateUserLegacyID, &l.Remark, &ct); err != nil {
+			return out, 0, err
+		}
+		l.Money, _ = centsOf(money)
+		l.CreateTime = ct
+		if l.ID > maxID {
+			maxID = l.ID
+		}
+		out.Logs = append(out.Logs, l)
+	}
+	return out, maxID, rows.Err()
+}
+
+// filterLogs 只保留 id > 阈值的流水(= 本 case 新产生的)。
+func filterLogs(logs []LogRow, afterID int64) []LogRow {
+	var out []LogRow
+	for _, l := range logs {
+		if l.ID > afterID {
+			out = append(out, l)
+		}
+	}
+	return out
+}
+
+// centsOf 用**迁移同一口径**把十进制串转成分(float 3 位 → ROUND 到 2 位)。
+func centsOf(s string) (int64, error) { return fixture.DecimalToCents(s) }
+
+// yuan 把分格式化成 2 位十进制串(写回账户列用)。
+func yuan(cents int64) string { return fixture.CentsToDecimal(cents) }
+
+func maxInt(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}

+ 451 - 0
internal/academic/dualrun/stage2/run.go

@@ -0,0 +1,451 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"strings"
+)
+
+// 本文件是比对与编排。
+
+// DefaultCases 返回三支 case:正常 / 幂等 / R15(余额不足)。
+//
+// 用户与机构校区取自真实数据(userid=17735 / 63317 / 5):
+// 该用户全局与校区余额都是 100.00、且四个金额列在 legacy 侧都是 2 位小数,
+// 因此**两侧基线天然逐分相等**(不需要人为造基线)—— 这正是 case 有效性的前提。
+func DefaultCases(uid string) []Case {
+	base := Case{
+		LegacyUserID:        17735,
+		InstitutionID:       63317,
+		CampusID:            5,
+		Type:                201,
+		Remark:              "",
+		RunTimes:            1,
+		ExpectTargetSuccess: true,
+		ExpectLegacySuccess: true,
+	}
+	_ = uid
+
+	c1 := base
+	c1.Name = "C1 正常消费(type=201,30.00)"
+	c1.AmountCents = 3000
+	c1.RequestID = "ac15-s2-c1"
+	c1.Note = "两侧都应成功,Δbalance 同为 -30.00,各写 1 条流水"
+
+	c2 := base
+	c2.Name = "C2 幂等(同一 requestId 连跑两次)"
+	c2.AmountCents = 3000
+	c2.RequestID = "ac15-s2-c2"
+	c2.RunTimes = 2
+	c2.Note = "target(Go)应 repeated=true 且只入账一次;legacy SP **没有幂等**,会重复入账 —— 这是设计内的 Go 增强"
+
+	c3 := base
+	c3.Name = "C3 R15 余额不足(余额 10.00,消费 100.00)"
+	c3.AmountCents = 10000
+	c3.RequestID = "ac15-s2-c3"
+	c3.Type = 200 // 历史扣费(同时动 spend,覆盖三分支里最复杂的一支)
+	c3.Note = "R15:SP 不校验余额 → 两侧都应成功且扣成 -90.00"
+	c3.Baseline = &Baseline{
+		GlobalBalance: 1000, GlobalSpend: 0, GlobalIncome: 0, GlobalExpenditure: 0,
+		CampusBalance: 1000, CampusSpend: 0, CampusIncome: 0, CampusExpenditure: 0,
+	}
+	return []Case{c1, c2, c3}
+}
+
+// RunOne 跑一个 case 的完整双跑闭环。
+func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
+	legacyDB, err := cfg.OpenWriteLegacy()
+	if err != nil {
+		return nil, err
+	}
+	targetDB, err := cfg.OpenTarget()
+	if err != nil {
+		return nil, err
+	}
+
+	uid := CanonicalUIDFor(c.LegacyUserID)
+	res := &CaseResult{Case: c}
+
+	// 事务**之前**先读一次 legacy 的天然状态:回滚之后要拿它做对照。
+	// (不能用 lg.Before —— R15 用例会在事务内把基线覆盖成 10.00,那不是"调用前"的真实状态。)
+	natural, err := readLegacyNatural(ctx, cfg, c.LegacyUserID)
+	if err != nil {
+		return nil, err
+	}
+
+	// ---- legacy 侧(事务内 CALL → 读回 → 回滚)----
+	lg, err := newLegacySide(legacyDB).Run(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+	res.Legacy = lg
+
+	// ---- target 侧(事务内 seed → service → 读回 → 回滚)----
+	tg, err := newTargetSide(targetDB).Run(ctx, c, uid)
+	if err != nil {
+		return nil, err
+	}
+	res.Target = tg
+
+	// ---- 比对 ----
+	res.Diffs, res.Assertions = compare(c, lg, tg)
+
+	// ---- 残留检查(两边都回滚之后)----
+	res.Residue = checkResidue(ctx, cfg, c, uid, natural)
+	if !res.Residue.Pass {
+		res.Assertions = append(res.Assertions, Assertion{
+			Name: "回滚后零残留(两侧都回到调用前状态)", Pass: false, Detail: res.Residue.Detail,
+		})
+	} else {
+		res.Assertions = append(res.Assertions, Assertion{
+			Name: "回滚后零残留(两侧都回到调用前状态)", Pass: true, Detail: res.Residue.Detail,
+		})
+	}
+	return res, nil
+}
+
+// expectedDelta 返回一侧在某 case 下的**期望增量**。
+//
+// 为什么要有"期望"而不只是"两侧相等":
+//   - 幂等用例里两侧本来就**不应该**相等(legacy SP 没有幂等,会重复入账);
+//   - 只比"相等"的话,这种情况只能算 FAIL,而它其实是设计内的已知差异。
+//
+// 所以这里把"每侧应有的行为"显式写出来,再拿实测去对 —— 偏离任何一侧的预期都会红。
+func expectedDelta(typ int32, amount int64, runs int) Account {
+	exp := Account{
+		Balance:     -amount * int64(runs),
+		Expenditure: amount * int64(runs),
+	}
+	if typ == 200 { // 历史扣费:同时计入 spend
+		exp.Spend = amount * int64(runs)
+	}
+	return exp
+}
+
+// compare 逐项比对两侧。
+func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	add := func(item, l, t string, equal bool, note string) {
+		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
+	}
+
+	// ① 基线逐分相等(case 有效性前提)
+	baseEqual := lg.Before.Global == tg.Before.Global && lg.Before.Campus == tg.Before.Campus
+	asserts = append(asserts, Assertion{
+		Name: "两侧基线逐分相等(case 有效性前提)",
+		Pass: baseEqual,
+		Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s",
+			fmtAcc(lg.Before.Global), fmtAcc(lg.Before.Campus),
+			fmtAcc(tg.Before.Global), fmtAcc(tg.Before.Campus)),
+	})
+
+	// ② Δ 逐分相等(核心):拿每侧实测 Δ 去对**该侧应有的期望值**
+	//
+	// runsLegacy:legacy SP 每次都真入账(无幂等键)→ 调用几次就入账几次
+	// runsTarget:Go 侧有 uk_request_type 幂等 → 无论调用几次只入账一次
+	runsLegacy := maxInt(1, c.RunTimes)
+	runsTarget := 1
+	noteRuns := ""
+	if c.RunTimes >= 2 {
+		noteRuns = fmt.Sprintf("预期差异:legacy 入账 %d 次(SP 无幂等)/ target 入账 1 次(幂等命中)", runsLegacy)
+	}
+	wantL := expectedDelta(c.Type, c.AmountCents, runsLegacy)
+	wantT := expectedDelta(c.Type, c.AmountCents, runsTarget)
+
+	dGlobal := sub(lg.After.Global, lg.Before.Global)
+	dCampus := sub(lg.After.Campus, lg.Before.Campus)
+	tGlobal := sub(tg.After.Global, tg.Before.Global)
+	tCampus := sub(tg.After.Campus, tg.Before.Campus)
+
+	add("Δbalance(全局)", yuan(dGlobal.Balance), yuan(tGlobal.Balance),
+		dGlobal.Balance == wantL.Balance && tGlobal.Balance == wantT.Balance,
+		"期望 "+yuan(wantL.Balance)+" / "+yuan(wantT.Balance)+";容差 0 "+noteRuns)
+	add("Δbalance(校区)", yuan(dCampus.Balance), yuan(tCampus.Balance),
+		dCampus.Balance == wantL.Balance && tCampus.Balance == wantT.Balance, "同上")
+	add("Δspend(全局)", yuan(dGlobal.Spend), yuan(tGlobal.Spend),
+		dGlobal.Spend == wantL.Spend && tGlobal.Spend == wantT.Spend,
+		"type=200 才动 spend;期望 "+yuan(wantL.Spend)+" / "+yuan(wantT.Spend))
+	add("Δspend(校区)", yuan(dCampus.Spend), yuan(tCampus.Spend),
+		dCampus.Spend == wantL.Spend && tCampus.Spend == wantT.Spend, "同上")
+	add("Δtotal_income(全局)", yuan(dGlobal.Income), yuan(tGlobal.Income),
+		dGlobal.Income == wantL.Income && tGlobal.Income == wantT.Income, "whitelist 内 type 都不动 total_income")
+	add("Δtotal_income(校区)", yuan(dCampus.Income), yuan(tCampus.Income),
+		dCampus.Income == wantL.Income && tCampus.Income == wantT.Income, "同上")
+	add("Δtotal_expenditure(全局)", yuan(dGlobal.Expenditure), yuan(tGlobal.Expenditure),
+		dGlobal.Expenditure == wantL.Expenditure && tGlobal.Expenditure == wantT.Expenditure,
+		"期望 "+yuan(wantL.Expenditure)+" / "+yuan(wantT.Expenditure))
+	add("Δtotal_expenditure(校区)", yuan(dCampus.Expenditure), yuan(tCampus.Expenditure),
+		dCampus.Expenditure == wantL.Expenditure && tCampus.Expenditure == wantT.Expenditure, "同上")
+
+	// ③ 流水:条数 / type / money / logId
+	add("流水条数", fmt.Sprint(len(lg.After.Logs)), fmt.Sprint(len(tg.After.Logs)),
+		len(lg.After.Logs) == runsLegacy && len(tg.After.Logs) == runsTarget,
+		fmt.Sprintf("期望 legacy=%d / target=%d", runsLegacy, runsTarget))
+
+	n := len(lg.After.Logs)
+	if len(tg.After.Logs) < n {
+		n = len(tg.After.Logs)
+	}
+	sameType, sameMoney, sameLogID := true, true, true
+	var tDetail, mDetail, lDetail []string
+	for i := 0; i < n; i++ {
+		l, t := lg.After.Logs[i], tg.After.Logs[i]
+		if l.Type != t.Type {
+			sameType = false
+			tDetail = append(tDetail, fmt.Sprintf("#%d legacy=%d target=%d", i, l.Type, t.Type))
+		}
+		if l.Money != t.Money {
+			sameMoney = false
+			mDetail = append(mDetail, fmt.Sprintf("#%d legacy=%s target=%s", i, yuan(l.Money), yuan(t.Money)))
+		}
+		if l.LogID != t.LogID {
+			sameLogID = false
+			lDetail = append(lDetail, fmt.Sprintf("#%d legacy=%q target=%q", i, l.LogID, t.LogID))
+		}
+	}
+	add("流水 type", firstOr(detailJoin(tDetail), "全部一致"), fmt.Sprint(n)+" 条", sameType, "")
+	add("流水 money", firstOr(detailJoin(mDetail), "逐分相等"), fmt.Sprint(n)+" 条", sameMoney, "两侧都为负数(支出方向)")
+	add("流水 logid/log_id", firstOr(detailJoin(lDetail), "全部一致"), fmt.Sprint(n)+" 条", sameLogID,
+		"legacy logid = inputRequestid;target 同时写 log_id 与 request_id")
+
+	// ④ target 独有列:request_id / balance_after(legacy 无此列 → N/A,但必须自洽)
+	reqOK := true
+	balOK := true
+	var reqDetail, balDetail []string
+	for _, t := range tg.After.Logs {
+		if t.RequestID != c.RequestID {
+			reqOK = false
+			reqDetail = append(reqDetail, fmt.Sprintf("id=%d request_id=%q(期望 %q)", t.ID, t.RequestID, c.RequestID))
+		}
+		if t.BalanceAfter == nil {
+			balOK = false
+			balDetail = append(balDetail, fmt.Sprintf("id=%d balance_after=NULL", t.ID))
+		} else if *t.BalanceAfter != tg.After.Global.Balance {
+			balOK = false
+			balDetail = append(balDetail, fmt.Sprintf("id=%d balance_after=%s ≠ 调用后全局余额 %s",
+				t.ID, yuan(*t.BalanceAfter), yuan(tg.After.Global.Balance)))
+		}
+	}
+	add("target.request_id(legacy 无此列)", "N/A", firstOr(detailJoin(reqDetail), "全部等于入参"),
+		reqOK, "Go 增强:幂等键落库")
+	add("target.balance_after(legacy 无此列)", "N/A",
+		firstOr(detailJoin(balDetail), "全部 == 调用后全局余额"), balOK, "Go 增强:余额快照")
+
+	// ⑤ 返回码 / 成功与否
+	lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
+	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
+	add("首次调用是否成功", fmt.Sprint(lgOK), fmt.Sprint(tgOK), lgOK == tgOK, "R15 场景两侧都应成功")
+
+	asserts = append(asserts, Assertion{
+		Name: "首次调用结果与预期一致(success / R15 不报余额不足)",
+		Pass: lgOK == c.ExpectLegacySuccess && tgOK == c.ExpectTargetSuccess,
+		Detail: fmt.Sprintf("legacy success=%v(期望 %v);target success=%v(期望 %v)|target err=%q",
+			lgOK, c.ExpectLegacySuccess, tgOK, c.ExpectTargetSuccess, errOf(tg.Calls)),
+	})
+
+	// ⑥ 幂等(两次调用时):target 必须 repeated,legacy 没有幂等 → 重复入账
+	if c.RunTimes >= 2 {
+		lgSecondOK := len(lg.Calls) >= 2 && lg.Calls[1].Success
+		tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
+		asserts = append(asserts, Assertion{
+			Name: "幂等:target 第二次 repeated=true;legacy SP 无幂等(会重复入账)",
+			Pass: tgRepeated && lgSecondOK,
+			Detail: fmt.Sprintf("target 第2次 repeated=%v(期望 true);legacy 第2次 success=%v(SP 无幂等键,确实重复入账)",
+				tgRepeated, lgSecondOK),
+		})
+		// legacy 侧流水应为 2 条、target 侧 1 条 —— 这是**已知且有意**的差异,单独记一条 diff
+		add("重复提交后的流水条数(已知差异)",
+			fmt.Sprint(len(lg.After.Logs)), fmt.Sprint(len(tg.After.Logs)),
+			true, "legacy=2(重复入账)/ target=1(幂等命中)—— 设计内的 Go 增强,不是缺陷")
+	}
+
+	// ⑦ R15:两侧余额都必须为负
+	if c.Baseline != nil {
+		negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&
+			lg.After.Campus.Balance < 0 && tg.After.Campus.Balance < 0
+		asserts = append(asserts, Assertion{
+			Name: "R15:余额不足时两侧都扣成负数且都返回成功(不校验余额)",
+			Pass: negOK,
+			Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s(都应为负)",
+				yuan(lg.After.Global.Balance), yuan(lg.After.Campus.Balance),
+				yuan(tg.After.Global.Balance), yuan(tg.After.Campus.Balance)),
+		})
+	}
+
+	return diffs, asserts
+}
+
+// readLegacyNatural 用独立连接读 legacy 侧全局账户的"天然状态"(事务外)。
+func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Account, error) {
+	db, err := cfg.OpenWriteLegacy()
+	if err != nil {
+		return Account{}, err
+	}
+	var bal, spend, inc, exp string
+	err = db.Raw(`
+SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp)
+	if err != nil {
+		return Account{}, fmt.Errorf("读 legacy 天然基线失败:%w", err)
+	}
+	b, _ := centsOf(bal)
+	sp, _ := centsOf(spend)
+	i, _ := centsOf(inc)
+	e, _ := centsOf(exp)
+	return Account{Balance: b, Spend: sp, Income: i, Expenditure: e}, nil
+}
+
+// checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
+func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural Account) ResidueReport {
+	var rep ResidueReport
+
+	// target:本 case 种进去的行必须全部消失(这些行本来就只在事务里存在)
+	tdb, err := cfg.OpenTarget()
+	if err == nil {
+		var n int64
+		_ = tdb.Raw(`SELECT COUNT(*) FROM t_user_expense WHERE user_id = ?`, uid).Row().Scan(&n)
+		rep.TargetGlobalExist = n > 0
+		_ = tdb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE user_id = ?`, uid).Row().Scan(&n)
+		rep.TargetLogCount = n
+		_ = tdb.Raw(`SELECT COUNT(*) FROM t_membership WHERE canonical_user_id = ?`, uid).Row().Scan(&n)
+		if n > 0 {
+			rep.TargetGlobalExist = true
+		}
+	}
+
+	// legacy:用户本来就存在,核对"余额回到调用前"
+	ldb, err := cfg.OpenWriteLegacy()
+	if err == nil {
+		var bal, spend, inc, exp string
+		err := ldb.Raw(`
+SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
+  FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp)
+		if err == nil {
+			b, _ := centsOf(bal)
+			s, _ := centsOf(spend)
+			i, _ := centsOf(inc)
+			e, _ := centsOf(exp)
+			rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e} != natural
+		}
+		_ = ldb.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ? AND logid IN (?, ?)`,
+			c.LegacyUserID, c.RequestID, c.RequestID).Row().Scan(&rep.LegacyLogCount)
+	}
+
+	rep.Pass = !rep.TargetGlobalExist && rep.TargetLogCount == 0 && !rep.LegacyGlobalExist && rep.LegacyLogCount == 0
+	rep.Detail = fmt.Sprintf("target 残留账户行=%v 流水=%d | legacy 余额偏离事务前=%v 本 case 流水残留=%d",
+		rep.TargetGlobalExist, rep.TargetLogCount, rep.LegacyGlobalExist, rep.LegacyLogCount)
+	return rep
+}
+
+// ---------- 小工具 ----------
+
+func sub(a, b Account) Account {
+	return Account{
+		Balance:     a.Balance - b.Balance,
+		Spend:       a.Spend - b.Spend,
+		Income:      a.Income - b.Income,
+		Expenditure: a.Expenditure - b.Expenditure,
+	}
+}
+
+func fmtAcc(a Account) string {
+	return fmt.Sprintf("{bal=%s spend=%s income=%s exp=%s}",
+		yuan(a.Balance), yuan(a.Spend), yuan(a.Income), yuan(a.Expenditure))
+}
+
+func abs64(v int64) int64 {
+	if v < 0 {
+		return -v
+	}
+	return v
+}
+
+func detailJoin(ss []string) string { return strings.Join(ss, ";") }
+
+func firstOr(s, def string) string {
+	if s == "" {
+		return def
+	}
+	return s
+}
+
+func errOf(calls []CallOutcome) string {
+	for _, c := range calls {
+		if c.ErrMsg != "" {
+			return c.ErrMsg
+		}
+	}
+	return ""
+}
+
+// PrintResult 打印一个 case 的双跑结果。
+func PrintResult(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 money=%s type=%d requestId=%q 调用 %d 次\n",
+		r.Case.LegacyUserID, r.Case.InstitutionID, r.Case.CampusID,
+		yuan(r.Case.AmountCents), r.Case.Type, r.Case.RequestID, maxInt(1, r.Case.RunTimes))
+
+	fmt.Fprintf(w, "  ── 账户变化 ──\n")
+	fmt.Fprintf(w, "    legacy 前:全局 %s 校区 %s\n", fmtAcc(r.Legacy.Before.Global), fmtAcc(r.Legacy.Before.Campus))
+	fmt.Fprintf(w, "    legacy 后:全局 %s 校区 %s\n", fmtAcc(r.Legacy.After.Global), fmtAcc(r.Legacy.After.Campus))
+	fmt.Fprintf(w, "    target 前:全局 %s 校区 %s\n", fmtAcc(r.Target.Before.Global), fmtAcc(r.Target.Before.Campus))
+	fmt.Fprintf(w, "    target 后:全局 %s 校区 %s\n", fmtAcc(r.Target.After.Global), fmtAcc(r.Target.After.Campus))
+
+	fmt.Fprintf(w, "  ── 逐项比对 ──\n")
+	for _, d := range r.Diffs {
+		mark := "✔"
+		if !d.Equal {
+			mark = "✘"
+		}
+		fmt.Fprintf(w, "    %s %-32s legacy=%-22s target=%-22s %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 = "✘"
+		}
+		fmt.Fprintf(w, "    %s %s\n       %s\n", mark, a.Name, a.Detail)
+	}
+
+	verdict := "PASS"
+	if !r.OK() {
+		verdict = "FAIL"
+	}
+	fmt.Fprintf(w, "  结论:%s(不一致 %d 项)\n\n", verdict, r.FailCount())
+}
+
+// Summarize 打印总体结论。
+func Summarize(w io.Writer, results []*CaseResult) bool {
+	total, pass := 0, 0
+	for _, r := range results {
+		total++
+		if r.OK() {
+			pass++
+		}
+	}
+	fmt.Fprintf(w, "══════════════════════════════════════════════════════════════\n")
+	fmt.Fprintf(w, "总览:%d 个 case,%d PASS,%d FAIL\n", total, pass, total-pass)
+	for _, r := range results {
+		st := "PASS"
+		if !r.OK() {
+			st = "FAIL"
+		}
+		fmt.Fprintf(w, "  %s  %-40s 不一致 %d\n", st, r.Case.Name, r.FailCount())
+	}
+	return pass == total
+}

+ 48 - 0
internal/academic/dualrun/stage2/stage2_test.go

@@ -0,0 +1,48 @@
+package stage2
+
+import "testing"
+
+// 本文件单测「期望增量」这一块纯逻辑:
+// 它决定了"每侧应该发生什么",比错一次双跑就全绿/全红,属于必须锁住的部分。
+
+func TestExpectedDelta(t *testing.T) {
+	cases := []struct {
+		name string
+		typ  int32
+		amt  int64
+		runs int
+		want Account
+	}{
+		{"type=201 一次", 201, 3000, 1, Account{Balance: -3000, Expenditure: 3000}},
+		{"type=201 两次(legacy 无幂等)", 201, 3000, 2, Account{Balance: -6000, Expenditure: 6000}},
+		{"type=200 还要动 spend", 200, 10000, 1, Account{Balance: -10000, Spend: 10000, Expenditure: 10000}},
+		{"type=200 两次", 200, 10000, 2, Account{Balance: -20000, Spend: 20000, Expenditure: 20000}},
+		{"type=101 不动 spend", 101, 500, 1, Account{Balance: -500, Expenditure: 500}},
+	}
+	for _, c := range cases {
+		if got := expectedDelta(c.typ, c.amt, c.runs); got != c.want {
+			t.Errorf("%s:expectedDelta(%d,%d,%d) = %+v, want %+v",
+				c.name, c.typ, c.amt, c.runs, got, c.want)
+		}
+	}
+}
+
+// TestToleranceMustBeZero 锁住「Δ 容差必须是 0」这条纪律。
+//
+// 变异 M1b 的教训:容差只要放宽到 1 分,target 多扣 1 分的真实误差就会被判 PASS。
+func TestToleranceMustBeZero(t *testing.T) {
+	if toleranceCents != 0 {
+		t.Fatalf("Δ 容差必须为 0(当前 %d)—— 放宽会掩盖真实的 1 分误差,见变异 M1b", toleranceCents)
+	}
+}
+
+// TestSubAccount 保证 Δ 计算方向正确(后 - 前)。
+func TestSubAccount(t *testing.T) {
+	before := Account{Balance: 10000, Spend: 0, Income: 0, Expenditure: 0}
+	after := Account{Balance: 7000, Spend: 100, Income: 0, Expenditure: 3000}
+	got := sub(after, before)
+	want := Account{Balance: -3000, Spend: 100, Income: 0, Expenditure: 3000}
+	if got != want {
+		t.Errorf("sub() = %+v, want %+v", got, want)
+	}
+}

+ 273 - 0
internal/academic/dualrun/stage2/target.go

@@ -0,0 +1,273 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
+	"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"
+)
+
+// 本文件是**target 侧**:在 yuansiwei_2026 的事务里种基线 → 跑**真实的** Go service → 读回 → 回滚。
+//
+// 三个关键设计:
+//
+//  1. **不绕过 assertInScope**:`service.NewFundService(tx, scope, port.NewOrgMembershipChecker(tx))`,
+//     用**真实的** membership checker 读 t_membership;case 里真的插了一行 active 成员关系。
+//     没有 super_admin、没有 nil checker、没有改签名。
+//  2. **事务归属**:我先把 `tx := db.Begin()` 打开,再把 **tx 本身**传给 NewFundService。
+//     `TxRunner.WithinTx` 开头判 `InTx(t.DB)` → 已处于事务中就直接透传(不再 BEGIN),
+//     所以 service 的写入落在**我这个事务**里,我可以读完再 `Rollback()`。
+//  3. **基线来自同一份 finance_bk**:`INSERT INTO ... SELECT ... FROM finance_bk.…`
+//     且用与迁移完全相同的 `ROUND(CAST(x AS DECIMAL(20,3)),2)`,避免"手抄一份基线"跑偏。
+
+// targetSide 是 target 侧运行器。
+type targetSide struct {
+	db *gorm.DB
+}
+
+func newTargetSide(db *gorm.DB) *targetSide { return &targetSide{db: db} }
+
+// Run 在事务内种基线、跑 service、读回、回滚。
+func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*SideResult, error) {
+	res := &SideResult{Side: "target"}
+	tx := s.db.WithContext(ctx).Begin()
+	if tx.Error != nil {
+		return nil, fmt.Errorf("target 开启事务失败:%w", tx.Error)
+	}
+	defer func() { _ = tx.Rollback() }()
+
+	if err := seedTarget(ctx, tx, c, canonicalUID); err != nil {
+		return nil, err
+	}
+	if c.Baseline != nil {
+		if err := applyBaselineTarget(ctx, tx, c, canonicalUID); err != nil {
+			return nil, err
+		}
+	}
+
+	before, maxLogID, err := snapshotTarget(ctx, tx, canonicalUID, c)
+	if err != nil {
+		return nil, err
+	}
+	res.Before = before
+
+	// 真实 service 装配:scope 来自 case(机构/校区/角色),checker 读 t_membership
+	sc := &appscope.Scope{
+		InstitutionID: c.InstitutionID,
+		CampusID:      c.CampusID,
+		UserID:        canonicalUID,
+		Role:          "finance",
+	}
+	svc := service.NewFundService(tx, sc, port.NewOrgMembershipChecker(tx))
+
+	for i := 0; i < maxInt(1, c.RunTimes); i++ {
+		out, err := svc.Expenditure(ctx, service.ExpenditureInput{
+			UserID:        canonicalUID,
+			InstitutionID: c.InstitutionID,
+			CampusID:      c.CampusID,
+			Money:         money.Amount(c.AmountCents),
+			Type:          c.Type,
+			RequestID:     c.RequestID,
+			Remark:        c.Remark,
+			OperatorID:    canonicalUID,
+		})
+		oc := CallOutcome{}
+		if err != nil {
+			oc.ErrMsg = err.Error()
+		} else {
+			oc.Success = true
+			oc.Repeated = out.Repeated
+			oc.Balance = int64(out.Balance)
+			oc.LogID = out.LogID
+		}
+		res.Calls = append(res.Calls, oc)
+	}
+
+	after, _, err := snapshotTarget(ctx, tx, canonicalUID, c)
+	if err != nil {
+		return nil, err
+	}
+	after.Logs = filterLogs(after.Logs, maxLogID)
+	res.After = after
+	return res, nil
+}
+
+// seedTarget 在事务内种 case 需要的最小数据(org + 人 + 成员关系 + 两份账户)。
+//
+// 全部走 `INSERT ... SELECT ... FROM finance_bk`,与迁移同一份来源、同一套 ROUND 口径。
+func seedTarget(ctx context.Context, tx *gorm.DB, c Case, canonicalUID string) error {
+	// ① 机构 / 校区(D1,本 case 只用得到两个)
+	if err := tx.Exec(`
+INSERT INTO t_institution (institution_id, institution_name, status, created_at, updated_at)
+VALUES (?, CONCAT('ac15-case-inst-', ?), 'active', NOW(3), NOW(3))
+ON DUPLICATE KEY UPDATE institution_name = VALUES(institution_name)`,
+		c.InstitutionID, c.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('ac15-case-campus-', ?), 'active', 1, NOW(3), NOW(3))
+ON DUPLICATE KEY UPDATE campus_name = VALUES(campus_name)`,
+		c.CampusID, c.InstitutionID, c.CampusID).Error; err != nil {
+		return fmt.Errorf("种子 t_campus 失败:%w", err)
+	}
+
+	// ② 人 + 映射 + 成员关系(assertInScope 必须有这行)
+	if err := tx.Exec(`
+INSERT INTO t_user (canonical_user_id, display_name, person_type, status)
+VALUES (?, CONCAT('ac15-case-user-', ?), 'student', 'active')
+ON DUPLICATE KEY UPDATE display_name = VALUES(display_name)`,
+		canonicalUID, c.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)`,
+		canonicalUID, c.LegacyUserID, c.InstitutionID, c.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'`,
+		canonicalUID, c.InstitutionID, c.CampusID).Error; err != nil {
+		return fmt.Errorf("种子 t_membership 失败:%w", err)
+	}
+
+	// ③ 全局账户(同一份 finance_bk + 同一 ROUND 口径)
+	if err := tx.Exec(`
+INSERT INTO t_user_expense
+  (user_id, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
+SELECT ?, CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
+          CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
+          CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
+          CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2)),
+          CAST(ROUND(CAST(IFNULL(total_recommended_amount,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+          CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2)         AS DECIMAL(12,2))
+  FROM finance_bk.t_user_expense WHERE userid = ?
+ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
+  total_income = VALUES(total_income), total_expenditure = VALUES(total_expenditure),
+  total_recommended_amount = VALUES(total_recommended_amount), special_expenses = VALUES(special_expenses)`,
+		canonicalUID, c.LegacyUserID).Error; err != nil {
+		return fmt.Errorf("种子 t_user_expense 失败:%w", err)
+	}
+
+	// ④ 校区账户
+	if err := tx.Exec(`
+INSERT INTO t_user_expense_school
+  (user_id, institution_id, campus_id, balance, spend, total_income, total_expenditure,
+   total_recommended_amount, special_expenses)
+SELECT ?, ?, ?,
+       CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)            AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2)              AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2)       AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2)  AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(IFNULL(total_recommended_amount,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
+       CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2)         AS DECIMAL(12,2))
+  FROM finance_bk.t_user_expense_school
+ WHERE userid = ? AND schoolid = ? AND campusid = ?
+ON DUPLICATE KEY UPDATE balance = VALUES(balance), spend = VALUES(spend),
+  total_income = VALUES(total_income), total_expenditure = VALUES(total_expenditure),
+  total_recommended_amount = VALUES(total_recommended_amount), special_expenses = VALUES(special_expenses)`,
+		canonicalUID, c.InstitutionID, c.CampusID,
+		c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
+		return fmt.Errorf("种子 t_user_expense_school 失败:%w", err)
+	}
+	return nil
+}
+
+// applyBaselineTarget 把 target 侧强制设置成与 legacy 同一份基线(R15 用)。
+func applyBaselineTarget(ctx context.Context, tx *gorm.DB, c Case, canonicalUID string) error {
+	b := c.Baseline
+	if err := tx.Exec(`
+UPDATE t_user_expense SET balance=?, spend=?, total_income=?, total_expenditure=? WHERE user_id=?`,
+		yuan(b.GlobalBalance), yuan(b.GlobalSpend), yuan(b.GlobalIncome), yuan(b.GlobalExpenditure),
+		canonicalUID).Error; err != nil {
+		return fmt.Errorf("target 设置全局基线失败:%w", err)
+	}
+	if err := tx.Exec(`
+UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expenditure=?
+ WHERE user_id=? AND institution_id=? AND campus_id=?`,
+		yuan(b.CampusBalance), yuan(b.CampusSpend), yuan(b.CampusIncome), yuan(b.CampusExpenditure),
+		canonicalUID, c.InstitutionID, c.CampusID).Error; err != nil {
+		return fmt.Errorf("target 设置校区基线失败:%w", err)
+	}
+	return nil
+}
+
+// snapshotTarget 读 target 侧当前状态(全局 + 校区 + 流水)。
+func snapshotTarget(ctx context.Context, tx *gorm.DB, canonicalUID string, c Case) (SideSnapshot, int64, error) {
+	var out SideSnapshot
+
+	var g struct{ Balance, Spend, Income, Expenditure string }
+	if err := tx.Raw(`
+SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
+       CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2))
+  FROM t_user_expense WHERE user_id = ?`, canonicalUID).
+		Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure); err != nil {
+		return out, 0, fmt.Errorf("读 target 全局账户失败:%w", err)
+	}
+	gs, _ := centsOf(g.Balance)
+	gsp, _ := centsOf(g.Spend)
+	gi, _ := centsOf(g.Income)
+	ge, _ := centsOf(g.Expenditure)
+	out.Global = Account{Balance: gs, Spend: gsp, Income: gi, Expenditure: ge}
+
+	var sc struct{ Balance, Spend, Income, Expenditure string }
+	if err := tx.Raw(`
+SELECT CAST(balance AS DECIMAL(12,2)), CAST(spend AS DECIMAL(12,2)),
+       CAST(total_income AS DECIMAL(12,2)), CAST(total_expenditure AS DECIMAL(12,2))
+  FROM t_user_expense_school WHERE user_id = ? AND institution_id = ? AND campus_id = ?`,
+		canonicalUID, c.InstitutionID, c.CampusID).
+		Row().Scan(&sc.Balance, &sc.Spend, &sc.Income, &sc.Expenditure); err != nil {
+		return out, 0, fmt.Errorf("读 target 校区账户失败:%w", err)
+	}
+	cs, _ := centsOf(sc.Balance)
+	csp, _ := centsOf(sc.Spend)
+	ci, _ := centsOf(sc.Income)
+	ce, _ := centsOf(sc.Expenditure)
+	out.Campus = Account{Balance: cs, Spend: csp, Income: ci, Expenditure: ce}
+
+	rows, err := tx.Raw(`
+SELECT id, type, CAST(money AS DECIMAL(12,2)), IFNULL(log_id,''), IFNULL(request_id,''),
+       CAST(balance_after AS DECIMAL(12,2)), IFNULL(remark,''), IFNULL(create_user_id,''), create_time
+  FROM t_user_expense_log WHERE user_id = ? ORDER BY id`, canonicalUID).Rows()
+	if err != nil {
+		return out, 0, fmt.Errorf("读 target 流水失败:%w", err)
+	}
+	defer rows.Close()
+	var maxID int64
+	for rows.Next() {
+		var l LogRow
+		var moneyStr string
+		var balAfter *string
+		var ct time.Time
+		if err := rows.Scan(&l.ID, &l.Type, &moneyStr, &l.LogID, &l.RequestID,
+			&balAfter, &l.Remark, &l.CreateUserUID, &ct); err != nil {
+			return out, 0, err
+		}
+		l.Money, _ = centsOf(moneyStr)
+		if balAfter != nil {
+			v, _ := centsOf(*balAfter)
+			l.BalanceAfter = &v
+		}
+		l.CreateTime = ct
+		if l.ID > maxID {
+			maxID = l.ID
+		}
+		out.Logs = append(out.Logs, l)
+	}
+	return out, maxID, rows.Err()
+}
+
+// CanonicalUIDFor 返回 case 用户的确定性 ULID(与迁移器同一函数,保证可复现)。
+func CanonicalUIDFor(legacyUserID int64) string { return fixture.FixtureCanonicalUserID(legacyUserID) }