Pārlūkot izejas kodu

feat(academic): AC-04 余额核心服务 8 SP + AC-05 退费/清零(双 log + 活动失效)

AC-04(service/fund_service.go,8 个余额 SP):
- Recharge(SP1/SP2 同构合并,8 参 + 双表同事务 + 幂等 repeated=true)
- Expenditure(SP3,type 白名单 101/104/105/200/201/204/205;200 分支含 spend+)
- Refund(SP4,**补 institution_id** 修 BUG#2;money<=0 → changed=false)
- RefundableAmount(SP5,只读,max(校区余额,0),账户缺失 0)
- Transfer(SP6,★dump 口径:**不动 balance**、106=+m / 206=−m、目标账户建户)
- BadDebt(SP7,只加 special_expenses)、AccumulateSpend(SP11,只改 spend,修 BUG#1 列数)

AC-05(service/refund_service.go + activity_expiry.go):
- CustomRefund(SP9):FOR UPDATE 锁双账户且**顺序固定 全局→校区**;
  requested>balance → badDebt=requested−balance、refunded=balance;**先 108(+badDebt) 后 207(−requested 全额)**;
  净扣减 = refunded;活动失效按 t_campus.self_support 分流(自营 IN(campus,0) / 加盟 =campus);
  返回 §4.6 逐字对齐的 CustomRefundResult(含 warnings[3103])
- ClearBalance(SP10):三条前置失败 → 3120;★全局表扣减基数 = campusBalance(漂移项)
- CancelRechargeActivity(SP8):跨域读 t_campus.institution_id(失败 3104)→ 余额/总充值减 + 报名 status=0 + 209
- ActivityExpirer:SP9/SP10 共用的失效器(同事务,自营/加盟唯一分流点)

repository 增量(AC-03 缺口补齐,均为"一条表一条 UPDATE"):
- AddColumnsGlobalThenCampus / AddCampusColumns(多列原子增减,全局→校区固定序)
- GetCampusForUpdateExact / GetGlobalAccount / GetCampusAccount / FindLogByRequest
- ActivityRepo.ListExpirableSignedActivityIDs / SetSignedStatusByActivityIDs
- infra/errors:新增 ErrBadFormat(2002),与 ErrBadParam(2001 缺参) 分工(设计 §10.1)

测试(新增 40 个用例,service+repository 全绿 156 PASS):
- 双 log **恒等式**断言:requested = refunded + badDebt 且 before + 207 + 108 = after(净减 = refunded)
- 顺序断言:108.id < 207.id;全局锁 SQL 早于校区锁 SQL;转结双校区按 campus_id ASC 加锁
- 20 并发同 requestId:1 条 207 + 1 条 108(充值 1 条 100),余额只变一次
- 失效范围反向断言:自营含 campus_id=0、加盟不含;type=3/他机构/他校区/已过期一律不动
- 失败零残留:活动失效步骤注入报错 → 余额/流水/special_expenses 全部回滚
- 金额越界:AC-04/AC-05 各 1 个写库前 ErrOverflow 负例(Value()/Validate() 兜底)
- 已做**变异验证**:反转锁序 / 强制自营分支 / 按 requested 扣减 → 三处用例分别变红

偏差(已在代码注释与报告中登记):
- BadDebt 按设计 §3.1 #7「只加 special_expenses」实现;dump 实为「balance 与 special_expenses 同加」,
  若回改需补 1 个列增量(type=108 与余额恒等式的关系已上报)
- RefundableAmount 按设计 §3.1 #5 收敛为 max(balance,0);dump 还扣减"已使用赠送金额"(P2 专题)
- 金额<=0 由 Go 显式 2002(dump 静默归零并留下 0 元流水占幂等键)
Q 20 stundas atpakaļ
vecāks
revīzija
e14f6138fc

+ 125 - 0
internal/academic/repository/activity_repo.go

@@ -436,6 +436,131 @@ func (r *ActivityRepo) CancelSigned(ctx context.Context, tx *gorm.DB, userID str
 	return res.RowsAffected, nil
 }
 
+// ListExpirableSignedActivityIDs 列出「退费/清零需要失效」的充值活动 ID(SP9/SP10 同源口径)。
+//
+// 依据:dump `sp_user_expense_custom_refund`(yuansiwei_finance.sql:764062)与
+// `sp_user_expense_school_cleared_balance`(:764338)**逐字相同**的两段临时表填充 SQL,
+// 只差最后一行的校区过滤——这正是"自营 / 加盟"分流的唯一区别(设计 §7.2 ④ / §11-2)。
+//
+// dump 原文(自营分支,`IF(campusSelfSupport)`):
+//
+//	SELECT t1.activityid FROM t_courses_activity_signed AS t1
+//	  LEFT JOIN t_courses_activity AS t2 ON t1.activityid = t2.id
+//	 WHERE t1.userid = ?          -- 报名用户
+//	   AND t2.type = 2            -- **只失效充值赠送活动**(代金券/老带新不动)
+//	   AND t1.status != 4         -- 已过期的不重复处理
+//	   AND t2.schoolid = ?        -- 机构(旧口径 schoolid → institution_id)
+//	   AND t2.campusid IN (?, 0)  -- 自营:本校区 + 全机构池(campus_id=0)
+//	-- 加盟分支:AND t2.campusid = ?   (不含 0=全机构池)
+//
+// 入参:
+//   - ctx 请求上下文(可携带事务,实现内部经 db(ctx) 复用同一 tx);
+//   - userID 报名用户(ULID;空 → ErrBadParam);
+//   - institutionID 机构 ID(**必填 >0**;与 scope 机构不一致 → ErrUnscoped(1004));
+//   - campusID 校区 ID(**必填 >0**;dump 的 `campusid<=0` 无意义);
+//   - selfSupport true=自营(`IN (campus,0)`)/ false=加盟(`= campus`)。
+//
+// 出参:
+//   - []int64:活动 ID(**去重 + 升序**,空 → 长度 0 的非 nil 切片);
+//   - ErrBadParam / ErrUnscoped / ErrDBError。
+//
+// 红线:
+//   - **不得**把 `t2.campusid` 的过滤写成 signed 上的 `campus_id`:活动 `campus_id=0`
+//     的语义是"机构所有自营校区",用 signed 的冗余列会漏掉这一类活动;
+//   - 机构过滤必须落在 `activity.institution_id`(dump 的 `t2.schoolid`),不可省略。
+func (r *ActivityRepo) ListExpirableSignedActivityIDs(
+	ctx context.Context, userID string, institutionID, campusID int64, selfSupport bool,
+) ([]int64, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListExpirableSignedActivityIDs 需要 user_id"))
+	}
+	if institutionID <= 0 || campusID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListExpirableSignedActivityIDs 需要 institution_id>0 且 campus_id>0"))
+	}
+	// 机构越权防线(dump 无此校验;目标态多租户红线,与 scopedCampusRows 同口径)
+	if r == nil || r.Sc == nil {
+		panic(errNoScope.Error())
+	}
+	if !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
+		return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("repository: 活动失效的机构与请求作用域不一致"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := db.Table("t_courses_activity_signed AS signed").
+		Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
+		Where("signed.user_id = ?", userID).
+		Where("activity.type = ?", model.ActivityTypeRecharge).
+		Where("signed.status <> ?", model.ActivitySignedExpired).
+		Where("activity.institution_id = ?", institutionID)
+	if selfSupport {
+		// 自营:本校区 + 全机构池(campus_id=0)
+		q = q.Where("activity.campus_id IN ?", []int64{campusID, 0})
+	} else {
+		// 加盟:只能动本校区自己的活动(不含 0=全机构池)
+		q = q.Where("activity.campus_id = ?", campusID)
+	}
+	ids := make([]int64, 0, 8)
+	if err := q.Distinct().Order("signed.activity_id ASC").Pluck("signed.activity_id", &ids).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	if ids == nil {
+		ids = make([]int64, 0)
+	}
+	return ids, nil
+}
+
+// SetSignedStatusByActivityIDs 批量置报名状态(活动失效 status=4 / 取消充值活动 status=0)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**:与资金增减同事务,否则出现"钱退了、活动还能用");
+//   - userID 报名用户(ULID;空 → ErrBadParam);
+//   - activityIDs 活动 ID 集合(空 → no-op 返回 0,不发 SQL);
+//   - status 目标状态(model.ActivitySigned*)。
+//
+// 出参:(受影响行数, error);ErrBadParam / ErrInternal / ErrDBError。
+//
+// SQL(dump 原文,**不带 status 过滤、不带作用域过滤**):
+//
+//	UPDATE t_courses_activity_signed SET status = ? WHERE user_id = ? AND activity_id IN (...)
+//
+// 为什么不带作用域过滤:候选集合已由 `ListExpirableSignedActivityIDs` 按
+// `activity.institution_id + activity.campus_id` 精确筛出(比 signed 的冗余列更准),
+// 再叠一层 signed 的 campus_id 等值会把 `campus_id=0` 的活动报名行漏掉。
+// 机构越权风险为 0:入参 activityIDs 只能来自上面的受控查询。
+func (r *ActivityRepo) SetSignedStatusByActivityIDs(
+	ctx context.Context, tx *gorm.DB, userID string, activityIDs []int64, status int32,
+) (int64, error) {
+	if err := requireTx(tx, "SetSignedStatusByActivityIDs"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: SetSignedStatusByActivityIDs 需要 user_id"))
+	}
+	clean := make([]int64, 0, len(activityIDs))
+	for _, id := range activityIDs {
+		if id > 0 {
+			clean = append(clean, id)
+		}
+	}
+	if len(clean) == 0 {
+		return 0, nil
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
+		Where("user_id = ? AND activity_id IN ?", userID, clean).
+		UpdateColumn("status", status)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
 // wrapQueryErr 把查询错误归一为 academic 错误契约(不存在 → ErrNotFound,其余 → ErrDBError)。
 //
 // 入参:err 原生错误;op 操作名(仅用于日志关联,不进入响应体)。

+ 341 - 0
internal/academic/repository/expense_repo.go

@@ -25,6 +25,7 @@ package repository
 
 import (
 	"context"
+	"errors"
 	"strings"
 	"time"
 
@@ -65,6 +66,93 @@ const (
 	expenseColTotalExpenditure = "total_expenditure"
 )
 
+// 列名常量的**导出别名**(AC-04/AC-05 的 Service 层构造 `ColumnDelta` 时使用)。
+//
+// 为什么导出:批量增减 primitive(AddColumnsGlobalThenCampus / AddCampusColumns)需要调用方
+// 声明"改哪几个列",而列名必须来自白名单(禁止字符串透传,防列名注入)。
+// 白名单校验仍由 `isExpenseColumn` 单点收口,导出别名只是让 service 不必复制字面量。
+const (
+	// ExpenseColumnBalance 余额列。
+	ExpenseColumnBalance = expenseColBalance
+	// ExpenseColumnSpend 上课消费课费单价之和列(只增)。
+	ExpenseColumnSpend = expenseColSpend
+	// ExpenseColumnSpecialExpenses 特殊费用列(坏账/清零累加)。
+	ExpenseColumnSpecialExpenses = expenseColSpecialExpenses
+	// ExpenseColumnTotalIncome 总充值列。
+	ExpenseColumnTotalIncome = expenseColTotalIncome
+	// ExpenseColumnTotalExpenditure 总消费列。
+	ExpenseColumnTotalExpenditure = expenseColTotalExpenditure
+)
+
+// ColumnDelta 单列增量(原子表达式 `col = col + ?` 的目标列 + 增量)。
+//
+// 用途:一次 UPDATE 同时改多列(dump 的 SP 每条 UPDATE 都同时改 2~3 列),
+// 避免"多列分多条语句"产生的中间态(对账/双跑会读到半成品)。
+//
+// 字段:
+//   - Column string:列名(**必须**取自 ExpenseColumn* 常量,白名单校验);
+//   - Delta money.Amount:增量(分;正=加,负=减,0=该列不参与本次更新)。
+type ColumnDelta struct {
+	// Column 目标列名(ExpenseColumn* 常量)。
+	Column string
+	// Delta 增量(分)。
+	Delta money.Amount
+}
+
+// buildColumnExprs 把 ColumnDelta 列表编译成 GORM 的"列 → 原子表达式"映射。
+//
+// 入参:op 操作名(错误文案定位);deltas 列增量列表(空 → ErrInternal,属装配错误)。
+// 出参:
+//   - map[string]any:可直接交给 `UpdateColumns` 的更新映射(值为 gorm.Expr);
+//     所有 Delta 均为 0 时返回 **空 map**(调用方据此判定 no-op);
+//   - ErrBadParam(非法列名/金额越界)/ ErrInternal。
+//
+// 红线:金额以 decimal **字符串字面量**入场(`Amount.String()`),全程不经 float64(仓库包注释)。
+func buildColumnExprs(op string, deltas []ColumnDelta) (map[string]any, error) {
+	if len(deltas) == 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("repository: "+op+" 未指定任何列(属装配错误)"))
+	}
+	updates := make(map[string]any, len(deltas))
+	for _, d := range deltas {
+		if !isExpenseColumn(d.Column) {
+			return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+				errString("repository: "+op+" 非法列名 "+d.Column))
+		}
+		if d.Delta.IsZero() {
+			continue
+		}
+		lit, err := amountLiteral(d.Delta)
+		if err != nil {
+			return nil, err
+		}
+		updates[d.Column] = gorm.Expr(d.Column+" + ?", lit)
+	}
+	return updates, nil
+}
+
+// institutionScoped 只叠「机构」作用域(**故意不叠 `Sc.CampusID` 等值过滤**)。
+//
+// 为什么不能用 Scoped:多校区写操作(课费转结 = 两个校区一进一出)一旦叠加
+// `campus_id = Sc.CampusID`,第二个校区必然查不到 → 转结永远失败;
+// 而 `t_courses_activity` 系列还存在 `campus_id = 0`(全机构池)语义,等值过滤同样会漏行。
+//
+// 安全边界(不放松):机构过滤仍强制(超管除外),且**调用方必须显式传 campus_id**,
+// 二者一起把写入范围钉死在「本机构的指定校区」(比 Scoped 的等值更精确,不是更宽)。
+func (r *ExpenseRepo) institutionScoped(db *gorm.DB) *gorm.DB {
+	if db == nil {
+		return nil
+	}
+	if r == nil || r.Sc == nil {
+		// 纵深防御:无作用域上下文禁止进入数据访问层(与 BaseRepo.Scoped 同口径 fail-fast)
+		panic(errNoScope.Error())
+	}
+	if r.Sc.SuperAdmin {
+		return db
+	}
+	return db.Where("institution_id = ?", r.Sc.InstitutionID)
+}
+
 // ExpenseRepo 余额账户仓储(全局钱包 + 校区账户 + 流水)。
 //
 // 继承 BaseRepo 的目的:**校区账户**(t_user_expense_school)强制机构/校区作用域;
@@ -485,6 +573,259 @@ func (r *ExpenseRepo) InsertLog(ctx context.Context, tx *gorm.DB, log *model.Use
 	return nil
 }
 
+// AddColumnsGlobalThenCampus 多列原子增减(**一张表一条 UPDATE**,固定顺序「全局 → 校区」)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**:两张账户表必须同事务,否则出现"全局扣了、校区没扣");
+//   - op 操作名(错误文案定位,如 "Recharge");
+//   - userID 用户(ULID;空 → ErrBadParam);
+//   - institutionID 机构 ID(**必填 >0**:校区账户必须按机构精确过滤,防跨机构同 campus_id 误更新
+//     ——设计 §3.1 #4 BUG#2 的同类缺陷,本方法把机构过滤做成硬参数);
+//   - campusID 校区 ID(>0 时同时更新校区账户;==0 只更新全局钱包);
+//   - deltas 列增量(如 `{balance: -refunded}, {total_income: -refunded}`)。
+//
+// 出参:
+//   - int64:全局账户受影响行数(>0 正常;0 且无错 = 全部增量为 0 的 no-op);
+//   - ErrAccountNotFound(账户未建户,调用顺序错误)/ ErrBadParam / ErrInternal / ErrDBError。
+//
+// SQL 形状(**必须保持"表达式 + 参数"**,禁读-改-写):
+//
+//	UPDATE t_user_expense        SET balance = balance + ?, total_income = total_income + ?
+//	 WHERE user_id = ?
+//	UPDATE t_user_expense_school SET balance = balance + ?, total_income = total_income + ?
+//	 WHERE user_id = ? AND institution_id = ? AND campus_id = ?
+//
+// 为什么两列合成一条 UPDATE:dump 的 SP 每条 UPDATE 都同时改 2~3 列,
+// 拆成多条会让"余额已减、总充值未减"的中间态被并发对账/双跑读到。
+func (r *ExpenseRepo) AddColumnsGlobalThenCampus(
+	ctx context.Context, tx *gorm.DB, op, userID string,
+	institutionID, campusID int64, deltas ...ColumnDelta,
+) (int64, error) {
+	if err := requireTx(tx, op); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id"))
+	}
+	if institutionID <= 0 || campusID < 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: "+op+" 需要 institution_id>0 且 campus_id>=0"))
+	}
+	updates, err := buildColumnExprs(op, deltas)
+	if err != nil {
+		return 0, err
+	}
+	if len(updates) == 0 {
+		return 0, nil
+	}
+	db := tx.WithContext(ctxOrBackground(ctx))
+
+	// ① 全局钱包(固定先全局,后校区:并发时锁顺序一致,避免死循环等待)
+	global := db.Model(&model.UserExpense{}).
+		Where("user_id = ?", userID).
+		UpdateColumns(updates)
+	if global.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, global.Error)
+	}
+	if global.RowsAffected == 0 {
+		return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 全局账户未建户"))
+	}
+
+	// ② 校区账户(campusID>0 才写;机构显式过滤,不叠 Sc.CampusID 等值——见 institutionScoped)
+	if campusID > 0 {
+		campus := r.institutionScoped(db.Model(&model.UserExpenseSchool{})).
+			Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
+			UpdateColumns(updates)
+		if campus.Error != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError, campus.Error)
+		}
+		if campus.RowsAffected == 0 {
+			return 0, infraerrors.WithCause(ErrAccountNotFound,
+				errString("repository: "+op+" 校区账户未建户(user_id/institution_id/campus_id 不匹配)"))
+		}
+	}
+	return global.RowsAffected, nil
+}
+
+// AddCampusColumns **只**更新校区账户的多列原子增减(不动全局钱包)。
+//
+// 使用场景(唯一):`sp_user_expense_school_transfer_expenditure` 课费转结——
+// dump 明确**不动 balance**,只在两个校区账户上调整 `total_expenditure` / `total_income`
+// (转出方各减、转入方各加);全局钱包不参与,故不能用 AddColumnsGlobalThenCampus。
+//
+// 入参/出参:同 AddColumnsGlobalThenCampus(institutionID 必填 >0,campusID 必填 >0)。
+//
+// SQL:`UPDATE t_user_expense_school SET <cols> WHERE user_id=? AND institution_id=? AND campus_id=?`
+func (r *ExpenseRepo) AddCampusColumns(
+	ctx context.Context, tx *gorm.DB, op, userID string,
+	institutionID, campusID int64, deltas ...ColumnDelta,
+) (int64, error) {
+	if err := requireTx(tx, op); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id"))
+	}
+	if institutionID <= 0 || campusID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: "+op+" 需要 institution_id>0 且 campus_id>0"))
+	}
+	updates, err := buildColumnExprs(op, deltas)
+	if err != nil {
+		return 0, err
+	}
+	if len(updates) == 0 {
+		return 0, nil
+	}
+	db := tx.WithContext(ctxOrBackground(ctx))
+	res := r.institutionScoped(db.Model(&model.UserExpenseSchool{})).
+		Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
+		UpdateColumns(updates)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	if res.RowsAffected == 0 {
+		return 0, infraerrors.WithCause(ErrAccountNotFound,
+			errString("repository: "+op+" 校区账户未建户(user_id/institution_id/campus_id 不匹配)"))
+	}
+	return res.RowsAffected, nil
+}
+
+// GetGlobalAccount 读全局钱包账户整行(**无行锁**,只用于写后快照/结果回读)。
+//
+// 入参:ctx / tx(可 nil:nil 时用连接池或 ctx 内事务)/ userID(空 → ErrBadParam)。
+// 出参:*model.UserExpense(金额为 decimal 字符串原文)+ ErrNotFound / ErrBadParam / ErrDBError。
+//
+// 红线:**不得**用本方法的余额做"够不够扣"的判断(读-改-写竞态),
+// 资金判定必须用 GetForUpdate(加锁读)。
+func (r *ExpenseRepo) GetGlobalAccount(ctx context.Context, tx *gorm.DB, userID string) (*model.UserExpense, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetGlobalAccount 需要 user_id"))
+	}
+	db, err := r.queryDB(ctx, tx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.UserExpense
+	if err := db.Model(&model.UserExpense{}).Where("user_id = ?", userID).Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetGlobalAccount(t_user_expense)")
+	}
+	return &out, nil
+}
+
+// GetCampusAccount 读校区账户整行(**无行锁**,写后快照/结果回读用)。
+//
+// 入参:ctx / tx(可 nil)/ userID / institutionID(必填 >0)/ campusID(>=0;0=机构级账户)。
+// 出参:*model.UserExpenseSchool + ErrNotFound / ErrBadParam / ErrDBError。
+//
+// 作用域:机构过滤强制(institutionScoped,超管除外);**不叠 Sc.CampusID 等值**,
+// 因为本方法用于"结果快照",入参已精确指定机构 + 校区(比叠加更严格,见 institutionScoped)。
+func (r *ExpenseRepo) GetCampusAccount(
+	ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
+) (*model.UserExpenseSchool, error) {
+	if strings.TrimSpace(userID) == "" || institutionID <= 0 || campusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetCampusAccount 需要 user_id 且 institution_id>0 且 campus_id>=0"))
+	}
+	db, err := r.queryDB(ctx, tx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.UserExpenseSchool
+	q := r.institutionScoped(db.Model(&model.UserExpenseSchool{}))
+	if err := q.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
+		Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetCampusAccount(t_user_expense_school)")
+	}
+	return &out, nil
+}
+
+// GetCampusForUpdateExact 取指定机构+校区账户并加行锁(**跨校区操作专用**)。
+//
+// 与 GetCampusForUpdate 的唯一区别:**不叠 `Sc.CampusID` 等值过滤**(只叠机构)。
+//
+// 为什么需要它:课费转结必然同时访问两个校区账户(源/目标)。若用 GetCampusForUpdate,
+// 操作者作用域里的 `Sc.CampusID` 会把另一个校区的行过滤掉 → 转结永远失败;
+// 而"转结必须在两个校区之间发生"是业务前提,不能用等值过滤把它排除。
+//
+// 安全边界:机构过滤强制 + 入参显式 campus_id 精确命中(跨机构转结需超管作用域)。
+//
+// 出参:*model.UserExpenseSchool + ErrNotFound / ErrBadParam / ErrDBError。
+func (r *ExpenseRepo) GetCampusForUpdateExact(
+	ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
+) (*model.UserExpenseSchool, error) {
+	if err := requireTx(tx, "GetCampusForUpdateExact"); err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(userID) == "" || institutionID <= 0 || campusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetCampusForUpdateExact 需要 user_id 且 institution_id>0 且 campus_id>=0"))
+	}
+	db := tx.WithContext(ctxOrBackground(ctx))
+	var out model.UserExpenseSchool
+	q := r.institutionScoped(db.Model(&model.UserExpenseSchool{}))
+	err := q.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
+		Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
+		Take(&out).Error
+	if err != nil {
+		return nil, wrapQueryErr(err, "GetCampusForUpdateExact(t_user_expense_school)")
+	}
+	return &out, nil
+}
+
+// queryDB 选择查询句柄(tx 优先 → ctx 内事务 → 连接池)。
+//
+// 入参:ctx 请求上下文(可 nil);tx 事务句柄(可 nil)。
+// 出参:(*gorm.DB, error);未装配 DB → ErrInternal。
+func (r *ExpenseRepo) queryDB(ctx context.Context, tx *gorm.DB) (*gorm.DB, error) {
+	if tx != nil {
+		return tx.WithContext(ctxOrBackground(ctx)), nil
+	}
+	return r.db(ctx)
+}
+
+// FindLogByRequest 按幂等键 `(request_id, type)` 查既有流水(幂等预检/命中回放)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**可 nil**:读方法,nil 时走 ctx 内事务或连接池);
+//   - userID 用户(ULID;空 → ErrBadParam);
+//   - requestID 幂等请求号(**空串 → 返回 (nil, nil),表示该请求不享幂等**,设计 §1.5「缺省」);
+//   - logType 流水类型(与 request_id 组成唯一键 uk_request_type)。
+//
+// 出参:
+//   - *model.UserExpenseLog:命中行;未命中返回 **(nil, nil)**(不是 ErrNotFound——预检语义是"有没有");
+//   - ErrBadParam / ErrDBError。
+//
+// 唯一键口径:`uk_request_type(request_id, type)`;MySQL 唯一键对 NULL 放行,
+// 因此历史行(request_id IS NULL)永不命中本方法(符合设计 §1.5:NULL 不享幂等)。
+func (r *ExpenseRepo) FindLogByRequest(
+	ctx context.Context, tx *gorm.DB, userID, requestID string, logType int32,
+) (*model.UserExpenseLog, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindLogByRequest 需要 user_id"))
+	}
+	if strings.TrimSpace(requestID) == "" {
+		return nil, nil
+	}
+	db, err := r.queryDB(ctx, tx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.UserExpenseLog
+	takeErr := db.Model(&model.UserExpenseLog{}).
+		Where("user_id = ? AND request_id = ? AND type = ?", userID, requestID, logType).
+		Take(&out).Error
+	if takeErr != nil {
+		if errors.Is(takeErr, gorm.ErrRecordNotFound) {
+			return nil, nil
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, takeErr)
+	}
+	return &out, nil
+}
+
 // ListLogs 分页查流水(按创建时间倒序,时间窗可空)。
 //
 // 入参:

+ 228 - 0
internal/academic/repository/expense_repo_columns_test.go

@@ -0,0 +1,228 @@
+// ExpenseRepo 新增原语单测(AC-04/AC-05 依赖):多列原子增减 / 精确机构+校区定位 /
+// 幂等键流水查询。
+//
+// 测试栈(与 AC-03 既有 expense_repo_test.go 同一套手法,助手直接复用):
+//   - **SQL 形状(UPDATE)**:真实 sqlite 库 + SQL 抓取器 —— `UpdateColumns` 在 DryRun 下
+//     仍会走 ConnPool,故 UPDATE 形状必须在**真实可执行的库**上抓(查询类才用 MySQL DryRun);
+//   - **SQL 形状(查询)**:MySQL 方言 DryRun(sqlite 不渲染行锁 clause,只有 MySQL 方言能证明
+//     `FOR UPDATE` 真进了 SQL);
+//   - **行为**:sqlite 文件库(原子表达式结果 / 账户缺失 / 列名白名单 / 幂等查询命中语义)。
+package repository
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"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"
+)
+
+// TestAddColumnsGlobalThenCampusSQL 多列增减必须**每表一条** UPDATE,且全局在前、校区带机构过滤。
+func TestAddColumnsGlobalThenCampusSQL(t *testing.T) {
+	db, capture := newExpenseRepoDBWithCapture(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
+	ctx := context.Background()
+	user := "01HUSER000000000000000901"
+
+	// 先建户(否则原子语句返回 0 行 → ErrAccountNotFound)
+	if err := db.Create(&model.UserExpense{UserID: user}).Error; err != nil {
+		t.Fatalf("seed 全局失败: %v", err)
+	}
+	if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2}).Error; err != nil {
+		t.Fatalf("seed 校区失败: %v", err)
+	}
+
+	capture.Reset()
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "TestSQL", user, 1, 2,
+			ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(-1050)},
+			ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(-1050)},
+		)
+		return e
+	}); err != nil {
+		t.Fatalf("多列增减失败: %v", err)
+	}
+	stmts := capture.All()
+	if len(stmts) != 2 {
+		t.Fatalf("应只产生 2 条 UPDATE(全局 + 校区),实得 %d 条:%v", len(stmts), stmts)
+	}
+	first := compactSQL(stmts[0])
+	second := compactSQL(stmts[1])
+	if !strings.Contains(first, "UPDATE`t_user_expense`SET") {
+		t.Fatalf("第 1 条必须是全局钱包 UPDATE(顺序=死锁防线),实得:%s", first)
+	}
+	if strings.Contains(first, "SELECT") || strings.Contains(second, "SELECT") {
+		t.Fatalf("多列增减不得退化为读-改-写(禁止 SELECT):%v", stmts)
+	}
+	// 两列必须合并在同一条 UPDATE(拆开会产生"余额已减、总充值未减"的中间态)
+	// 断言 `=balance+` / `=total_income+`(跳过方言的标识符引号差异:sqlite 用反引号、MySQL 也是)
+	if !strings.Contains(first, "=balance+") || !strings.Contains(first, "=total_income+") {
+		t.Fatalf("全局 UPDATE 必须用原子表达式并合并两列:%s", first)
+	}
+	if !strings.Contains(second, "UPDATE`t_user_expense_school`SET") {
+		t.Fatalf("第 2 条必须是校区账户 UPDATE,实得:%s", second)
+	}
+	if !strings.Contains(second, "institution_id=1") || !strings.Contains(second, "campus_id=2") {
+		t.Fatalf("校区 UPDATE 必须按机构+校区精确定位(BUG#2 同类缺陷):%s", second)
+	}
+}
+
+// TestAddCampusColumnsNeverTouchesGlobal 转结专用原语:只动校区表,全局表不出现。
+func TestAddCampusColumnsNeverTouchesGlobal(t *testing.T) {
+	db, capture := newExpenseRepoDBWithCapture(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
+	user := "01HUSER000000000000000902"
+	if err := db.Create(&model.UserExpense{UserID: user}).Error; err != nil {
+		t.Fatalf("seed 全局失败: %v", err)
+	}
+	if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2}).Error; err != nil {
+		t.Fatalf("seed 校区失败: %v", err)
+	}
+
+	capture.Reset()
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := repo.AddCampusColumns(context.Background(), tx, "TestCampusOnly", user, 1, 2,
+			ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(500)})
+		return e
+	}); err != nil {
+		t.Fatalf("校区单表增减失败: %v", err)
+	}
+	stmts := capture.All()
+	if len(stmts) != 1 {
+		t.Fatalf("应只产生 1 条 UPDATE,实得 %d 条:%v", len(stmts), stmts)
+	}
+	if strings.Contains(compactSQL(stmts[0]), "UPDATE`t_user_expense`SET") {
+		t.Fatalf("转结原语不得更新全局钱包(dump:余额不动、全局不参与):%s", stmts[0])
+	}
+}
+
+// TestGetCampusForUpdateExactSQL MySQL 方言必须渲染 FOR UPDATE,且**不叠** Sc.CampusID 等值过滤。
+func TestGetCampusForUpdateExactSQL(t *testing.T) {
+	mdb, capture := newMySQLDryRunDB(t)
+	// 作用域校区=99(故意与入参校区=2 不同):证明跨校区操作不被 Sc.CampusID 卡死
+	repo := NewExpenseRepo(mdb, &appscope.Scope{InstitutionID: 1, CampusID: 99})
+
+	capture.Reset()
+	_, _ = repo.GetCampusForUpdateExact(context.Background(), mdb, "01HUSER000000000000000903", 1, 2)
+	raw := capture.Last()
+	sql := compactSQL(raw)
+	if !strings.Contains(sql, "FORUPDATE") {
+		t.Fatalf("精确校区加锁必须渲染 FOR UPDATE:%q", sql)
+	}
+	if !strings.Contains(sql, "institution_id=1") || !strings.Contains(sql, "campus_id=2") {
+		t.Fatalf("必须按机构 + 入参校区精确定位:%q", sql)
+	}
+	if strings.Contains(sql, "campus_id=99") {
+		t.Fatalf("不得叠加作用域校区等值过滤(跨校区转结会被卡死):%q", sql)
+	}
+}
+
+// TestAddColumnsGlobalThenCampusBehaviour sqlite 行为:两表同步增减 + 账户缺失报错 + 全零 no-op + 列名白名单。
+func TestAddColumnsGlobalThenCampusBehaviour(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
+	ctx := context.Background()
+	user := "01HUSER000000000000000904"
+
+	if err := db.Create(&model.UserExpense{UserID: user, Balance: "100.00", TotalIncome: "100.00"}).Error; err != nil {
+		t.Fatalf("seed 全局失败: %v", err)
+	}
+	if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 2,
+		Balance: "100.00", TotalIncome: "100.00"}).Error; err != nil {
+		t.Fatalf("seed 校区失败: %v", err)
+	}
+
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "Behaviour", user, 1, 2,
+			ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(-2500)},
+			ColumnDelta{Column: ExpenseColumnTotalIncome, Delta: money.Amount(-2500)},
+			ColumnDelta{Column: ExpenseColumnSpend, Delta: 0}, // 零增量列必须被跳过
+		)
+		return e
+	}); err != nil {
+		t.Fatalf("多列增减失败: %v", err)
+	}
+
+	global, err := repo.GetGlobalAccount(ctx, nil, user)
+	if err != nil {
+		t.Fatalf("读全局账户失败: %v", err)
+	}
+	if parseAmount(t, global.Balance) != 7500 || parseAmount(t, global.TotalIncome) != 7500 {
+		t.Fatalf("全局两列应各 7500 分,实得 %s/%s", global.Balance, global.TotalIncome)
+	}
+	school, err := repo.GetCampusAccount(ctx, nil, user, 1, 2)
+	if err != nil {
+		t.Fatalf("读校区账户失败: %v", err)
+	}
+	if parseAmount(t, school.Balance) != 7500 {
+		t.Fatalf("校区 balance 应 7500 分,实得 %s", school.Balance)
+	}
+
+	// 校区账户缺失 → ErrAccountNotFound(不静默跳过,否则"全局扣了、校区没扣")
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "MissingCampus", user, 1, 77,
+			ColumnDelta{Column: ExpenseColumnBalance, Delta: money.Amount(100)})
+		return e
+	}); !errors.Is(err, ErrAccountNotFound) {
+		t.Fatalf("校区账户缺失应返回 ErrAccountNotFound,实得: %v", err)
+	}
+
+	// 全部增量为 0 → no-op(不报错、不产生 SQL)
+	err = db.Transaction(func(tx *gorm.DB) error {
+		rows, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "Noop", user, 1, 2,
+			ColumnDelta{Column: ExpenseColumnBalance, Delta: 0})
+		if e != nil {
+			return e
+		}
+		if rows != 0 {
+			t.Fatalf("全零增量应 no-op,实得 rows=%d", rows)
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("全零增量不应报错,实得: %v", err)
+	}
+
+	// 非法列名 → ErrInternal(列名白名单,防注入)
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := repo.AddColumnsGlobalThenCampus(ctx, tx, "BadColumn", user, 1, 2,
+			ColumnDelta{Column: "balance); DROP TABLE t_user_expense; --", Delta: money.Amount(1)})
+		return e
+	}); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("非法列名应返回 ErrInternal,实得: %v", err)
+	}
+}
+
+// TestFindLogByRequest 幂等键查询:命中 / type 不同不命中 / 空 requestID 不享幂等。
+func TestFindLogByRequest(t *testing.T) {
+	db := newExpenseRepoDB(t)
+	repo := NewExpenseRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 0})
+	ctx := context.Background()
+	user := "01HUSER000000000000000905"
+	reqID := "REQ-FIND-1"
+
+	row := &model.UserExpenseLog{UserID: user, Type: model.ExpenseLogTypeRecharge,
+		Money: "10.00", RequestID: &reqID}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 流水失败: %v", err)
+	}
+
+	hit, err := repo.FindLogByRequest(ctx, nil, user, reqID, model.ExpenseLogTypeRecharge)
+	if err != nil || hit == nil || hit.ID != row.ID {
+		t.Fatalf("命中的流水应为 id=%d,实得 (%+v, %v)", row.ID, hit, err)
+	}
+	// type 不同 → 不命中(type 参与唯一键)
+	if hit, err := repo.FindLogByRequest(ctx, nil, user, reqID, model.ExpenseLogTypeRefund); err != nil || hit != nil {
+		t.Fatalf("不同类型不应命中,实得 (%+v, %v)", hit, err)
+	}
+	// 空 requestID → 恒 (nil,nil):NULL 不享幂等(设计 §1.5 缺省)
+	if hit, err := repo.FindLogByRequest(ctx, nil, user, "", model.ExpenseLogTypeRecharge); err != nil || hit != nil {
+		t.Fatalf("空 requestID 应返回 (nil,nil),实得 (%+v, %v)", hit, err)
+	}
+}

+ 109 - 0
internal/academic/service/activity_expiry.go

@@ -0,0 +1,109 @@
+// 活动失效器(自定义退费 / 余额清零共用)——AC-05。
+//
+// 依据:unified/03-migration/academic-domain-design.md §7.2 ④(custom_refund 的活动失效时序)、
+// §3.1 #9/#10、§11-2(自营/加盟判定字段 self_support)。
+//
+// 为什么单独抽一个"失效器":
+//
+//	dump 的 `sp_user_expense_custom_refund`(yuansiwei_finance.sql:764062)与
+//	`sp_user_expense_school_cleared_balance`(:764338)在这一点上**逐字相同**——
+//	同一段临时表填充 SQL + 同一句 `UPDATE ... SET status=4 ... IN (tmp)`,
+//	唯一差异是"自营校区 `campus_id IN (x,0)` / 加盟校区 `= x`"。
+//	抽成一个失效器后:① 两处调用共享同一份口径,不会各改一半;
+//	② 自营/加盟的分流只有**一个**判定点(`port.CampusReader` + repo 的 `selfSupport` 参数)。
+//
+// 事务纪律(红线):失效动作**必须与资金变动同事务**——`ExpireForUser` 只接受调用方传入的
+// `tx`,并在内部用 `repository.WithTxContext(ctx, tx)` 把同一 tx 传给仓储(§10.3 L4 约定:
+// 事务内跨组件调用必须显式透传,否则会新开独立事务,"钱退了、活动还能用"将不再回滚)。
+package service
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// activitySignedExpired 报名状态「过期不能使用」(model.ActivitySignedExpired 的本地别名,
+// 使本文件的 SQL 语义读起来与 dump 的 `status=4` 一一对应)。
+const activitySignedExpired int32 = model.ActivitySignedExpired
+
+// ActivityExpirer 活动失效器(SP9 / SP10 的共用副作用)。
+//
+// 字段:
+//   - repo:活动仓储(报名行 + 活动方案)。
+type ActivityExpirer struct {
+	repo *repository.ActivityRepo
+}
+
+// NewActivityExpirer 构造活动失效器。
+//
+// 入参:db *gorm.DB 连接池句柄;sc *appscope.Scope 请求作用域(机构越权防线需要)。
+// 出参:*ActivityExpirer。
+func NewActivityExpirer(db *gorm.DB, sc *appscope.Scope) *ActivityExpirer {
+	return &ActivityExpirer{repo: repository.NewActivityRepo(db, sc)}
+}
+
+// ExpireForUser 在**当前事务内**把用户的"充值赠送"活动报名置为过期(status=4)。
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**:与资金变动同事务,任一步失败整体回滚);
+//   - userID 报名用户(ULID);
+//   - institutionID 机构 ID(>0;由 CampusReader 解析或调用方传入);
+//   - campusID 校区 ID(>0);
+//   - selfSupport 自营 = true(失效范围 `campus_id IN (campusID, 0)`)
+//     / 加盟 = false(只失效 `campus_id = campusID`)。
+//
+// 出参:
+//   - []int64:本次被置为过期的活动 ID(**升序去重**;空 → 长度 0 的非 nil 切片,§4.6「空传 []」);
+//   - ErrBadParam / ErrUnscoped(1004) / ErrDBError。
+//
+// 业务口径(dump 逐字,两处 SP 相同):
+//   - 只处理 `t_courses_activity.type = 2`(充值赠送)——代金券(type=3)/老带新(type=4) 不动;
+//   - 只处理报名行 `status != 4`(已过期的重复处理无意义);
+//   - 机构必须等于 `activity.institution_id`(旧 `t2.schoolid`);
+//   - 自营校区额外覆盖 `campus_id = 0` 的"全机构池"活动,加盟校区不覆盖(避免误失效他校区的活动)。
+func (e *ActivityExpirer) ExpireForUser(
+	ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64, selfSupport bool,
+) ([]int64, error) {
+	if tx == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: ActivityExpirer.ExpireForUser 必须传入 tx(与资金变动同事务)"))
+	}
+	if e == nil || e.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityExpirer 未装配"))
+	}
+	// 同一 tx 注入 ctx:仓储读方法(db(ctx))据此复用事务快照
+	txCtx := repository.WithTxContext(ctx, tx)
+	ids, err := e.repo.ListExpirableSignedActivityIDs(txCtx, userID, institutionID, campusID, selfSupport)
+	if err != nil {
+		return nil, err
+	}
+	if len(ids) == 0 {
+		return make([]int64, 0), nil
+	}
+	if _, err := e.repo.SetSignedStatusByActivityIDs(txCtx, tx, userID, ids, activitySignedExpired); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+// SetSignedStatus 在**当前事务内**批量置报名状态(取消充值活动的 `status=0` 等场景复用本方法)。
+//
+// 入参:ctx / tx(必传)/ userID / activityIDs / status 目标状态。
+// 出参:(受影响行数, error)。
+//
+// 与 ExpireForUser 的分工:本方法是"不加业务判据的纯写",判据(type=2 / status!=4 / 机构校区过滤)
+// 一律在 ExpireForUser 内完成——避免调用方各自拼 SQL 导致失效范围漂移。
+func (e *ActivityExpirer) SetSignedStatus(
+	ctx context.Context, tx *gorm.DB, userID string, activityIDs []int64, status int32,
+) (int64, error) {
+	if e == nil || e.repo == nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityExpirer 未装配"))
+	}
+	return e.repo.SetSignedStatusByActivityIDs(repository.WithTxContext(ctx, tx), tx, userID, activityIDs, status)
+}

+ 1303 - 0
internal/academic/service/fund_service.go

@@ -0,0 +1,1303 @@
+// 余额核心服务(8 个余额类 SP 的 Go 化落点)——AC-04。
+//
+// 依据:unified/03-migration/academic-domain-design.md §3.1(余额 11 SP 映射,本文件覆盖 #1~#7 + #11)
+// / §1.4(事务模板)/ §1.5(幂等键 uk_request_type)/ §7.1(充值时序)/ §10.2(type 编码与方向)
+// / §10.3(事务边界只在 Service)/ §10.7(金额溢出与量级上限)。
+//
+// SP 映射(dump 行号见 §3.1,**顺序与方向一律按 dump,不得"顺手修正"**):
+//
+//	#1  sp_user_expense_recharge                → Recharge(8 参、**双表更新**:全局 + 校区同事务)
+//	#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(只读,无事务)
+//	#6  sp_user_expense_school_transfer_expenditure  → Transfer(**不动 balance**;106=+m / 206=−m)
+//	#7  sp_user_expense_baddebt                 → BadDebt(只加 special_expenses,不动 balance)
+//	#11 sp_user_expense_school_spend            → AccumulateSpend(只更新 spend,不动 balance;BUG#1)
+//
+// 两条不可违反的纪律(评审逐处检查):
+//
+//  1. **事务边界只在 Service**:写路径一律 `s.runner.WithinTx`;仓储方法不开事务,
+//     事务内下游调用一律经 `repository.WithTxContext(ctx, tx)` 注入的 ctx(§10.3)。
+//  2. **固定写序「全局 → 校区」**:所有双表写都收敛到
+//     `ExpenseRepo.AddColumnsGlobalThenCampus`(一条表一条 UPDATE,全局在前),
+//     与 RefundService 的**加锁**顺序保持一致,否则与并发充值交叉即成环(§1.4 / §7.2)。
+//
+// 金额纪律:全链路 `money.Amount`(int64 分),入参先过 `validatePositiveAmount`
+// (业务量级 + `Validate()`),落库前再由仓储的 `amountLiteral` 二次 `Validate()`(§10.7 兜底网)。
+package service
+
+import (
+	"context"
+	"errors"
+	"sort"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"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"
+)
+
+// 默认备注文案(dump 的 `IF inputRemark=”` 分支,**逐字对齐**,双跑会比对 log.remark)。
+const (
+	// defaultRechargeRemark SP1 `sp_user_expense_recharge` 默认备注。
+	defaultRechargeRemark = "余额充值"
+	// defaultSchoolRechargeRemark SP2 `sp_user_expense_school_recharge` 默认备注(与 SP1 不同)。
+	defaultSchoolRechargeRemark = "充值缴费"
+	// defaultExpenditureRemark SP3 `sp_user_expense_school_expenditure` 默认备注。
+	defaultExpenditureRemark = "历史扣费"
+	// defaultRefundRemark SP4 `sp_user_expense_school_refund` 默认备注。
+	defaultRefundRemark = "报课的费用退回"
+	// defaultBadDebtRemark SP7 `sp_user_expense_baddebt` 默认备注。
+	defaultBadDebtRemark = "坏账处理"
+)
+
+// maxSingleAmount 单笔金额业务上限(1,000,000.00 元)。
+//
+// 依据设计 §10.7「配套纪律 2」:Service 入口对 money 做业务量级断言,把"脏数据放大"挡在运算前
+// (超出 → 2002)。注意与 `money.MaxAmount`(decimal(12,2) 全域 ±99.99 亿元)不是一回事:
+// 前者是**业务**天花板,后者是**存储**天花板,两者都必须校验。
+const maxSingleAmount money.Amount = 100_000_000
+
+// RechargeKind 充值 SP 形态(#1 与 #2 的**唯一差异**是默认备注,合并后用它区分双跑映射)。
+type RechargeKind int
+
+const (
+	// RechargeKindDefault 对应 SP1 `sp_user_expense_recharge`(默认备注"余额充值")。
+	RechargeKindDefault RechargeKind = iota
+	// RechargeKindSchool 对应 SP2 `sp_user_expense_school_recharge`(默认备注"充值缴费")。
+	RechargeKindSchool
+)
+
+// FundService 余额核心服务(充值/消费/退费/转结/坏账/累计消费/可退金额)。
+//
+// 字段:
+//   - repo:余额账户仓储(全局 + 校区 + 流水;校区账户走机构作用域);
+//   - runner:事务运行器(顶层 BEGIN,事务内透传);
+//   - member:成员关系校验器(可 nil:系统任务/割接脚本模式不做对象级越权断言)。
+type FundService struct {
+	repo   *repository.ExpenseRepo
+	runner *repository.TxRunner
+	member port.MembershipChecker
+}
+
+// NewFundService 构造余额核心服务(repo 与 runner 绑定同一 DB 句柄,避免跨连接池事务失效)。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填);
+//   - sc *appscope.Scope:本次请求作用域(校区账户读写必需;系统任务可 nil,
+//     此时**只能**调用全局/流水方法,触达校区账户会 panic fail-fast);
+//   - member port.MembershipChecker:成员校验器(可 nil)。
+//
+// 出参:*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,
+	}
+}
+
+// ---------------------------------------------------------------- 入参 / 出参
+
+// RechargeInput 充值入参(SP1 `sp_user_expense_recharge` 8 参 / SP2 同构 7 参)。
+//
+// 入参纪律:`Money` 已由 Handler 用 `money.Parse` 解析(**拒绝 float**,§10.6),
+// 本层不再解析字符串,只校验量级与业务规则。
+type RechargeInput struct {
+	// UserID 充值用户(canonical_user_id ULID;必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0;dump 的 inputSchoolid)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0;dump 的 inputCampusid)。
+	CampusID int64 `json:"campusId"`
+	// Money 充值金额(>0;dump 的 inputMoney)。
+	Money money.Amount `json:"money"`
+	// Type 流水类型(dump 的 inputType;<100 一律归 100,对齐 dump `IF inputType<100`)。
+	Type int32 `json:"type"`
+	// RequestID 幂等请求号(→ request_id 幂等键 + log_id 业务单号;空 = 不享幂等)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(空 → 按 Kind 取默认文案)。
+	Remark string `json:"remark"`
+	// OperatorID 操作人(→ create_user_id;空 → NULL)。
+	OperatorID string `json:"-"`
+	// Kind SP 形态(默认 RechargeKindDefault)。
+	Kind RechargeKind `json:"-"`
+}
+
+// RechargeResult 充值结果(端点 3 响应 `{logId,balance,repeated}`)。
+type RechargeResult struct {
+	// LogID 流水 ID(幂等命中时为**首次**流水 ID)。
+	LogID int64 `json:"logId"`
+	// Balance 充值后校区账户余额(balance_after 口径)。
+	Balance money.Amount `json:"balance"`
+	// Repeated true = 幂等命中(requestId 已处理),未重复入账。
+	Repeated bool `json:"repeated"`
+}
+
+// ExpenditureInput 消费入参(SP3 `sp_user_expense_school_expenditure` 8 参)。
+type ExpenditureInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId"`
+	// Money 消费金额(>0;流水落 `-Money`)。
+	Money money.Amount `json:"money"`
+	// Type 消费类型(101/104/105/200/201/204/205,白名单;非法 → 2002)。
+	Type int32 `json:"type"`
+	// RequestID 幂等请求号(空 = 不享幂等)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(空 → "历史扣费")。
+	Remark string `json:"remark"`
+	// OperatorID 操作人。
+	OperatorID string `json:"-"`
+}
+
+// ExpenditureResult 消费结果(端点 4 响应 `{logId,balance}`)。
+type ExpenditureResult struct {
+	// LogID 流水 ID。
+	LogID int64 `json:"logId"`
+	// Balance 消费后校区账户余额。
+	Balance money.Amount `json:"balance"`
+	// Repeated true = 幂等命中。
+	Repeated bool `json:"repeated"`
+}
+
+// RefundInput 退费入参(SP4 `sp_user_expense_school_refund` 7 参 + **补 institution_id**)。
+type RefundInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0;**dump 无此参数 = BUG#2**,见 Refund 方法注释)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId"`
+	// Money 退费金额(>0 才生效;<=0 → changed=false 的 no-op,对齐 dump `IF inputMoney>0`)。
+	Money money.Amount `json:"money"`
+	// Type 流水类型(dump 的 inputType;必须属 model.ExpenseLogType* 白名单)。
+	Type int32 `json:"type"`
+	// RequestID 幂等请求号(空 = 不享幂等)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(空 → "报课的费用退回")。
+	Remark string `json:"remark"`
+	// OperatorID 操作人。
+	OperatorID string `json:"-"`
+}
+
+// RefundResult 退费结果(端点 5 响应 `{logId,balance,changed}`)。
+type RefundResult struct {
+	// LogID 流水 ID(no-op 时为 0)。
+	LogID int64 `json:"logId"`
+	// Balance 退费后校区账户余额。
+	Balance money.Amount `json:"balance"`
+	// Changed false = 未发生资金变动(money<=0 的 no-op,或幂等命中)。
+	Changed bool `json:"changed"`
+	// Repeated true = 幂等命中。
+	Repeated bool `json:"repeated"`
+}
+
+// TransferInput 转结入参(SP6 `sp_user_expense_school_transfer_expenditure` 8 参)。
+type TransferInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// FromInstitutionID 转出机构 ID(必填 >0)。
+	FromInstitutionID int64 `json:"fromInstitutionId"`
+	// FromCampusID 转出校区 ID(必填 >0)。
+	FromCampusID int64 `json:"fromCampusId"`
+	// ToInstitutionID 转入机构 ID(必填 >0)。
+	ToInstitutionID int64 `json:"toInstitutionId"`
+	// ToCampusID 转入校区 ID(必填 >0)。
+	ToCampusID int64 `json:"toCampusId"`
+	// Money 转结金额(>0)。
+	Money money.Amount `json:"money"`
+	// RequestID 幂等请求号(**两条流水共用**同一 request_id,type 不同故不撞键)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(dump 无默认值)。
+	Remark string `json:"remark"`
+	// OperatorID 操作人。
+	OperatorID string `json:"-"`
+}
+
+// TransferResult 转结结果(端点 7 响应 `{logId106,logId206}`)。
+type TransferResult struct {
+	// LogID106 type=106 转结出流水 ID(dump: money = **+Money**)。
+	LogID106 int64 `json:"logId106"`
+	// LogID206 type=206 转结入流水 ID(dump: money = **−Money**)。
+	LogID206 int64 `json:"logId206"`
+	// FromBalance 转出校区账户余额(转结**不动 balance**,故与转结前相同)。
+	FromBalance money.Amount `json:"fromBalance"`
+	// ToBalance 转入校区账户余额(同上)。
+	ToBalance money.Amount `json:"toBalance"`
+	// Repeated true = 幂等命中。
+	Repeated bool `json:"repeated"`
+}
+
+// BadDebtInput 坏账入参(SP7 `sp_user_expense_baddebt` 7 参)。
+type BadDebtInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId"`
+	// Money 坏账金额(>0)。
+	Money money.Amount `json:"money"`
+	// RequestID 幂等请求号(空 = 不享幂等)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(空 → "坏账处理")。
+	Remark string `json:"remark"`
+	// OperatorID 操作人。
+	OperatorID string `json:"-"`
+}
+
+// BadDebtResult 坏账结果(端点 8 响应 `{logId,specialExpenses}`)。
+type BadDebtResult struct {
+	// LogID type=108 流水 ID。
+	LogID int64 `json:"logId"`
+	// SpecialExpenses 处理后校区账户特殊费用累计。
+	SpecialExpenses money.Amount `json:"specialExpenses"`
+	// Balance 处理后校区账户余额(本方法不动 balance,回显仅为对账方便)。
+	Balance money.Amount `json:"balance"`
+	// Repeated true = 幂等命中。
+	Repeated bool `json:"repeated"`
+}
+
+// SpendInput 累计消费入参(SP11 `sp_user_expense_school_spend` 4 参)。
+type SpendInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId"`
+	// SpendMoney 本次累加的课费单价之和(元;**负数一律归 0**,dump INSERT 分支同口径)。
+	SpendMoney money.Amount `json:"spendMoney"`
+}
+
+// SpendResult 累计消费结果(端点 10 响应 `{spend}`)。
+type SpendResult struct {
+	// Spend 累加后校区账户 spend 列(**不是** balance)。
+	Spend money.Amount `json:"spend"`
+}
+
+// ---------------------------------------------------------------- 校验助手
+
+// validateUserScope 校验"用户 + 机构 + 校区"三元组(资金方法入口统一形状)。
+//
+// 入参:userID / institutionID / campusID;op 操作名(错误文案定位);campusRequired 是否要求校区>0。
+// 出参:nil 合法;2001(缺 user_id)/ 3140(校区或机构缺失,对齐设计 §1.6「campusid<=0 → 3140」)。
+func validateUserScope(userID string, institutionID, campusID int64, op string, campusRequired bool) error {
+	if strings.TrimSpace(userID) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+op+" 需要 user_id"))
+	}
+	if institutionID <= 0 {
+		return infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString("service: "+op+" 需要 institution_id > 0"))
+	}
+	if campusRequired && campusID <= 0 {
+		// dump:`IF (inputSchoolid<1 || inputCampusid<1) THEN LEAVE`(静默 no-op)
+		// Go:显式 3140,避免"调用成功但什么都没做"
+		return infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString("service: "+op+" 需要 campus_id > 0(dump:必须有校区信息)"))
+	}
+	if campusID < 0 {
+		return infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString("service: "+op+" 的 campus_id 不得为负"))
+	}
+	return nil
+}
+
+// validatePositiveAmount 校验金额为正且不超业务/存储天花板(§10.7)。
+//
+// 入参:amount 金额(分);op 操作名。
+// 出参:nil 合法;2002(<=0 / 超业务上限 / 超 decimal(12,2) 全域)。
+//
+// 与 dump 的差异(登记为有意偏离):dump 对负数是 `IF inputMoney<0 THEN SET inputMoney=0`
+// **静默归零后继续记账**,会留下 money=0 的流水并占住幂等键;Go 显式拒绝(2002)。
+func validatePositiveAmount(amount money.Amount, op string) error {
+	if amount <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: "+op+" 金额必须 > 0(dump 静默归零 → Go 显式拒绝)"))
+	}
+	if amount > maxSingleAmount {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: "+op+" 金额超出单笔业务上限"))
+	}
+	if err := amount.Validate(); err != nil {
+		return infraerrors.WithCause(infraerrors.ErrBadFormat, err)
+	}
+	return nil
+}
+
+// parseAccountAmount 把账户 decimal 字符串列解析为 money.Amount。
+//
+// 入参:raw 列原文(如 "12.34");col 列名(错误文案定位)。
+// 出参:money.Amount;ErrDBError(历史脏值/格式非法——属数据问题,不是入参问题)。
+func parseAccountAmount(raw, col string) (money.Amount, error) {
+	amount, err := money.Parse(raw)
+	if err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 账户列 "+col+" 金额非法("+raw+")"))
+	}
+	return amount, nil
+}
+
+// optString 把空串转成 nil 指针(*string 列的 NULL 语义:request_id 为空时"不享幂等")。
+func optString(s string) *string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return nil
+	}
+	return &s
+}
+
+// amountPtr 返回金额两位小数字符串的指针(`balance_after` 等可空金额列用)。
+func amountPtr(a money.Amount) *string {
+	s := a.String()
+	return &s
+}
+
+// newExpenseLog 组装一条余额变更流水(字段口径收口,避免各方法重复拼装)。
+//
+// 入参:userID/typ/money 金额/requestID/remark/operatorID/balanceAfter(nil = 不写快照)。
+// 出参:*model.UserExpenseLog(未落库;Create 后 ID 由 GORM 回填)。
+func newExpenseLog(userID string, typ int32, amount money.Amount, requestID, remark, operatorID string,
+	balanceAfter *money.Amount) *model.UserExpenseLog {
+	lg := &model.UserExpenseLog{
+		UserID:       userID,
+		Type:         typ,
+		Money:        amount.String(),
+		RequestID:    optString(requestID),
+		LogID:        optString(requestID), // dump:logid = 外部单号(SP1 的 inputOrder / SP2 的 inputRequestid)
+		CreateUserID: optString(operatorID),
+		Remark:       optString(remark),
+	}
+	if balanceAfter != nil {
+		lg.BalanceAfter = amountPtr(*balanceAfter)
+	}
+	return lg
+}
+
+// assertInScope 对象级越权断言(全局钱包无作用域列,只能由 Service 显式校验,设计 §1.7 例外一)。
+//
+// 入参:ctx / userID / institutionID / campusID。
+// 出参:nil 通过(或未注入 checker 的系统任务模式);1004 越权;5002 DB 异常。
+func (s *FundService) assertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
+	if s == nil || s.member == nil {
+		return nil // 系统任务/割接脚本模式:不注入 checker 即不做对象级断言(装配层责任)
+	}
+	return s.member.AssertInScope(ctx, userID, institutionID, campusID)
+}
+
+// ---------------------------------------------------------------- SP1 / SP2 充值
+
+// Recharge 余额充值(SP1 `sp_user_expense_recharge` + SP2 `sp_user_expense_school_recharge`)。
+//
+// dump 语义(yuansiwei_finance.sql:764247,★8 参 + **双表更新**):
+//
+//	① inputMoney<0 → 0;inputType<100 → 100;inputRemark='' → 默认备注;
+//	② schoolid<1 || campusid<1 → LEAVE(静默 no-op);
+//	③ 校区账户不存在则 INSERT;全局账户不存在则 INSERT;
+//	④ UPDATE 校区:balance+, total_income+ ;UPDATE 全局:balance+, total_income+;
+//	⑤ INSERT log(type=inputType, logid=inputOrder, money=inputMoney)。
+//
+// Go 的三处**有意强化**(不改变资金结果):
+//   - 建户合并为一次 UPSERT(`ON CONFLICT DO NOTHING`),替代 dump 的 `COUNT` 判存在(§1.5);
+//   - 写序固定为 **全局 → 校区**(dump 是校区 → 全局;顺序不同但结果一致,
+//     统一顺序是**死锁预防**的硬要求,见 §1.4 与 §7.2 的加锁基线);
+//   - 幂等:`request_id + type` 唯一键,并发重复提交只落 1 条流水(dump 无幂等,会重复入账)。
+//
+// 入参:ctx / in(见 RechargeInput;Kind 决定默认备注)。
+// 出参:*RechargeResult(logId/balance/repeated);错误码 2001/2002/3140/1004/5002。
+func (s *FundService) Recharge(ctx context.Context, in RechargeInput) (*RechargeResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Recharge", true); err != nil {
+		return nil, err
+	}
+	if err := validatePositiveAmount(in.Money, "Recharge"); err != nil {
+		return nil, err
+	}
+	if in.Type < 100 {
+		// dump:`IF inputType<100 THEN SET inputType=100`(充值流水类型下限)
+		in.Type = model.ExpenseLogTypeRecharge
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		if in.Kind == RechargeKindSchool {
+			remark = defaultSchoolRechargeRemark
+		} else {
+			remark = defaultRechargeRemark
+		}
+	}
+
+	var out RechargeResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		// ① 幂等预检(`uk_request_type`):命中 → 返回首次结果,不重复入账
+		hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
+		if e != nil {
+			return e
+		}
+		if hit != nil {
+			bal, pe := balanceAfterOf(hit)
+			if pe != nil {
+				return pe
+			}
+			out = RechargeResult{LogID: hit.ID, Balance: bal, Repeated: true}
+			return nil
+		}
+
+		// ② 作用域断言(全局钱包无作用域列 → Service 层显式校验)
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+
+		// ③ 建户(两个 UPSERT,**不动已有余额**)
+		if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+			UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
+		}); e != nil {
+			return e
+		}
+
+		// ④ 原子增减(固定「全局 → 校区」,两列合并为每表一条 UPDATE)
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Recharge", in.UserID,
+			in.InstitutionID, in.CampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money},
+		); e != nil {
+			return e
+		}
+
+		// ⑤ 流水(balance_after = 充值后校区余额快照,供对账/双跑逐字段比对)
+		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+		lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &bal)
+		if e := s.insertLog(txCtx, tx, lg); e != nil {
+			return e
+		}
+		out = RechargeResult{LogID: lg.ID, Balance: bal}
+		return nil
+	})
+	if err != nil {
+		return nil, s.mapIdempotentHit(err, func() error {
+			return s.replayRecharge(ctx, in, &out)
+		})
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- SP3 消费
+
+// Expenditure 余额消费扣费(SP3 `sp_user_expense_school_expenditure`)。
+//
+// dump 语义(:764364,**type 决定列组合,方向不可改**):
+//
+//	inputType=200(历史扣费)  → balance−, total_expenditure+, spend+
+//	inputType=207(余额退费)  → balance−, total_income−
+//	其余(101/104/105/201/204/205)→ balance−, total_expenditure+
+//	两条 UPDATE(校区 + 全局)+ log(money = **−inputMoney**)
+//
+// Go 的口径选择(**与 dump 的两处差异,均为设计 §3.1 #3 明确要求**):
+//   - type 白名单校验:`101/104/105/200/201/204/205`(非法 → 2002)。dump 会把
+//     `type<200 或 >299` **静默改成 200**,把"调用方传错类型"变成"记成历史扣费";
+//   - dump 的 `type=207` 分支**不开放**给本方法(退费走 `Refund`/`CustomRefund`,
+//     否则同一语义有两条写入口,流水方向难以对账)。
+//
+// 入参:ctx / in。出参:*ExpenditureResult;错误码 2001/2002/3140/1004/5002。
+func (s *FundService) Expenditure(ctx context.Context, in ExpenditureInput) (*ExpenditureResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Expenditure", true); err != nil {
+		return nil, err
+	}
+	if err := validatePositiveAmount(in.Money, "Expenditure"); err != nil {
+		return nil, err
+	}
+	if !isExpenditureType(in.Type) {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: Expenditure 的 type 非法(白名单 101/104/105/200/201/204/205)"))
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		remark = defaultExpenditureRemark
+	}
+	deltas := expenditureDeltas(in.Type, in.Money)
+
+	var out ExpenditureResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
+		if e != nil {
+			return e
+		}
+		if hit != nil {
+			bal, pe := balanceAfterOf(hit)
+			if pe != nil {
+				return pe
+			}
+			out = ExpenditureResult{LogID: hit.ID, Balance: bal, Repeated: true}
+			return nil
+		}
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+			UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
+		}); e != nil {
+			return e
+		}
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Expenditure", in.UserID,
+			in.InstitutionID, in.CampusID, deltas...); e != nil {
+			return e
+		}
+		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		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)
+		if e := s.insertLog(txCtx, tx, lg); e != nil {
+			return e
+		}
+		out = ExpenditureResult{LogID: lg.ID, Balance: bal}
+		return nil
+	})
+	if err != nil {
+		return nil, s.mapIdempotentHit(err, func() error {
+			return s.replayExpenditure(ctx, in, &out)
+		})
+	}
+	return &out, nil
+}
+
+// expenditureDeltas 按 type 生成列增量(dump 三个分支的逐列对应,**顺序/方向不得改**)。
+//
+// 入参:typ 消费类型(已过白名单);amount 消费金额(>0)。
+// 出参:[]repository.ColumnDelta(balance 恒为 −amount)。
+func expenditureDeltas(typ int32, amount money.Amount) []repository.ColumnDelta {
+	switch typ {
+	case model.ExpenseLogTypeHistoryDeduct:
+		// dump:`balance-`,`total_expenditure+`,`spend+`(历史扣费同时计入消费统计)
+		return []repository.ColumnDelta{
+			{Column: repository.ExpenseColumnBalance, Delta: amount.Neg()},
+			{Column: repository.ExpenseColumnTotalExpenditure, Delta: amount},
+			{Column: repository.ExpenseColumnSpend, Delta: amount},
+		}
+	default:
+		// dump:`balance-`,`total_expenditure+`(报课学费 / 学校消费)
+		return []repository.ColumnDelta{
+			{Column: repository.ExpenseColumnBalance, Delta: amount.Neg()},
+			{Column: repository.ExpenseColumnTotalExpenditure, Delta: amount},
+		}
+	}
+}
+
+// isExpenditureType 消费类型白名单(设计 §3.1 #3:101/104/105/200/201/204/205)。
+func isExpenditureType(typ int32) bool {
+	switch typ {
+	case model.ExpenseLogTypeCourseCampus, // 101
+		model.ExpenseLogTypeCourseSchoolA, // 104
+		model.ExpenseLogTypeCourseSchoolB, // 105
+		model.ExpenseLogTypeHistoryDeduct, // 200
+		model.ExpenseLogTypeCourseCampusB, // 201
+		model.ExpenseLogTypeCourseSchoolC, // 204
+		model.ExpenseLogTypeCourseSchoolD: // 205
+		return true
+	default:
+		return false
+	}
+}
+
+// ---------------------------------------------------------------- SP4 退费
+
+// Refund 余额退费(SP4 `sp_user_expense_school_refund`)。
+//
+// dump 语义(:764576):`IF inputMoney>0` 才动作(<=0 静默 no-op);
+// 校区账户已存在才 UPDATE(balance+, total_expenditure−);全局账户已存在才 UPDATE;
+// 任一更新成功才写 log(money = **+inputMoney**,type = inputType)。
+//
+// **BUG#2 修正(设计 §3.1 #4)**:dump 的定位条件是 `userid + campusid`,**没有 schoolid**——
+// 同一 campus_id 若在多机构复用(历史数据/跨库迁移期),会误更新别的机构的账户行。
+// Go 侧补 `institution_id` 参数并进 WHERE,定位三元组精确到 `(user, institution, campus)`。
+//
+// 入参:ctx / in。出参:*RefundResult(changed=false 表示无资金变动);
+// 错误码 2001/2002/3140/1004/3001/5002。
+func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Refund", true); err != nil {
+		return nil, err
+	}
+	if in.Type <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: Refund 需要 type"))
+	}
+	if !isKnownLogType(in.Type) {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: Refund 的 type 不在流水类型白名单内"))
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		remark = defaultRefundRemark
+	}
+
+	// dump:`IF inputMoney>0` 才动作 → Go 返回成功但 changed=false(显式语义,不静默)
+	if in.Money <= 0 {
+		return &RefundResult{Changed: false}, nil
+	}
+	if err := validatePositiveAmount(in.Money, "Refund"); err != nil {
+		return nil, err
+	}
+
+	var out RefundResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
+		if e != nil {
+			return e
+		}
+		if hit != nil {
+			bal, pe := balanceAfterOf(hit)
+			if pe != nil {
+				return pe
+			}
+			out = RefundResult{LogID: hit.ID, Balance: bal, Changed: true, Repeated: true}
+			return nil
+		}
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+		// dump 不建户(account 不存在则什么都不做);Go 显式 3001,避免"调用成功但没退钱"
+		if _, e := s.repo.GetCampusForUpdateExact(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+		// dump:`balance+`,`total_expenditure-`
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Refund", in.UserID,
+			in.InstitutionID, in.CampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money.Neg()},
+		); e != nil {
+			return e
+		}
+		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+		lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &bal)
+		if e := s.insertLog(txCtx, tx, lg); e != nil {
+			return e
+		}
+		out = RefundResult{LogID: lg.ID, Balance: bal, Changed: true}
+		return nil
+	})
+	if err != nil {
+		return nil, s.mapIdempotentHit(err, func() error {
+			return s.replayRefund(ctx, in, &out)
+		})
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- SP5 可退金额(只读)
+
+// RefundableAmount 查询校区账户可退金额(SP5 `sp_user_expense_school_get_refundable_amount`,**只读**)。
+//
+// 口径(设计 §3.1 #5 定稿):`max(校区余额, 0)`;账户不存在 → 0(旧行为);
+// 余额 <= 0(欠费)→ 0。
+//
+// ⚠ 与 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 补齐,
+// 只需扩展本方法内部(调用方签名不变)。
+//
+// 入参:ctx / userID / institutionID / campusID。
+// 出参:(可退金额, 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 {
+		return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+	if err := s.assertInScope(ctx, userID, institutionID, campusID); err != nil {
+		return 0, err
+	}
+	acc, err := s.repo.GetCampusAccount(ctx, nil, userID, institutionID, campusID)
+	if err != nil {
+		if errors.Is(err, infraerrors.ErrNotFound) {
+			return 0, nil // dump:账户不存在 → 0
+		}
+		return 0, err
+	}
+	bal, err := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+	if err != nil {
+		return 0, err
+	}
+	if bal <= 0 {
+		return 0, nil
+	}
+	return bal, nil
+}
+
+// ---------------------------------------------------------------- SP6 课费转结
+
+// Transfer 课费转结(SP6 `sp_user_expense_school_transfer_expenditure`)。
+//
+// dump 语义(:764661,★**与 P0-04 规格相反,以 dump 为准**,设计 §3.1 #6 / §10.2):
+//
+//	IF inputMoney>0:
+//	  源校区账户存在 →
+//	    目标校区账户不存在则 INSERT;
+//	    UPDATE 源:total_expenditure−m, total_income−m
+//	    UPDATE 目标:total_expenditure+m, total_income+m
+//	    INSERT log(type=106, money=**+m**)   ← 转结出
+//	    INSERT log(type=206, money=**−m**)   ← 转结入
+//
+// **四个"不得改动"的点**:
+//  1. **不动 balance**(dump 全程没有 balance 赋值)——转结只调"总消费/总充值"两个统计口径;
+//  2. 106 记 **+m**、206 记 **−m**(与"出/入"的直觉相反,dump 原文如此,§11-6 #6);
+//  3. 两条流水的 `request_id` **相同**、`type` 不同 → 不撞 `uk_request_type`(唯一键含 type);
+//  4. 目标账户不存在时**建户**(dump 的 INSERT),源账户不存在时**不动**(dump 的 `IF campusLogid1>0`)。
+//
+// Go 的两处显式化:源账户不存在 → 3001(不静默);同校区自转 → 3140(净效应为零的无意义操作)。
+//
+// 入参:ctx / in。出参:*TransferResult{logId106,logId206};错误码 2001/2002/3140/1004/3001/5002。
+func (s *FundService) Transfer(ctx context.Context, in TransferInput) (*TransferResult, error) {
+	if err := validateUserScope(in.UserID, in.FromInstitutionID, in.FromCampusID, "Transfer(from)", true); err != nil {
+		return nil, err
+	}
+	if err := validateUserScope(in.UserID, in.ToInstitutionID, in.ToCampusID, "Transfer(to)", true); err != nil {
+		return nil, err
+	}
+	if err := validatePositiveAmount(in.Money, "Transfer"); err != nil {
+		return nil, err
+	}
+	if in.FromInstitutionID == in.ToInstitutionID && in.FromCampusID == in.ToCampusID {
+		return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString("service: Transfer 的转出与转入校区不能相同"))
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+
+	var out TransferResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		// ① 幂等预检:106 是"转结出"主流水(必然存在),用它做命中判定
+		hit106, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeTransferOut)
+		if e != nil {
+			return e
+		}
+		if hit106 != nil {
+			out = TransferResult{LogID106: hit106.ID, Repeated: true}
+			if bal, pe := balanceAfterOf(hit106); pe == nil {
+				out.FromBalance = bal
+			}
+			hit206, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
+			if e != nil {
+				return e
+			}
+			if hit206 != nil {
+				out.LogID206 = hit206.ID
+				if bal, pe := balanceAfterOf(hit206); pe == nil {
+					out.ToBalance = bal
+				}
+			}
+			return nil
+		}
+
+		// ② 作用域断言(两侧机构都必须可见)
+		if e := s.assertInScope(txCtx, in.UserID, in.FromInstitutionID, in.FromCampusID); e != nil {
+			return e
+		}
+		if in.ToInstitutionID != in.FromInstitutionID {
+			if e := s.assertInScope(txCtx, in.UserID, in.ToInstitutionID, in.ToCampusID); e != nil {
+				return e
+			}
+		}
+
+		// ③ 加锁:两个校区账户**按 campus_id ASC 排序**后依次加锁(§1.4 多账户场景防死锁)
+		refs := []campusRef{
+			{InstitutionID: in.FromInstitutionID, CampusID: in.FromCampusID},
+			{InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID},
+		}
+		sort.Slice(refs, func(i, j int) bool { return refs[i].CampusID < refs[j].CampusID })
+		locked := make(map[campusRef]bool, len(refs))
+		for _, ref := range refs {
+			_, le := s.repo.GetCampusForUpdateExact(txCtx, tx, in.UserID, ref.InstitutionID, ref.CampusID)
+			if le != nil {
+				if errors.Is(le, infraerrors.ErrNotFound) {
+					locked[ref] = false
+					continue
+				}
+				return le
+			}
+			locked[ref] = true
+		}
+		fromRef := campusRef{InstitutionID: in.FromInstitutionID, CampusID: in.FromCampusID}
+		toRef := campusRef{InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID}
+		if !locked[fromRef] {
+			// dump:源账户不存在 → `IF campusLogid1>0` 不成立 → 静默什么都不做
+			return infraerrors.WithCause(infraerrors.ErrNotFound,
+				errString("service: Transfer 转出校区账户不存在"))
+		}
+		if !locked[toRef] {
+			// dump:目标账户不存在 → INSERT 建户
+			if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+				UserID: in.UserID, InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID,
+			}); e != nil {
+				return e
+			}
+		}
+
+		// ④ 4 次 UPDATE → 2 条语句(每表两列),**balance 一律不动**
+		if _, e := s.repo.AddCampusColumns(txCtx, tx, "Transfer(out)", in.UserID,
+			in.FromInstitutionID, in.FromCampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money.Neg()},
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money.Neg()},
+		); e != nil {
+			return e
+		}
+		if _, e := s.repo.AddCampusColumns(txCtx, tx, "Transfer(in)", in.UserID,
+			in.ToInstitutionID, in.ToCampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money},
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money},
+		); e != nil {
+			return e
+		}
+
+		// ⑤ 两条流水(106 在前、206 在后;方向 ★dump 口径)
+		fromAcc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.FromInstitutionID, in.FromCampusID)
+		if e != nil {
+			return e
+		}
+		fromBal, e := parseAccountAmount(fromAcc.Balance, "t_user_expense_school.balance")
+		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")
+		if e != nil {
+			return e
+		}
+		lg106 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferOut, in.Money,
+			in.RequestID, in.Remark, in.OperatorID, &fromBal)
+		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)
+		if e := s.insertLog(txCtx, tx, lg206); e != nil {
+			return e
+		}
+		out = TransferResult{
+			LogID106: lg106.ID, LogID206: lg206.ID,
+			FromBalance: fromBal, ToBalance: toBal,
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, s.mapIdempotentHit(err, func() error {
+			return s.replayTransfer(ctx, in, &out)
+		})
+	}
+	return &out, nil
+}
+
+// campusRef 校区账户定位三元组的"机构 + 校区"部分(转结加锁排序用)。
+type campusRef struct {
+	// InstitutionID 机构 ID。
+	InstitutionID int64
+	// CampusID 校区 ID。
+	CampusID int64
+}
+
+// ---------------------------------------------------------------- SP7 坏账
+
+// BadDebt 坏账处理(SP7 `sp_user_expense_baddebt`)。
+//
+// 口径(设计 §3.1 #7):**只加 `special_expenses`,不动 balance**;log type=108、money=+Money。
+//
+// ⚠ 与 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 回改)。
+//
+// 入参:ctx / in。出参:*BadDebtResult;错误码 2001/2002/3140/1004/5002。
+func (s *FundService) BadDebt(ctx context.Context, in BadDebtInput) (*BadDebtResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "BadDebt", true); err != nil {
+		return nil, err
+	}
+	if err := validatePositiveAmount(in.Money, "BadDebt"); err != nil {
+		return nil, err
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		remark = defaultBadDebtRemark
+	}
+
+	var out BadDebtResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
+		if e != nil {
+			return e
+		}
+		if hit != nil {
+			out = BadDebtResult{LogID: hit.ID, Repeated: true}
+			if acc, ae := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
+				if spec, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
+					out.SpecialExpenses = spec
+				}
+				if bal, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
+					out.Balance = bal
+				}
+			}
+			return nil
+		}
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+			UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
+		}); e != nil {
+			return e
+		}
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "BadDebt", in.UserID,
+			in.InstitutionID, in.CampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: in.Money},
+		); e != nil {
+			return e
+		}
+		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+		spec, e := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses")
+		if e != nil {
+			return e
+		}
+		lg := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, in.Money,
+			in.RequestID, remark, in.OperatorID, &bal)
+		if e := s.insertLog(txCtx, tx, lg); e != nil {
+			return e
+		}
+		out = BadDebtResult{LogID: lg.ID, SpecialExpenses: spec, Balance: bal}
+		return nil
+	})
+	if err != nil {
+		return nil, s.mapIdempotentHit(err, func() error {
+			return s.replayBadDebt(ctx, in, &out)
+		})
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- SP11 累计消费(spend)
+
+// AccumulateSpend 累计消费金额(SP11 `sp_user_expense_school_spend`)。
+//
+// 口径(设计 §3.1 #11):**只更新 `spend`,不动 `balance`**;无流水(dump 不写 log)。
+//
+// **BUG#1 修正**:dump 的全局分支 `INSERT INTO t_user_expense(userid, spend) VALUES(inputUserid,
+// inputMoney, inputSpendMoney)` 是 **3 个值对 2 列**(且 inputMoney 在该 SP 里根本未声明)→ 必报错。
+// Go 修正为"UPSERT 建户 + `spend = spend + ?`",列数与列名对齐 DDL。
+//
+// 负值处理:dump 仅在 **INSERT 分支**把负数归 0(UPDATE 分支会照减);本实现统一归 0
+// (`spend` 是"只增"的统计口径列,model 注释亦如此约定),并回显当前值。
+//
+// 幂等:dump 的 SP11 **没有请求号**(签名只有 4 参),因此本方法不参与 `uk_request_type` 幂等;
+// 重复调用会重复累加 `spend`——这是 dump 的既有语义(调用方为"结束上课"链路,每次课一条),
+// 不做"按 requestId 去重"的加法,以免与 dump 的双跑口径分叉。
+//
+// 入参:ctx / in。出参:*SpendResult{spend};错误码 2001/3140/5002。
+func (s *FundService) AccumulateSpend(ctx context.Context, in SpendInput) (*SpendResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "AccumulateSpend", true); err != nil {
+		return nil, err
+	}
+	if err := in.SpendMoney.Validate(); err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
+	}
+	if in.SpendMoney < 0 {
+		in.SpendMoney = 0 // dump INSERT 分支:负数归 0(spend 只增)
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
+	}
+
+	var out SpendResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+			UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
+		}); e != nil {
+			return e
+		}
+		if !in.SpendMoney.IsZero() {
+			if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "AccumulateSpend", in.UserID,
+				in.InstitutionID, in.CampusID,
+				repository.ColumnDelta{Column: repository.ExpenseColumnSpend, Delta: in.SpendMoney},
+			); e != nil {
+				return e
+			}
+		}
+		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		spend, e := parseAccountAmount(acc.Spend, "t_user_expense_school.spend")
+		if e != nil {
+			return e
+		}
+		out = SpendResult{Spend: spend}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- 幂等公共形状
+
+// insertLog 写流水并把"撞 uk_request_type"翻译成内部幂等哨兵(`apperr.ErrRepeated`)。
+//
+// 入参:txCtx(含 tx 的 ctx)/ tx / lg 流水行。
+// 出参:nil 成功;apperr.ErrRepeated(**并发幂等命中**,调用方据此回放首次结果);
+// 其余错误原样上抛(ErrBadParam/ErrDBError)。
+//
+// 为什么用 ErrRepeated 作为事务内哨兵:它是 *AppError,`TxRunner.wrapTxErr` 会原样透传
+// (不降级成 5002),且它是设计里"仅内部日志用"的码(§10.1),不会泄漏给前端。
+func (s *FundService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
+	err := s.repo.InsertLog(txCtx, tx, lg)
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, repository.ErrDuplicateLog) {
+		return apperr.ErrRepeated
+	}
+	return err
+}
+
+// mapIdempotentHit 把"并发幂等命中"从错误路径转成"回放首次结果"的成功路径。
+//
+// 入参:
+//   - err:WithinTx 返回的错误;
+//   - replay:回放函数(重新查询首次流水并填充 out)。
+//
+// 出参:nil(回放成功)/ 回放错误 / 原错误(非幂等命中)。
+//
+// 语义(§1.5):`uk_request_type` 撞键 = 同一请求号 + 同一 type 的第二次写入,
+// **不是错误**(code 仍 200,data.repeated=true)。实现上先整笔回滚(本次零残留),再回放。
+func (s *FundService) mapIdempotentHit(err error, replay func() error) error {
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, apperr.ErrRepeated) {
+		return replay()
+	}
+	return err
+}
+
+// balanceAfterOf 从流水行取 balance_after 快照(NULL → 0,幂等回放用)。
+//
+// 入参:lg 流水行(可 nil)。出参:(金额, error);balance_after 为 NULL 时返回 0 且无错
+// (历史行没有快照列,回放时余额字段退化为 0 并保持 repeated=true)。
+func balanceAfterOf(lg *model.UserExpenseLog) (money.Amount, error) {
+	if lg == nil || lg.BalanceAfter == nil {
+		return 0, nil
+	}
+	return parseAccountAmount(*lg.BalanceAfter, "t_user_expense_log.balance_after")
+}
+
+// replayRecharge 幂等回放(充值)。
+func (s *FundService) replayRecharge(ctx context.Context, in RechargeInput, out *RechargeResult) error {
+	hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
+	if err != nil {
+		return err
+	}
+	if hit == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: Recharge 幂等命中但首次流水已不可见"))
+	}
+	bal, err := balanceAfterOf(hit)
+	if err != nil {
+		return err
+	}
+	*out = RechargeResult{LogID: hit.ID, Balance: bal, Repeated: true}
+	return nil
+}
+
+// replayExpenditure 幂等回放(消费)。
+func (s *FundService) replayExpenditure(ctx context.Context, in ExpenditureInput, out *ExpenditureResult) error {
+	hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
+	if err != nil {
+		return err
+	}
+	if hit == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: Expenditure 幂等命中但首次流水已不可见"))
+	}
+	bal, err := balanceAfterOf(hit)
+	if err != nil {
+		return err
+	}
+	*out = ExpenditureResult{LogID: hit.ID, Balance: bal, Repeated: true}
+	return nil
+}
+
+// replayRefund 幂等回放(退费)。
+func (s *FundService) replayRefund(ctx context.Context, in RefundInput, out *RefundResult) error {
+	hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
+	if err != nil {
+		return err
+	}
+	if hit == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: Refund 幂等命中但首次流水已不可见"))
+	}
+	bal, err := balanceAfterOf(hit)
+	if err != nil {
+		return err
+	}
+	*out = RefundResult{LogID: hit.ID, Balance: bal, Changed: true, Repeated: true}
+	return nil
+}
+
+// replayBadDebt 幂等回放(坏账)。
+func (s *FundService) replayBadDebt(ctx context.Context, in BadDebtInput, out *BadDebtResult) error {
+	hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
+	if err != nil {
+		return err
+	}
+	if hit == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: BadDebt 幂等命中但首次流水已不可见"))
+	}
+	var spec, bal money.Amount
+	if acc, ae := s.repo.GetCampusAccount(ctx, nil, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
+		if v, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
+			spec = v
+		}
+		if v, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
+			bal = v
+		}
+	}
+	*out = BadDebtResult{LogID: hit.ID, SpecialExpenses: spec, Balance: bal, Repeated: true}
+	return nil
+}
+
+// replayTransfer 幂等回放(转结)。
+func (s *FundService) replayTransfer(ctx context.Context, in TransferInput, out *TransferResult) error {
+	hit106, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferOut)
+	if err != nil {
+		return err
+	}
+	if hit106 == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: Transfer 幂等命中但首次流水已不可见"))
+	}
+	res := TransferResult{LogID106: hit106.ID, Repeated: true}
+	if bal, pe := balanceAfterOf(hit106); pe == nil {
+		res.FromBalance = bal
+	}
+	hit206, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
+	if err != nil {
+		return err
+	}
+	if hit206 != nil {
+		res.LogID206 = hit206.ID
+		if bal, pe := balanceAfterOf(hit206); pe == nil {
+			res.ToBalance = bal
+		}
+	}
+	*out = res
+	return nil
+}
+
+// isKnownLogType 流水类型白名单(`model.ExpenseLogType*` 全集,§10.2)。
+//
+// 用途:`Refund` 的 type 来自调用方(dump 透传),必须落在已知编码内,
+// 否则会污染流水表并让"按 type 汇总"的对账口径失效。
+func isKnownLogType(typ int32) bool {
+	switch typ {
+	case model.ExpenseLogTypeRecharge,
+		model.ExpenseLogTypeCourseCampus,
+		model.ExpenseLogTypeCourseSchoolA,
+		model.ExpenseLogTypeCourseSchoolB,
+		model.ExpenseLogTypeTransferOut,
+		model.ExpenseLogTypeRecommend,
+		model.ExpenseLogTypeBadDebt,
+		model.ExpenseLogTypeHistoryDeduct,
+		model.ExpenseLogTypeCourseCampusB,
+		model.ExpenseLogTypeCourseSchoolC,
+		model.ExpenseLogTypeCourseSchoolD,
+		model.ExpenseLogTypeTransferIn,
+		model.ExpenseLogTypeRefund,
+		model.ExpenseLogTypeClearBalance,
+		model.ExpenseLogTypeCancelRechargeAct:
+		return true
+	default:
+		return false
+	}
+}

+ 775 - 0
internal/academic/service/fund_service_test.go

@@ -0,0 +1,775 @@
+// FundService 单测(AC-04):充值双表 / 幂等(含 20 并发)/ 消费三列组合 / 退费 BUG#2 /
+// 可退金额 / 坏账 / 累计消费 / 落库金额越界负例。
+//
+// 测试栈(与 AC-03 仓储测试同一套手法):
+//   - 行为用例:glebarez/sqlite 文件库(真实事务、真实唯一键 uk_request_type);
+//   - SQL 顺序用例:给 GORM 挂"抓 SQL"的 logger(sqlRecorder),断言仓储**真实生成**的语句顺序;
+//   - 索引纪律:academic model 多表共用 `idx_inst_campus` 等索引名,SQLite 的索引名是**库级**唯一
+//     (MySQL 表级),故 migrateFundTables 逐表迁移后**只删非唯一索引**——
+//     `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 必须保留,
+//     否则幂等用例会静默变成"永远不撞键"的假绿。
+package service
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试基础设施
+
+// sqlRecorder GORM logger:按执行顺序收集 SQL(不改写、不过滤)。
+//
+// 用途:断言"全局账户锁先于校区账户锁"这类**顺序性**红约束(顺序错 = 与并发充值交叉即成环)。
+// 只实现 logger.Interface 的最小集合(Trace 收到 Explain 后的完整 SQL)。
+type sqlRecorder struct {
+	mu    sync.Mutex
+	stmts []string
+}
+
+// Printf 收口(logger.Writer 兼容方法)。
+func (r *sqlRecorder) Printf(format string, args ...any) {
+	msg := strings.TrimSpace(fmt.Sprintf(format, args...))
+	if msg == "" {
+		return
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.stmts = append(r.stmts, msg)
+}
+
+// LogMode 实现 logger.Interface(不做级别过滤)。
+func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
+
+// Info 实现 logger.Interface(单测不需要普通日志)。
+func (r *sqlRecorder) Info(context.Context, string, ...any) {}
+
+// Warn 实现 logger.Interface。
+func (r *sqlRecorder) Warn(context.Context, string, ...any) {}
+
+// Error 实现 logger.Interface。
+func (r *sqlRecorder) Error(context.Context, string, ...any) {}
+
+// Trace 实现 logger.Interface:把本次语句追加进 stmts。
+func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
+	sql, _ := fc()
+	r.Printf("%s", sql)
+}
+
+// Snapshot 返回当前收集到的语句快照(调用方不得修改返回切片内部元素语义)。
+func (r *sqlRecorder) Snapshot() []string {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	out := make([]string, len(r.stmts))
+	copy(out, r.stmts)
+	return out
+}
+
+// Reset 清空收集。
+func (r *sqlRecorder) Reset() {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.stmts = nil
+}
+
+// migrateFundTables 逐表 AutoMigrate,并**只删除非唯一索引**。
+//
+// 为什么只删非唯一:SQLite 索引名库级唯一(`idx_inst_campus` 同时出现在
+// t_user_expense_school 与 t_courses_activity_signed),一次性迁移会报 already exists;
+// 但 `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 是**幂等与唯一性的实现载体**,
+// 删掉会让"并发重复提交只落 1 条流水"的用例变成永远通过的假绿。
+func migrateFundTables(t *testing.T, db *gorm.DB, tables ...any) {
+	t.Helper()
+	for _, tbl := range tables {
+		if err := db.AutoMigrate(tbl); err != nil {
+			t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
+		}
+		stmt := &gorm.Statement{DB: db}
+		if err := stmt.Parse(tbl); err != nil {
+			t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
+		}
+		type idxRow struct {
+			Name string
+			SQL  *string
+		}
+		var rows []idxRow
+		if err := db.Raw(
+			"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
+			stmt.Schema.Table).Scan(&rows).Error; err != nil {
+			t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
+		}
+		for _, row := range rows {
+			sqlText := ""
+			if row.SQL != nil {
+				sqlText = strings.ToUpper(*row.SQL)
+			}
+			if strings.Contains(sqlText, "UNIQUE") {
+				continue // 唯一约束必须保留(幂等键/业务 UK)
+			}
+			if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
+				t.Fatalf("删除索引(%s) 失败: %v", row.Name, err)
+			}
+		}
+	}
+}
+
+// newFundServiceDB 余额域测试库(余额 3 表 + 活动 2 表 + 简化 t_campus)。
+//
+// t_campus 用原生 DDL 建(academic 侧只有 port 的只读 row struct,不参与 AutoMigrate,
+// 这正是设计 §1.3「跨域只读」的落地形态:academic 不建 org 的表,只读)。
+func newFundServiceDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	db, _ := newFundServiceDBWithRecorder(t)
+	return db
+}
+
+// newFundServiceDBWithRecorder 同 newFundServiceDB,并返回 SQL 记录器。
+func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_fund_service_test.db")
+	rec := &sqlRecorder{}
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: rec})
+	if err != nil {
+		t.Fatalf("gorm.Open(余额域文件库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	// 并发用例需要"多 goroutine 抢同一库":SQLite 写事务必须串行,
+	// 单连接池即可让 20 个 goroutine 依次推进(生产 MySQL 是行锁串行,此处用连接池串行等价模拟)。
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+
+	migrateFundTables(t, db,
+		&model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
+		&model.CoursesActivity{}, &model.CoursesActivitySigned{},
+	)
+	// 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟)
+	if err := db.Exec(`CREATE TABLE t_campus (
+		campus_id INTEGER PRIMARY KEY,
+		institution_id INTEGER NOT NULL DEFAULT 0,
+		self_support BOOLEAN NOT NULL DEFAULT 1,
+		status VARCHAR(32) NOT NULL DEFAULT 'active'
+	)`).Error; err != nil {
+		t.Fatalf("建 t_campus 失败: %v", err)
+	}
+	return db, rec
+}
+
+// fundUserID 生成 26 位类 ULID 的测试用户 ID(列宽 varchar(26))。
+func fundUserID(i int) string { return fmt.Sprintf("01HUSER%019d", i) }
+
+// fundScope 构造普通校区作用域(机构 1 / 校区 10)。
+func fundScope() *appscope.Scope {
+	return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "finance"}
+}
+
+// fundScopeFor 构造指定机构/校区的作用域(跨机构用例)。
+func fundScopeFor(institutionID, campusID int64) *appscope.Scope {
+	return &appscope.Scope{InstitutionID: institutionID, CampusID: campusID, UserID: "01HOPERATOR0000000000001", Role: "finance"}
+}
+
+// fundSuperScope 平台超管作用域(跨机构视角,用于"必须能同时看到两个机构"的用例)。
+func fundSuperScope() *appscope.Scope {
+	return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true}
+}
+
+// mustAmount 解析金额(测试内固定字面量)。
+func mustAmount(t *testing.T, s string) money.Amount {
+	t.Helper()
+	a, err := money.Parse(s)
+	if err != nil {
+		t.Fatalf("金额解析失败 %q: %v", s, err)
+	}
+	return a
+}
+
+// seedCampus 落一行 t_campus(self_support: true=自营 / false=加盟)。
+func seedCampus(t *testing.T, db *gorm.DB, campusID, institutionID int64, selfSupport bool) {
+	t.Helper()
+	if err := db.Exec(
+		"INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(?,?,?,?)",
+		campusID, institutionID, selfSupport, "active").Error; err != nil {
+		t.Fatalf("seed t_campus(%d) 失败: %v", campusID, err)
+	}
+}
+
+// readGlobal 读全局钱包。
+func readGlobal(t *testing.T, db *gorm.DB, userID string) model.UserExpense {
+	t.Helper()
+	var row model.UserExpense
+	if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
+		t.Fatalf("读全局账户(%s) 失败: %v", userID, err)
+	}
+	return row
+}
+
+// readSchool 读校区账户。
+func readSchool(t *testing.T, db *gorm.DB, userID string, institutionID, campusID int64) model.UserExpenseSchool {
+	t.Helper()
+	var row model.UserExpenseSchool
+	if err := db.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
+		Take(&row).Error; err != nil {
+		t.Fatalf("读校区账户(%s/%d/%d) 失败: %v", userID, institutionID, campusID, err)
+	}
+	return row
+}
+
+// logsByType 取某用户的指定 type 流水(按 id ASC)。
+func logsByType(t *testing.T, db *gorm.DB, userID string, typ int32) []model.UserExpenseLog {
+	t.Helper()
+	var rows []model.UserExpenseLog
+	if err := db.Where("user_id = ? AND type = ?", userID, typ).Order("id ASC").Find(&rows).Error; err != nil {
+		t.Fatalf("读流水(type=%d) 失败: %v", typ, err)
+	}
+	return rows
+}
+
+// countLogs 统计某用户指定 type 的流水条数。
+func countLogsByType(t *testing.T, db *gorm.DB, userID string, typ int32) int {
+	t.Helper()
+	return len(logsByType(t, db, userID, typ))
+}
+
+// ---------------------------------------------------------------- 充值(SP1 / SP2)
+
+// TestRechargeUpdatesBothTables 充值双表同事务:全局 + 校区 balance/total_income 同步,流水含 balance_after。
+func TestRechargeUpdatesBothTables(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(1)
+
+	res, err := svc.Recharge(ctx, RechargeInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-RECHARGE-1",
+	})
+	if err != nil {
+		t.Fatalf("Recharge 失败: %v", err)
+	}
+	if res.Repeated {
+		t.Fatalf("首次充值不应 repeated=true")
+	}
+	if res.LogID <= 0 {
+		t.Fatalf("logId 应 > 0,实得 %d", res.LogID)
+	}
+	if got := cents(t, res.Balance.String()); got != 10000 {
+		t.Fatalf("返回余额=%d 分,期望 10000", got)
+	}
+
+	global := readGlobal(t, db, user)
+	if got := cents(t, global.Balance); got != 10000 {
+		t.Fatalf("全局 balance=%d 分,期望 10000", got)
+	}
+	if got := cents(t, global.TotalIncome); got != 10000 {
+		t.Fatalf("全局 total_income=%d 分,期望 10000", got)
+	}
+	school := readSchool(t, db, user, 1, 10)
+	if got := cents(t, school.Balance); got != 10000 {
+		t.Fatalf("校区 balance=%d 分,期望 10000", got)
+	}
+	if got := cents(t, school.TotalIncome); got != 10000 {
+		t.Fatalf("校区 total_income=%d 分,期望 10000", got)
+	}
+
+	logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
+	if len(logs) != 1 {
+		t.Fatalf("充值流水应为 1 条,实得 %d", len(logs))
+	}
+	if got := cents(t, logs[0].Money); got != 10000 {
+		t.Fatalf("流水 money=%d 分,期望 10000(方向为正)", got)
+	}
+	if logs[0].RequestID == nil || *logs[0].RequestID != "REQ-RECHARGE-1" {
+		t.Fatalf("流水 request_id 未落:%+v", logs[0].RequestID)
+	}
+	if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
+		t.Fatalf("流水 balance_after 快照错误:%+v", logs[0].BalanceAfter)
+	}
+	if logs[0].Remark == nil || *logs[0].Remark != defaultRechargeRemark {
+		t.Fatalf("流水 remark 默认文案错误:%+v", logs[0].Remark)
+	}
+}
+
+// TestRechargeKindSetsSchoolRemark SP1 / SP2 的唯一差异:默认备注文案。
+func TestRechargeKindSetsSchoolRemark(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(2)
+
+	if _, err := svc.Recharge(ctx, RechargeInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-KIND-1", Kind: RechargeKindSchool,
+	}); err != nil {
+		t.Fatalf("Recharge(Kind=School) 失败: %v", err)
+	}
+	logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
+	if len(logs) != 1 || logs[0].Remark == nil || *logs[0].Remark != defaultSchoolRechargeRemark {
+		t.Fatalf("SP2 默认备注应为 %q,实得 %+v", defaultSchoolRechargeRemark, logs)
+	}
+}
+
+// TestRechargeIdempotentSameRequestID 同 requestId 第二次提交 → repeated=true、不重复入账。
+func TestRechargeIdempotentSameRequestID(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(3)
+	in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "50.00"), RequestID: "REQ-IDEMPOTENT-1"}
+
+	first, err := svc.Recharge(ctx, in)
+	if err != nil {
+		t.Fatalf("首次充值失败: %v", err)
+	}
+	second, err := svc.Recharge(ctx, in)
+	if err != nil {
+		t.Fatalf("重复充值应返回成功(幂等),实得错误: %v", err)
+	}
+	if !second.Repeated {
+		t.Fatalf("重复提交应 repeated=true")
+	}
+	if second.LogID != first.LogID {
+		t.Fatalf("重复提交应回放首次 logId=%d,实得 %d", first.LogID, second.LogID)
+	}
+	if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
+		t.Fatalf("流水应只 1 条,实得 %d", count)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 5000 {
+		t.Fatalf("余额应只变一次(5000 分),实得 %d", got)
+	}
+}
+
+// TestRechargeConcurrentSameRequestID 20 goroutine 同 requestId → 1 条流水、余额只变一次。
+//
+// SQLite 无行锁,用单连接池把写事务串行化(等价于 MySQL 的行锁串行语义);
+// 断言点与生产一致:**流水条数**与**余额增量**(不是"是否报错")。
+func TestRechargeConcurrentSameRequestID(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(4)
+	in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "30.00"), RequestID: "REQ-CONCURRENT-1"}
+
+	const goroutines = 20
+	var wg sync.WaitGroup
+	results := make([]*RechargeResult, goroutines)
+	errs := make([]error, goroutines)
+	for i := 0; i < goroutines; i++ {
+		wg.Add(1)
+		go func(idx int) {
+			defer wg.Done()
+			results[idx], errs[idx] = svc.Recharge(ctx, in)
+		}(i)
+	}
+	wg.Wait()
+
+	for i, err := range errs {
+		if err != nil {
+			t.Fatalf("并发第 %d 次充值失败: %v", i, err)
+		}
+	}
+	if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
+		t.Fatalf("并发同 requestId 应只 1 条流水,实得 %d", count)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 3000 {
+		t.Fatalf("余额应只入账一次(3000 分),实得 %d", got)
+	}
+	repeated := 0
+	for _, r := range results {
+		if r != nil && r.Repeated {
+			repeated++
+		}
+	}
+	if repeated != goroutines-1 {
+		t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated)
+	}
+}
+
+// TestRechargeValidation 入参校验:空用户 2001 / 校区缺失 3140 / 金额<=0 与超上限 2002。
+func TestRechargeValidation(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+
+	cases := []struct {
+		name string
+		in   RechargeInput
+		want int
+	}{
+		{"空用户", RechargeInput{UserID: "", InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "1.00")}, 2001},
+		{"校区缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 0, Money: mustAmount(t, "1.00")}, 3140},
+		{"机构缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 0, CampusID: 10, Money: mustAmount(t, "1.00")}, 3140},
+		{"金额为 0", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: 0}, 2002},
+		{"金额为负", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "-1.00")}, 2002},
+		{"超单笔上限", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "1000000.01")}, 2002},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := svc.Recharge(ctx, tc.in)
+			requireAppCode(t, err, tc.want)
+		})
+	}
+}
+
+// ---------------------------------------------------------------- 消费(SP3)
+
+// TestExpenditureTypeBranches dump 的两个列组合分支 + 流水方向为负。
+func TestExpenditureTypeBranches(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(6)
+
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+
+	// type=200(历史扣费):balance−、total_expenditure+、spend+
+	if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "30.00"), Type: model.ExpenseLogTypeHistoryDeduct, RequestID: "REQ-EXP-200"}); err != nil {
+		t.Fatalf("Expenditure(200) 失败: %v", err)
+	}
+	school := readSchool(t, db, user, 1, 10)
+	if got := cents(t, school.Balance); got != 7000 {
+		t.Fatalf("200 后 balance 应 7000 分,实得 %d", got)
+	}
+	if got := cents(t, school.TotalExpenditure); got != 3000 {
+		t.Fatalf("200 后 total_expenditure 应 3000 分,实得 %d", got)
+	}
+	if got := cents(t, school.Spend); got != 3000 {
+		t.Fatalf("200 后 spend 应 3000 分,实得 %d", got)
+	}
+
+	// type=201(校区报课消费):balance−、total_expenditure+,**spend 不动**
+	if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampusB, RequestID: "REQ-EXP-201"}); err != nil {
+		t.Fatalf("Expenditure(201) 失败: %v", err)
+	}
+	school = readSchool(t, db, user, 1, 10)
+	if got := cents(t, school.Balance); got != 5000 {
+		t.Fatalf("201 后 balance 应 5000 分,实得 %d", got)
+	}
+	if got := cents(t, school.TotalExpenditure); got != 5000 {
+		t.Fatalf("201 后 total_expenditure 应 5000 分,实得 %d", got)
+	}
+	if got := cents(t, school.Spend); got != 3000 {
+		t.Fatalf("201 不应改 spend(应仍 3000 分),实得 %d", got)
+	}
+	// 全局表同列同步
+	global := readGlobal(t, db, user)
+	if got := cents(t, global.Balance); got != 5000 {
+		t.Fatalf("全局 balance 应 5000 分,实得 %d", got)
+	}
+	if got := cents(t, global.TotalExpenditure); got != 5000 {
+		t.Fatalf("全局 total_expenditure 应 5000 分,实得 %d", got)
+	}
+
+	// 流水金额恒为负(dump: `VALUES(...,-inputMoney,...)`)
+	logs := logsByType(t, db, user, model.ExpenseLogTypeHistoryDeduct)
+	if len(logs) != 1 {
+		t.Fatalf("200 流水应 1 条,实得 %d", len(logs))
+	}
+	if got := cents(t, logs[0].Money); got != -3000 {
+		t.Fatalf("200 流水 money 应 -3000 分,实得 %d", got)
+	}
+}
+
+// TestExpenditureInvalidType 白名单外 type → 2002(dump 会静默改成 200,Go 显式拒绝)。
+func TestExpenditureInvalidType(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	_, err := svc.Expenditure(context.Background(), ExpenditureInput{
+		UserID: fundUserID(7), InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "10.00"), Type: 999,
+	})
+	requireAppCode(t, err, 2002)
+}
+
+// ---------------------------------------------------------------- 退费(SP4,BUG#2)
+
+// TestRefundInstitutionFilterPreventsCrossInstitutionUpdate BUG#2 回归:
+// 同一 campus_id 在两个机构都有账户行时,只允许更新入参机构那一行。
+//
+// dump 的 WHERE 是 `userid + campusid`(无 schoolid)→ 两行都会被改;Go 补 institution_id。
+func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil) // 超管视角:底层两行都可见
+	ctx := context.Background()
+	user := fundUserID(8)
+
+	// 两个机构、同一个 campus_id=10
+	for _, inst := range []int64{1, 2} {
+		if err := db.Create(&model.UserExpenseSchool{
+			UserID: user, InstitutionID: inst, CampusID: 10,
+			Balance: "100.00", TotalExpenditure: "50.00",
+		}).Error; err != nil {
+			t.Fatalf("seed 校区账户(inst=%d) 失败: %v", inst, err)
+		}
+	}
+	if err := db.Create(&model.UserExpense{UserID: user, Balance: "200.00", TotalExpenditure: "50.00"}).Error; err != nil {
+		t.Fatalf("seed 全局账户失败: %v", err)
+	}
+
+	res, err := svc.Refund(ctx, RefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"),
+		Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-REFUND-1",
+	})
+	if err != nil {
+		t.Fatalf("Refund 失败: %v", err)
+	}
+	if !res.Changed {
+		t.Fatalf("Refund 应 changed=true")
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 12000 {
+		t.Fatalf("目标机构 balance 应 12000 分,实得 %d", got)
+	}
+	// 另一机构必须零变动(BUG#2 的核心断言)
+	if got := cents(t, readSchool(t, db, user, 2, 10).Balance); got != 10000 {
+		t.Fatalf("另一机构 balance 必须不变(10000 分),实得 %d", got)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).TotalExpenditure); got != 3000 {
+		t.Fatalf("目标机构 total_expenditure 应 3000 分(退费回冲),实得 %d", got)
+	}
+	logs := logsByType(t, db, user, model.ExpenseLogTypeCourseCampus)
+	if len(logs) != 1 || cents(t, logs[0].Money) != 2000 {
+		t.Fatalf("退费流水应为 +2000 分一条,实得 %+v", logs)
+	}
+}
+
+// TestRefundZeroMoneyIsNoop money<=0 → changed=false(对齐 dump `IF inputMoney>0`),不写流水。
+func TestRefundZeroMoneyIsNoop(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	user := fundUserID(9)
+	res, err := svc.Refund(context.Background(), RefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: 0, Type: model.ExpenseLogTypeCourseCampus,
+	})
+	if err != nil {
+		t.Fatalf("money=0 应返回成功(no-op),实得错误: %v", err)
+	}
+	if res.Changed || res.Repeated || res.LogID != 0 {
+		t.Fatalf("money=0 应 changed=false 且无流水,实得 %+v", res)
+	}
+	if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
+		t.Fatalf("no-op 不应写流水,实得 %d 条", count)
+	}
+}
+
+// TestRefundAccountMissing 账户未建户 → 3001(dump 静默无操作;Go 显式失败)。
+func TestRefundAccountMissing(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	_, err := svc.Refund(context.Background(), RefundInput{
+		UserID: fundUserID(10), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "5.00"),
+		Type: model.ExpenseLogTypeCourseCampus,
+	})
+	requireAppCode(t, err, 3001)
+}
+
+// ---------------------------------------------------------------- 可退金额(SP5)
+
+// TestRefundableAmount 账户不存在 → 0;负余额 → 0;正余额 → 余额。
+func TestRefundableAmount(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+
+	// 账户不存在
+	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)
+	}
+	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)
+	}
+	if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
+		t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
+	}
+	// 校区缺失 → 3140
+	if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
+		requireAppCode(t, err, 3140)
+	}
+}
+
+// ---------------------------------------------------------------- 坏账(SP7)
+
+// TestBadDebtOnlySpecialExpenses 只加 special_expenses(**不动 balance**),108 流水为正。
+func TestBadDebtOnlySpecialExpenses(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(14)
+
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	res, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "30.00")})
+	if err != nil {
+		t.Fatalf("BadDebt 失败: %v", err)
+	}
+	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.SpecialExpenses); got != 3000 {
+		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 != 10000 {
+		t.Fatalf("全局 balance 不应变(应仍 10000 分),实得 %d", got)
+	}
+	if res.SpecialExpenses.Cents() != 3000 {
+		t.Fatalf("返回 specialExpenses 应 3000 分,实得 %d", res.SpecialExpenses.Cents())
+	}
+	logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
+	if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
+		t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
+	}
+}
+
+// ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
+
+// TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。
+func TestAccumulateSpendOnlySpend(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(15)
+
+	// 账户不存在时也应能建户并写 spend(dump 的 INSERT 分支;BUG#1 修正前该分支是 3 值对 2 列必报错)
+	res, err := svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		SpendMoney: mustAmount(t, "25.00")})
+	if err != nil {
+		t.Fatalf("AccumulateSpend 失败: %v", err)
+	}
+	if res.Spend.Cents() != 2500 {
+		t.Fatalf("返回 spend 应 2500 分,实得 %d", res.Spend.Cents())
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Spend); got != 2500 {
+		t.Fatalf("校区 spend 应 2500 分,实得 %d", got)
+	}
+	if got := cents(t, readGlobal(t, db, user).Spend); got != 2500 {
+		t.Fatalf("全局 spend 应 2500 分,实得 %d", got)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 0 {
+		t.Fatalf("spend SP 不得改 balance(应 0),实得 %d", got)
+	}
+	var total int64
+	if err := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", user).Count(&total).Error; err != nil {
+		t.Fatalf("统计流水失败: %v", err)
+	}
+	if total != 0 {
+		t.Fatalf("spend SP 不写流水(dump 无 INSERT log),实得 %d 条", total)
+	}
+
+	// 负数一律归 0(dump INSERT 分支同口径;spend 只增)
+	res, err = svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		SpendMoney: mustAmount(t, "-10.00")})
+	if err != nil {
+		t.Fatalf("AccumulateSpend(负数) 失败: %v", err)
+	}
+	if res.Spend.Cents() != 2500 {
+		t.Fatalf("负数应被归 0(spend 仍 2500 分),实得 %d", res.Spend.Cents())
+	}
+}
+
+// ---------------------------------------------------------------- 幂等键与金额越界(负例)
+
+// TestInsertLogDuplicateMapsToRepeated 撞 uk_request_type → 内部哨兵 3114(不是 5002)。
+func TestInsertLogDuplicateMapsToRepeated(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(16)
+
+	mk := func() *model.UserExpenseLog {
+		return newExpenseLog(user, model.ExpenseLogTypeRecharge, mustAmount(t, "1.00"),
+			"REQ-DUP-1", "dup", "", nil)
+	}
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
+	}); err != nil {
+		t.Fatalf("首次写流水失败: %v", err)
+	}
+	err := db.Transaction(func(tx *gorm.DB) error {
+		return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
+	})
+	if !errors.Is(err, apperr.ErrRepeated) {
+		t.Fatalf("同 request_id+type 二次写入应映射为 3114 幂等哨兵,实得: %v", err)
+	}
+}
+
+// TestAmountOverflowRejectedBeforeWrite AC-04 的落库负例:
+// 超出 decimal(12,2) 全域的金额必须在**写库前**被 `Amount.Validate()` 拦下(ErrOverflow),
+// 且账户零残留(不是"写了个截断值")。
+func TestAmountOverflowRejectedBeforeWrite(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(17)
+
+	if err := db.Create(&model.UserExpense{UserID: user, Balance: "0.00"}).Error; err != nil {
+		t.Fatalf("seed 全局账户失败: %v", err)
+	}
+	if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
+		Balance: "0.00"}).Error; err != nil {
+		t.Fatalf("seed 校区账户失败: %v", err)
+	}
+
+	overflow := money.MaxAmount + 1
+	err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := svc.repo.AddColumnsGlobalThenCampus(ctx, tx, "OverflowTest", user, 1, 10,
+			repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: overflow})
+		return e
+	})
+	if !errors.Is(err, money.ErrOverflow) {
+		t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err)
+	}
+	var appErr *infraerrors.AppError
+	if !errors.As(err, &appErr) {
+		t.Fatalf("越界金额应返回 *AppError,实得: %v", err)
+	}
+	// 仓储层(AC-03 既有形状)把越界金额挂在 2001 哨兵上;Service 层对**入参**金额用 2002
+	// (见 validatePositiveAmount)。此处断言的是"落库前被拦下"这一红约束,两层都属 2xxx 参数错。
+	if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d: %v", appErr.Code, err)
+	}
+	if got := cents(t, readGlobal(t, db, user).Balance); got != 0 {
+		t.Fatalf("越界金额不得落库(余额应仍 0),实得 %d", got)
+	}
+}

+ 276 - 0
internal/academic/service/fund_service_transfer_test.go

@@ -0,0 +1,276 @@
+// FundService.Transfer 单测(AC-04 ④):课费转结按 **dump 语义**钉死——
+// 不动 balance、106=+m / 206=−m、两侧 total_expenditure/total_income 反向调整、
+// 目标账户不存在则建户、同校区自转与源账户缺失显式失败、同 requestId 幂等。
+//
+// 测试栈与 fund_service_test.go 同一套(sqlite 文件库 + 真实唯一键;助手复用同 package)。
+package service
+
+import (
+	"context"
+	"regexp"
+	"strconv"
+	"strings"
+	"testing"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+)
+
+// TestTransferLeavesBalanceUntouchedAndWritesDumpDirections 转结核心口径(★dump 漂移项)。
+//
+// dump(yuansiwei_finance.sql:764661):
+//
+//	源:total_expenditure−m, total_income−m ;目标:total_expenditure+m, total_income+m
+//	log 106 = **+m**(转结出);log 206 = **−m**(转结入);**两端 balance 都不动**
+//
+// 与 P0-04 规格(106 负 / 206 正 且动 balance)相反——本用例就是把 dump 口径钉死的地方。
+func TestTransferLeavesBalanceUntouchedAndWritesDumpDirections(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil) // 转结跨机构/跨校区:用超管作用域
+	ctx := context.Background()
+	user := fundUserID(20)
+
+	// 源校区(机构 1 / 校区 10)余额 100,目标校区(机构 1 / 校区 20)尚不存在
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-TRANSFER-SEED"}); err != nil {
+		t.Fatalf("准备源校区余额失败: %v", err)
+	}
+
+	res, err := svc.Transfer(ctx, TransferInput{
+		UserID:            user,
+		FromInstitutionID: 1, FromCampusID: 10,
+		ToInstitutionID: 1, ToCampusID: 20,
+		Money: mustAmount(t, "40.00"), RequestID: "REQ-TRANSFER-1", Remark: "课费转结",
+	})
+	if err != nil {
+		t.Fatalf("Transfer 失败: %v", err)
+	}
+	if res.LogID106 <= 0 || res.LogID206 <= 0 {
+		t.Fatalf("两条流水 ID 都应 > 0,实得 %+v", res)
+	}
+
+	from := readSchool(t, db, user, 1, 10)
+	to := readSchool(t, db, user, 1, 20)
+
+	// ① **balance 不动**(dump 全程没有 balance 赋值)
+	if got := cents(t, from.Balance); got != 10000 {
+		t.Fatalf("源校区 balance 不得变动(应 10000 分),实得 %d", got)
+	}
+	if got := cents(t, to.Balance); got != 0 {
+		t.Fatalf("目标校区 balance 不得变动(应 0 分),实得 %d", got)
+	}
+	// ② 源:total_expenditure/total_income 各 −40;目标:各 +40
+	//    注意:充值只累加 balance/total_income,不动 total_expenditure,
+	//    故源 total_expenditure 是 0 − 4000 = **−4000**(dump 无下限保护,按 dump 保留负值)
+	if got := cents(t, from.TotalExpenditure); got != -4000 {
+		t.Fatalf("源校区 total_expenditure 应 -4000 分(0−4000,dump 无下限),实得 %d", got)
+	}
+	if got := cents(t, from.TotalIncome); got != 6000 {
+		t.Fatalf("源校区 total_income 应 6000 分,实得 %d", got)
+	}
+	if got := cents(t, to.TotalExpenditure); got != 4000 {
+		t.Fatalf("目标校区 total_expenditure 应 4000 分,实得 %d", got)
+	}
+	if got := cents(t, to.TotalIncome); got != 4000 {
+		t.Fatalf("目标校区 total_income 应 4000 分,实得 %d", got)
+	}
+	// ③ 全局钱包不参与转结
+	global := readGlobal(t, db, user)
+	if got := cents(t, global.Balance); got != 10000 {
+		t.Fatalf("全局 balance 不应变动(应 10000 分),实得 %d", got)
+	}
+	if got := cents(t, global.TotalExpenditure); got != 0 {
+		t.Fatalf("全局 total_expenditure 不应变动(应 0),实得 %d", got)
+	}
+
+	// ④ 流水方向:106 = +4000,206 = −4000(★与"出/入"直觉相反,按 dump)
+	logs106 := logsByType(t, db, user, model.ExpenseLogTypeTransferOut)
+	logs206 := logsByType(t, db, user, model.ExpenseLogTypeTransferIn)
+	if len(logs106) != 1 || cents(t, logs106[0].Money) != 4000 {
+		t.Fatalf("106 流水应为 +4000 分一条,实得 %+v", logs106)
+	}
+	if len(logs206) != 1 || cents(t, logs206[0].Money) != -4000 {
+		t.Fatalf("206 流水应为 -4000 分一条,实得 %+v", logs206)
+	}
+}
+
+// TestTransferIdempotentSameRequestID 同 requestId 再转 → 两条流水都不新增、金额不再变动。
+func TestTransferIdempotentSameRequestID(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(21)
+
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	in := TransferInput{
+		UserID:            user,
+		FromInstitutionID: 1, FromCampusID: 10,
+		ToInstitutionID: 1, ToCampusID: 20,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-IDEMPOTENT",
+	}
+	first, err := svc.Transfer(ctx, in)
+	if err != nil {
+		t.Fatalf("首次转结失败: %v", err)
+	}
+	second, err := svc.Transfer(ctx, in)
+	if err != nil {
+		t.Fatalf("重复转结应幂等成功: %v", err)
+	}
+	if !second.Repeated {
+		t.Fatalf("重复转结应 repeated=true")
+	}
+	if second.LogID106 != first.LogID106 || second.LogID206 != first.LogID206 {
+		t.Fatalf("重复转结应回放首次流水 %+v,实得 %+v", first, second)
+	}
+	if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferOut)); got != 1 {
+		t.Fatalf("106 流水应只 1 条,实得 %d", got)
+	}
+	if got := len(logsByType(t, db, user, model.ExpenseLogTypeTransferIn)); got != 1 {
+		t.Fatalf("206 流水应只 1 条,实得 %d", got)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 9000 {
+		t.Fatalf("源校区 total_income 应只减一次(9000 分),实得 %d", got)
+	}
+}
+
+// TestTransferSameCampusRejected 同校区自转(净效应为零)→ 3140。
+func TestTransferSameCampusRejected(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundScope(), nil)
+	_, err := svc.Transfer(context.Background(), TransferInput{
+		UserID:            fundUserID(22),
+		FromInstitutionID: 1, FromCampusID: 10,
+		ToInstitutionID: 1, ToCampusID: 10,
+		Money: mustAmount(t, "10.00"),
+	})
+	requireAppCode(t, err, 3140)
+}
+
+// TestTransferSourceAccountMissing 源账户不存在 → 3001(dump 静默 no-op;Go 显式失败)。
+func TestTransferSourceAccountMissing(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil)
+	_, err := svc.Transfer(context.Background(), TransferInput{
+		UserID:            fundUserID(23),
+		FromInstitutionID: 1, FromCampusID: 10,
+		ToInstitutionID: 1, ToCampusID: 20,
+		Money: mustAmount(t, "10.00"),
+	})
+	requireAppCode(t, err, 3001)
+}
+
+// TestTransferRejectsInvalidAmount 金额 <=0 / 超上限 → 2002(不改任何账户)。
+func TestTransferRejectsInvalidAmount(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(24)
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	base := TransferInput{
+		UserID:            user,
+		FromInstitutionID: 1, FromCampusID: 10,
+		ToInstitutionID: 1, ToCampusID: 20,
+	}
+	for _, amount := range []string{"0.00", "-1.00", "1000000.01"} {
+		in := base
+		in.Money = mustAmount(t, amount)
+		_, err := svc.Transfer(ctx, in)
+		var want = 2002
+		requireAppCode(t, err, want)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 10000 {
+		t.Fatalf("非法金额不得改动账户(应仍 10000 分),实得 %d", got)
+	}
+}
+
+// TestTransferLocksCampusAccountsAscending 多账户加锁顺序:按 campus_id ASC(§1.4 防死锁)。
+//
+// 断言口径:抓真实执行的 SQL 序列,要求"较小 campus_id 的校区锁语句"先出现。
+func TestTransferLocksCampusAccountsAscending(t *testing.T) {
+	db, rec := newFundServiceDBWithRecorder(t)
+	svc := NewFundService(db, fundSuperScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(25)
+
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 20,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	// 源校区 campus_id=20,目标校区 campus_id=10 → 加锁必须先 10 后 20(升序)
+	rec.Reset()
+	if _, err := svc.Transfer(ctx, TransferInput{
+		UserID:            user,
+		FromInstitutionID: 1, FromCampusID: 20,
+		ToInstitutionID: 1, ToCampusID: 10,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-TRANSFER-LOCK",
+	}); err != nil {
+		t.Fatalf("Transfer 失败: %v", err)
+	}
+	order := campusLockOrder(t, rec.Snapshot())
+	if order.firstCampusID != 10 || order.secondCampusID != 20 {
+		t.Fatalf("校区加锁顺序应为 10 → 20(campus_id ASC),实得 %d → %d",
+			order.firstCampusID, order.secondCampusID)
+	}
+}
+
+// campusLockOrder 从 SQL 序列里提取"两次校区账户 SELECT ... FOR UPDATE 命中的 campus_id"。
+//
+// 实现说明:sqlite 不渲染 `FOR UPDATE`,故用**语句里的 campus_id 字面量**判定命中的是哪一行
+// (GetCampusForUpdateExact 的 WHERE 一定带 `campus_id = ?` 参数,GORM 会把参数内联进日志)。
+type lockOrder struct {
+	firstCampusID  int64
+	secondCampusID int64
+}
+
+// campusLockOrder 解析加锁顺序(见类型注释)。
+func campusLockOrder(t *testing.T, stmts []string) lockOrder {
+	t.Helper()
+	var out lockOrder
+	seen := 0
+	for _, sql := range stmts {
+		if !strings.Contains(sql, "FROM") || !strings.Contains(sql, "t_user_expense_school") {
+			continue
+		}
+		if !strings.Contains(sql, "LIMIT") {
+			continue // 只要点查(加锁读)语句,跳过 UPDATE
+		}
+		id := extractCampusIDParam(t, sql)
+		if id <= 0 {
+			continue
+		}
+		seen++
+		switch seen {
+		case 1:
+			out.firstCampusID = id
+		case 2:
+			out.secondCampusID = id
+		}
+		if seen == 2 {
+			return out
+		}
+	}
+	t.Fatalf("未捕获到两次校区账户加锁语句(seen=%d),实际 SQL=%v", seen, stmts)
+	return out
+}
+
+// campusIDParamRe 匹配语句中被内联的 `campus_id = N` 参数(GORM 日志 Explain 会内联实参)。
+var campusIDParamRe = regexp.MustCompile(`campus_id\s*=\s*(-?\d+)`)
+
+// extractCampusIDParam 从单条 SQL 中取出 campus_id 实参(取不到 → 0)。
+func extractCampusIDParam(t *testing.T, sql string) int64 {
+	t.Helper()
+	m := campusIDParamRe.FindStringSubmatch(sql)
+	if len(m) < 2 {
+		return 0
+	}
+	v, err := strconv.ParseInt(m[1], 10, 64)
+	if err != nil {
+		return 0
+	}
+	return v
+}

+ 861 - 0
internal/academic/service/refund_service.go

@@ -0,0 +1,861 @@
+// 退费与清零服务(自定义退费双 log + 余额清零 + 取消充值活动)——AC-05。
+//
+// 依据:unified/03-migration/academic-domain-design.md §3.1 #8~#10(SP 映射与 dump 行号)
+// / §4.6(`CustomRefundResult` / `ClearBalanceResult` 字段契约,**逐字对齐**)
+// / §7.2(自定义退费时序:双账户锁 + 双 log + 活动失效 + 锁序)
+// / §3.8-b R1/R2(相对 dump 的两处有意强化)/ §10.3(事务)/ §10.7(金额)。
+//
+// SP 映射:
+//
+//	#8  sp_user_expense_cancel_recharge_activity  → CancelRechargeActivity(跨域读 t_campus.school_id)
+//	#9  sp_user_expense_custom_refund            → CustomRefund(**双 log:先 108 后 207**)
+//	#10 sp_user_expense_school_cleared_balance   → ClearBalance(★全局表扣减基数 = campusBalance)
+//
+// 三条与 dump 的**有意偏离**(设计 §3.8-b 已登记,双跑报告按 `INTENTIONAL-DEV` 归因):
+//
+//	R1  校区查不到:dump 的 `IF(NULL)` 让 `campusSelfSupport` 保持 0 → 静默走**加盟**分支;
+//	    Go 硬化:`CampusReader` 取不到 → **3104**("加盟校区退费误失效本校区活动"是不可接受的静默行为)。
+//	R2  并发:dump 读余额**无 `FOR UPDATE`**(生产存在并发超退);Go 对双账户显式加锁。
+//	    加锁顺序 **全局 `t_user_expense` → 校区 `t_user_expense_school`**(与 §7.2 / §1.4 的写序一致)。
+//	R-3 清零/退费前置失败:dump 静默 `LEAVE`(前端"成功但没变");Go 返回 3120 + 原因。
+//
+// 账务恒等式(CustomRefund,写进单测与 AC-17 ④,**不是各比各的**):
+//
+//	requested = refunded + badDebt
+//	账户净变动 = -refunded = 207(−requested) + 108(+badDebt)   ← 三条流水的代数和恒等于净扣减
+//	balance_after = balance_before - refunded
+package service
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"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"
+)
+
+// 自定义退费 / 余额清零 / 取消充值活动的默认备注(dump `IF inputRemark=”`,逐字对齐)。
+const (
+	// defaultCustomRefundRemark SP9 默认备注。
+	defaultCustomRefundRemark = "余额退费"
+	// defaultClearBalanceRemark SP10 默认备注。
+	defaultClearBalanceRemark = "余额清零"
+	// defaultCancelRechargeRemark SP8 默认备注。
+	defaultCancelRechargeRemark = "取消充值活动"
+	// badDebtRemarkSuffix SP9 dump 追加文案(`<br/>` 原样保留,前端按 HTML 渲染)。
+	badDebtRemarkSuffix = "<br/>退费的金额大于可退费部分,超出部分为坏账部分。"
+)
+
+// RefundService 退费与清零服务(depends: 余额仓储 + 活动失效器 + 校区只读端口)。
+//
+// 字段:
+//   - repo:余额账户仓储(全局 + 校区 + 流水);
+//   - runner:事务运行器(顶层 BEGIN,事务内透传);
+//   - campus:校区只读端口(跨域读 `t_campus`:self_support / institution_id,禁止跨库 SQL);
+//   - member:成员关系校验器(可 nil:系统任务模式);
+//   - expirer:活动失效器(与资金同事务)。
+type RefundService struct {
+	repo    *repository.ExpenseRepo
+	runner  *repository.TxRunner
+	campus  port.CampusReader
+	member  port.MembershipChecker
+	expirer *ActivityExpirer
+}
+
+// NewRefundService 构造退费与清零服务。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填);
+//   - sc *appscope.Scope:本次请求作用域;
+//   - campus port.CampusReader:校区只读端口(**必须注入**:自营/加盟判定与 3104 硬化都依赖它;
+//     实现见 `port.NewOrgCampusReader`,事务内读取走其 `TxCampusReader` 扩展);
+//   - member port.MembershipChecker:成员校验器(可 nil)。
+//
+// 出参:*RefundService。
+func NewRefundService(
+	db *gorm.DB, sc *appscope.Scope, campus port.CampusReader, member port.MembershipChecker,
+) *RefundService {
+	return &RefundService{
+		repo:    repository.NewExpenseRepo(db, sc),
+		runner:  repository.NewTxRunner(db),
+		campus:  campus,
+		member:  member,
+		expirer: NewActivityExpirer(db, sc),
+	}
+}
+
+// ---------------------------------------------------------------- DTO(§4.6 逐字对齐)
+
+// Warning warn 级提示(HTTP 200 / body code 200,前端按 code 展示文案)。
+type Warning struct {
+	// Code 31xx 业务码(当前仅 3103 可退金额不足)。
+	Code int `json:"code"`
+	// Message 后端出的人话文案(前端直显)。
+	Message string `json:"message"`
+}
+
+// CustomRefundResult 自定义退费响应(端点 6 / SP9)。
+//
+// 业务语义(对齐 dump `sp_user_expense_custom_refund`,双 log):
+//
+//	前置 balance <= 0 → 3102(无 result);requested <= 0 → 2002(不做 no-op,因本流程含活动失效副作用)。
+//	requested > balance:badDebt = requested - balance,扣减仅 balance;
+//	                    先写 108(+badDebt,remark 追加"退费的金额大于可退费部分,超出部分为坏账部分。"),
+//	                    再写 207(-requested,**全额**,★ 与扣减额不同,前端勿据此推导余额变化)。
+//	requested <= balance:badDebt = 0,无 108 流水,207 = -requested。
+//	净资金变化 = -(requested - badDebt) = -(refunded)。
+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)
+	Balance            money.Amount `json:"balance"`            // 退费后校区账户余额
+	TotalBalance       money.Amount `json:"totalBalance"`       // 退费后全局账户(跨校区)余额快照
+	TotalIncome        money.Amount `json:"totalIncome"`        // 退费后校区账户 total_income 快照
+	SpecialExpenses    money.Amount `json:"specialExpenses"`    // 退费后校区账户特殊费用累计(坏账累计口径)
+	LogID207           int64        `json:"logId207"`           // type=207 流水 ID(money = -requested 全额)
+	LogID108           *int64       `json:"logId108"`           // type=108 流水 ID(money = +badDebt);无坏账 → null
+	ExpiredActivityIDs []int64      `json:"expiredActivityIds"` // 本次被置为 status=4 的活动 ID(空 → [])
+	Warnings           []Warning    `json:"warnings"`           // warn 级提示(非阻断;空 → [])
+	Repeated           bool         `json:"repeated"`           // true = 幂等命中,其余字段回放首次结果
+}
+
+// ClearBalanceResult 余额清零响应(端点 9 / SP10)。
+//
+// 三条前置校验失败(campusBalance<=0 / totalBalance<=0 / campusBalance>totalBalance)→ 3120 + reason,无 result。
+//
+// ★ 漂移项(设计 §3.1 #10):全局表 `t_user_expense` 的扣减基数是 **campusBalance**(不是 totalBalance),
+// 即清零只清"本校区那部分",全局钱包剩余部分保留。
+type ClearBalanceResult struct {
+	Cleared             money.Amount `json:"cleared"`             // 已清零金额 = 清零前校区 balance(= 208 流水绝对值)
+	CampusBalanceBefore money.Amount `json:"campusBalanceBefore"` // 清零前校区余额快照
+	TotalBalanceBefore  money.Amount `json:"totalBalanceBefore"`  // 清零前全局余额快照
+	Balance             money.Amount `json:"balance"`             // 清零后校区余额(恒 "0.00")
+	TotalBalance        money.Amount `json:"totalBalance"`        // 清零后全局余额
+	SpecialExpenses     money.Amount `json:"specialExpenses"`     // 清零后特殊费用累计
+	LogID208            int64        `json:"logId208"`            // type=208 流水 ID(money = -cleared)
+	ExpiredActivityIDs  []int64      `json:"expiredActivityIds"`  // 失效活动 ID(空 → [])
+	Repeated            bool         `json:"repeated"`            // 幂等命中(SP10 无请求号 → 恒 false)
+}
+
+// CancelRechargeActivityResult 取消充值活动结果(端点 30 / SP8)。
+type CancelRechargeActivityResult struct {
+	// InstitutionID 由校区反查出的机构 ID(dump 末尾 `SELECT selectSchoolid AS schoolid`)。
+	InstitutionID int64 `json:"institutionId"`
+	// LogID type=209 流水 ID。
+	LogID int64 `json:"logId"`
+	// Balance 取消后校区账户余额。
+	Balance money.Amount `json:"balance"`
+	// Repeated true = 幂等命中。
+	Repeated bool `json:"repeated"`
+}
+
+// ---------------------------------------------------------------- 入参
+
+// CustomRefundInput 自定义退费入参(SP9 7 参;`RequestID` **必填**,作幂等键)。
+type CustomRefundInput struct {
+	// UserID 退费用户(必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId"`
+	// Money 请求退费金额(requested;必须 >0,允许超过可退金额 → 差额计坏账)。
+	Money money.Amount `json:"money"`
+	// RequestID 幂等请求号(**必填**;空则失去幂等保护,直接 2001)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(空 → "余额退费")。
+	Remark string `json:"remark"`
+	// OperatorID 操作人(→ create_user_id)。
+	OperatorID string `json:"-"`
+}
+
+// ClearBalanceInput 余额清零入参(SP10 5 参;**dump 无请求号**)。
+type ClearBalanceInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// InstitutionID 机构 ID(必填 >0)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区 ID(必填 >0)。
+	CampusID int64 `json:"campusId"`
+	// Remark 备注(空 → "余额清零")。
+	Remark string `json:"remark"`
+	// OperatorID 操作人。
+	OperatorID string `json:"-"`
+}
+
+// CancelRechargeActivityInput 取消充值活动入参(SP8 7 参)。
+type CancelRechargeActivityInput struct {
+	// UserID 用户(必填)。
+	UserID string `json:"userId"`
+	// ActivityID 活动 ID(必填 >0)。
+	ActivityID int64 `json:"activityId"`
+	// CampusID 校区 ID(必填 >0;机构由 `t_campus.institution_id` 反查)。
+	CampusID int64 `json:"campusId"`
+	// Money 取消金额(>0;流水落 `-Money`)。
+	Money money.Amount `json:"money"`
+	// RequestID 幂等请求号(空 = 不享幂等)。
+	RequestID string `json:"requestId"`
+	// Remark 备注(空 → "取消充值活动")。
+	Remark string `json:"remark"`
+	// OperatorID 操作人。
+	OperatorID string `json:"-"`
+}
+
+// ---------------------------------------------------------------- SP9 自定义退费
+
+// CustomRefund 自定义余额退费(SP9 `sp_user_expense_custom_refund`,**双账户锁 + 双 log + 活动失效**)。
+//
+// 时序(设计 §7.2 逐步骤,顺序不可调整):
+//
+//	① 幂等预检(207 为恒存在的主流水)→ 命中即回放首次结果(repeated=true)
+//	② 作用域断言(全局钱包无作用域列 → Service 显式校验)
+//	③ **加锁:全局 → 校区**(固定顺序,与充值写序一致,避免"充值持全局等校区 / 退费持校区等全局"死锁)
+//	   └ 校区账户不存在 或 balance<=0 → **3102**(dump "余额不能≤0")
+//	④ 拆分:refundable = max(balance,0);refunded = min(requested, refundable);
+//	   badDebt = requested - refunded
+//	⑤ 双表**同额**扣 refunded(balance−, total_income−),badDebt>0 时再 special_expenses+badDebt
+//	⑥ **先 108(+badDebt)再 207(−requested 全额)**(dump 顺序;扣减额 ≠ 207,两者差额恰为 108)
+//	⑦ 活动失效(同一事务,自营/加盟分流 → ActivityExpirer)
+//	⑧ 快照回填(退费后余额/累计/特殊费用)
+//
+// 与 SP10 的口径差异(**不得混淆**):本方法**不做** `campusBalance <= totalBalance` 校验——
+// 那是 `cleared_balance` 的规则(设计 §3.1 #9 明确标注);退费只受"本校区可退金额"约束。
+//
+// 入参:ctx / in。出参:*CustomRefundResult;错误码 2001/2002/3140/3102/3104/1004/3001/5002。
+func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput) (*CustomRefundResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "CustomRefund", true); err != nil {
+		return nil, err
+	}
+	if err := validatePositiveAmount(in.Money, "CustomRefund"); err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(in.RequestID) == "" {
+		// 本流程含"活动失效"副作用,没有幂等键就无法防重放 → 必填(设计 §4.1 端点 6)
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: CustomRefund 需要 requestId(幂等键,必填)"))
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		remark = defaultCustomRefundRemark
+	}
+
+	var out CustomRefundResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		// ① 幂等预检
+		hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeRefund)
+		if e != nil {
+			return e
+		}
+		if hit != nil {
+			return s.replayCustomRefund(txCtx, tx, in, &out, hit)
+		}
+
+		// ② 作用域断言
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+
+		// ③ 加锁:**全局 → 校区**(顺序是硬约束,单测用 SQL 顺序断言钉住)
+		if _, e := s.repo.GetForUpdate(txCtx, tx, in.UserID); e != nil {
+			return e // 全局账户不存在 → 3001(dump 会静默 0 行更新,Go 显式失败)
+		}
+		school, e := s.repo.GetCampusForUpdate(txCtx, tx, in.UserID, in.CampusID)
+		if e != nil {
+			if errors.Is(e, infraerrors.ErrNotFound) {
+				return infraerrors.WithCause(apperr.ErrNoBalanceToRefund,
+					errString("service: CustomRefund 校区账户不存在,余额不能≤0"))
+			}
+			return e
+		}
+		balance, e := parseAccountAmount(school.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+		if balance <= 0 {
+			return infraerrors.WithCause(apperr.ErrNoBalanceToRefund,
+				errString("service: CustomRefund 余额不能≤0(dump 原文文案)"))
+		}
+
+		// ④ 可退金额 + 坏账拆分
+		refundable := balance
+		if refundable < 0 {
+			refundable = 0
+		}
+		refunded := in.Money
+		if refunded > refundable {
+			refunded = refundable
+		}
+		badDebt := in.Money.Sub(refunded)
+		if badDebt < 0 {
+			badDebt = 0 // 防御:refunded <= requested 恒成立,此处只为可读性
+		}
+
+		// ⑤ 双表同额扣减(badDebt>0 时同步累加 special_expenses)
+		deltas := []repository.ColumnDelta{
+			{Column: repository.ExpenseColumnBalance, Delta: refunded.Neg()},
+			{Column: repository.ExpenseColumnTotalIncome, Delta: refunded.Neg()},
+		}
+		if badDebt > 0 {
+			deltas = append(deltas, repository.ColumnDelta{
+				Column: repository.ExpenseColumnSpecialExpenses, Delta: badDebt,
+			})
+		}
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "CustomRefund", in.UserID,
+			in.InstitutionID, in.CampusID, deltas...); e != nil {
+			return e
+		}
+		after, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		balanceAfter, e := parseAccountAmount(after.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+
+		// ⑥ 双 log:**先 108(+badDebt)再 207(−requested 全额)**
+		var logID108 *int64
+		if badDebt > 0 {
+			lg108 := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, badDebt, in.RequestID,
+				remark+badDebtRemarkSuffix, in.OperatorID, &balanceAfter)
+			if e := s.insertLog(txCtx, tx, lg108); e != nil {
+				return e
+			}
+			id := lg108.ID
+			logID108 = &id
+		}
+		lg207 := newExpenseLog(in.UserID, model.ExpenseLogTypeRefund, in.Money.Neg(), in.RequestID,
+			remark, in.OperatorID, &balanceAfter)
+		if e := s.insertLog(txCtx, tx, lg207); e != nil {
+			return e
+		}
+
+		// ⑦ 活动失效(同事务;自营 `IN (campus,0)` / 加盟 `= campus`)
+		expired, e := s.expireRechargeActivities(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+
+		// ⑧ 快照回填
+		globalAfter, e := s.repo.GetGlobalAccount(txCtx, tx, in.UserID)
+		if e != nil {
+			return e
+		}
+		totalBalance, e := parseAccountAmount(globalAfter.Balance, "t_user_expense.balance")
+		if e != nil {
+			return e
+		}
+		totalIncome, e := parseAccountAmount(after.TotalIncome, "t_user_expense_school.total_income")
+		if e != nil {
+			return e
+		}
+		special, e := parseAccountAmount(after.SpecialExpenses, "t_user_expense_school.special_expenses")
+		if e != nil {
+			return e
+		}
+		out = CustomRefundResult{
+			Requested:          in.Money,
+			Refunded:           refunded,
+			BadDebt:            badDebt,
+			RefundableAmount:   refundable,
+			Balance:            balanceAfter,
+			TotalBalance:       totalBalance,
+			TotalIncome:        totalIncome,
+			SpecialExpenses:    special,
+			LogID207:           lg207.ID,
+			LogID108:           logID108,
+			ExpiredActivityIDs: expired,
+			Warnings:           refundWarnings(badDebt),
+			Repeated:           false,
+		}
+		return nil
+	})
+	if err != nil {
+		// 并发幂等命中(撞 uk_request_type)→ 整笔回滚后回放首次结果
+		if errors.Is(err, apperr.ErrRepeated) {
+			return s.replayCustomRefundAfterRace(ctx, in)
+		}
+		return nil, err
+	}
+	return &out, nil
+}
+
+// replayCustomRefund 幂等回放(**事务内**路径:预检命中,未产生任何写入)。
+//
+// 入参:txCtx(含 tx)/ tx / in / out / hit(首次 207 流水)。
+// 出参:nil(out 已填充)/ 错误。
+//
+// 回放口径:`requested/badDebt/refunded/logId207/logId108/warnings` 由**首次流水**重建;
+// `balance/totalBalance/totalIncome/specialExpenses` 取**当前**账户快照
+// (中间若发生别的资金操作,这些快照会前进——属可接受的"当前值",不伪造历史)。
+func (s *RefundService) replayCustomRefund(
+	ctx context.Context, tx *gorm.DB, in CustomRefundInput, out *CustomRefundResult, hit *model.UserExpenseLog,
+) error {
+	requested, err := money.Parse(strings.TrimPrefix(hit.Money, "-"))
+	if err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 首次 207 流水金额非法"))
+	}
+	// 使用票据金额的绝对值:207 恒为 −requested
+	requested = absAmount(requested)
+	var badDebt money.Amount
+	var logID108 *int64
+	hit108, e := s.repo.FindLogByRequest(ctx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
+	if e != nil {
+		return e
+	}
+	if hit108 != nil {
+		if v, pe := money.Parse(hit108.Money); pe == nil {
+			badDebt = absAmount(v)
+			id := hit108.ID
+			logID108 = &id
+		}
+	}
+	res := CustomRefundResult{
+		Requested:          requested,
+		BadDebt:            badDebt,
+		Refunded:           requested.Sub(badDebt),
+		RefundableAmount:   requested.Sub(badDebt),
+		LogID207:           hit.ID,
+		LogID108:           logID108,
+		ExpiredActivityIDs: make([]int64, 0),
+		Warnings:           refundWarnings(badDebt),
+		Repeated:           true,
+	}
+	if acc, ae := s.repo.GetCampusAccount(ctx, tx, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
+		if v, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
+			res.Balance = v
+		}
+		if v, pe := parseAccountAmount(acc.TotalIncome, "t_user_expense_school.total_income"); pe == nil {
+			res.TotalIncome = v
+		}
+		if v, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
+			res.SpecialExpenses = v
+		}
+	}
+	if gl, ge := s.repo.GetGlobalAccount(ctx, tx, in.UserID); ge == nil {
+		if v, pe := parseAccountAmount(gl.Balance, "t_user_expense.balance"); pe == nil {
+			res.TotalBalance = v
+		}
+	}
+	*out = res
+	return nil
+}
+
+// replayCustomRefundAfterRace 并发幂等命中后的回放(**事务外**路径:本次事务已整笔回滚)。
+//
+// 入参:ctx / in。出参:*CustomRefundResult(Repeated=true)/ 错误。
+//
+// 为什么要等事务回滚后再查:并发对手的流水只有在它提交后才可见,
+// 因此这里是"先让本次事务彻底结束(回滚),再重新读首次结果"(设计 §1.4 幂等模板)。
+func (s *RefundService) replayCustomRefundAfterRace(
+	ctx context.Context, in CustomRefundInput,
+) (*CustomRefundResult, error) {
+	hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeRefund)
+	if err != nil {
+		return nil, err
+	}
+	if hit == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: CustomRefund 幂等命中但首次流水已不可见"))
+	}
+	var out CustomRefundResult
+	if err := s.replayCustomRefund(ctx, nil, in, &out, hit); err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// refundWarnings 按 badDebt 生成 warn 列表(3103 不阻断,§4.6)。
+//
+// 入参:badDebt 坏账金额。出参:[]Warning(**空时返回长度 0 的非 nil 切片**,JSON 序列化为 [])。
+func refundWarnings(badDebt money.Amount) []Warning {
+	if badDebt <= 0 {
+		return make([]Warning, 0)
+	}
+	return []Warning{{
+		Code:    apperr.CodeRefundableShort,
+		Message: "可退金额不足,差额 " + badDebt.String() + " 已计入坏账",
+	}}
+}
+
+// absAmount 取金额绝对值(流水方向 → 金额绝对值,回放用)。
+func absAmount(a money.Amount) money.Amount {
+	if a < 0 {
+		return a.Neg()
+	}
+	return a
+}
+
+// ---------------------------------------------------------------- SP10 余额清零
+
+// ClearBalance 余额清零(SP10 `sp_user_expense_school_cleared_balance`)。
+//
+// dump 语义(:764295,★**全局表扣减基数是 campusBalance**,不是 totalBalance,设计 §3.1 #10):
+//
+//	① 读校区余额;ISNULL 或 <=0 → LEAVE
+//	② 读全局余额;ISNULL 或 <=0 → LEAVE
+//	③ campusBalance > totalBalance → LEAVE
+//	④ UPDATE 校区:balance−campusBalance, special_expenses+campusBalance, spend+campusBalance
+//	   UPDATE 全局:balance−campusBalance, special_expenses+campusBalance, spend+campusBalance
+//	⑤ INSERT log(type=208, money=−campusBalance)
+//	⑥ 活动失效(与 SP9 同一段 SQL)
+//
+// Go 的差异:①②③ 失败 → **3120 + 原因**(dump 静默 LEAVE;前端会显示"成功但余额没变")。
+//
+// 幂等:SP10 **没有请求号**(dump 签名只有 5 参),因此无幂等键可落;重复调用第二次
+// `campusBalance=0` → 3120(天然幂等,不会重复清零)。`Repeated` 恒 false。
+//
+// 入参:ctx / in。出参:*ClearBalanceResult;错误码 2001/3140/3120/3104/1004/3001/5002。
+func (s *RefundService) ClearBalance(ctx context.Context, in ClearBalanceInput) (*ClearBalanceResult, error) {
+	if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "ClearBalance", true); err != nil {
+		return nil, err
+	}
+	if s == nil || s.runner == nil || s.repo == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		remark = defaultClearBalanceRemark
+	}
+
+	var out ClearBalanceResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
+			return e
+		}
+
+		// ① 加锁:全局 → 校区(同 §7.2 基线)
+		global, e := s.repo.GetForUpdate(txCtx, tx, in.UserID)
+		if e != nil {
+			if errors.Is(e, infraerrors.ErrNotFound) {
+				return clearBalanceRejected("全局账户不存在(totalBalance 视为 0)")
+			}
+			return e
+		}
+		school, e := s.repo.GetCampusForUpdate(txCtx, tx, in.UserID, in.CampusID)
+		if e != nil {
+			if errors.Is(e, infraerrors.ErrNotFound) {
+				return clearBalanceRejected("校区账户不存在(campusBalance 视为 0)")
+			}
+			return e
+		}
+		campusBalance, e := parseAccountAmount(school.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+		totalBalance, e := parseAccountAmount(global.Balance, "t_user_expense.balance")
+		if e != nil {
+			return e
+		}
+
+		// ② 三条前置校验(失败 → 3120 + 原因,**不做 no-op**)
+		if campusBalance <= 0 {
+			return clearBalanceRejected("校区余额 ≤ 0,无需清零")
+		}
+		if totalBalance <= 0 {
+			return clearBalanceRejected("全局余额 ≤ 0,无需清零")
+		}
+		if campusBalance > totalBalance {
+			return clearBalanceRejected("校区余额大于全局余额(数据异常,拒绝清零)")
+		}
+
+		// ③ 双表同额扣减:balance−campusBalance、special_expenses+campusBalance、spend+campusBalance
+		//    ★ 全局表扣减基数同样是 campusBalance(漂移项,不得改成 totalBalance)
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "ClearBalance", in.UserID,
+			in.InstitutionID, in.CampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: campusBalance.Neg()},
+			repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: campusBalance},
+			repository.ColumnDelta{Column: repository.ExpenseColumnSpend, Delta: campusBalance},
+		); e != nil {
+			return e
+		}
+		after, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		balanceAfter, e := parseAccountAmount(after.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+		specialAfter, e := parseAccountAmount(after.SpecialExpenses, "t_user_expense_school.special_expenses")
+		if e != nil {
+			return e
+		}
+
+		// ④ 流水 type=208,money = −campusBalance(无请求号 → request_id/log_id 均为 NULL)
+		lg := newExpenseLog(in.UserID, model.ExpenseLogTypeClearBalance, campusBalance.Neg(),
+			"", remark, in.OperatorID, &balanceAfter)
+		if e := s.insertLog(txCtx, tx, lg); e != nil {
+			return e
+		}
+
+		// ⑤ 活动失效(同事务,与 SP9 同源)
+		expired, e := s.expireRechargeActivities(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
+		if e != nil {
+			return e
+		}
+
+		globalAfter, e := s.repo.GetGlobalAccount(txCtx, tx, in.UserID)
+		if e != nil {
+			return e
+		}
+		totalAfter, e := parseAccountAmount(globalAfter.Balance, "t_user_expense.balance")
+		if e != nil {
+			return e
+		}
+		out = ClearBalanceResult{
+			Cleared:             campusBalance,
+			CampusBalanceBefore: campusBalance,
+			TotalBalanceBefore:  totalBalance,
+			Balance:             balanceAfter,
+			TotalBalance:        totalAfter,
+			SpecialExpenses:     specialAfter,
+			LogID208:            lg.ID,
+			ExpiredActivityIDs:  expired,
+			Repeated:            false,
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// clearBalanceRejected 构造 3120(前置条件不满足)+ 具体原因(设计 §3.1 #10 的显式化)。
+//
+// 入参:reason 人话原因(会进日志,不进响应体正文之外的结构)。
+// 出参:*infraerrors.AppError(3120)。
+func clearBalanceRejected(reason string) error {
+	return infraerrors.WithCause(apperr.ErrClearBalanceRejected, errString("service: ClearBalance "+reason))
+}
+
+// ---------------------------------------------------------------- SP8 取消充值活动
+
+// CancelRechargeActivity 取消充值活动(SP8 `sp_user_expense_cancel_recharge_activity`)。
+//
+// dump 语义(:763935):
+//
+//	① `SELECT school_id FROM yuansiwei.t_school_campus WHERE id=inputCampusid`
+//	   → ISNULL 或 0:`msg='找不到学校信息'` + LEAVE(success=0)
+//	② 校区账户/全局账户不存在则建户
+//	③ UPDATE 校区:balance−money, total_income−money ;UPDATE 全局:同
+//	④ UPDATE t_courses_activity_signed SET status=**0** WHERE userid AND activityid(注意不是 4)
+//	⑤ INSERT log(type=209, money=**−money**)
+//
+// Go 的两处收敛:
+//   - 跨库读 `yuansiwei.t_school_campus` → `port.CampusReader.GetSchoolID`(同事务只读,禁止跨库 SQL,§1.6);
+//     取不到 → **3104**(dump 返回 success=0 + 文案,无错误码);
+//   - 幂等:`request_id + type(209)` 唯一键(dump 无幂等)。
+//
+// 归属说明:设计 §3.1 #8 把本方法挂在 `RecommendService.CancelRechargeActivity`(AC-08);
+// 本批(AC-05)先落在 RefundService 上——两者是同一个资金动作 + 同一句活动状态回写,
+// AC-08 落地时 RecommendService 可直接转调本方法(签名不变),避免同一逻辑写两份。
+//
+// 入参:ctx / in。出参:*CancelRechargeActivityResult;错误码 2001/2002/3140/3104/1004/3001/5002。
+func (s *RefundService) CancelRechargeActivity(
+	ctx context.Context, in CancelRechargeActivityInput,
+) (*CancelRechargeActivityResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: CancelRechargeActivity 需要 user_id"))
+	}
+	if in.CampusID <= 0 {
+		return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
+			errString("service: CancelRechargeActivity 需要 campus_id > 0"))
+	}
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: CancelRechargeActivity 需要 activity_id > 0"))
+	}
+	if err := validatePositiveAmount(in.Money, "CancelRechargeActivity"); err != nil {
+		return nil, err
+	}
+	if s == nil || s.runner == nil || s.repo == nil || s.campus == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配"))
+	}
+	remark := strings.TrimSpace(in.Remark)
+	if remark == "" {
+		remark = defaultCancelRechargeRemark
+	}
+
+	var out CancelRechargeActivityResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeCancelRechargeAct)
+		if e != nil {
+			return e
+		}
+		if hit != nil {
+			bal, pe := balanceAfterOf(hit)
+			if pe != nil {
+				return pe
+			}
+			out = CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}
+			if inst, ie := s.readCampus(txCtx, tx, in.CampusID); ie == nil {
+				out.InstitutionID = inst.SchoolID
+			}
+			return nil
+		}
+
+		// ① 跨域读校区 → 机构 ID(失败 3104)
+		info, e := s.readCampus(txCtx, tx, in.CampusID)
+		if e != nil {
+			return e
+		}
+		if info.SchoolID <= 0 {
+			return infraerrors.WithCause(apperr.ErrCampusNotFound,
+				errString("service: CancelRechargeActivity 校区未归属机构"))
+		}
+		if e := s.assertInScope(txCtx, in.UserID, info.SchoolID, in.CampusID); e != nil {
+			return e
+		}
+
+		// ② 建户(dump 的 COUNT 判存在 → UPSERT)
+		if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
+			return e
+		}
+		if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+			UserID: in.UserID, InstitutionID: info.SchoolID, CampusID: in.CampusID,
+		}); e != nil {
+			return e
+		}
+
+		// ③ 余额−、总充值−
+		if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "CancelRechargeActivity", in.UserID,
+			info.SchoolID, in.CampusID,
+			repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money.Neg()},
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money.Neg()},
+		); e != nil {
+			return e
+		}
+
+		// ④ 报名状态归 0(dump:`SET status=0`,**不是 4**)
+		if _, e := s.expirer.SetSignedStatus(
+			txCtx, tx, in.UserID, []int64{in.ActivityID}, model.ActivitySignedCancelled,
+		); e != nil {
+			return e
+		}
+
+		acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, info.SchoolID, in.CampusID)
+		if e != nil {
+			return e
+		}
+		bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
+		if e != nil {
+			return e
+		}
+
+		// ⑤ 流水 type=209,money = −money
+		lg := newExpenseLog(in.UserID, model.ExpenseLogTypeCancelRechargeAct, in.Money.Neg(),
+			in.RequestID, remark, in.OperatorID, &bal)
+		if e := s.insertLog(txCtx, tx, lg); e != nil {
+			return e
+		}
+		out = CancelRechargeActivityResult{
+			InstitutionID: info.SchoolID,
+			LogID:         lg.ID,
+			Balance:       bal,
+		}
+		return nil
+	})
+	if err != nil {
+		if errors.Is(err, apperr.ErrRepeated) {
+			hit, re := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeCancelRechargeAct)
+			if re != nil {
+				return nil, re
+			}
+			if hit == nil {
+				return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+					errString("service: CancelRechargeActivity 幂等命中但首次流水已不可见"))
+			}
+			bal, pe := balanceAfterOf(hit)
+			if pe != nil {
+				return nil, pe
+			}
+			return &CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}, nil
+		}
+		return nil, err
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- 内部助手
+
+// assertInScope 对象级越权断言(RefundService 版本,语义同 FundService)。
+func (s *RefundService) assertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
+	if s == nil || s.member == nil {
+		return nil // 系统任务/割接脚本模式
+	}
+	return s.member.AssertInScope(ctx, userID, institutionID, campusID)
+}
+
+// insertLog 写流水(撞 `uk_request_type` → 内部幂等哨兵,语义同 FundService.insertLog)。
+func (s *RefundService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
+	err := s.repo.InsertLog(txCtx, tx, lg)
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, repository.ErrDuplicateLog) {
+		return apperr.ErrRepeated
+	}
+	return err
+}
+
+// readCampus 同事务读校区(优先 `TxCampusReader`,退化到连接池版 `CampusReader`)。
+//
+// 入参:ctx / tx / campusID(>0)。
+// 出参:*port.CampusInfo;3104(校区不存在)/ 3140(campusID<=0)/ 5002。
+//
+// 为什么优先 TxCampusReader:dump 是同库 JOIN(同一快照),目标态跨域读必须显式传 tx,
+// 否则读到的是连接池的默认隔离级别快照,与本次资金事务不一致(§1.6)。
+func (s *RefundService) readCampus(ctx context.Context, tx *gorm.DB, campusID int64) (*port.CampusInfo, error) {
+	if s.campus == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("service: 未注入 port.CampusReader(无法判定自营/机构)"))
+	}
+	if txReader, ok := s.campus.(port.TxCampusReader); ok {
+		return txReader.GetByIDTx(ctx, tx, campusID)
+	}
+	return s.campus.GetByID(ctx, campusID)
+}
+
+// expireRechargeActivities 判定自营/加盟并在同一事务内失效活动(SP9/SP10 共用)。
+//
+// 入参:txCtx / tx / userID / institutionID / campusID。
+// 出参:(失效活动 ID 列表, error);3104(校区不存在,R1 硬化)/ 5002。
+//
+// 分流(dump `IF(campusSelfSupport)`):
+//   - 自营 true  → `activity.campus_id IN (campusID, 0)`(含"全机构池"活动)
+//   - 加盟 false → `activity.campus_id = campusID`(**不含** 0,避免误失效他校区活动)
+func (s *RefundService) expireRechargeActivities(
+	ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
+) ([]int64, error) {
+	info, err := s.readCampus(ctx, tx, campusID)
+	if err != nil {
+		return nil, err
+	}
+	if info.SchoolID > 0 && info.SchoolID != institutionID {
+		// 校区归属机构与入参不一致 → 同码 3104(不泄露存在性,不误写)
+		return nil, infraerrors.WithCause(apperr.ErrCampusNotFound,
+			errString("service: 校区归属机构与入参 institution_id 不一致"))
+	}
+	return s.expirer.ExpireForUser(ctx, tx, userID, institutionID, campusID, info.SelfSupport)
+}

+ 933 - 0
internal/academic/service/refund_service_test.go

@@ -0,0 +1,933 @@
+// RefundService 单测(AC-05):自定义退费双 log(含账务恒等式)/ 坏账边界 / 锁序 /
+// 20 并发幂等 / 自营与加盟的失效范围 / 失败零残留 / 余额清零(★campusBalance 基数)/ 取消充值活动。
+//
+// 断言纪律(本文件的核心价值):
+//  1. **恒等式断言**而不是"各比各的":`requested = refunded + badDebt` 且
+//     `balance_before + 207.money + 108.money = balance_after`(三条流水代数和 = 净扣减);
+//  2. **顺序性**:108 必须在 207 之前写入(dump 顺序);全局锁必须在校区锁之前(§7.2 防死锁);
+//  3. **反向断言**:加盟校区不得失效 `campus_id=0` 的活动、type=3 活动不得被失效、
+//     失败用例必须余额/流水/报名状态**零残留**。
+package service
+
+import (
+	"context"
+	"errors"
+	"regexp"
+	"strings"
+	"sync"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"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"
+)
+
+// ---------------------------------------------------------------- 辅助
+
+// seedActivity 落活动 + 报名行(status=1 报名)。
+func seedActivity(t *testing.T, db *gorm.DB, id int64, typ int8, institutionID, campusID int64) {
+	t.Helper()
+	act := &model.CoursesActivity{
+		ID: id, Type: typ, InstitutionID: institutionID, CampusID: campusID,
+		Status: model.ActivityStatusEnabled, Money: "100.00",
+	}
+	if err := db.Create(act).Error; err != nil {
+		t.Fatalf("seed 活动(%d) 失败: %v", id, err)
+	}
+}
+
+// seedSigned 落报名行(status 可指定;user 必须与退款用户一致才会被失效)。
+func seedSigned(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32,
+	institutionID, campusID int64) {
+	t.Helper()
+	row := &model.CoursesActivitySigned{
+		ActivityID: activityID, UserID: userID, InstitutionID: institutionID, CampusID: campusID,
+		Status: status, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 报名(%d) 失败: %v", activityID, err)
+	}
+}
+
+// signedStatus 读报名行状态。
+func signedStatus(t *testing.T, db *gorm.DB, userID string, activityID int64) int32 {
+	t.Helper()
+	var row model.CoursesActivitySigned
+	if err := db.Where("user_id = ? AND activity_id = ?", userID, activityID).Take(&row).Error; err != nil {
+		t.Fatalf("读报名(%d) 失败: %v", activityID, err)
+	}
+	return row.Status
+}
+
+// expirableIDsOf 排序后的活动 ID(把返回切片转成便于断言的集合)。
+func expirableIDsOf(ids []int64) map[int64]bool {
+	out := make(map[int64]bool, len(ids))
+	for _, id := range ids {
+		out[id] = true
+	}
+	return out
+}
+
+// ---------------------------------------------------------------- 自定义退费:全额
+
+// TestCustomRefundFullAmount 全额退:无 108、207=−requested、双表同额扣、total_income 同步减。
+func TestCustomRefundFullAmount(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(30)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "500.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+
+	res, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "200.00"), RequestID: "REQ-CUSTOM-FULL",
+	})
+	if err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+	if res.Repeated {
+		t.Fatalf("首次退费不应 repeated=true")
+	}
+	if res.BadDebt.Cents() != 0 || res.LogID108 != nil {
+		t.Fatalf("全额退不应产生坏账:badDebt=%d logId108=%v", res.BadDebt.Cents(), res.LogID108)
+	}
+	if len(res.Warnings) != 0 {
+		t.Fatalf("全额退 warnings 应为空,实得 %+v", res.Warnings)
+	}
+	if res.Refunded.Cents() != 20000 || res.Requested.Cents() != 20000 {
+		t.Fatalf("refunded/requested 应各 20000 分,实得 %d/%d", res.Refunded.Cents(), res.Requested.Cents())
+	}
+	if res.Balance.Cents() != 30000 {
+		t.Fatalf("退费后校区余额应 30000 分,实得 %d", res.Balance.Cents())
+	}
+
+	// 双表同额扣减 + total_income 同步减
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 30000 {
+		t.Fatalf("校区 balance 应 30000 分,实得 %d", got)
+	}
+	if got := cents(t, readGlobal(t, db, user).Balance); got != 30000 {
+		t.Fatalf("全局 balance 应同额扣(30000 分),实得 %d", got)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 30000 {
+		t.Fatalf("校区 total_income 应 30000 分,实得 %d", got)
+	}
+	if got := cents(t, readGlobal(t, db, user).TotalIncome); got != 30000 {
+		t.Fatalf("全局 total_income 应 30000 分,实得 %d", got)
+	}
+
+	// 207 = -requested(全额)
+	logs := logsByType(t, db, user, model.ExpenseLogTypeRefund)
+	if len(logs) != 1 {
+		t.Fatalf("207 流水应 1 条,实得 %d", len(logs))
+	}
+	if got := cents(t, logs[0].Money); got != -20000 {
+		t.Fatalf("207 流水应 -20000 分,实得 %d", got)
+	}
+	if logs[0].Remark == nil || *logs[0].Remark != defaultCustomRefundRemark {
+		t.Fatalf("207 默认备注应为 %q,实得 %+v", defaultCustomRefundRemark, logs[0].Remark)
+	}
+}
+
+// ---------------------------------------------------------------- 自定义退费:坏账 + 恒等式
+
+// TestCustomRefundBadDebtIdentity 部分退(requested > 可退):
+// 双 log 顺序 + 账务恒等式 + special_expenses 累加 + warnings[3103]。
+func TestCustomRefundBadDebtIdentity(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(31)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "320.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	before := cents(t, readSchool(t, db, user, 1, 10).Balance)
+
+	// requested=500 > 可退 320 → badDebt=180
+	res, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "500.00"), RequestID: "REQ-CUSTOM-BADDEBT", Remark: "退学退费",
+	})
+	if err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+
+	// ① 金额拆分恒等式:requested = refunded + badDebt
+	if res.Requested.Cents() != 50000 {
+		t.Fatalf("requested 应 50000 分,实得 %d", res.Requested.Cents())
+	}
+	if res.Refunded.Cents() != 32000 {
+		t.Fatalf("refunded 应 32000 分,实得 %d", res.Refunded.Cents())
+	}
+	if res.BadDebt.Cents() != 18000 {
+		t.Fatalf("badDebt 应 18000 分,实得 %d", res.BadDebt.Cents())
+	}
+	if res.Requested.Cents() != res.Refunded.Cents()+res.BadDebt.Cents() {
+		t.Fatalf("恒等式 requested = refunded + badDebt 不成立:%d vs %d+%d",
+			res.Requested.Cents(), res.Refunded.Cents(), res.BadDebt.Cents())
+	}
+	if res.RefundableAmount.Cents() != 32000 {
+		t.Fatalf("refundableAmount 快照应 32000 分,实得 %d", res.RefundableAmount.Cents())
+	}
+
+	// ② 双 log 内容与**顺序**:108 先、207 后
+	logs108 := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
+	logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
+	if len(logs108) != 1 || len(logs207) != 1 {
+		t.Fatalf("108/207 应各 1 条,实得 %d/%d", len(logs108), len(logs207))
+	}
+	if got := cents(t, logs108[0].Money); got != 18000 {
+		t.Fatalf("108 流水应 +18000 分(坏账部分),实得 %d", got)
+	}
+	if got := cents(t, logs207[0].Money); got != -50000 {
+		t.Fatalf("207 流水应 -50000 分(**全额**,与扣减额不同),实得 %d", got)
+	}
+	if logs108[0].ID >= logs207[0].ID {
+		t.Fatalf("108 必须先于 207 写入(dump 顺序),实得 108.id=%d 207.id=%d", logs108[0].ID, logs207[0].ID)
+	}
+	if logs108[0].Remark == nil || !strings.Contains(*logs108[0].Remark, "退费的金额大于可退费部分,超出部分为坏账部分。") {
+		t.Fatalf("108 流水 remark 必须含 dump 指定文案,实得 %+v", logs108[0].Remark)
+	}
+	if logs108[0].RequestID == nil || *logs108[0].RequestID != "REQ-CUSTOM-BADDEBT" {
+		t.Fatalf("108 的 request_id 应落幂等键,实得 %+v", logs108[0].RequestID)
+	}
+
+	// ③ **账务恒等式**:balance_before + 207 + 108 = balance_after(代数和 = 净扣减)
+	schoolAfter := readSchool(t, db, user, 1, 10)
+	after := cents(t, schoolAfter.Balance)
+	sum := cents(t, logs207[0].Money) + cents(t, logs108[0].Money)
+	if before+sum != after {
+		t.Fatalf("恒等式不成立:before(%d) + ∑log(%d) = %d ≠ after(%d)", before, sum, before+sum, after)
+	}
+	if after != before-32000 {
+		t.Fatalf("净扣减应等于 refunded(32000 分):before=%d after=%d", before, after)
+	}
+	// 双账户同额(全局也净减 refunded,而不是 requested)
+	if got := cents(t, readGlobal(t, db, user).Balance); got != after {
+		t.Fatalf("全局余额应与校区同额(%d 分),实得 %d", after, got)
+	}
+	// ④ special_expenses 双表各 +badDebt
+	if got := cents(t, schoolAfter.SpecialExpenses); got != 18000 {
+		t.Fatalf("校区 special_expenses 应 18000 分,实得 %d", got)
+	}
+	if got := cents(t, readGlobal(t, db, user).SpecialExpenses); got != 18000 {
+		t.Fatalf("全局 special_expenses 应 18000 分,实得 %d", got)
+	}
+	// ⑤ warnings[3103](warn 级,不阻断)
+	if len(res.Warnings) != 1 || res.Warnings[0].Code != apperr.CodeRefundableShort {
+		t.Fatalf("应有 1 条 3103 warning,实得 %+v", res.Warnings)
+	}
+	if !strings.Contains(res.Warnings[0].Message, "180.00") {
+		t.Fatalf("warning 文案应含差额 180.00,实得 %q", res.Warnings[0].Message)
+	}
+	if res.LogID108 == nil || *res.LogID108 != logs108[0].ID {
+		t.Fatalf("logId108 应回填 %d,实得 %+v", logs108[0].ID, res.LogID108)
+	}
+	if res.LogID207 != logs207[0].ID {
+		t.Fatalf("logId207 应回填 %d,实得 %d", logs207[0].ID, res.LogID207)
+	}
+}
+
+// TestCustomRefundExactBalance requested == balance 边界:badDebt=0、余额归零。
+func TestCustomRefundExactBalance(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(32)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "120.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	res, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "120.00"), RequestID: "REQ-CUSTOM-EXACT",
+	})
+	if err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+	if res.BadDebt.Cents() != 0 || res.LogID108 != nil {
+		t.Fatalf("恰好退完不应有坏账,实得 %+v", res)
+	}
+	if res.Balance.Cents() != 0 {
+		t.Fatalf("退完后余额应 0,实得 %d", res.Balance.Cents())
+	}
+}
+
+// ---------------------------------------------------------------- 自定义退费:锁序
+
+// TestCustomRefundLocksGlobalBeforeCampus 加锁顺序 **全局 → 校区**(§7.2;错序 = 与充值并发成环)。
+//
+// 断言口径:抓真实 SQL 序列,要求第一条 `t_user_expense`(全局)点查出现在
+// 第一条 `t_user_expense_school`(校区)点查之前。
+func TestCustomRefundLocksGlobalBeforeCampus(t *testing.T) {
+	db, rec := newFundServiceDBWithRecorder(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(33)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+
+	rec.Reset()
+	if _, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-CUSTOM-LOCK",
+	}); err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+	stmts := rec.Snapshot()
+	globalIdx := firstTouchIndex(stmts, `\bt_user_expense\b`)
+	campusIdx := firstTouchIndex(stmts, `\bt_user_expense_school\b`)
+	if globalIdx < 0 || campusIdx < 0 {
+		t.Fatalf("未捕获到两次账户点查(global=%d campus=%d):%v", globalIdx, campusIdx, stmts)
+	}
+	if globalIdx > campusIdx {
+		t.Fatalf("锁序错误:全局账户(第 %d 条)必须早于校区账户(第 %d 条)", globalIdx, campusIdx)
+	}
+}
+
+// ---------------------------------------------------------------- 自定义退费:并发幂等
+
+// TestCustomRefundConcurrentSameRequestID 20 并发同 requestId → 1 条 207 + 1 条 108、余额只变一次。
+//
+// SQLite 无行锁:用单连接池把写事务串行化(等价 MySQL 行锁串行)。
+// 断言点是**流水条数与余额增量**,而不是"有没有报错"——这才是资金正确性。
+func TestCustomRefundConcurrentSameRequestID(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(34)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "300.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	in := CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "400.00"), RequestID: "REQ-CUSTOM-RACE", // 400 > 300 → 必然产生 108
+	}
+
+	const goroutines = 20
+	var wg sync.WaitGroup
+	results := make([]*CustomRefundResult, goroutines)
+	errs := make([]error, goroutines)
+	for i := 0; i < goroutines; i++ {
+		wg.Add(1)
+		go func(idx int) {
+			defer wg.Done()
+			results[idx], errs[idx] = svc.CustomRefund(ctx, in)
+		}(i)
+	}
+	wg.Wait()
+
+	for i, err := range errs {
+		if err != nil {
+			t.Fatalf("并发第 %d 次退费失败: %v", i, err)
+		}
+	}
+	if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 1 {
+		t.Fatalf("207 流水应只 1 条,实得 %d", n)
+	}
+	if n := len(logsByType(t, db, user, model.ExpenseLogTypeBadDebt)); n != 1 {
+		t.Fatalf("108 流水应只 1 条,实得 %d", n)
+	}
+	school := readSchool(t, db, user, 1, 10)
+	if got := cents(t, school.Balance); got != 0 {
+		t.Fatalf("余额应只扣一次(300→0),实得 %d", got)
+	}
+	if got := cents(t, school.SpecialExpenses); got != 10000 {
+		t.Fatalf("special_expenses 应只加一次(10000 分 = 400−300),实得 %d", got)
+	}
+	repeated := 0
+	for _, r := range results {
+		if r != nil && r.Repeated {
+			repeated++
+		}
+	}
+	if repeated != goroutines-1 {
+		t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated)
+	}
+}
+
+// TestCustomRefundIdempotentReplaysFirstResult 顺序重复调用 → repeated=true 且字段回放首次结果。
+func TestCustomRefundIdempotentReplaysFirstResult(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(35)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	in := CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "150.00"), RequestID: "REQ-CUSTOM-REPLAY"}
+	first, err := svc.CustomRefund(ctx, in)
+	if err != nil {
+		t.Fatalf("首次退费失败: %v", err)
+	}
+	second, err := svc.CustomRefund(ctx, in)
+	if err != nil {
+		t.Fatalf("重复退费应幂等成功: %v", err)
+	}
+	if !second.Repeated {
+		t.Fatalf("重复退费应 repeated=true")
+	}
+	if second.LogID207 != first.LogID207 {
+		t.Fatalf("应回放首次 logId207=%d,实得 %d", first.LogID207, second.LogID207)
+	}
+	if second.BadDebt.Cents() != first.BadDebt.Cents() || second.Refunded.Cents() != first.Refunded.Cents() {
+		t.Fatalf("应回放首次坏账/退款额:first=%+v second=%+v", first, second)
+	}
+	if second.LogID108 == nil || first.LogID108 == nil || *second.LogID108 != *first.LogID108 {
+		t.Fatalf("应回放首次 logId108,first=%+v second=%+v", first.LogID108, second.LogID108)
+	}
+	if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 1 {
+		t.Fatalf("207 流水应只 1 条,实得 %d", n)
+	}
+}
+
+// ---------------------------------------------------------------- 活动失效范围
+
+// TestCustomRefundExpirySelfSupportCoversInstitutionPool 自营校区:失效本校区 + `campus_id=0` 池活动,
+// 且**不得**动 type=3 / 他机构 / 已过期(status=4)的行。
+func TestCustomRefundExpirySelfSupportCoversInstitutionPool(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(36)
+
+	seedCampus(t, db, 10, 1, true) // 自营
+	// 活动 101 本校区(10) type=2 → 失效;102 全机构池(0) type=2 → 自营也失效
+	// 103 代金券 type=3 → 不动;104 他机构(2) → 不动;105 已过期报名 → 不在候选;106 他校区(11) type=2 → 不动
+	seedActivity(t, db, 101, model.ActivityTypeRecharge, 1, 10)
+	seedActivity(t, db, 102, model.ActivityTypeRecharge, 1, 0)
+	seedActivity(t, db, 103, model.ActivityTypeVoucher, 1, 10)
+	seedActivity(t, db, 104, model.ActivityTypeRecharge, 2, 10)
+	seedActivity(t, db, 105, model.ActivityTypeRecharge, 1, 10)
+	seedActivity(t, db, 106, model.ActivityTypeRecharge, 1, 11)
+	for _, id := range []int64{101, 102, 103, 104, 106} {
+		seedSigned(t, db, user, id, model.ActivitySignedSigned, 1, 10)
+	}
+	seedSigned(t, db, user, 105, model.ActivitySignedExpired, 1, 10)
+
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	res, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-EXPIRY-SELF",
+	})
+	if err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+
+	got := expirableIDsOf(res.ExpiredActivityIDs)
+	if !got[101] {
+		t.Fatalf("自营校区应失效本校区活动 101,实得 %v", res.ExpiredActivityIDs)
+	}
+	if !got[102] {
+		t.Fatalf("自营校区应失效全机构池活动 102(campus_id=0),实得 %v", res.ExpiredActivityIDs)
+	}
+	if got[103] {
+		t.Fatalf("type=3 代金券活动不得失效(dump 只处理 type=2),实得 %v", res.ExpiredActivityIDs)
+	}
+	if got[104] {
+		t.Fatalf("他机构活动不得失效,实得 %v", res.ExpiredActivityIDs)
+	}
+	if got[105] {
+		t.Fatalf("已过期(status=4)的报名不应出现在失效列表,实得 %v", res.ExpiredActivityIDs)
+	}
+	if got[106] {
+		t.Fatalf("他校区活动(campus_id=11)在自营分支也不得失效,实得 %v", res.ExpiredActivityIDs)
+	}
+
+	// 落库断言(不只信返回值)
+	if s := signedStatus(t, db, user, 101); s != model.ActivitySignedExpired {
+		t.Fatalf("报名 101 应为 4,实得 %d", s)
+	}
+	if s := signedStatus(t, db, user, 102); s != model.ActivitySignedExpired {
+		t.Fatalf("报名 102 应为 4,实得 %d", s)
+	}
+	if s := signedStatus(t, db, user, 103); s != model.ActivitySignedSigned {
+		t.Fatalf("报名 103(type=3)必须保持 1,实得 %d", s)
+	}
+	if s := signedStatus(t, db, user, 104); s != model.ActivitySignedSigned {
+		t.Fatalf("报名 104(他机构)必须保持 1,实得 %d", s)
+	}
+	if s := signedStatus(t, db, user, 106); s != model.ActivitySignedSigned {
+		t.Fatalf("报名 106(他校区)必须保持 1,实得 %d", s)
+	}
+}
+
+// TestCustomRefundExpiryFranchiseExcludesInstitutionPool 加盟校区:**只**失效 `campus_id = campusID`,
+// 不得碰 `campus_id=0` 的全机构池活动(R1:错走加盟分支会误失效他校区活动)。
+func TestCustomRefundExpiryFranchiseExcludesInstitutionPool(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(37)
+
+	seedCampus(t, db, 10, 1, false) // 加盟
+	seedActivity(t, db, 201, model.ActivityTypeRecharge, 1, 10)
+	seedActivity(t, db, 202, model.ActivityTypeRecharge, 1, 0)
+	seedSigned(t, db, user, 201, model.ActivitySignedSigned, 1, 10)
+	seedSigned(t, db, user, 202, model.ActivitySignedSigned, 1, 10)
+
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	res, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-EXPIRY-FRANCHISE",
+	})
+	if err != nil {
+		t.Fatalf("CustomRefund 失败: %v", err)
+	}
+	got := expirableIDsOf(res.ExpiredActivityIDs)
+	if !got[201] {
+		t.Fatalf("加盟校区应失效本校区活动 201,实得 %v", res.ExpiredActivityIDs)
+	}
+	if got[202] {
+		t.Fatalf("加盟校区**不得**失效全机构池活动 202(campus_id=0),实得 %v", res.ExpiredActivityIDs)
+	}
+	if s := signedStatus(t, db, user, 202); s != model.ActivitySignedSigned {
+		t.Fatalf("报名 202 必须保持 1,实得 %d", s)
+	}
+}
+
+// ---------------------------------------------------------------- 失败回滚(零残留)
+
+// TestCustomRefundRollbackLeavesNoResidue 注入失败(活动失效步骤报错)→
+// 余额/流水/报名状态**零残留**(同事务整体回滚)。
+//
+// 注入方式:删掉 `t_courses_activity_signed` 表 → 失效器查询必然报错。
+// 这样失败点落在"资金已写、活动未失效"的最坏位置,正好验证原子性。
+func TestCustomRefundRollbackLeavesNoResidue(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(38)
+
+	seedCampus(t, db, 10, 1, true)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	beforeSchool := readSchool(t, db, user, 1, 10)
+	beforeGlobal := readGlobal(t, db, user)
+
+	if err := db.Exec("DROP TABLE t_courses_activity_signed").Error; err != nil {
+		t.Fatalf("注入失败(删表)失败: %v", err)
+	}
+	_, err := svc.CustomRefund(ctx, CustomRefundInput{
+		UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "150.00"), RequestID: "REQ-ROLLBACK",
+	})
+	if err == nil {
+		t.Fatalf("活动失效失败时退费必须整体失败,实得 nil")
+	}
+
+	afterSchool := readSchool(t, db, user, 1, 10)
+	if cents(t, afterSchool.Balance) != cents(t, beforeSchool.Balance) {
+		t.Fatalf("回滚后校区余额必须不变:before=%s after=%s", beforeSchool.Balance, afterSchool.Balance)
+	}
+	if cents(t, afterSchool.TotalIncome) != cents(t, beforeSchool.TotalIncome) {
+		t.Fatalf("回滚后校区 total_income 必须不变:before=%s after=%s",
+			beforeSchool.TotalIncome, afterSchool.TotalIncome)
+	}
+	if cents(t, afterSchool.SpecialExpenses) != 0 {
+		t.Fatalf("回滚后 special_expenses 必须为 0,实得 %s", afterSchool.SpecialExpenses)
+	}
+	afterGlobal := readGlobal(t, db, user)
+	if cents(t, afterGlobal.Balance) != cents(t, beforeGlobal.Balance) {
+		t.Fatalf("回滚后全局余额必须不变:before=%s after=%s", beforeGlobal.Balance, afterGlobal.Balance)
+	}
+	if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 0 {
+		t.Fatalf("回滚后不得残留 207 流水,实得 %d 条", n)
+	}
+	if n := len(logsByType(t, db, user, model.ExpenseLogTypeBadDebt)); n != 0 {
+		t.Fatalf("回滚后不得残留 108 流水,实得 %d 条", n)
+	}
+}
+
+// ---------------------------------------------------------------- 错误码
+
+// TestCustomRefundErrorCodes 3102 / 2002 / 3104 / 2001。
+func TestCustomRefundErrorCodes(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+
+	seedCampus(t, db, 10, 1, true)
+
+	t.Run("余额为0_3102", func(t *testing.T) {
+		user := fundUserID(40)
+		if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "50.00")}); err != nil {
+			t.Fatalf("准备余额失败: %v", err)
+		}
+		// 先全额退完(余额归 0)
+		if _, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "50.00"), RequestID: "REQ-ERR-3102-A"}); err != nil {
+			t.Fatalf("准备归零失败: %v", err)
+		}
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "10.00"), RequestID: "REQ-ERR-3102-B"})
+		requireAppCode(t, err, apperr.CodeNoBalanceToRefund)
+	})
+
+	t.Run("校区账户不存在_3102", func(t *testing.T) {
+		user := fundUserID(41)
+		if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "10.00")}); err != nil {
+			t.Fatalf("准备余额失败: %v", err)
+		}
+		// campus 12 无账户(全局有余额)
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 12,
+			Money: mustAmount(t, "5.00"), RequestID: "REQ-ERR-3102-C"})
+		requireAppCode(t, err, apperr.CodeNoBalanceToRefund)
+	})
+
+	t.Run("金额为负_2002", func(t *testing.T) {
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "-1.00"), RequestID: "REQ-ERR-2002"})
+		requireAppCode(t, err, 2002)
+	})
+
+	t.Run("金额超单笔上限_2002", func(t *testing.T) {
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "1000000.01"), RequestID: "REQ-ERR-2002B"})
+		requireAppCode(t, err, 2002)
+	})
+
+	t.Run("缺requestId_2002", func(t *testing.T) {
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: fundUserID(42), InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "1.00")})
+		requireAppCode(t, err, 2002)
+	})
+
+	t.Run("校区不存在_3104", func(t *testing.T) {
+		user := fundUserID(43)
+		if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "10.00")}); err != nil {
+			t.Fatalf("准备余额失败: %v", err)
+		}
+		// 手工造一个 t_campus 里不存在的校区账户(模拟历史脏数据/校区被删)
+		if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 99,
+			Balance: "10.00"}).Error; err != nil {
+			t.Fatalf("seed 脏校区账户失败: %v", err)
+		}
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: user, InstitutionID: 1, CampusID: 99,
+			Money: mustAmount(t, "5.00"), RequestID: "REQ-ERR-3104"})
+		requireAppCode(t, err, apperr.CodeCampusNotFound)
+		// R1 + 原子性:3104 时不得留下任何资金痕迹
+		if got := cents(t, readSchool(t, db, user, 1, 99).Balance); got != 1000 {
+			t.Fatalf("3104 回滚后余额必须不变(1000 分),实得 %d", got)
+		}
+		if n := len(logsByType(t, db, user, model.ExpenseLogTypeRefund)); n != 0 {
+			t.Fatalf("3104 不得写流水,实得 %d 条", n)
+		}
+	})
+
+	t.Run("缺用户_2001", func(t *testing.T) {
+		_, err := svc.CustomRefund(ctx, CustomRefundInput{UserID: "", InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, "1.00"), RequestID: "REQ-ERR-2001"})
+		requireAppCode(t, err, 2001)
+	})
+}
+
+// ---------------------------------------------------------------- 余额清零(SP10)
+
+// TestClearBalanceUsesCampusBalanceAsBase ★漂移项:全局表扣减基数是 **campusBalance**(不是 totalBalance)。
+//
+// 场景:校区 10 有 100,校区 20 有 100(全局合计 200)。清零校区 10 后:
+// 校区 10 余额 0、全局余额 100(= 200 − 100,而不是 0)。
+func TestClearBalanceUsesCampusBalanceAsBase(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(50)
+
+	seedCampus(t, db, 10, 1, true)
+	seedActivity(t, db, 301, model.ActivityTypeRecharge, 1, 10)
+	seedSigned(t, db, user, 301, model.ActivitySignedSigned, 1, 10)
+	for _, campusID := range []int64{10, 20} {
+		if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: campusID,
+			Money: mustAmount(t, "100.00")}); err != nil {
+			t.Fatalf("准备余额(campus=%d)失败: %v", campusID, err)
+		}
+	}
+
+	res, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
+	if err != nil {
+		t.Fatalf("ClearBalance 失败: %v", err)
+	}
+	if res.Cleared.Cents() != 10000 {
+		t.Fatalf("cleared 应 10000 分,实得 %d", res.Cleared.Cents())
+	}
+	if res.CampusBalanceBefore.Cents() != 10000 || res.TotalBalanceBefore.Cents() != 20000 {
+		t.Fatalf("清零前快照错误:campus=%d total=%d",
+			res.CampusBalanceBefore.Cents(), res.TotalBalanceBefore.Cents())
+	}
+	if res.Balance.Cents() != 0 {
+		t.Fatalf("清零后校区余额应 0,实得 %d", res.Balance.Cents())
+	}
+	if res.TotalBalance.Cents() != 10000 {
+		t.Fatalf("★ 清零后全局余额应 10000 分(200−100,基数是 campusBalance),实得 %d",
+			res.TotalBalance.Cents())
+	}
+	school := readSchool(t, db, user, 1, 10)
+	if got := cents(t, school.Balance); got != 0 {
+		t.Fatalf("校区 balance 应 0,实得 %d", got)
+	}
+	if got := cents(t, school.SpecialExpenses); got != 10000 {
+		t.Fatalf("校区 special_expenses 应 10000 分,实得 %d", got)
+	}
+	if got := cents(t, school.Spend); got != 10000 {
+		t.Fatalf("校区 spend 应 10000 分(dump 清零同时累加 spend),实得 %d", got)
+	}
+	global := readGlobal(t, db, user)
+	if got := cents(t, global.Balance); got != 10000 {
+		t.Fatalf("全局 balance 应 10000 分,实得 %d", got)
+	}
+	if got := cents(t, global.SpecialExpenses); got != 10000 {
+		t.Fatalf("全局 special_expenses 应 10000 分,实得 %d", got)
+	}
+	// 流水 208 = −campusBalance
+	logs := logsByType(t, db, user, model.ExpenseLogTypeClearBalance)
+	if len(logs) != 1 || cents(t, logs[0].Money) != -10000 {
+		t.Fatalf("208 流水应为 -10000 分一条,实得 %+v", logs)
+	}
+	if logs[0].Remark == nil || *logs[0].Remark != defaultClearBalanceRemark {
+		t.Fatalf("208 默认备注应为 %q,实得 %+v", defaultClearBalanceRemark, logs[0].Remark)
+	}
+	// 活动失效(清零同样触发,与 SP9 同源)
+	if !expirableIDsOf(res.ExpiredActivityIDs)[301] {
+		t.Fatalf("清零应失效充值活动 301,实得 %v", res.ExpiredActivityIDs)
+	}
+	if s := signedStatus(t, db, user, 301); s != model.ActivitySignedExpired {
+		t.Fatalf("报名 301 应为 4,实得 %d", s)
+	}
+
+	// 再次清零 → 3120(天然幂等,不会重复清零)
+	if _, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10}); err == nil {
+		t.Fatalf("余额已为 0 时再次清零必须失败(3120)")
+	} else {
+		requireAppCode(t, err, apperr.CodeClearBalanceRejected)
+	}
+}
+
+// TestClearBalancePreconditions 三条前置校验 → 3120(dump 静默 LEAVE,Go 显式)。
+func TestClearBalancePreconditions(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+
+	seedCampus(t, db, 10, 1, true)
+	seedCampus(t, db, 11, 1, true)
+
+	t.Run("校区余额为0", func(t *testing.T) {
+		user := fundUserID(51)
+		if err := db.Create(&model.UserExpense{UserID: user, Balance: "100.00"}).Error; err != nil {
+			t.Fatalf("seed 全局失败: %v", err)
+		}
+		if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
+			Balance: "0.00"}).Error; err != nil {
+			t.Fatalf("seed 校区失败: %v", err)
+		}
+		_, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
+		requireAppCode(t, err, apperr.CodeClearBalanceRejected)
+	})
+
+	t.Run("全局余额非正", func(t *testing.T) {
+		user := fundUserID(52)
+		if err := db.Create(&model.UserExpense{UserID: user, Balance: "-10.00"}).Error; err != nil {
+			t.Fatalf("seed 全局失败: %v", err)
+		}
+		if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
+			Balance: "50.00"}).Error; err != nil {
+			t.Fatalf("seed 校区失败: %v", err)
+		}
+		_, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
+		requireAppCode(t, err, apperr.CodeClearBalanceRejected)
+	})
+
+	t.Run("校区余额大于全局", func(t *testing.T) {
+		user := fundUserID(53)
+		if err := db.Create(&model.UserExpense{UserID: user, Balance: "50.00"}).Error; err != nil {
+			t.Fatalf("seed 全局失败: %v", err)
+		}
+		if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
+			Balance: "80.00"}).Error; err != nil {
+			t.Fatalf("seed 校区失败: %v", err)
+		}
+		_, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 10})
+		requireAppCode(t, err, apperr.CodeClearBalanceRejected)
+	})
+
+	t.Run("校区账户不存在", func(t *testing.T) {
+		user := fundUserID(54)
+		if err := db.Create(&model.UserExpense{UserID: user, Balance: "50.00"}).Error; err != nil {
+			t.Fatalf("seed 全局失败: %v", err)
+		}
+		_, err := svc.ClearBalance(ctx, ClearBalanceInput{UserID: user, InstitutionID: 1, CampusID: 11})
+		requireAppCode(t, err, apperr.CodeClearBalanceRejected)
+	})
+}
+
+// ---------------------------------------------------------------- 取消充值活动(SP8)
+
+// TestCancelRechargeActivity 正常路径:跨域读机构 → 余额/总充值减 → 报名 status=0 → 209 流水为负。
+func TestCancelRechargeActivity(t *testing.T) {
+	db := newFundServiceDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(60)
+
+	seedCampus(t, db, 10, 1, true)
+	seedActivity(t, db, 401, model.ActivityTypeRecharge, 1, 10)
+	seedSigned(t, db, user, 401, model.ActivitySignedSigned, 1, 10)
+	if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "200.00")}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+
+	res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
+		UserID: user, ActivityID: 401, CampusID: 10,
+		Money: mustAmount(t, "50.00"), RequestID: "REQ-CANCEL-1",
+	})
+	if err != nil {
+		t.Fatalf("CancelRechargeActivity 失败: %v", err)
+	}
+	if res.InstitutionID != 1 {
+		t.Fatalf("应由校区反查机构 1,实得 %d", res.InstitutionID)
+	}
+	school := readSchool(t, db, user, 1, 10)
+	if got := cents(t, school.Balance); got != 15000 {
+		t.Fatalf("取消后校区余额应 15000 分,实得 %d", got)
+	}
+	if got := cents(t, school.TotalIncome); got != 15000 {
+		t.Fatalf("取消后校区 total_income 应 15000 分,实得 %d", got)
+	}
+	if got := cents(t, readGlobal(t, db, user).Balance); got != 15000 {
+		t.Fatalf("取消后全局余额应 15000 分(双表同步),实得 %d", got)
+	}
+	// 报名状态归 0(dump:SET status=0,不是 4)
+	if s := signedStatus(t, db, user, 401); s != model.ActivitySignedCancelled {
+		t.Fatalf("报名状态应为 0(取消),实得 %d", s)
+	}
+	logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct)
+	if len(logs) != 1 || cents(t, logs[0].Money) != -5000 {
+		t.Fatalf("209 流水应为 -5000 分一条,实得 %+v", logs)
+	}
+
+	// 幂等:同 requestId 再调 → repeated=true、不再扣钱
+	again, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
+		UserID: user, ActivityID: 401, CampusID: 10,
+		Money: mustAmount(t, "50.00"), RequestID: "REQ-CANCEL-1",
+	})
+	if err != nil {
+		t.Fatalf("重复取消应幂等成功: %v", err)
+	}
+	if !again.Repeated {
+		t.Fatalf("重复取消应 repeated=true")
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 15000 {
+		t.Fatalf("重复取消不得再扣(应仍 15000 分),实得 %d", got)
+	}
+}
+
+// TestCancelRechargeActivityCampusMissing 校区不存在 → 3104(dump 返回 success=0 + 文案)。
+func TestCancelRechargeActivityCampusMissing(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	_, err := svc.CancelRechargeActivity(context.Background(), CancelRechargeActivityInput{
+		UserID: fundUserID(61), ActivityID: 401, CampusID: 999,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-CANCEL-3104",
+	})
+	requireAppCode(t, err, apperr.CodeCampusNotFound)
+}
+
+// ---------------------------------------------------------------- AC-05 落库越界负例
+
+// TestClearBalanceAmountOverflow AC-05 的落库负例:批量列更新(AddCampusColumns)在写库前
+// 拦下超出 decimal(12,2) 的金额(`Amount.Validate()` → ErrOverflow),账户零残留。
+func TestClearBalanceAmountOverflow(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
+	ctx := context.Background()
+	user := fundUserID(62)
+
+	if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
+		Balance: "0.00"}).Error; err != nil {
+		t.Fatalf("seed 校区账户失败: %v", err)
+	}
+	err := db.Transaction(func(tx *gorm.DB) error {
+		_, e := svc.repo.AddCampusColumns(ctx, tx, "OverflowTest", user, 1, 10,
+			repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: money.MaxAmount + 1})
+		return e
+	})
+	if !errors.Is(err, money.ErrOverflow) {
+		t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err)
+	}
+	var appErr *infraerrors.AppError
+	if !errors.As(err, &appErr) {
+		t.Fatalf("越界金额应返回 *AppError,实得 %T", err)
+	}
+	if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d", appErr.Code)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).TotalIncome); got != 0 {
+		t.Fatalf("越界金额不得落库(total_income 应仍 0),实得 %d", got)
+	}
+}
+
+// ---------------------------------------------------------------- 顺序性工具
+
+// firstTouchIndex 返回第一条"命中 tableRe"的 SQL 下标(未命中 → -1;`-1` 便于比较)。
+//
+// 用正则而非 strings.Contains 的原因:`t_user_expense` 是 `t_user_expense_school` 的前缀,
+// 用 `\b` 词界才能把两张表区分开(`_` 属词字符,故 `t_user_expense\b` 不会命中 school)。
+func firstTouchIndex(stmts []string, tableRe string) int {
+	re := regexp.MustCompile(tableRe)
+	for i, sql := range stmts {
+		if re.MatchString(sql) {
+			return i
+		}
+	}
+	return -1
+}

+ 10 - 0
internal/infra/errors/errors.go

@@ -177,3 +177,13 @@ var (
 	ErrInternal     = &AppError{Code: CodeInternal, Msg: "系统繁忙"}
 	ErrDBError      = &AppError{Code: CodeDBError, Msg: "数据服务异常"}
 )
+
+// ErrBadFormat 入参"格式/取值"非法(2002:金额字符串非法、枚举不在白名单、超业务上限等)。
+//
+// 与 ErrBadParam(2001) 的分工(设计 §10.1「参数错 2001/2002」):
+//   - 2001:**缺参**(字段没传/为空/缺失必填项);
+//   - 2002:**传了但值不合法**(金额 <=0 或格式非法、type 不在白名单、金额超单笔业务上限)。
+//
+// 资金/退费域按此分工显式区分,前端可据此提示"漏填"还是"填错"。
+// 独立成块(不并入上方 var 组)以免改动既有行的 gofmt 对齐——CRLF 检出纪律见设计 §2.6 T-CO-1。
+var ErrBadFormat = &AppError{Code: CodeBadFormat, Msg: "参数格式错误"}