Parcourir la source

fix(academic): BadDebt 双加 + RefundableAmount 两步修正(按 dump 原文推翻旧口径)

背景:AC-04 的 BadDebt / RefundableAmount 两处“已知偏离”经 dump 原文核实属**实现错误**,
本次按 dump 改回,并补齐两处反向断言(设计 §3.1 #5 / #7)。

① BadDebt(sp_user_expense_baddebt,dump :763890)
- dump 两条 UPDATE 都是 `balance = balance + m, special_expenses = special_expenses + m`
  (SP 头注释即「用户坏账处理(余额+,特殊费用+)」)→ 改为**双加**:全局 + 校区都同时
  加 balance 与 special_expenses,108 流水 +m。
- BadDebtResult.Balance 语义随之改为“坏账后(已 +m)的校区余额”。
- 兼容注释写清 type=108 的**两种来源**与可机械执行的判别式:
  · 独立坏账(本 SP)→ balance+ 且 special_expenses+,108 参与“balance 增量 = Σ流水”归因;
  · 退费差额(CustomRefund)→ 只 special_expenses+,207 记 −requested 全额,108 不参与归因;
  · 判别式:同 logid(= request_id)存在 207 的 108 = 差额型,否则 = 独立坏账。

② RefundableAmount(sp_user_expense_school_get_refundable_amount,dump :764421)
- 由 `max(校区余额, 0)` 改为 dump 的两步修正:
  ① campusBalance > totalBalance → 按 totalBalance 截断;
  ② 再减已使用赠送额,下限 0 → `max(min(校区余额, 全局余额) − usedGift, 0)`。
- 新增 repository.BalanceRequestRepo.SumRechargeActivityUsedGift:
  dump 的 `t1.id = t2.requestid` 按目标态列映射改写为 `br.biz_no = cas.request_no`
  (旧 UUID 主键 → biz_no;旧 requestid → request_no),照抄旧列名会恒不命中;
  Go 侧定点累加(禁 SQL SUM decimal);显式别名限定作用域(Scoped 的裸列名在 JOIN 下二义)。
- 显式化:campusBalance<=0 / totalBalance<=0 / 账户缺失 → 0。

测试(反向断言)
- TestBadDebtAddsBalanceAndSpecialExpenses:balance 与 special_expenses **同增**(全局+校区四列)。
- TestBalanceIdentityIndependentBadDebtCounted:108 无 207 同伴 → 参与恒等式
  (余额增量 == 108 金额;balance == Σ全部流水)。
- TestBalanceIdentityDifferentialBadDebtExcluded:108 有 207 同伴 → 不参与“108 → 余额增量”归因
  (207 单独记账 ≠ 余额变化;207+108 成对 = 净扣减;再算一次 108 会凭空多出 badDebt);
  同时 balance == Σ全部流水 仍成立。
- TestRefundableAmount 扩到 10 个边界:含**赠送已用 → 可退额 < 余额**、
  **campus(200) > total(120) → 截断为 120**、赠送用尽不扣、未审批不扣、截断后下限 0。
- 新增 repo 侧两条:四类过滤 + 多行累加 + 参数校验 + “id 伪匹配”负例;
  以及 MySQL DryRun 断言 SQL 含 `ON br.biz_no = cas.request_no` 与别名限定列。

go build / go vet / go test -short 全绿。
Q il y a 18 heures
Parent
commit
57808bd9c5

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

@@ -89,7 +89,7 @@ const (
 // (设计 §1.7 例外一,代码评审须逐处确认"未绕过 Scoped 就代表了它不需要 scoped")。
 type UserExpense struct {
 	UserID                 string `gorm:"column:user_id;primaryKey;size:26;not null" json:"userId"`                                               // 用户(ULID 26 位,D1;原 userid int 经 t_legacy_mapping 重映射)|枚举:无|写入:首次资金操作 UPSERT|FK:→ t_user.canonical_user_id(无物理 FK)|红线:旧 int userid 只进映射表,禁止在此落 int
-	Balance                string `gorm:"column:balance;type:decimal(12,2);not null;default:0.00" json:"balance"`                                 // 余额(元,±;负数=欠费)|枚举:无|写入:充值/消费/退费/清零事务内更新|FK:无|红线:只允许 `balance = balance + ?` 原子表达式,禁止读-改-写(防丢更新)
+	Balance                string `gorm:"column:balance;type:decimal(12,2);not null;default:0.00" json:"balance"`                                 // 余额(元,±;负数=欠费)|枚举:无|写入:充值/消费/退费/坏账(SP7 双加)/清零事务内更新|FK:无|红线:只允许 `balance = balance + ?` 原子表达式,禁止读-改-写(防丢更新)
 	Spend                  string `gorm:"column:spend;type:decimal(12,2);not null;default:0.00" json:"spend"`                                     // 上课消费课费单价之和(元;只增)|枚举:无|写入:spend SP(FundService.AccumulateSpend)|FK:无|红线:本列不动 balance,勿混用为扣费
 	TotalIncome            string `gorm:"column:total_income;type:decimal(12,2);not null;default:0.00" json:"totalIncome"`                        // 总充值(元)|枚举:无|写入:充值+/退费-/清零不动/转结双向|FK:无|红线:报表口径列,禁止业务分支改写
 	TotalExpenditure       string `gorm:"column:total_expenditure;type:decimal(12,2);not null;default:0.00" json:"totalExpenditure"`              // 总消费(元;报课学费之和+历史欠费)|枚举:无|写入:消费+/退费-/转结双向调整|FK:无|红线:与 t_user_expense_school 同名列须同事务同步
@@ -110,7 +110,7 @@ type UserExpenseSchool struct {
 	UserID                 string `gorm:"uniqueIndex:uk_user_inst_campus,priority:1;column:user_id;size:26;not null" json:"userId"`                                                                            // 用户(ULID;原 userid)|枚举:无|写入:账户创建时|FK:→ t_user(逻辑)|红线:不得跨界改写
 	InstitutionID          int64  `gorm:"uniqueIndex:uk_user_inst_campus,priority:2;index:idx_inst_campus,priority:1;column:institution_id;not null" json:"institutionId"`                                     // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:账户创建时锁定,永不更新|FK:→ t_institution|红线:scoping 强依赖,禁止 0 值写入
 	CampusID               int64  `gorm:"uniqueIndex:uk_user_inst_campus,priority:3;index:idx_inst_campus,priority:2;index:idx_campus_balance,priority:1;column:campus_id;not null;default:0" json:"campusId"` // 校区 ID(原 campusid;0=机构级)|枚举:无|写入:账户创建时锁定|FK:→ t_campus|红线:UK 第 3 列,改动即账户错位
-	Balance                string `gorm:"index:idx_campus_balance,priority:2;column:balance;type:decimal(12,2);not null;default:0.00" json:"balance"`                                                          // 校区余额(元,±;负数=欠费)|枚举:无|写入:资金事务内原子增减|FK:无|红线:与全局 balance 同事务更新(固定顺序:全局→校区,防死锁)
+	Balance                string `gorm:"index:idx_campus_balance,priority:2;column:balance;type:decimal(12,2);not null;default:0.00" json:"balance"`                                                          // 校区余额(元,±;负数=欠费)|枚举:无|写入:资金事务内原子增减(含坏账 SP7 双加中的 balance+)|FK:无|红线:与全局 balance 同事务更新(固定顺序:全局→校区,防死锁)
 	Spend                  string `gorm:"column:spend;type:decimal(12,2);not null;default:0.00" json:"spend"`                                                                                                  // 校区上课消费课费单价之和(元)|枚举:无|写入:spend SP|FK:无|红线:只增
 	TotalIncome            string `gorm:"column:total_income;type:decimal(12,2);not null;default:0.00" json:"totalIncome"`                                                                                     // 校区总充值(元)|枚举:无|写入:充值/退费/转结|FK:无|红线:与全局表同事务
 	TotalExpenditure       string `gorm:"column:total_expenditure;type:decimal(12,2);not null;default:0.00" json:"totalExpenditure"`                                                                           // 校区总消费(元)|枚举:无|写入:消费/退费/转结|FK:无|红线:与全局表同事务

+ 93 - 0
internal/academic/repository/balance_request_repo.go

@@ -23,6 +23,7 @@ import (
 	"gorm.io/gorm/clause"
 
 	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
 )
@@ -181,6 +182,98 @@ func (r *BalanceRequestRepo) UpdateApproved(ctx context.Context, tx *gorm.DB, id
 	return res.RowsAffected, nil
 }
 
+// SumRechargeActivityUsedGift 汇总用户在某机构某校区「已使用的充值赠送金额」(SP5 第二步修正)。
+//
+// 用途:`sp_user_expense_school_get_refundable_amount` 的可退额计算**第二步**——
+// `refundable = max(min(校区余额, 全局余额) − 已用赠送额, 0)`(第一步的截断在 Service 层)。
+//
+// dump 原文(yuansiwei_finance.sql:764421 起,游标段被 `/* */` 注释掉,实际只生效这条 SUM):
+//
+//	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;
+//
+// 目标态列映射(**必须按映射改写,不能照抄 dump 的列名**):
+//
+//	t1.id        (旧库 UUID 主键)   → t_user_balance_request.biz_no
+//	              (设计 §2.2 / §11-10:旧 UUID 原样搬进 biz_no,新 id 是自增 int64)
+//	t2.requestid (旧库字符串申请单号) → t_courses_activity_signed.request_no
+//	              (DDL 注释:「申请单号(原 requestid,关联 t_user_balance_request.biz_no)」)
+//	t1.userid / t1.schoolid / t1.campusid / t1.aproved → user_id / institution_id / campus_id / approved
+//
+// → JOIN 条件因此是 **`br.biz_no = cas.request_no`**,不是 `br.id = cas.request_no`:
+// 新 `id` 是自增整数,与字符串单号**永不等值**(照抄 dump 会静默恒不命中 → 可退额永远不少扣,属资金口径 BUG);
+// `t_courses_activity_signed` 的 model 红线亦已写明「字符串保留(非 int64 FK),勿 JOIN id」。
+//
+// 两个过滤条件的语义(照抄 dump,**不得"顺手简化"**):
+//   - `cas.activity_money > 0`:只统计赠送额为正的活动行(负数=抵扣,不进赠送池);
+//   - `cas.activity_money_used < cas.activity_money`:只统计赠送池**尚未用尽**的行——
+//     用尽的行其赠送额已全部转为可用余额且不可再退,故不参与本次扣减。
+//
+// 作用域:**显式**按 `br.institution_id` / `br.campus_id` 过滤,**不**用 `BaseRepo.Scoped`——
+// Scoped 生成的是裸列名 `institution_id = ?`,在 JOIN 下两表同名列会二义、SQL 直接报错;
+// 这里把等价过滤写成别名限定列,安全边界不变(普通角色同样被钉在本机构 + 本校区,超管由调用方
+// 的 campusID 入参钉住,与 Scoped 的语义差异仅"不因超管而放宽",属于更严而非更宽)。
+//
+// 精度:**Go 侧定点累加**(本仓红线:禁止 `SQL SUM(decimal)` 回 REAL 再转 Amount)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 用户(ULID;空 → ErrBadParam);
+//   - institutionID 机构 ID(**必须 > 0**:与 dump 的 `schoolid` 等值);
+//   - campusID 校区 ID(**必须 > 0**:与 dump 的 `campusid` 等值)。
+//
+// 出参:
+//   - money.Amount:已用赠送额合计(分;无匹配行 → 0,**不返回 ErrNotFound**);
+//   - ErrBadParam / ErrDBError。
+func (r *BalanceRequestRepo) SumRechargeActivityUsedGift(
+	ctx context.Context, userID string, institutionID, campusID int64,
+) (money.Amount, error) {
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: SumRechargeActivityUsedGift 需要 user_id"))
+	}
+	if institutionID <= 0 || campusID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: SumRechargeActivityUsedGift 需要 institution_id>0 且 campus_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return 0, err
+	}
+	// 扫描目标用 *string:LEFT JOIN 未命中时该列为 NULL,string 直接接会扫描报错。
+	type usedGiftRow struct {
+		ActivityMoneyUsed *string `gorm:"column:activity_money_used"`
+	}
+	rows := make([]usedGiftRow, 0, 8)
+	scanErr := db.Table("t_user_balance_request AS br").
+		Select("cas.activity_money_used AS activity_money_used").
+		Joins("LEFT JOIN t_courses_activity_signed AS cas ON br.biz_no = cas.request_no").
+		Where("br.type = ? AND br.user_id = ? AND br.institution_id = ? AND br.campus_id = ? AND br.approved = ?",
+			model.BalanceRequestTypeRecharge, userID, institutionID, campusID,
+			model.BalanceRequestApprovedPassed).
+		Where("cas.activity_money > 0 AND cas.activity_money_used < cas.activity_money").
+		Scan(&rows).Error
+	if scanErr != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, scanErr)
+	}
+	total := money.Amount(0)
+	for i := range rows {
+		if rows[i].ActivityMoneyUsed == nil {
+			continue // LEFT JOIN 未命中的占位行(不可能进入 where,防御性跳过)
+		}
+		amount, perr := money.Parse(*rows[i].ActivityMoneyUsed)
+		if perr != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		total = total.Add(amount)
+	}
+	return total, nil
+}
+
 // ListByUser 分页列出某用户的工单(按 id 倒序,最新在前)。
 //
 // 入参:

+ 141 - 0
internal/academic/repository/balance_request_repo_test.go

@@ -242,3 +242,144 @@ func TestBalanceRequestListByUserPaging(t *testing.T) {
 		t.Fatalf("pageSize 钳制 = %d,期望 %d", got, MaxPageSize)
 	}
 }
+
+// ---------------------------------------------------------------- SP5 已用赠送额
+
+// newBalanceRequestGiftDB 工单 + 活动报名双表库(SP5 已用赠送额需要 JOIN 两表)。
+//
+// 与 newBalanceRequestRepoDB 分开建库的原因:那套用例刻意只迁单表(注释说明索引名冲突理由),
+// 本用例必须同时有 t_courses_activity_signed 才能验证 `biz_no = request_no` 的 JOIN 口径。
+func newBalanceRequestGiftDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_balance_request_gift_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(工单+报名文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(&model.UserBalanceRequest{}, &model.CoursesActivitySigned{}); err != nil {
+		t.Fatalf("AutoMigrate(工单+报名) 失败: %v", err)
+	}
+	return db
+}
+
+// seedGiftPair 落「充值工单 + 活动报名」一对行。
+func seedGiftPair(t *testing.T, db *gorm.DB, bizNo, userID string, institutionID, campusID, activityID int64,
+	approved int8, activityMoney, activityMoneyUsed string) {
+	t.Helper()
+	if err := db.Create(&model.UserBalanceRequest{
+		BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
+		InstitutionID: institutionID, CampusID: campusID, Approved: approved,
+	}).Error; err != nil {
+		t.Fatalf("seed 工单(%s) 失败: %v", bizNo, err)
+	}
+	reqNo := bizNo
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: activityID, UserID: userID, InstitutionID: institutionID, CampusID: campusID,
+		ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo,
+	}).Error; err != nil {
+		t.Fatalf("seed 报名(%s) 失败: %v", bizNo, err)
+	}
+}
+
+// TestBalanceRequestSumRechargeActivityUsedGift SP5 第二步的四个过滤条件 + 多行累加 + 参数校验;
+// 另含**旧 dump 列名直接照抄会恒不命中**的负例(`br.id = cas.request_no` 的反向证明)。
+func TestBalanceRequestSumRechargeActivityUsedGift(t *testing.T) {
+	db := newBalanceRequestGiftDB(t)
+	repo := NewBalanceRequestRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+	const user = "01HUSER000000000000000205"
+
+	// ① 正常命中:20.00;② 第二行再 5.00 → 合计 25.00(验证 Go 侧定点累加多行)
+	seedGiftPair(t, db, "BIZ-GIFT-1", user, 1, 2, 1, model.BalanceRequestApprovedPassed, "50.00", "20.00")
+	seedGiftPair(t, db, "BIZ-GIFT-2", user, 1, 2, 2, model.BalanceRequestApprovedPassed, "30.00", "5.00")
+	// ③ 其他校区:不参与
+	seedGiftPair(t, db, "BIZ-GIFT-3", user, 1, 9, 3, model.BalanceRequestApprovedPassed, "50.00", "40.00")
+	// ④ 其他机构:不参与
+	seedGiftPair(t, db, "BIZ-GIFT-4", user, 2, 2, 4, model.BalanceRequestApprovedPassed, "50.00", "40.00")
+	// ⑤ 未审批:不参与
+	seedGiftPair(t, db, "BIZ-GIFT-5", user, 1, 2, 5, model.BalanceRequestApprovedPending, "50.00", "40.00")
+	// ⑥ 赠送已用尽(used == activity_money):不参与
+	seedGiftPair(t, db, "BIZ-GIFT-6", user, 1, 2, 6, model.BalanceRequestApprovedPassed, "50.00", "50.00")
+	// ⑦ 赠送额为负(抵扣,不进赠送池):不参与
+	seedGiftPair(t, db, "BIZ-GIFT-7", user, 1, 2, 7, model.BalanceRequestApprovedPassed, "-10.00", "3.00")
+	// ⑧ 非充值工单(type != 100):不参与
+	if err := db.Create(&model.UserBalanceRequest{
+		BizNo: "BIZ-GIFT-8", Type: model.BalanceRequestTypeRefund, UserID: user,
+		InstitutionID: 1, CampusID: 2, Approved: model.BalanceRequestApprovedPassed,
+	}).Error; err != nil {
+		t.Fatalf("seed 退费工单失败: %v", err)
+	}
+	reqNo8 := "BIZ-GIFT-8"
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 8, UserID: user, InstitutionID: 1, CampusID: 2,
+		ActivityMoney: "50.00", ActivityMoneyUsed: "45.00", RequestNo: &reqNo8,
+	}).Error; err != nil {
+		t.Fatalf("seed 报名(BIZ-GIFT-8) 失败: %v", err)
+	}
+	// ⑨ **列名映射负例**:报名单号只等于工单的自增 id("1"),不等于 biz_no → 不得命中。
+	//    照抄 dump 的 `t1.id = t2.requestid` 时会命中这一行(可退额被少扣),正确映射下不命中。
+	reqNo9 := "1"
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 9, UserID: user, InstitutionID: 1, CampusID: 2,
+		ActivityMoney: "999.00", ActivityMoneyUsed: "999.00", RequestNo: &reqNo9,
+	}).Error; err != nil {
+		t.Fatalf("seed 报名(按 id 伪匹配) 失败: %v", err)
+	}
+
+	got, err := repo.SumRechargeActivityUsedGift(ctx, user, 1, 2)
+	if err != nil {
+		t.Fatalf("SumRechargeActivityUsedGift 失败: %v", err)
+	}
+	if got.Cents() != 2500 {
+		t.Fatalf("已用赠送额应 2500 分(20.00+5.00),实得 %d", got.Cents())
+	}
+
+	// ⑩ 无匹配行 → 0(不是 ErrNotFound)
+	if v, err := repo.SumRechargeActivityUsedGift(ctx, "01HUSER000000000000000206", 1, 2); err != nil || v != 0 {
+		t.Fatalf("无匹配行应返回 0 且无错,实得 (%d, %v)", v, err)
+	}
+	// ⑪ 参数校验:user_id 空 / institution<=0 / campus<=0 → ErrBadParam
+	if _, err := repo.SumRechargeActivityUsedGift(ctx, " ", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := repo.SumRechargeActivityUsedGift(ctx, user, 0, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("institution_id=0 应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := repo.SumRechargeActivityUsedGift(ctx, user, 1, 0); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("campus_id=0 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestBalanceRequestSumGiftSQL MySQL 方言:JOIN 键必须是 `biz_no = request_no`(不是 id),
+// 且作用域过滤必须是**别名限定**列(裸列名在两表同名列下会二义,MySQL 直接报错)。
+func TestBalanceRequestSumGiftSQL(t *testing.T) {
+	mdb, capture := newMySQLDryRunDB(t)
+	repo := NewBalanceRequestRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	capture.Reset()
+	_, _ = repo.SumRechargeActivityUsedGift(ctx, "01HUSER000000000000000207", 1, 2)
+	sql := capture.Last()
+	for _, want := range []string{
+		"t_user_balance_request AS br",
+		"t_courses_activity_signed AS cas",
+		"ON br.biz_no = cas.request_no",
+		"br.institution_id",
+		"br.campus_id",
+		"br.approved",
+		"cas.activity_money > 0",
+		"cas.activity_money_used < cas.activity_money",
+	} {
+		if !strings.Contains(sql, want) {
+			t.Fatalf("SQL 缺少片段 %q:%q", want, sql)
+		}
+	}
+	if strings.Contains(sql, "br.id = cas.request_no") {
+		t.Fatalf("JOIN 不得用自增 id 对字符串单号(会恒不命中):%q", sql)
+	}
+}

+ 103 - 29
internal/academic/service/fund_service.go

@@ -10,9 +10,9 @@
 //	#2  sp_user_expense_school_recharge         → Recharge(同构合并,仅默认备注不同 → RechargeKind)
 //	#3  sp_user_expense_school_expenditure      → Expenditure(type 由调用方传,白名单校验)
 //	#4  sp_user_expense_school_refund           → Refund(**补 institution_id 参数**,BUG#2 修正)
-//	#5  sp_user_expense_school_get_refundable_amount → RefundableAmount(只读,无事务)
+//	#5  sp_user_expense_school_get_refundable_amount → RefundableAmount(只读,无事务;**两步修正**)
 //	#6  sp_user_expense_school_transfer_expenditure  → Transfer(**不动 balance**;106=+m / 206=−m)
-//	#7  sp_user_expense_baddebt                 → BadDebt(只加 special_expenses,不动 balance)
+//	#7  sp_user_expense_baddebt                 → BadDebt(SP 头注释即「余额+,特殊费用+」→ **双加**)
 //	#11 sp_user_expense_school_spend            → AccumulateSpend(只更新 spend,不动 balance;BUG#1)
 //
 // 两条不可违反的纪律(评审逐处检查):
@@ -79,12 +79,15 @@ const (
 //
 // 字段:
 //   - repo:余额账户仓储(全局 + 校区 + 流水;校区账户走机构作用域);
+//   - requests:余额工单仓储(**只读**用途:SP5 可退额需要按「充值工单 × 活动报名」汇总已用赠送额,
+//     设计 §3.1 #5 的第二步修正,`SumRechargeActivityUsedGift`);
 //   - runner:事务运行器(顶层 BEGIN,事务内透传);
 //   - member:成员关系校验器(可 nil:系统任务/割接脚本模式不做对象级越权断言)。
 type FundService struct {
-	repo   *repository.ExpenseRepo
-	runner *repository.TxRunner
-	member port.MembershipChecker
+	repo     *repository.ExpenseRepo
+	requests *repository.BalanceRequestRepo
+	runner   *repository.TxRunner
+	member   port.MembershipChecker
 }
 
 // NewFundService 构造余额核心服务(repo 与 runner 绑定同一 DB 句柄,避免跨连接池事务失效)。
@@ -98,9 +101,10 @@ type FundService struct {
 // 出参:*FundService。
 func NewFundService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FundService {
 	return &FundService{
-		repo:   repository.NewExpenseRepo(db, sc),
-		runner: repository.NewTxRunner(db),
-		member: member,
+		repo:     repository.NewExpenseRepo(db, sc),
+		requests: repository.NewBalanceRequestRepo(db, sc),
+		runner:   repository.NewTxRunner(db),
+		member:   member,
 	}
 }
 
@@ -263,7 +267,7 @@ type BadDebtResult struct {
 	LogID int64 `json:"logId"`
 	// SpecialExpenses 处理后校区账户特殊费用累计。
 	SpecialExpenses money.Amount `json:"specialExpenses"`
-	// Balance 处理后校区账户余额(本方法不动 balance,回显仅为对账方便)。
+	// Balance 处理后校区账户余额(**dump 口径:坏账双加,故已 +Money**)。
 	Balance money.Amount `json:"balance"`
 	// Repeated true = 幂等命中。
 	Repeated bool `json:"repeated"`
@@ -735,29 +739,45 @@ func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult
 
 // RefundableAmount 查询校区账户可退金额(SP5 `sp_user_expense_school_get_refundable_amount`,**只读**)。
 //
-// 口径(设计 §3.1 #5 定稿):`max(校区余额, 0)`;账户不存在 → 0(旧行为);
-// 余额 <= 0(欠费)→ 0。
+// dump 语义(:764421,**两步修正,缺一不可**;dump 里的游标段被 `/* */` 注释掉,实际只生效下面的 SUM):
 //
-// ⚠ 与 dump 的差异(**已知偏离,登记于本批报告**):dump 在 `campusBalance - SUM(已使用赠送金额)`
-// 之后取 max(…,0)(`totalDiscountAmountUsed` 来自 t_user_balance_request × t_courses_activity_signed
-// 的关联聚合,yuansiwei_finance.sql:764472)。设计 §3.1 #5 把该方法**收敛为** `max(balance,0)`
-// (赠送扣减属 P2 的"赠送金额可退性"专题),本实现按设计执行;若后续要按 dump 补齐,
-// 只需扩展本方法内部(调用方签名不变)。
+//	① 读 campusBalance(t_user_expense_school.balance)与 totalBalance(t_user_expense.balance);
+//	② **截断**:`IF campusBalance > totalBalance THEN campusBalance = totalBalance`
+//	   (校区余额不可能大于跨校区总余额;超出即历史脏数据,按总余额截断而不是报错);
+//	③ **减已使用赠送额**(`t_user_balance_request × t_courses_activity_signed` 关联汇总,
+//	   口径见 `BalanceRequestRepo.SumRechargeActivityUsedGift`):
+//	     refundable = campusBalance − usedGift,**下限 0**。
+//
+// Go 的显式化(不改变资金结果,只把 dump 的静默分支显式化):
+//   - 校区账户不存在 → (0, nil)(dump:ISNULL → 0);
+//   - `campusBalance <= 0`(欠费)或 `totalBalance <= 0` → 0(dump 下负数参与减法后取 max(…,0) 亦为 0,
+//     这里提前返回是同结果的短路,避免把"欠费"当成"可退");
+//   - 全局账户不存在 → 0(totalBalance 视为 0 → 截断后必为 0)。
+//
+// 只读且**无事务**:本方法不写任何表,故不开事务、也不加行锁(加锁只属于资金写路径,§10.3)。
+// 与之配套的调用纪律:可退额是"查询口径",**不得**用它做写路径的扣减基数——
+// 退费扣减基数由 `CustomRefund` 在事务内用加锁读的校区余额决定(§7.2 R2)。
+//
+// 与 SP9 内部快照的区别(**勿混用**):`CustomRefund` 的 `RefundableAmount` 字段是
+// "退费前校区余额快照"(扣减基数,dump 的 `balance<=0 → 3102` 口径),不含赠送额扣减;
+// 本 SP5 方法是"外部查询可退额",含赠送额扣减。两者语义不同,故数值可以不等。
 //
 // 入参:ctx / userID / institutionID / campusID。
-// 出参:(可退金额, error);账户不存在 → (0, nil);错误码 2001/3140/1004/5002。
+// 出参:(可退金额, error);账户不存在 / 余额非正 → (0, nil);错误码 2001/3140/1004/5002。
 func (s *FundService) RefundableAmount(
 	ctx context.Context, userID string, institutionID, campusID int64,
 ) (money.Amount, error) {
 	if err := validateUserScope(userID, institutionID, campusID, "RefundableAmount", true); err != nil {
 		return 0, err
 	}
-	if s == nil || s.repo == nil {
+	if s == nil || s.repo == nil || s.requests == nil {
 		return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
 	}
 	if err := s.assertInScope(ctx, userID, institutionID, campusID); err != nil {
 		return 0, err
 	}
+
+	// ① 校区余额(读不到 = 账户不存在 → 0,dump 的 ISNULL 分支)
 	acc, err := s.repo.GetCampusAccount(ctx, nil, userID, institutionID, campusID)
 	if err != nil {
 		if errors.Is(err, infraerrors.ErrNotFound) {
@@ -765,14 +785,45 @@ func (s *FundService) RefundableAmount(
 		}
 		return 0, err
 	}
-	bal, err := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+	campusBalance, err := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+	if err != nil {
+		return 0, err
+	}
+	if campusBalance <= 0 {
+		return 0, nil
+	}
+
+	// ② 全局余额(跨校区总余额;这是 dump 的"截断上界")
+	global, err := s.repo.GetGlobalAccount(ctx, nil, userID)
+	if err != nil {
+		if errors.Is(err, infraerrors.ErrNotFound) {
+			return 0, nil // totalBalance 视为 0 → 截断后必为 0
+		}
+		return 0, err
+	}
+	totalBalance, err := parseAccountAmount(global.Balance, "t_user_expense.balance")
 	if err != nil {
 		return 0, err
 	}
-	if bal <= 0 {
+	if totalBalance <= 0 {
 		return 0, nil
 	}
-	return bal, nil
+
+	// ③ **第一步修正**:校区余额按全局余额截断(dump:campusBalance > totalBalance → 取 totalBalance)
+	if campusBalance > totalBalance {
+		campusBalance = totalBalance
+	}
+
+	// ④ **第二步修正**:扣掉已使用的充值赠送额(赠送额不可退)
+	usedGift, err := s.requests.SumRechargeActivityUsedGift(ctx, userID, institutionID, campusID)
+	if err != nil {
+		return 0, err
+	}
+	refundable := campusBalance.Sub(usedGift)
+	if refundable <= 0 {
+		return 0, nil // dump:max(…, 0)
+	}
+	return refundable, nil
 }
 
 // ---------------------------------------------------------------- SP6 课费转结
@@ -956,15 +1007,36 @@ type campusRef struct {
 
 // BadDebt 坏账处理(SP7 `sp_user_expense_baddebt`)。
 //
-// 口径(设计 §3.1 #7):**只加 `special_expenses`,不动 balance**;log type=108、money=+Money。
+// dump 语义(:763890,SP 头注释即「用户坏账处理(余额+,特殊费用+)」,**双加**):
+//
+//	UPDATE t_user_expense_school SET balance = balance + inputMoney,
+//	                                 special_expenses = special_expenses + inputMoney
+//	 WHERE userid AND schoolid AND campusid;
+//	UPDATE t_user_expense        SET balance = balance + inputMoney,
+//	                                 special_expenses = special_expenses + inputMoney
+//	 WHERE userid;
+//	INSERT t_user_expense_log(..., type = 108, money = +inputMoney, ...);
+//
+// 即:**全局 + 校区都同时加 `balance` 与 `special_expenses`**(两列同增,金额相同),
+// 流水 type=108、money=+Money。Go 侧严格照做(不做"只统计特殊费用"的收敛)。
+//
+// ★ type=108 的**两种来源**(对账/恒等式判别的唯一依据,AC-17 ④ 按此落地):
 //
-// ⚠ 与 dump 的差异(**已知偏离,登记于本批报告**):dump(:763890)两条 UPDATE 都是
-// `balance = balance + m, special_expenses = special_expenses + m`——即**同时加余额**。
-// 设计与本实现按"坏账 = 只统计特殊费用"执行(不凭空给学员加可用余额);
-// 若按 dump 回改,只需在下面的 `AddColumnsGlobalThenCampus` 里补一个 balance 列增量,
-// 并把 `BadDebtResult.Balance` 的断言改为"增加后余额"。
-// 连带影响(已上报):dump 口径下 108 流水与账户余额同增,余额恒等式成立;
-// 本口径下 type=108 **不参与**余额恒等式(AC-17 ④ 需排除 108 或按 dump 回改)。
+//	① **独立坏账**(本 SP):`balance += m` **且** `special_expenses += m`,108.money = +m;
+//	   —— 108 上记的 +m **真实对应余额增加**,**参与**"balance 增量 = Σ流水"的归因。
+//	② **退费差额**(`RefundService.CustomRefund` 的 `requested > 可退` 分支):
+//	   只 `special_expenses += m`(余额净减 = refunded),108.money = +badDebt,
+//	   而同一请求的 207.money = **−requested(全额)**——坏账部分已含在 207 里;
+//	   —— 该 108 **不再构成一次独立的余额增加**,**不参与**"108 → balance 增量"的归因
+//	   (否则会与 207 重复计入)。
+//
+//	**判别式(唯一、可机械执行)**:取该 108 流水的 `logid`(= `request_id`,
+//	`newExpenseLog` 对两者写同一值)——**同 logid 存在 type=207 的流水 = 差额型(②);
+//	不存在 = 独立坏账(①)**。
+//	· 两条来源都存在时,"balance = Σ全部流水" 仍恒成立(②里 108 与 207 的代数和恰为净扣减
+//	  −(requested−badDebt) = −refunded),但"逐条 108 是否代表一次余额增加"必须用上面的判别式区分。
+//
+// 幂等:`request_id + type(108)` 唯一键;无 requestId 时不享幂等(dump 同口径)。
 //
 // 入参:ctx / in。出参:*BadDebtResult;错误码 2001/2002/3140/1004/5002。
 func (s *FundService) BadDebt(ctx context.Context, in BadDebtInput) (*BadDebtResult, error) {
@@ -1013,8 +1085,10 @@ func (s *FundService) BadDebt(ctx context.Context, in BadDebtInput) (*BadDebtRes
 		}); e != nil {
 			return e
 		}
+		// dump:两条 UPDATE 都是 `balance + inputMoney, special_expenses + inputMoney` → **双加**
 		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "BadDebt", in.UserID,
 			in.InstitutionID, in.CampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
 			repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: in.Money},
 		); e != nil {
 			return e

+ 273 - 26
internal/academic/service/fund_service_test.go

@@ -27,6 +27,7 @@ import (
 	"yuansiwei/unified-backend/internal/academic/apperr"
 	"yuansiwei/unified-backend/internal/academic/model"
 	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
 	"yuansiwei/unified-backend/internal/academic/repository"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
@@ -160,6 +161,9 @@ func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) {
 	migrateFundTables(t, db,
 		&model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
 		&model.CoursesActivity{}, &model.CoursesActivitySigned{},
+		// t_user_balance_request:SP5 可退额第二步(已用赠送额)要按「充值工单 × 活动报名」汇总,
+		// 缺表会让 SP5 用例退化成"永远查不到赠送额"的假绿。
+		&model.UserBalanceRequest{},
 	)
 	// 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟)
 	if err := db.Exec(`CREATE TABLE t_campus (
@@ -587,44 +591,120 @@ func TestRefundAccountMissing(t *testing.T) {
 	requireAppCode(t, err, 3001)
 }
 
-// ---------------------------------------------------------------- 可退金额(SP5)
+// ---------------------------------------------------------------- 可退金额(SP5,两步修正)
 
-// TestRefundableAmount 账户不存在 → 0;负余额 → 0;正余额 → 余额。
+// seedExpenseAccounts 落全局 + 校区两个账户行(SP5 第一步截断需要两张表同时有值)。
+func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID, campusBalance, globalBalance string) {
+	t.Helper()
+	if err := db.Create(&model.UserExpense{UserID: userID, Balance: globalBalance}).Error; err != nil {
+		t.Fatalf("seed 全局账户(%s) 失败: %v", userID, err)
+	}
+	if err := db.Create(&model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 10,
+		Balance: campusBalance}).Error; err != nil {
+		t.Fatalf("seed 校区账户(%s) 失败: %v", userID, err)
+	}
+}
+
+// seedGiftUsed 落一对「充值工单 + 活动报名」行(SP5 第二步「已用赠送额」的唯一数据来源)。
+//
+// 关键映射(照 dump 改写的依据):工单的 `id`(旧 UUID)→ 目标态 `biz_no`;
+// 报名的 `requestid` → 目标态 `request_no`;JOIN 走 `biz_no = request_no`。
+func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID int64,
+	approved int8, activityMoney, activityMoneyUsed string) {
+	t.Helper()
+	if err := db.Create(&model.UserBalanceRequest{
+		BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
+		InstitutionID: 1, CampusID: 10, Approved: approved,
+	}).Error; err != nil {
+		t.Fatalf("seed 充值工单(%s) 失败: %v", bizNo, err)
+	}
+	reqNo := bizNo
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
+		ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo,
+	}).Error; err != nil {
+		t.Fatalf("seed 活动报名(%s) 失败: %v", bizNo, err)
+	}
+}
+
+// TestRefundableAmount SP5 的全部边界:账户缺失 / 欠费 / 正常全额 /
+// **campus>total 截断** / **赠送已用小余额** / 赠送用尽不扣 / 未审批不扣 / 下限 0 / 3140。
 func TestRefundableAmount(t *testing.T) {
 	db := newFundServiceDB(t)
 	svc := NewFundService(db, fundScope(), nil)
 	ctx := context.Background()
 
-	// 账户不存在
+	// ① 账户不存在 → 0
 	if got, err := svc.RefundableAmount(ctx, fundUserID(11), 1, 10); err != nil || got != 0 {
 		t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, err)
 	}
-	// 负数余额(欠费)
-	if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(12), InstitutionID: 1, CampusID: 10,
-		Balance: "-25.00"}).Error; err != nil {
-		t.Fatalf("seed 欠费账户失败: %v", err)
-	}
+
+	// ② 欠费(校区余额为负)→ 0(总额再大也不可退)
+	seedExpenseAccounts(t, db, fundUserID(12), "-25.00", "100.00")
 	if got, err := svc.RefundableAmount(ctx, fundUserID(12), 1, 10); err != nil || got != 0 {
 		t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, err)
 	}
-	// 正余额
-	if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(13), InstitutionID: 1, CampusID: 10,
-		Balance: "88.50"}).Error; err != nil {
-		t.Fatalf("seed 正余额账户失败: %v", err)
-	}
+
+	// ③ 正常:校区 88.50 ≤ 全局 100.00,且无赠送 → 全额 88.50
+	seedExpenseAccounts(t, db, fundUserID(13), "88.50", "100.00")
 	if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
 		t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
 	}
-	// 校区缺失 → 3140
+
+	// ④ 校区缺失 → 3140
 	if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
 		requireAppCode(t, err, 3140)
 	}
+
+	// ⑤ 全局账户缺失(totalBalance 视为 0)→ 0
+	if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(18), InstitutionID: 1, CampusID: 10,
+		Balance: "50.00"}).Error; err != nil {
+		t.Fatalf("seed 校区账户失败: %v", err)
+	}
+	if got, err := svc.RefundableAmount(ctx, fundUserID(18), 1, 10); err != nil || got != 0 {
+		t.Fatalf("全局账户缺失应返回 0,实得 (%d, %v)", got, err)
+	}
+
+	// ⑥ **第一步修正**:campusBalance > totalBalance → 按 totalBalance 截断(不得回 200)
+	seedExpenseAccounts(t, db, fundUserID(19), "200.00", "120.00")
+	if got, err := svc.RefundableAmount(ctx, fundUserID(19), 1, 10); err != nil || got.Cents() != 12000 {
+		t.Fatalf("campus(200)>total(120) 应截断为 12000 分,实得 (%d, %v)", got, err)
+	}
+
+	// ⑦ **第二步修正**:赠送已用 → 可退额必须**小于**余额(100 − 20 = 80)
+	seedExpenseAccounts(t, db, fundUserID(20), "100.00", "100.00")
+	seedGiftUsed(t, db, fundUserID(20), "BR-GIFT-1", 1, model.BalanceRequestApprovedPassed, "50.00", "20.00")
+	if got, err := svc.RefundableAmount(ctx, fundUserID(20), 1, 10); err != nil || got.Cents() != 8000 {
+		t.Fatalf("赠送已用 20 应扣减(8000 分 < 余额 10000),实得 (%d, %v)", got, err)
+	}
+
+	// ⑧ 赠送**已用尽**(used == activity_money)→ 按 dump 的 `<` 条件不参与统计 → 不扣
+	seedExpenseAccounts(t, db, fundUserID(21), "100.00", "100.00")
+	seedGiftUsed(t, db, fundUserID(21), "BR-GIFT-2", 2, model.BalanceRequestApprovedPassed, "50.00", "50.00")
+	if got, err := svc.RefundableAmount(ctx, fundUserID(21), 1, 10); err != nil || got.Cents() != 10000 {
+		t.Fatalf("赠送用尽行不参与统计(应 10000),实得 (%d, %v)", got, err)
+	}
+
+	// ⑨ 工单**未审批**(approved != 1)→ 不参与统计
+	seedExpenseAccounts(t, db, fundUserID(22), "100.00", "100.00")
+	seedGiftUsed(t, db, fundUserID(22), "BR-GIFT-3", 3, model.BalanceRequestApprovedPending, "50.00", "20.00")
+	if got, err := svc.RefundableAmount(ctx, fundUserID(22), 1, 10); err != nil || got.Cents() != 10000 {
+		t.Fatalf("未审批工单不参与统计(应 10000),实得 (%d, %v)", got, err)
+	}
+
+	// ⑩ 截断后再扣赠送,仍**下限 0**(120 − 150 < 0 → 0)
+	seedExpenseAccounts(t, db, fundUserID(23), "200.00", "120.00")
+	seedGiftUsed(t, db, fundUserID(23), "BR-GIFT-4", 4, model.BalanceRequestApprovedPassed, "200.00", "150.00")
+	if got, err := svc.RefundableAmount(ctx, fundUserID(23), 1, 10); err != nil || got != 0 {
+		t.Fatalf("截断后扣赠送仍为负 → 应返回 0,实得 (%d, %v)", got, err)
+	}
 }
 
-// ---------------------------------------------------------------- 坏账(SP7)
+// ---------------------------------------------------------------- 坏账(SP7,双加)
 
-// TestBadDebtOnlySpecialExpenses 只加 special_expenses(**不动 balance**),108 流水为正。
-func TestBadDebtOnlySpecialExpenses(t *testing.T) {
+// TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
+// **两列同增**(全局 + 校区都是),108 流水为正。
+func TestBadDebtAddsBalanceAndSpecialExpenses(t *testing.T) {
 	db := newFundServiceDB(t)
 	svc := NewFundService(db, fundScope(), nil)
 	ctx := context.Background()
@@ -639,29 +719,196 @@ func TestBadDebtOnlySpecialExpenses(t *testing.T) {
 	if err != nil {
 		t.Fatalf("BadDebt 失败: %v", err)
 	}
+
+	// 校区:balance 100 → 130(双加),special_expenses 0 → 30
 	school := readSchool(t, db, user, 1, 10)
-	if got := cents(t, school.Balance); got != 10000 {
-		t.Fatalf("坏账不应改 balance(应仍 10000 分),实得 %d", got)
+	if got := cents(t, school.Balance); got != 13000 {
+		t.Fatalf("校区 balance 应 10000+3000=13000 分(dump 双加),实得 %d", got)
 	}
 	if got := cents(t, school.SpecialExpenses); got != 3000 {
-		t.Fatalf("special_expenses 应 3000 分,实得 %d", got)
+		t.Fatalf("校区 special_expenses 应 3000 分,实得 %d", got)
 	}
+	// 全局:同样双加
 	global := readGlobal(t, db, user)
-	if got := cents(t, global.SpecialExpenses); got != 3000 {
-		t.Fatalf("全局 special_expenses 应 3000 分(双表同步),实得 %d", got)
+	if got := cents(t, global.Balance); got != 13000 {
+		t.Fatalf("全局 balance 应 13000 分(双表同步双加),实得 %d", got)
 	}
-	if got := cents(t, global.Balance); got != 10000 {
-		t.Fatalf("全局 balance 不应变(应仍 10000 分),实得 %d", got)
+	if got := cents(t, global.SpecialExpenses); got != 3000 {
+		t.Fatalf("全局 special_expenses 应 3000 分,实得 %d", got)
 	}
-	if res.SpecialExpenses.Cents() != 3000 {
-		t.Fatalf("返回 specialExpenses 应 3000 分,实得 %d", res.SpecialExpenses.Cents())
+	// 返回体:余额已是 +Money 之后的值
+	if res.Balance.Cents() != 13000 || res.SpecialExpenses.Cents() != 3000 {
+		t.Fatalf("返回体应 balance=13000/special=3000,实得 %d/%d",
+			res.Balance.Cents(), res.SpecialExpenses.Cents())
 	}
+	// 流水:108 = +3000 一条
 	logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
 	if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
 		t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
 	}
 }
 
+// ---------------------------------------------------------------- 账务恒等式(AC-17 ④ 口径)
+
+// balanceLogs 取某用户全部流水(按 id ASC)。
+func balanceLogs(t *testing.T, db *gorm.DB, userID string) []model.UserExpenseLog {
+	t.Helper()
+	var rows []model.UserExpenseLog
+	if err := db.Where("user_id = ?", userID).Order("id ASC").Find(&rows).Error; err != nil {
+		t.Fatalf("读全部流水(%s) 失败: %v", userID, err)
+	}
+	return rows
+}
+
+// sumLogMoney 流水金额**代数和**(Go 侧定点累加;禁 SQL SUM(decimal) 回 REAL)。
+func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount {
+	t.Helper()
+	total := money.Amount(0)
+	for i := range rows {
+		amount, err := money.Parse(rows[i].Money)
+		if err != nil {
+			t.Fatalf("流水(%d) 金额非法: %v", rows[i].ID, err)
+		}
+		total = total.Add(amount)
+	}
+	return total
+}
+
+// classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
+//
+// 判别式(**唯一、可机械执行**):取 108 流水的 `logid`(= `request_id`)——
+//   - **同 logid 存在 type=207 流水** → 差额型(`CustomRefund` 的退费差额):
+//     该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
+//   - **不存在** → 独立坏账(`FundService.BadDebt`):balance 与 special_expenses 双加,
+//     该 108 **就是**一次余额增加。
+func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
+	independent, differential []model.UserExpenseLog,
+) {
+	t.Helper()
+	all := balanceLogs(t, db, userID)
+	refundKeys := make(map[string]bool)
+	for i := range all {
+		if all[i].Type != model.ExpenseLogTypeRefund || all[i].LogID == nil {
+			continue
+		}
+		refundKeys[*all[i].LogID] = true
+	}
+	for i := range all {
+		if all[i].Type != model.ExpenseLogTypeBadDebt {
+			continue
+		}
+		if all[i].LogID != nil && refundKeys[*all[i].LogID] {
+			differential = append(differential, all[i])
+			continue
+		}
+		independent = append(independent, all[i])
+	}
+	return independent, differential
+}
+
+// TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
+// 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
+func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(24)
+
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-BAL-RECHARGE"}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	before := cents(t, readSchool(t, db, user, 1, 10).Balance)
+	if before != 10000 {
+		t.Fatalf("准备余额应为 10000 分,实得 %d", before)
+	}
+	if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "30.00"), RequestID: "REQ-ID-BAL-BADDEBT"}); err != nil {
+		t.Fatalf("BadDebt 失败: %v", err)
+	}
+	after := cents(t, readSchool(t, db, user, 1, 10).Balance)
+
+	independent, differential := classifyBadDebtLogs(t, db, user)
+	if len(independent) != 1 || len(differential) != 0 {
+		t.Fatalf("独立坏账应 1 条且无差额型,实得 independent=%d differential=%d",
+			len(independent), len(differential))
+	}
+	// ① 108 参与:余额增量 == 108 金额(+3000)
+	if after-before != cents(t, independent[0].Money) {
+		t.Fatalf("独立坏账应使余额 +%d 分,实得增量 %d",
+			cents(t, independent[0].Money), after-before)
+	}
+	// ② 全局口径「balance = Σ全部流水」
+	all := balanceLogs(t, db, user)
+	if sum := sumLogMoney(t, all).Cents(); sum != after {
+		t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
+	}
+}
+
+// TestBalanceIdentityDifferentialBadDebtExcluded 退费差额型(108 有 207 同伴)**不参与**
+// 「108 → 余额增量」的归因:坏账部分已含在 207 的 −requested 全额里,
+// 若把它再算一次独立余额增加,余额会凭空多出 badDebt(正是旧口径/新口径最容易混的一处)。
+func TestBalanceIdentityDifferentialBadDebtExcluded(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(25)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-DIFF-RECHARGE"}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	before := cents(t, readSchool(t, db, user, 1, 10).Balance)
+	// 150 > 可退 100 → refunded 100、badDebt 50
+	if _, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "150.00"), RequestID: "REQ-ID-DIFF-REFUND",
+	}); err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+	after := cents(t, readSchool(t, db, user, 1, 10).Balance)
+
+	independent, differential := classifyBadDebtLogs(t, db, user)
+	if len(differential) != 1 || len(independent) != 0 {
+		t.Fatalf("退费差额应 1 条差额型且无独立坏账,实得 independent=%d differential=%d",
+			len(independent), len(differential))
+	}
+	logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
+	if len(logs207) != 1 {
+		t.Fatalf("207 应 1 条,实得 %d", len(logs207))
+	}
+	// ① 差额型 108 的 logid 必须与 207 相同(判别式的实现载体)
+	if differential[0].LogID == nil || logs207[0].LogID == nil ||
+		*differential[0].LogID != *logs207[0].LogID {
+		t.Fatalf("差额型 108 应与 207 同 logid,实得 108=%+v 207=%+v",
+			differential[0].LogID, logs207[0].LogID)
+	}
+	// ② 不参与:光看 207 就已经把这次余额变动记完了
+	//     before + 207 = after + badDebt > after → 差额恰是 badDebt,且必须靠 108 补平;
+	//     但把该 108 再当成「一次独立余额增加」就会重复计入 → 合计比真实余额多一个 badDebt。
+	onlyRefund := before + cents(t, logs207[0].Money)
+	if onlyRefund == after {
+		t.Fatalf("207 单独记账不应等于余额变化(差额型 108 必须存在):before=%d 207=%d after=%d",
+			before, cents(t, logs207[0].Money), after)
+	}
+	pair := onlyRefund + cents(t, differential[0].Money)
+	if pair != after {
+		t.Fatalf("差额型 108 与 207 的代数和应等于净扣减:before(%d)+207(%d)+108(%d)=%d,after=%d",
+			before, cents(t, logs207[0].Money), cents(t, differential[0].Money), pair, after)
+	}
+	if pair+cents(t, differential[0].Money) == after {
+		t.Fatalf("差额型 108 不得再算一次独立的余额增加(重复计入会凭空多出 %d 分)",
+			cents(t, differential[0].Money))
+	}
+	// ③ 但「balance = Σ全部流水」整体仍成立(108 与 207 成对出现,代数和 = 净扣减)
+	all := balanceLogs(t, db, user)
+	if sum := sumLogMoney(t, all).Cents(); sum != after {
+		t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
+	}
+}
+
 // ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
 
 // TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。

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

@@ -116,7 +116,7 @@ type CustomRefundResult struct {
 	Requested          money.Amount `json:"requested"`          // 请求退费金额(回显输入;>0)
 	Refunded           money.Amount `json:"refunded"`           // 实际退出资金净额 = requested - badDebt(≥0)
 	BadDebt            money.Amount `json:"badDebt"`            // 差额坏账 = max(requested-可退, 0);0 = 无坏账
-	RefundableAmount   money.Amount `json:"refundableAmount"`   // 退费前可退金额快照 = max(校区 balance, 0)
+	RefundableAmount   money.Amount `json:"refundableAmount"`   // 退费前可退金额快照 = max(校区 balance, 0)(本流程扣减基数;**不含**赠送额扣减,与 SP5 查询口径不同,见 FundService.RefundableAmount 注释)
 	Balance            money.Amount `json:"balance"`            // 退费后校区账户余额
 	TotalBalance       money.Amount `json:"totalBalance"`       // 退费后全局账户(跨校区)余额快照
 	TotalIncome        money.Amount `json:"totalIncome"`        // 退费后校区账户 total_income 快照
@@ -328,6 +328,14 @@ func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput)
 		}
 
 		// ⑥ 双 log:**先 108(+badDebt)再 207(−requested 全额)**
+		//
+		// ★ 兼容注释:type=108 有两种来源,此处写的是**第二种**(退费差额型)——
+		//   - 差额型(本方法):只累加 special_expenses(见 ⑤),balance 净减 = refunded;
+		//     108.money = +badDebt,207.money = **−requested 全额**,坏账部分已含在 207 内,
+		//     故该 108 **不构成**一次独立的余额增加(判据:同 logid 存在 207);
+		//   - 独立坏账(`FundService.BadDebt`,SP7):balance 与 special_expenses **双加**,
+		//     108.money = +m 真实对应余额增加(判据:同 logid 无 207)。
+		//   对账口径详见 `FundService.BadDebt` 方法注释;AC-17 ④ 的恒等式判别按此落地。
 		var logID108 *int64
 		if badDebt > 0 {
 			lg108 := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, badDebt, in.RequestID,