Explorar o código

fix(academic): balance_after 改为「全局账户余额快照(同操作多行同值)」+ 台账两条门禁用例

对齐设计 v0.1.16 §3.8-f ②-bis 与 §3.8-(f) ①⑵(架构师/评审拍板:原"校区余额、逐行链式"是错的,
转结与自定义退费逐行链第一行就破式)。

## 语义变更(代码)
- 新增两个收口 helper:
  · `globalBalanceAfter` = 该操作完成后**全局账户**余额 → **唯一**用于流水 `balance_after`;
  · `campusBalanceAfter` = 校区账户余额 → **只**用于 API 响应体的 `Balance` 字段(契约未变)。
- 全部写路径的 `balance_after` 改为全局快照,且**组内复用同一值**:
  Recharge / Expenditure / Refund / BadDebt 单行;Transfer 的 106/206 同值;
  CustomRefund 的 108/207 同值;ClearBalance(208) / CancelRechargeActivity(209) 单行。
  取值时机统一为"写完账户、写第一条流水之前取一次"。
- 幂等回放路径反向修正:`replayRecharge/Expenditure/Refund/Transfer`、
  `CancelRechargeActivity` 命中分支改为用 `campusBalanceAfter` 回读**校区**余额
  (否则响应体的 Balance 会被全局快照串口径);`balanceAfterOf` 注释标注"仅排障用,勿填响应体"。
- `models_fund.go` 的 `balance_after` 字段注释同步为「全局账户余额快照(同操作多行同值)+ 本表无 campus_id」。
- 注释登记:`t_user_expense_log` **无 campus_id** ⇒ 恒等式只能按 user_id 对全局账户,禁止 GROUP BY campus_id。

## 新增门禁用例(balance_ledger_test.go)
- `assertGeneralIdentity`:§3.8-(f) ① 主判据,**全类型计入、零分支**(不特判 108)。
- `assertBalanceAfterChain`:按 `request_id` 分组(NULL 行按 id 单行成组)校验
  `after(op_n) = after(op_{n−1}) + Σ(money of op_n)`,同组多行必须同值,末组 == 当前全局余额。
- `TestBalanceAfterIsGlobalSnapshotAndGrouped`:构造"全局 150 / 校区 A 100"的错位场景,
  断言校区 A 上坏账后的 `balance_after` 必须是 **18000(全局)**而不是 13000(校区)——
  直接把"取校区余额/逐条各算一次"的实现打红;并验转结 106/206、退费 108/207 同值。
- `TestCampusGlobalDeltaFrozen`:§3.8-(f) ⑵ 差值冻结,
  `Σ_campus balance − global balance` 在 8 条写路径(充值×2/转结/退费/坏账/自定义退费/
  取消充值活动/清零)前后**绝对不变**,且每步都复核通用恒等式——单边写入(双表撕裂)立刻红。

## 未完成:DDL 注释三处同步(被阻塞,需输入)
`internal/academic/model/testdata/academic-model-ddl.sql`(fixture)与文档侧
`02-data-model/academic-model-ddl.sql` 都不在本工作区(本机只有 fixture 一份,docs 仓未 clone)。
已实测 4 种可确定的变体(仅改 121 行注释 / 仅把 `idx_order_inst_campus` 前置 /
两者都改)算得 sha256 分别为 9769ebd7… / f9db054f… / bc92a7d3…,**均不等于**架构师给出的
目标值 a9220e7a…,说明缺口是 `t_order_pay` 那条 KEY 行上的 **COMMENT 正文**(架构师引用时以「…」截断),
无法凭猜写重建。故本提交**不动 fixture、不改 .sha256**(避免锁死一个仍与 doc 不符的版本);
拿到 doc 侧原文后按「改 121 行注释 → 整文件以 doc 覆盖 → 重算 sha256」一次提交即可。

go build / go vet / go test -short ./... 全绿。
Q hai 19 horas
pai
achega
60c1315f21

+ 1 - 1
internal/academic/model/models_fund.go

@@ -132,7 +132,7 @@ type UserExpenseLog struct {
 	LogID        *string   `gorm:"index:idx_log_id;column:log_id;size:64" json:"logId,omitempty"`                                                    // 业务单号(原 logid:报课 id/缴费申请 id/结课申请 id/充值单号;无则 NULL)|枚举:无|写入:资金方法内|FK:弱关联 t_user_balance_request.biz_no 等|红线:非唯一,勿当幂等键
 	RequestID    *string   `gorm:"uniqueIndex:uk_request_type,priority:1;column:request_id;size:64" json:"requestId,omitempty"`                      // 幂等请求号(原 SP inputRequestid;NULL=历史数据)|枚举:无|写入:调用方传入(工单 biz_no/支付单 biz_no)|FK:无|红线:与 type 组唯一键,重复提交必须命中而非重复入账
 	Money        string    `gorm:"column:money;type:decimal(12,2);not null;default:0.00" json:"money"`                                               // 变更金额(元;正负号表方向,见 ExpenseLogType* 注释)|枚举:无|写入:资金方法内|FK:无|红线:符号方向须与 type 口径一致(106/206 ★反向)
-	BalanceAfter *string   `gorm:"column:balance_after;type:decimal(12,2)" json:"balanceAfter,omitempty"`                                            // 变更后校区余额快照(元;新增列,便于对账/双跑)|枚举:无|写入:同事务内 UPDATE 后回读|FK:无|红线:历史行为 NULL,不得回填猜测值
+	BalanceAfter *string   `gorm:"column:balance_after;type:decimal(12,2)" json:"balanceAfter,omitempty"`                                            // 该操作完成后**全局账户**余额快照(元;同操作多行同值;历史行 NULL)|枚举:无|写入:Service 在写完账户、写首条流水前取一次并组内复用|FK:无|红线:本表无 campus_id,只能做 user 级全局链;禁止逐条重算
 	CreateUserID *string   `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                      // 创建人(ULID;原 create_userid int,0/系统 → NULL)|枚举:无|写入:Service 从 scope/user 上下文取|FK:→ t_user(逻辑)|红线:不得写入旧 int id
 	CreateTime   time.Time `gorm:"index:idx_user_time,priority:2;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                     // 创建时间(datetime(3))|枚举:无|写入:DB 默认 CURRENT_TIMESTAMP(3)|FK:无|红线:只增不改(勿 UPDATE)
 	Remark       *string   `gorm:"column:remark;size:1000" json:"remark,omitempty"`                                                                  // 备注(原 text → varchar(1000),超长截断见迁移报告)|枚举:无|写入:资金方法内|FK:无|红线:不得写入敏感信息(手机号/证件号)

+ 353 - 0
internal/academic/service/balance_ledger_test.go

@@ -0,0 +1,353 @@
+// 余额台账两条门禁(AC-17 ④⑴⑵)的单测:通用恒等式 + `balance_after` 分组链 + 差值冻结。
+//
+// 口径来源(设计 v0.1.16):
+//
+//	§3.8-(f) ① 主判据(**全类型一律计入、零分支**):
+//	    current_balance = baseline_balance + Σ(log.money WHERE user_id=? AND create_time > baseline_at)
+//	    10 条写路径逐条成立;**不得把 type=108 从 Σ 里剔除**(剔了只剩 207=−requested,恒等式反而破)。
+//	    A18 的「独立坏账 / 差额型」只用于**报告归因**,不参与判据。
+//	§3.8-f ②-bis `balance_after`:
+//	    语义 = 该操作完成后**全局账户**余额(不是校区余额);同操作多行**同值**;
+//	    链校验按 `request_id` 分组(NULL 行按 id 单行成组):
+//	        balance_after(op_n) = balance_after(op_{n−1}) + Σ(money of op_n)
+//	    老带新双边两行属**不同 user** → 各取本 user 的全局快照,**不要求相等**。
+//	§3.8-(f) ⑵ 校区侧**差值冻结**:`Σ_campus balance − global balance` 在每次写入前后不变
+//	    (每条写路径对全局与其校区加同一增量);只要求"不再变化",不要求存量相等。
+package service
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+)
+
+// ---------------------------------------------------------------- ① 通用恒等式
+
+// assertGeneralIdentity 断言 §3.8-(f) ① 的主判据(**零分支、全类型计入**)。
+//
+// 入参:db / userID / baselineBalance(本题从 0 起算就是 0)。
+// 语义:`Σ(全部流水 money) == 全局账户 balance`。任何写路径漏记/多记一条流水,本断言即红。
+func assertGeneralIdentity(t *testing.T, db *gorm.DB, userID string, baselineCents int64) {
+	t.Helper()
+	sum := sumLogMoney(t, balanceLogs(t, db, userID)).Cents()
+	got := cents(t, readGlobal(t, db, userID).Balance)
+	if baselineCents+sum != got {
+		t.Fatalf("通用恒等式破式:baseline(%d) + Σ流水(%d) = %d ≠ 全局 balance(%d)",
+			baselineCents, sum, baselineCents+sum, got)
+	}
+}
+
+// ---------------------------------------------------------------- ② balance_after 分组链
+
+// logOpKey 返回流水所属"操作"的分组键。
+//
+// 口径(§3.8-f ②-bis):`request_id` 非空 → 同 `request_id` 的行属同一操作;
+// 为空/NULL(历史行、清零 208 等无请求号的操作)→ 按 `id` 单行成组(组内只有它自己)。
+func logOpKey(row model.UserExpenseLog) string {
+	if key := logKeyOf(row); key != "" {
+		return "req:" + key
+	}
+	return "id:" + money.FromCents(row.ID).String()
+}
+
+// assertBalanceAfterChain 断言 `balance_after` 的**分组链**(禁止逐行做 n=(n−1)+money)。
+//
+// 规则:
+//  1. 同一操作内多行 `balance_after` **必须同值**(转结 106/206、退费 108/207);
+//  2. 组间链式:`after(op_n) == after(op_{n−1}) + Σ(money of op_n)`;
+//  3. 末组的 `balance_after` == 当前全局账户余额(快照语义是"全局")。
+//
+// 入参:db / userID。出参:无(失败直接 t.Fatalf)。
+func assertBalanceAfterChain(t *testing.T, db *gorm.DB, userID string) {
+	t.Helper()
+	rows := balanceLogs(t, db, userID) // 按 id ASC
+	type group struct {
+		key   string
+		after int64
+		sum   int64
+	}
+	groups := make([]group, 0, len(rows))
+	index := make(map[string]int, len(rows))
+	for i := range rows {
+		if rows[i].BalanceAfter == nil {
+			t.Fatalf("流水(id=%d,type=%d) 缺 balance_after 快照", rows[i].ID, rows[i].Type)
+		}
+		after := cents(t, *rows[i].BalanceAfter)
+		key := logOpKey(rows[i])
+		amt := cents(t, rows[i].Money)
+		pos, ok := index[key]
+		if !ok {
+			index[key] = len(groups)
+			groups = append(groups, group{key: key, after: after, sum: amt})
+			continue
+		}
+		// 规则 1:同操作多行必须同值
+		if groups[pos].after != after {
+			t.Fatalf("同一操作(%s) 的 balance_after 不一致:%d vs %d(应同值)",
+				key, groups[pos].after, after)
+		}
+		groups[pos].sum += amt
+	}
+	// 规则 2:组间链式
+	var prev int64
+	for i, g := range groups {
+		if i > 0 && prev+g.sum != g.after {
+			t.Fatalf("第 %d 组(%s) 链式破式:上组 after(%d) + 本组 Σmoney(%d) = %d ≠ 本组 after(%d)",
+				i+1, g.key, prev, g.sum, prev+g.sum, g.after)
+		}
+		prev = g.after
+	}
+	// 规则 3:末组快照 == 当前全局余额
+	if len(groups) > 0 {
+		if got := cents(t, readGlobal(t, db, userID).Balance); prev != got {
+			t.Fatalf("末条 balance_after(%d) 应等于当前全局账户余额(%d)", prev, got)
+		}
+	}
+}
+
+// TestBalanceAfterIsGlobalSnapshotAndGrouped `balance_after` = 全局快照 + 同操作同值 + 分组链。
+//
+// 关键反向断言(把"取校区余额"的实现打红):校区 A 与全局余额**故意不相等**——
+// 先在 A 充 100、再在 B 充 50,则全局 150 而 A 仍 100;此时 A 上的坏账 30:
+//   - 全局 180 → 流水 balance_after 必须是 **18000**;
+//   - 若实现取的是校区余额会写 13000(差评:这就是本次返工要修的点)。
+func TestBalanceAfterIsGlobalSnapshotAndGrouped(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(300)
+
+	// ① 单行操作:校区 A 充 100 → 全局 100
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-BA-1"}); err != nil {
+		t.Fatalf("Recharge(A) 失败: %v", err)
+	}
+	// ② 单行操作:校区 B 充 50 → 全局 150,A 仍 100(两者**故意不等**)
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
+		Money: mustAmount(t, "50.00"), RequestID: "REQ-BA-2"}); err != nil {
+		t.Fatalf("Recharge(B) 失败: %v", err)
+	}
+	if got := cents(t, readGlobal(t, db, user).Balance); got != 15000 {
+		t.Fatalf("准备态:全局应 15000 分,实得 %d", got)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 10000 {
+		t.Fatalf("准备态:校区 A 应 10000 分,实得 %d", got)
+	}
+
+	// ③ 坏账 30(校区 A):全局 180 / 校区 A 130 → 快照必须是 18000
+	if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "30.00"), RequestID: "REQ-BA-3"}); err != nil {
+		t.Fatalf("BadDebt 失败: %v", err)
+	}
+	logs108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
+	if len(logs108) != 1 || logs108[0].BalanceAfter == nil {
+		t.Fatalf("108 流水应 1 条且带快照,实得 %+v", logs108)
+	}
+	if got := cents(t, *logs108[0].BalanceAfter); got != 18000 {
+		t.Fatalf("balance_after 必须是**全局**快照 18000 分(校区余额是 13000),实得 %d", got)
+	}
+
+	// ④ 多行同操作:转结 40(校区 A → 校区 B),balance 不动 → 106/206 同一全局快照
+	if _, err := svc.Transfer(ctx, TransferInput{UserID: user,
+		FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
+		Money: mustAmount(t, "40.00"), RequestID: "REQ-BA-4"}); err != nil {
+		t.Fatalf("Transfer 失败: %v", err)
+	}
+	l106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut)
+	l206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn)
+	if len(l106) != 1 || len(l206) != 1 {
+		t.Fatalf("转结应各 1 条流水,实得 %d/%d", len(l106), len(l206))
+	}
+	if l106[0].BalanceAfter == nil || l206[0].BalanceAfter == nil {
+		t.Fatalf("转结流水必须带 balance_after:106=%+v 206=%+v", l106[0], l206[0])
+	}
+	if *l106[0].BalanceAfter != *l206[0].BalanceAfter {
+		t.Fatalf("转结 106/206 的 balance_after 必须同值:106=%s 206=%s(同一操作多行同值)",
+			*l106[0].BalanceAfter, *l206[0].BalanceAfter)
+	}
+	if got := cents(t, *l106[0].BalanceAfter); got != 18000 {
+		t.Fatalf("转结不改 balance,快照应仍 18000 分,实得 %d", got)
+	}
+
+	// ⑤ 多行同操作:自定义退费(150 > 可退 90 → badDebt 60),108/207 同一全局快照
+	refund := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	seedCampus(t, db, 10, 1, true)
+	if _, err := refund.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "150.00"), RequestID: "REQ-BA-5"}); err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+	l108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
+	var last108 model.UserExpenseLog
+	for i := range l108 {
+		if l108[i].RequestID != nil && *l108[i].RequestID == "REQ-BA-5" {
+			last108 = l108[i]
+		}
+	}
+	l207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
+	if last108.ID == 0 || len(l207) != 1 {
+		t.Fatalf("退费应产生 1 条 108 与 1 条 207,实得 108(id=%d)/207(%d)", last108.ID, len(l207))
+	}
+	if last108.BalanceAfter == nil || l207[0].BalanceAfter == nil {
+		t.Fatalf("退费流水必须带 balance_after:108=%+v 207=%+v", last108, l207[0])
+	}
+	if *last108.BalanceAfter != *l207[0].BalanceAfter {
+		t.Fatalf("退费 108/207 的 balance_after 必须同值:108=%s 207=%s",
+			*last108.BalanceAfter, *l207[0].BalanceAfter)
+	}
+
+	// ⑥ 分组链 + 通用恒等式一起收口
+	assertBalanceAfterChain(t, db, user)
+	assertGeneralIdentity(t, db, user, 0)
+}
+
+// ---------------------------------------------------------------- ② 差值冻结
+
+// campusGlobalDelta 计算 `Σ_campus balance − 全局 balance`(冻结量的定义)。
+//
+// 账户尚未建户时按 0 计(首个写路径之前是常态:建户发生在事务内的第一个 UPSERT)。
+func campusGlobalDelta(t *testing.T, db *gorm.DB, userID string) int64 {
+	t.Helper()
+	var rows []model.UserExpenseSchool
+	if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil {
+		t.Fatalf("读校区账户失败: %v", err)
+	}
+	sum := int64(0)
+	for i := range rows {
+		sum += cents(t, rows[i].Balance)
+	}
+	var global model.UserExpense
+	globalCents := int64(0)
+	err := db.Where("user_id = ?", userID).Take(&global).Error
+	switch {
+	case err == nil:
+		globalCents = cents(t, global.Balance)
+	case errors.Is(err, gorm.ErrRecordNotFound):
+		globalCents = 0 // 未建户
+	default:
+		t.Fatalf("读全局账户失败: %v", err)
+	}
+	return sum - globalCents
+}
+
+// TestCampusGlobalDeltaFrozen 差值冻结(§3.8-(f) ⑵):每条写路径对全局与其校区加**同一增量**,
+// 故 `Σ_campus balance − global balance` 在所有写操作前后**不变**。
+//
+// 覆盖:充值(两个校区)/ 转结 / 退费 / 坏账(双加)/ 自定义退费(差额)/ 取消充值活动 / 余额清零。
+// 这条断言的作用:任一路径只更新了单边(dump 的"双表撕裂"),差值立刻漂移 → 红。
+func TestCampusGlobalDeltaFrozen(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	refund := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(301)
+
+	seedCampus(t, db, 10, 1, true)
+	seedCampus(t, db, 20, 1, true)
+	seedRechargeActivityForCancel(t, db, user, 77)
+
+	steps := []struct {
+		name string
+		run  func()
+	}{
+		{"recharge@A", func() {
+			mustNoErr(t, func() error {
+				_, e := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+					Money: mustAmount(t, "200.00"), RequestID: "REQ-FR-1"})
+				return e
+			})
+		}},
+		{"recharge@B", func() {
+			mustNoErr(t, func() error {
+				_, e := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
+					Money: mustAmount(t, "100.00"), RequestID: "REQ-FR-2"})
+				return e
+			})
+		}},
+		{"transfer", func() {
+			mustNoErr(t, func() error {
+				_, e := fund.Transfer(ctx, TransferInput{UserID: user,
+					FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
+					Money: mustAmount(t, "50.00"), RequestID: "REQ-FR-3"})
+				return e
+			})
+		}},
+		{"fund.refund", func() {
+			mustNoErr(t, func() error {
+				_, e := fund.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+					Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-FR-4"})
+				return e
+			})
+		}},
+		{"baddebt", func() {
+			mustNoErr(t, func() error {
+				_, e := fund.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
+					Money: mustAmount(t, "10.00"), RequestID: "REQ-FR-5"})
+				return e
+			})
+		}},
+		{"customRefund", func() {
+			mustNoErr(t, func() error {
+				_, e := refund.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+					Money: mustAmount(t, "30.00"), RequestID: "REQ-FR-6"})
+				return e
+			})
+		}},
+		{"cancelRechargeActivity", func() {
+			mustNoErr(t, func() error {
+				_, e := refund.CancelRechargeActivity(ctx, CancelRechargeActivityInput{UserID: user,
+					ActivityID: 77, CampusID: 10, Money: mustAmount(t, "5.00"), RequestID: "REQ-FR-7"})
+				return e
+			})
+		}},
+		{"clearBalance", func() {
+			mustNoErr(t, func() error {
+				_, e := refund.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 20})
+				return e
+			})
+		}},
+	}
+	delta := campusGlobalDelta(t, db, user)
+	for _, step := range steps {
+		step.run()
+		if got := campusGlobalDelta(t, db, user); got != delta {
+			t.Fatalf("%s 之后差值冻结被破坏:前 %d 后 %d(说明该路径只更新了全局/校区单边)",
+				step.name, delta, got)
+		}
+		assertGeneralIdentity(t, db, user, 0)
+	}
+	// 至少确认确实发生了资金变动(防止"全都没生效 → 差值自然不变"的假绿)
+	if got := cents(t, readGlobal(t, db, user).Balance); got == 0 {
+		t.Fatalf("准备态异常:全部写路径跑完后全局余额仍为 0,差值为 %d", delta)
+	}
+}
+
+// mustNoErr 执行 fn 并要求无错误(表驱动里扁平化错误处理)。
+func mustNoErr(t *testing.T, fn func() error) {
+	t.Helper()
+	if err := fn(); err != nil {
+		t.Fatalf("步骤失败: %v", err)
+	}
+}
+
+// seedRechargeActivityForCancel 为「取消充值活动」准备活动 + 报名行(type=1 充值赠送)。
+func seedRechargeActivityForCancel(t *testing.T, db *gorm.DB, userID string, activityID int64) {
+	t.Helper()
+	if err := db.Create(&model.CoursesActivity{
+		ID: activityID, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivityStatusEnabled, Money: "50.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 活动(%d) 失败: %v", activityID, err)
+	}
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "50.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 报名(%d) 失败: %v", activityID, err)
+	}
+}

+ 80 - 28
internal/academic/service/fund_service.go

@@ -354,6 +354,44 @@ func parseAccountAmount(raw, col string) (money.Amount, error) {
 	return amount, nil
 }
 
+// globalBalanceAfter 取「该笔操作完成后**全局账户**(t_user_expense)余额」快照。
+//
+// 用途(唯一):流水列 `t_user_expense_log.balance_after` 的取值口径(设计 §3.8-f ②-bis 裁定):
+//   - 语义 = **全局账户余额**,不是校区余额(`t_user_expense_log` 上没有 `campus_id`,
+//     只有 user 级全局链才能做 `balance_after(op_n) = balance_after(op_{n−1}) + Σ(money of op_n)` 校验);
+//   - 取值时机 = **写完账户、写第一条流水之前取一次**,同一操作的多行流水(转结 106/206、
+//     退费 108/207)**复用同一个值**;**禁止逐条重算**(并发下会取到不同值,链式校验必乱)。
+//
+// 入参:ctx / repo / tx(**必传**:必须与本操作的账户写入同事务同快照)/ userID。
+// 出参:(全局余额, error);ErrNotFound(账户不存在,属调用顺序错误)/ ErrDBError。
+func globalBalanceAfter(
+	ctx context.Context, repo *repository.ExpenseRepo, tx *gorm.DB, userID string,
+) (money.Amount, error) {
+	global, err := repo.GetGlobalAccount(ctx, tx, userID)
+	if err != nil {
+		return 0, err
+	}
+	return parseAccountAmount(global.Balance, "t_user_expense.balance")
+}
+
+// campusBalanceAfter 取「该笔操作完成后**校区账户**余额」快照。
+//
+// 用途(唯一):**API 响应体的 `Balance` 字段**(各 Result.Balance 的契约是"校区账户余额",
+// 与流水的 `balance_after` 语义不同,**不得**混用同一个值)。
+//
+// 入参:ctx / repo / tx / userID / institutionID / campusID。
+// 出参:(校区余额, error);ErrNotFound / ErrDBError。
+func campusBalanceAfter(
+	ctx context.Context, repo *repository.ExpenseRepo, tx *gorm.DB,
+	userID string, institutionID, campusID int64,
+) (money.Amount, error) {
+	acc, err := repo.GetCampusAccount(ctx, tx, userID, institutionID, campusID)
+	if err != nil {
+		return 0, err
+	}
+	return parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+}
+
 // optString 把空串转成 nil 指针(*string 列的 NULL 语义:request_id 为空时"不享幂等")。
 func optString(s string) *string {
 	s = strings.TrimSpace(s)
@@ -371,7 +409,8 @@ func amountPtr(a money.Amount) *string {
 
 // newExpenseLog 组装一条余额变更流水(字段口径收口,避免各方法重复拼装)。
 //
-// 入参:userID/typ/money 金额/requestID/remark/operatorID/balanceAfter(nil = 不写快照)。
+// 入参:userID/typ/money 金额/requestID/remark/operatorID/**balanceAfter = 该操作完成后全局账户余额快照**
+// (nil = 不写快照;同一操作的多行流水必须传**同一个值**,见 `globalBalanceAfter` 的口径说明)。
 // 出参:*model.UserExpenseLog(未落库;Create 后 ID 由 GORM 回填)。
 func newExpenseLog(userID string, typ int32, amount money.Amount, requestID, remark, operatorID string,
 	balanceAfter *money.Amount) *model.UserExpenseLog {
@@ -486,16 +525,16 @@ func (s *FundService) Recharge(ctx context.Context, in RechargeInput) (*Recharge
 			return e
 		}
 
-		// ⑤ 流水(balance_after = 充值后校区余额快照,供对账/双跑逐字段比对)
-		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		// ⑤ 流水(`balance_after` = **充值后全局账户余额**快照;响应体 Balance 仍是校区余额)
+		globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
 		if e != nil {
 			return e
 		}
-		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		bal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.InstitutionID, in.CampusID)
 		if e != nil {
 			return e
 		}
-		lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &bal)
+		lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &globalAfter)
 		if e := s.insertLog(txCtx, tx, lg); e != nil {
 			return e
 		}
@@ -579,16 +618,17 @@ func (s *FundService) Expenditure(ctx context.Context, in ExpenditureInput) (*Ex
 			in.InstitutionID, in.CampusID, deltas...); e != nil {
 			return e
 		}
-		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		// `balance_after` = 扣费后**全局账户余额**快照;响应体 Balance 仍是校区余额
+		globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
 		if e != nil {
 			return e
 		}
-		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		bal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.InstitutionID, in.CampusID)
 		if e != nil {
 			return e
 		}
 		// dump:INSERT log(money = **−inputMoney**)
-		lg := newExpenseLog(in.UserID, in.Type, in.Money.Neg(), in.RequestID, remark, in.OperatorID, &bal)
+		lg := newExpenseLog(in.UserID, in.Type, in.Money.Neg(), in.RequestID, remark, in.OperatorID, &globalAfter)
 		if e := s.insertLog(txCtx, tx, lg); e != nil {
 			return e
 		}
@@ -714,15 +754,15 @@ func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult
 		); e != nil {
 			return e
 		}
-		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
 		if e != nil {
 			return e
 		}
-		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		bal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.InstitutionID, in.CampusID)
 		if e != nil {
 			return e
 		}
-		lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &bal)
+		lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &globalAfter)
 		if e := s.insertLog(txCtx, tx, lg); e != nil {
 			return e
 		}
@@ -968,29 +1008,28 @@ func (s *FundService) Transfer(ctx context.Context, in TransferInput) (*Transfer
 		}
 
 		// ⑤ 两条流水(106 在前、206 在后;方向 ★dump 口径)
-		fromAcc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.FromInstitutionID, in.FromCampusID)
+		//
+		// `balance_after` 口径(§3.8-f ②-bis):转结**不动 balance**,故 106 与 206 **写同一个全局快照**;
+		// 响应体的 FromBalance / ToBalance 才是两个校区各自的余额(两列语义不同,勿混用)。
+		globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
 		if e != nil {
 			return e
 		}
-		fromBal, e := parseAccountAmount(fromAcc.Balance, "t_user_expense_school.balance")
+		fromBal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.FromInstitutionID, in.FromCampusID)
 		if e != nil {
 			return e
 		}
-		toAcc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.ToInstitutionID, in.ToCampusID)
-		if e != nil {
-			return e
-		}
-		toBal, e := parseAccountAmount(toAcc.Balance, "t_user_expense_school.balance")
+		toBal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.ToInstitutionID, in.ToCampusID)
 		if e != nil {
 			return e
 		}
 		lg106 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferOut, in.Money,
-			in.RequestID, in.Remark, in.OperatorID, &fromBal)
+			in.RequestID, in.Remark, in.OperatorID, &globalAfter)
 		if e := s.insertLog(txCtx, tx, lg106); e != nil {
 			return e
 		}
 		lg206 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferIn, in.Money.Neg(),
-			in.RequestID, in.Remark, in.OperatorID, &toBal)
+			in.RequestID, in.Remark, in.OperatorID, &globalAfter)
 		if e := s.insertLog(txCtx, tx, lg206); e != nil {
 			return e
 		}
@@ -1128,8 +1167,13 @@ func (s *FundService) BadDebt(ctx context.Context, in BadDebtInput) (*BadDebtRes
 		if e != nil {
 			return e
 		}
+		// `balance_after` = 坏账后**全局账户余额**快照(响应体 Balance 是校区余额,两者此时数值相同但口径不同)
+		globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
+		if e != nil {
+			return e
+		}
 		lg := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, in.Money,
-			in.RequestID, remark, in.OperatorID, &bal)
+			in.RequestID, remark, in.OperatorID, &globalAfter)
 		if e := s.insertLog(txCtx, tx, lg); e != nil {
 			return e
 		}
@@ -1256,10 +1300,14 @@ func (s *FundService) mapIdempotentHit(err error, replay func() error) error {
 	return err
 }
 
-// balanceAfterOf 从流水行取 balance_after 快照(NULL → 0,幂等回放用)。
+// balanceAfterOf 从流水行取 balance_after 快照(NULL → 0)。
+//
+// ⚠ 口径提醒:`balance_after` 自 v0.1.16 起是「该操作完成后**全局账户**余额」(§3.8-f ②-bis),
+// 而各 `Result.Balance` 的契约是**校区**余额 —— 因此**响应体不得再用本函数填充**(会串口径),
+// 回放一律走 `campusBalanceAfter` 读当前校区账户。本函数仅保留给"确实要读全局快照"的对账/排障用途。
 //
 // 入参:lg 流水行(可 nil)。出参:(金额, error);balance_after 为 NULL 时返回 0 且无错
-// (历史行没有快照列,回放时余额字段退化为 0 并保持 repeated=true)。
+// (历史行没有快照列,调用方需能接受 0)。
 func balanceAfterOf(lg *model.UserExpenseLog) (money.Amount, error) {
 	if lg == nil || lg.BalanceAfter == nil {
 		return 0, nil
@@ -1277,7 +1325,8 @@ func (s *FundService) replayRecharge(ctx context.Context, in RechargeInput, out
 		return infraerrors.WithCause(infraerrors.ErrInternal,
 			errString("service: Recharge 幂等命中但首次流水已不可见"))
 	}
-	bal, err := balanceAfterOf(hit)
+	// 响应体 Balance 是**校区**余额(与流水的 balance_after 全局口径不同),回放按当前校区账户取值
+	bal, err := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.InstitutionID, in.CampusID)
 	if err != nil {
 		return err
 	}
@@ -1295,7 +1344,8 @@ func (s *FundService) replayExpenditure(ctx context.Context, in ExpenditureInput
 		return infraerrors.WithCause(infraerrors.ErrInternal,
 			errString("service: Expenditure 幂等命中但首次流水已不可见"))
 	}
-	bal, err := balanceAfterOf(hit)
+	// 响应体 Balance 是**校区**余额(与流水的 balance_after 全局口径不同),回放按当前校区账户取值
+	bal, err := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.InstitutionID, in.CampusID)
 	if err != nil {
 		return err
 	}
@@ -1313,7 +1363,8 @@ func (s *FundService) replayRefund(ctx context.Context, in RefundInput, out *Ref
 		return infraerrors.WithCause(infraerrors.ErrInternal,
 			errString("service: Refund 幂等命中但首次流水已不可见"))
 	}
-	bal, err := balanceAfterOf(hit)
+	// 响应体 Balance 是**校区**余额(与流水的 balance_after 全局口径不同),回放按当前校区账户取值
+	bal, err := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.InstitutionID, in.CampusID)
 	if err != nil {
 		return err
 	}
@@ -1354,8 +1405,9 @@ func (s *FundService) replayTransfer(ctx context.Context, in TransferInput, out
 		return infraerrors.WithCause(infraerrors.ErrInternal,
 			errString("service: Transfer 幂等命中但首次流水已不可见"))
 	}
+	// FromBalance / ToBalance 是两个**校区**各自余额(流水 balance_after 是全局口径,不能拿来充两个值)
 	res := TransferResult{LogID106: hit106.ID, Repeated: true}
-	if bal, pe := balanceAfterOf(hit106); pe == nil {
+	if bal, pe := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.FromInstitutionID, in.FromCampusID); pe == nil {
 		res.FromBalance = bal
 	}
 	hit206, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
@@ -1364,7 +1416,7 @@ func (s *FundService) replayTransfer(ctx context.Context, in TransferInput, out
 	}
 	if hit206 != nil {
 		res.LogID206 = hit206.ID
-		if bal, pe := balanceAfterOf(hit206); pe == nil {
+		if bal, pe := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.ToInstitutionID, in.ToCampusID); pe == nil {
 			res.ToBalance = bal
 		}
 	}

+ 28 - 9
internal/academic/service/refund_service.go

@@ -326,6 +326,12 @@ func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput)
 		if e != nil {
 			return e
 		}
+		// `balance_after` 快照口径(§3.8-f ②-bis):**全局账户**余额,写完账户后取一次,
+		// 108 与 207 两条流水**写同一个值**(响应体的 Balance 仍是校区余额,两者语义不同)。
+		globalSnapshot, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
+		if e != nil {
+			return e
+		}
 
 		// ⑥ 双 log:**先 108(+badDebt)再 207(−requested 全额)**
 		//
@@ -339,7 +345,7 @@ func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput)
 		var logID108 *int64
 		if badDebt > 0 {
 			lg108 := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, badDebt, in.RequestID,
-				remark+badDebtRemarkSuffix, in.OperatorID, &balanceAfter)
+				remark+badDebtRemarkSuffix, in.OperatorID, &globalSnapshot)
 			if e := s.insertLog(txCtx, tx, lg108); e != nil {
 				return e
 			}
@@ -347,7 +353,7 @@ func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput)
 			logID108 = &id
 		}
 		lg207 := newExpenseLog(in.UserID, model.ExpenseLogTypeRefund, in.Money.Neg(), in.RequestID,
-			remark, in.OperatorID, &balanceAfter)
+			remark, in.OperatorID, &globalSnapshot)
 		if e := s.insertLog(txCtx, tx, lg207); e != nil {
 			return e
 		}
@@ -607,8 +613,13 @@ func (s *RefundService) ClearBalance(ctx context.Context, in ClearBalanceInput)
 		}
 
 		// ④ 流水 type=208,money = −campusBalance(无请求号 → request_id/log_id 均为 NULL)
+		//    `balance_after` = 清零后**全局账户**余额快照(§3.8-f ②-bis;响应体 Balance 仍是校区余额 0)
+		globalSnapshot, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
+		if e != nil {
+			return e
+		}
 		lg := newExpenseLog(in.UserID, model.ExpenseLogTypeClearBalance, campusBalance.Neg(),
-			"", remark, in.OperatorID, &balanceAfter)
+			"", remark, in.OperatorID, &globalSnapshot)
 		if e := s.insertLog(txCtx, tx, lg); e != nil {
 			return e
 		}
@@ -712,13 +723,16 @@ func (s *RefundService) CancelRechargeActivity(
 			return e
 		}
 		if hit != nil {
-			bal, pe := balanceAfterOf(hit)
-			if pe != nil {
-				return pe
-			}
-			out = CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}
+			out = CancelRechargeActivityResult{LogID: hit.ID, Repeated: true}
+			// 响应体 Balance 是**校区**余额(流水 balance_after 自 v0.1.16 起是全局口径),
+			// 故按"校区 → 机构 → 账户"重新回读当前值;读不到就回退 0(不伪造)。
 			if inst, ie := s.readCampus(txCtx, tx, in.CampusID); ie == nil {
 				out.InstitutionID = inst.SchoolID
+				if inst.SchoolID > 0 {
+					if bal, pe := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, inst.SchoolID, in.CampusID); pe == nil {
+						out.Balance = bal
+					}
+				}
 			}
 			return nil
 		}
@@ -772,8 +786,13 @@ func (s *RefundService) CancelRechargeActivity(
 		}
 
 		// ⑤ 流水 type=209,money = −money
+		//    `balance_after` = 取消后**全局账户**余额快照(§3.8-f ②-bis;响应体 Balance 仍是校区余额)
+		globalSnapshot, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
+		if e != nil {
+			return e
+		}
 		lg := newExpenseLog(in.UserID, model.ExpenseLogTypeCancelRechargeAct, in.Money.Neg(),
-			in.RequestID, remark, in.OperatorID, &bal)
+			in.RequestID, remark, in.OperatorID, &globalSnapshot)
 		if e := s.insertLog(txCtx, tx, lg); e != nil {
 			return e
 		}