ソースを参照

fix(academic): SP5 三步修正补齐 + R6 五处 2002 统一 + 108 判别式(A18) 落地注释

对齐设计 v0.1.14 §3.1 #5/#7、§3.8-b R4/R5/R6、(f) A18,与上一条 57808bd 构成 AC-04/AC-05 返工。

① RefundableAmount(§3.1 #5)
- 补上 dump 的「截断后再早退一次」:`campusBalance > totalBalance` 截断后,若仍 <= 0 直接 0,
  不在非正基数上继续做减项(此前只有末尾 max(…,0),结果相同但语义未对齐 dump)。
- 注释登记两个**别复原**陷阱:
  · `totalDiscountAmount = SUM(activity_money)` 是**死变量**(全文未引用)→ 不得据此加"赠送额上限"约束;
  · 按 response_time DESC 逐笔累加的游标整段被 `/* */` 注释 → 不得照注释补回,实际口径只有一条聚合 SUM。
- 明确 `custom_refund`(SP9)**不调本方法**、其坏账基数是校区 balance 本身,两者不得互相复用。
- 门禁用例改成 §8 AC-04 ⑦ 的 5 组表驱动(基线/截断/减项/归零/截断+减项叠加)
  + 赠送用尽不参与/未审批不参与/多笔累加/账户缺失/欠费/全局非正/3140 + 只读断言。
  ⑤ 叠加组能把"先减后截/漏截断"的实现区分出来(min(200,120)−30=90)。

② R6:money<=0 → 2002(五处统一)
- `Refund` 原为 dump 口径的 no-op(返回 changed=false),现按 R6 在入口显式 2002;
  `RefundResult.Changed` 字段保留但恒 true(仅幂等命中用 Repeated 表达),注释同步。
- 新增 `TestNonPositiveMoneyRejectedEverywhere`:recharge/expenditure/baddebt/refund/transfer
  五处 × {0.00, −10.00} 全部 2002,且**零副作用**(账户四列不变、流水条数不变、
  `(request_id,type)` 幂等键**未被占死**——同键随后真金额能正常入账)。
- 原 `TestRefundZeroMoneyIsNoop` → `TestRefundRejectsNonPositiveMoney`(期望值 2002)。

③ R4 / R5 复核(已由既有实现满足,本次只补断言+注释口径)
- R4:`Refund` 用 `GetCampusForUpdateExact(user, institution, campus)` 精确三元组定位,
  缺失 → ErrNotFound → **3001**(既有 `TestRefundAccountMissing` 覆盖);
  `Transfer` 源账户缺失 → **3001**(既有 `TestTransferSourceAccountMissing`)。
- R5:`Transfer` 同校区 → **3140**(既有 `TestTransferSameCampusRejected`)。

④ type=108 判别式(A18):注释内写出可直接落地的判定 SQL
(同 logid 存在 207 且 `log_id <> ''` = 差额型),测试侧 `logKeyOf` 用同口径(NULL/空 → ""),
禁止用一条 `SUM(type=108)` 混算两种语义。

go build / go vet / go test -short ./... 全绿。
Q 18 時間 前
親
コミット
79b6d1c84b

+ 45 - 22
internal/academic/service/fund_service.go

@@ -197,11 +197,12 @@ type RefundInput struct {
 
 // RefundResult 退费结果(端点 5 响应 `{logId,balance,changed}`)。
 type RefundResult struct {
-	// LogID 流水 ID(no-op 时为 0)。
+	// LogID 流水 ID(恒 > 0:money<=0 已在入口按 R6 拒为 2002)。
 	LogID int64 `json:"logId"`
 	// Balance 退费后校区账户余额。
 	Balance money.Amount `json:"balance"`
-	// Changed false = 未发生资金变动(money<=0 的 no-op,或幂等命中)。
+	// Changed 恒 true:money<=0 由 R6 在入口拒为 2002(不再有 no-op 分支),
+	// 幂等命中用 Repeated 表达(字段保留是为兼容既有响应契约,不再有 false 的业务分支)。
 	Changed bool `json:"changed"`
 	// Repeated true = 幂等命中。
 	Repeated bool `json:"repeated"`
@@ -652,7 +653,12 @@ func isExpenditureType(typ int32) bool {
 // 同一 campus_id 若在多机构复用(历史数据/跨库迁移期),会误更新别的机构的账户行。
 // Go 侧补 `institution_id` 参数并进 WHERE,定位三元组精确到 `(user, institution, campus)`。
 //
-// 入参:ctx / in。出参:*RefundResult(changed=false 表示无资金变动);
+// **R6(设计 §3.8-b)强化**:`money <= 0` → **2002**(与 recharge/expenditure/baddebt/transfer 五处统一)。
+// 与 dump 的差异是**有意偏离**:dump 对 <=0 是"静默不动作",前端会显示"成功但余额没变";
+// Go 显式拒绝。注:本处 dump 分支**不写流水**、不占幂等键,与另三处"归 0 仍写 0 元流水"不同,
+// 但对外统一成 2002 更不易被误用(前端不会把"没退钱"当成功)。
+//
+// 入参:ctx / in。出参:*RefundResult;
 // 错误码 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 {
@@ -665,6 +671,10 @@ func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult
 		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
 			errString("service: Refund 的 type 不在流水类型白名单内"))
 	}
+	// R6:金额 <= 0 一律 2002(dump 是 `IF inputMoney>0` 静默跳过;Go 显式拒绝)
+	if err := validatePositiveAmount(in.Money, "Refund"); err != nil {
+		return nil, err
+	}
 	if s == nil || s.runner == nil || s.repo == nil {
 		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
 	}
@@ -673,14 +683,6 @@ func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult
 		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)
@@ -739,14 +741,21 @@ func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult
 
 // RefundableAmount 查询校区账户可退金额(SP5 `sp_user_expense_school_get_refundable_amount`,**只读**)。
 //
-// dump 语义(:764421,**两步修正,缺一不可**;dump 里的游标段被 `/* */` 注释掉,实际只生效下面的 SUM):
+// dump 语义(:764421,**三步修正,缺一不可**;dump 里的游标段整段被 `/* */` 注释掉,实际只生效下面的 SUM):
 //
 //	① 读 campusBalance(t_user_expense_school.balance)与 totalBalance(t_user_expense.balance);
-//	② **截断**:`IF campusBalance > totalBalance THEN campusBalance = totalBalance`
-//	   (校区余额不可能大于跨校区总余额;超出即历史脏数据,按总余额截断而不是报错);
+//	   任一 <= 0(含 NULL/账户不存在)→ 0;
+//	② **截断**:`IF campusBalance > totalBalance THEN campusBalance = totalBalance`,
+//	   截断后再早退一次(`campusBalance <= 0 → 0`,dump 真有这句,最易漏);
 //	③ **减已使用赠送额**(`t_user_balance_request × t_courses_activity_signed` 关联汇总,
 //	   口径见 `BalanceRequestRepo.SumRechargeActivityUsedGift`):
-//	     refundable = campusBalance − usedGift,**下限 0**。
+//	     refundable = campusBalance − usedGift,**下限 0**(SUM 为 NULL → 视作 0)。
+//
+// 两个**别复原**的陷阱(设计 §3.8-b 明确登记,评审逐处检查):
+//   - 同一 SP 里还把 `SUM(activity_money)` 存进 `totalDiscountAmount`,但**全文再未引用**(死变量)
+//     → **不得**据此加"赠送额上限/赠送额不可超余额"之类的额外约束;
+//   - 那段按 `response_time DESC` 逐笔累加的游标(`DECLARE cur` / `OPEN` / 循环)**整段被注释掉**
+//     → **不得**照注释补回逐笔逻辑,实际口径就是一条聚合 SUM。
 //
 // Go 的显式化(不改变资金结果,只把 dump 的静默分支显式化):
 //   - 校区账户不存在 → (0, nil)(dump:ISNULL → 0);
@@ -758,9 +767,8 @@ func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult
 // 与之配套的调用纪律:可退额是"查询口径",**不得**用它做写路径的扣减基数——
 // 退费扣减基数由 `CustomRefund` 在事务内用加锁读的校区余额决定(§7.2 R2)。
 //
-// 与 SP9 内部快照的区别(**勿混用**):`CustomRefund` 的 `RefundableAmount` 字段是
-// "退费前校区余额快照"(扣减基数,dump 的 `balance<=0 → 3102` 口径),不含赠送额扣减;
-// 本 SP5 方法是"外部查询可退额",含赠送额扣减。两者语义不同,故数值可以不等。
+// 与 SP9 的关系(**勿互相复用**):`custom_refund` **不调用**本方法,它内部的坏账基数是
+// **校区 `balance` 本身**;两者数值天然不同(本方法扣赠送额与全局截断,SP9 不扣不截)。
 //
 // 入参:ctx / userID / institutionID / campusID。
 // 出参:(可退金额, error);账户不存在 / 余额非正 → (0, nil);错误码 2001/3140/1004/5002。
@@ -813,6 +821,11 @@ func (s *FundService) RefundableAmount(
 	if campusBalance > totalBalance {
 		campusBalance = totalBalance
 	}
+	// ③-b 截断后再早退一次(dump 里真有这句,最容易被"省掉"):截断后仍非正 → 0,
+	// 不要在非正基数上继续做减项(否则 -- 与 max(…,0) 的结果虽同,语义却把"欠费/脏数据"当成可退基数)。
+	if campusBalance <= 0 {
+		return 0, nil
+	}
 
 	// ④ **第二步修正**:扣掉已使用的充值赠送额(赠送额不可退)
 	usedGift, err := s.requests.SumRechargeActivityUsedGift(ctx, userID, institutionID, campusID)
@@ -1030,11 +1043,21 @@ type campusRef struct {
 //	   —— 该 108 **不再构成一次独立的余额增加**,**不参与**"108 → balance 增量"的归因
 //	   (否则会与 207 重复计入)。
 //
-//	**判别式(唯一、可机械执行)**:取该 108 流水的 `logid`(= `request_id`,
-//	`newExpenseLog` 对两者写同一值)——**同 logid 存在 type=207 的流水 = 差额型(②);
-//	不存在 = 独立坏账(①)**。
+//	**判别式(唯一、可机械执行,设计 §3.8-b (f) A18)**:取该 108 流水的 `logid`
+//	(= `request_id`,`newExpenseLog` 对两者写同一值)——
+//	**同 `logid` 存在 type=207 且 `logid <> ''`(非 NULL/空)= 差额型(②)**;
+//	**不存在(含 logid 为空)= 独立坏账(①)**。可直接落地的判定 SQL(AC-17 ④ 可照抄):
+//
+//	    SELECT l.id, l.money,
+//	           (l.log_id IS NOT NULL AND l.log_id <> ''
+//	            AND EXISTS (SELECT 1 FROM t_user_expense_log r
+//	                         WHERE r.user_id = l.user_id AND r.type = 207 AND r.log_id = l.log_id)) AS differential
+//	      FROM t_user_expense_log l
+//	     WHERE l.user_id = ? AND l.type = 108;
+//
 //	· 两条来源都存在时,"balance = Σ全部流水" 仍恒成立(②里 108 与 207 的代数和恰为净扣减
-//	  −(requested−badDebt) = −refunded),但"逐条 108 是否代表一次余额增加"必须用上面的判别式区分。
+//	  −(requested−badDebt) = −refunded),但"逐条 108 是否代表一次余额增加"必须用上面的判别式区分;
+//	  **禁止**用一条 `SUM(money) WHERE type=108` 把两种语义混算。
 //
 // 幂等:`request_id + type(108)` 唯一键;无 requestId 时不享幂等(dump 同口径)。
 //

+ 204 - 79
internal/academic/service/fund_service_test.go

@@ -561,22 +561,108 @@ func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) {
 	}
 }
 
-// TestRefundZeroMoneyIsNoop money<=0 → changed=false(对齐 dump `IF inputMoney>0`),不写流水。
-func TestRefundZeroMoneyIsNoop(t *testing.T) {
+// TestRefundRejectsNonPositiveMoney R6(§3.8-b):money<=0 → **2002**,且不写任何流水。
+//
+// 口径变更说明:dump(SP4)对 `inputMoney<=0` 是 `IF inputMoney>0` 静默跳过(本次之前实现为
+// changed=false 的 no-op),v0.1.14 的 R6 把五处入口(recharge/expenditure/baddebt/refund/transfer)
+// 统一为显式 2002,故此处期望值由"成功 + changed=false"改为错误码 2002。
+func TestRefundRejectsNonPositiveMoney(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)
+
+	for _, amount := range []string{"0.00", "-1.00"} {
+		_, err := svc.Refund(context.Background(), RefundInput{
+			UserID: user, InstitutionID: 1, CampusID: 10,
+			Money: mustAmount(t, amount), Type: model.ExpenseLogTypeCourseCampus,
+		})
+		requireAppCode(t, err, 2002)
 	}
 	if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
-		t.Fatalf("no-op 不应写流水,实得 %d 条", count)
+		t.Fatalf("非法金额不得写流水,实得 %d 条", count)
+	}
+	if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 0 {
+		t.Fatalf("非法金额不得写充值流水,实得 %d 条", count)
+	}
+}
+
+// TestNonPositiveMoneyRejectedEverywhere R6 的**五处全覆盖**(charge/expenditure/baddebt/refund/transfer):
+// money<=0 → 2002,且**零副作用**(不建户、不改账户、不占 `(request_id,type)` 幂等键)。
+//
+// 为什么要显式钉"不占幂等键":dump 的 recharge/expenditure/baddebt 分支会把负值**归 0 后照常记账**,
+// 留下 money=0 的流水并把 `(request_id,type)` 占死,导致"同键后续真金额被幂等跳过"(资金静默丢失)。
+func TestNonPositiveMoneyRejectedEverywhere(t *testing.T) {
+	db := newFundServiceDB(t)
+	svc := NewFundService(db, fundSuperScope(), nil)
+	ctx := context.Background()
+	user := fundUserID(26)
+
+	// 预置账户,确保"零副作用"的断言不是"账户本来就不存在"导致的假绿
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-R6-SEED"}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	before := readSchool(t, db, user, 1, 10)
+	logsBefore := len(balanceLogs(t, db, user))
+
+	cases := []struct {
+		name string
+		call func(amount money.Amount) error
+	}{
+		{"recharge", func(a money.Amount) error {
+			_, e := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+				Money: a, RequestID: "REQ-R6-RECHARGE"})
+			return e
+		}},
+		{"expenditure", func(a money.Amount) error {
+			_, e := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
+				Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-EXPEND"})
+			return e
+		}},
+		{"baddebt", func(a money.Amount) error {
+			_, e := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
+				Money: a, RequestID: "REQ-R6-BADDEBT"})
+			return e
+		}},
+		{"refund", func(a money.Amount) error {
+			_, e := svc.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
+				Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-REFUND"})
+			return e
+		}},
+		{"transfer", func(a money.Amount) error {
+			_, e := svc.Transfer(ctx, TransferInput{UserID: user,
+				FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
+				Money: a, RequestID: "REQ-R6-TRANSFER"})
+			return e
+		}},
+	}
+	for _, amount := range []string{"0.00", "-10.00"} {
+		for _, c := range cases {
+			err := c.call(mustAmount(t, amount))
+			if err == nil {
+				t.Fatalf("%s(%s) 应返回 2002,实得成功", c.name, amount)
+			}
+			requireAppCode(t, err, 2002)
+		}
+	}
+
+	after := readSchool(t, db, user, 1, 10)
+	if cents(t, after.Balance) != cents(t, before.Balance) ||
+		cents(t, after.TotalIncome) != cents(t, before.TotalIncome) ||
+		cents(t, after.TotalExpenditure) != cents(t, before.TotalExpenditure) ||
+		cents(t, after.SpecialExpenses) != cents(t, before.SpecialExpenses) {
+		t.Fatalf("R6 拒绝的请求不得改动账户:before=%+v after=%+v", before, after)
+	}
+	if got := len(balanceLogs(t, db, user)); got != logsBefore {
+		t.Fatalf("R6 拒绝的请求不得写流水(幂等键不得被占):before=%d after=%d", logsBefore, got)
+	}
+	// 幂等键未被占死:同键 + 真金额必须能正常入账
+	if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
+		Money: mustAmount(t, "10.00"), RequestID: "REQ-R6-RECHARGE"}); err != nil {
+		t.Fatalf("被拒请求不得占死幂等键,同键真金额应能入账: %v", err)
+	}
+	if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 11000 {
+		t.Fatalf("真金额入账后余额应 11000 分,实得 %d", got)
 	}
 }
 
@@ -627,79 +713,108 @@ func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID in
 	}
 }
 
-// TestRefundableAmount SP5 的全部边界:账户缺失 / 欠费 / 正常全额 /
-// **campus>total 截断** / **赠送已用小余额** / 赠送用尽不扣 / 未审批不扣 / 下限 0 / 3140。
+// TestRefundableAmount SP5 门禁用例(§8 AC-04 ⑦ 的 5 组表驱动 + 退化分支)。
+//
+// 五组必须区分得开"单步实现"与"三步实现"(原 case 组区分不出,正是错口径活到交付的原因):
+//
+//	① 基线无赠送    → 只有"读余额"也能过
+//	② 截断          → 只有实现 ① 截断才能过(campus 200 > total 120)
+//	③ 减项          → 只有实现 ③ 减赠送额才能过(100 − 20 = 80)
+//	④ 归零          → 减项后为负必须取 0(不是负数透出)
+//	⑤ 截断+减项叠加 → 只有两步都实现且**顺序正确**才能过(min(200,120) − 30 = 90;
+//	                  若先减后截、或漏截断,会得到 170 / 150 / 70 等错值)
 func TestRefundableAmount(t *testing.T) {
+	const (
+		inst = int64(1)
+		camp = int64(10)
+	)
+	type gift struct {
+		BizNo     string
+		Activity  int64
+		Approved  int8
+		Money     string
+		MoneyUsed string
+	}
+	cases := []struct {
+		name      string
+		campusBal *string // nil = 校区账户不存在
+		globalBal *string // nil = 全局账户不存在
+		gifts     []gift
+		wantCents int64
+	}{
+		{name: "①基线_无赠送", campusBal: strPtr("88.50"), globalBal: strPtr("100.00"), wantCents: 8850},
+		{name: "②截断_campus>total", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"), wantCents: 12000},
+		{name: "③减项_赠送已用", campusBal: strPtr("100.00"), globalBal: strPtr("150.00"),
+			gifts:     []gift{{"BR-SP5-3", 3, model.BalanceRequestApprovedPassed, "50.00", "20.00"}},
+			wantCents: 8000},
+		{name: "④归零_赠送>截断基数", campusBal: strPtr("50.00"), globalBal: strPtr("50.00"),
+			gifts:     []gift{{"BR-SP5-4", 4, model.BalanceRequestApprovedPassed, "90.00", "80.00"}},
+			wantCents: 0},
+		{name: "⑤截断+减项叠加", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"),
+			gifts:     []gift{{"BR-SP5-5", 5, model.BalanceRequestApprovedPassed, "100.00", "30.00"}},
+			wantCents: 9000},
+		{name: "⑥赠送用尽不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
+			gifts:     []gift{{"BR-SP5-6", 6, model.BalanceRequestApprovedPassed, "50.00", "50.00"}},
+			wantCents: 10000},
+		{name: "⑦未审批不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
+			gifts:     []gift{{"BR-SP5-7", 7, model.BalanceRequestApprovedPending, "50.00", "20.00"}},
+			wantCents: 10000},
+		{name: "⑧多笔赠送累加", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
+			gifts: []gift{
+				{"BR-SP5-8a", 8, model.BalanceRequestApprovedPassed, "50.00", "20.00"},
+				{"BR-SP5-8b", 9, model.BalanceRequestApprovedPassed, "50.00", "10.00"},
+			},
+			wantCents: 7000},
+		{name: "⑨校区账户不存在", globalBal: strPtr("100.00"), wantCents: 0},
+		{name: "⑩全局账户不存在", campusBal: strPtr("50.00"), wantCents: 0},
+		{name: "⑪校区欠费", campusBal: strPtr("-25.00"), globalBal: strPtr("100.00"), wantCents: 0},
+		{name: "⑫全局非正", campusBal: strPtr("50.00"), globalBal: strPtr("0.00"), wantCents: 0},
+	}
 	db := newFundServiceDB(t)
 	svc := NewFundService(db, fundScope(), nil)
 	ctx := context.Background()
 
-	// ① 账户不存在 → 0
-	if got, err := svc.RefundableAmount(ctx, fundUserID(11), 1, 10); err != nil || got != 0 {
-		t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, err)
-	}
-
-	// ② 欠费(校区余额为负)→ 0(总额再大也不可退)
-	seedExpenseAccounts(t, db, fundUserID(12), "-25.00", "100.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(12), 1, 10); err != nil || got != 0 {
-		t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, err)
-	}
-
-	// ③ 正常:校区 88.50 ≤ 全局 100.00,且无赠送 → 全额 88.50
-	seedExpenseAccounts(t, db, fundUserID(13), "88.50", "100.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
-		t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
+	for i, c := range cases {
+		user := fundUserID(200 + i)
+		if c.globalBal != nil {
+			if err := db.Create(&model.UserExpense{UserID: user, Balance: *c.globalBal}).Error; err != nil {
+				t.Fatalf("%s: seed 全局账户失败: %v", c.name, err)
+			}
+		}
+		if c.campusBal != nil {
+			if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: inst, CampusID: camp,
+				Balance: *c.campusBal}).Error; err != nil {
+				t.Fatalf("%s: seed 校区账户失败: %v", c.name, err)
+			}
+		}
+		for _, g := range c.gifts {
+			seedGiftUsed(t, db, user, g.BizNo, g.Activity, g.Approved, g.Money, g.MoneyUsed)
+		}
+		got, err := svc.RefundableAmount(ctx, user, inst, camp)
+		if err != nil {
+			t.Fatalf("%s: RefundableAmount 失败: %v", c.name, err)
+		}
+		if got.Cents() != c.wantCents {
+			t.Fatalf("%s: 实得 %d 分,期望 %d 分", c.name, got.Cents(), c.wantCents)
+		}
+		if got < 0 {
+			t.Fatalf("%s: 可退额不得为负,实得 %d", c.name, got.Cents())
+		}
 	}
 
-	// ④ 校区缺失 → 3140
-	if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
+	// campusID <= 0 → 3140(入参校验,与账户数据无关)
+	if _, err := svc.RefundableAmount(ctx, fundUserID(200), inst, 0); err != nil {
 		requireAppCode(t, err, 3140)
 	}
-
-	// ⑤ 全局账户缺失(totalBalance 视为 0)→ 0
-	if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(18), InstitutionID: 1, CampusID: 10,
-		Balance: "50.00"}).Error; err != nil {
-		t.Fatalf("seed 校区账户失败: %v", err)
-	}
-	if got, err := svc.RefundableAmount(ctx, fundUserID(18), 1, 10); err != nil || got != 0 {
-		t.Fatalf("全局账户缺失应返回 0,实得 (%d, %v)", got, err)
-	}
-
-	// ⑥ **第一步修正**:campusBalance > totalBalance → 按 totalBalance 截断(不得回 200)
-	seedExpenseAccounts(t, db, fundUserID(19), "200.00", "120.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(19), 1, 10); err != nil || got.Cents() != 12000 {
-		t.Fatalf("campus(200)>total(120) 应截断为 12000 分,实得 (%d, %v)", got, err)
-	}
-
-	// ⑦ **第二步修正**:赠送已用 → 可退额必须**小于**余额(100 − 20 = 80)
-	seedExpenseAccounts(t, db, fundUserID(20), "100.00", "100.00")
-	seedGiftUsed(t, db, fundUserID(20), "BR-GIFT-1", 1, model.BalanceRequestApprovedPassed, "50.00", "20.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(20), 1, 10); err != nil || got.Cents() != 8000 {
-		t.Fatalf("赠送已用 20 应扣减(8000 分 < 余额 10000),实得 (%d, %v)", got, err)
-	}
-
-	// ⑧ 赠送**已用尽**(used == activity_money)→ 按 dump 的 `<` 条件不参与统计 → 不扣
-	seedExpenseAccounts(t, db, fundUserID(21), "100.00", "100.00")
-	seedGiftUsed(t, db, fundUserID(21), "BR-GIFT-2", 2, model.BalanceRequestApprovedPassed, "50.00", "50.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(21), 1, 10); err != nil || got.Cents() != 10000 {
-		t.Fatalf("赠送用尽行不参与统计(应 10000),实得 (%d, %v)", got, err)
-	}
-
-	// ⑨ 工单**未审批**(approved != 1)→ 不参与统计
-	seedExpenseAccounts(t, db, fundUserID(22), "100.00", "100.00")
-	seedGiftUsed(t, db, fundUserID(22), "BR-GIFT-3", 3, model.BalanceRequestApprovedPending, "50.00", "20.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(22), 1, 10); err != nil || got.Cents() != 10000 {
-		t.Fatalf("未审批工单不参与统计(应 10000),实得 (%d, %v)", got, err)
-	}
-
-	// ⑩ 截断后再扣赠送,仍**下限 0**(120 − 150 < 0 → 0)
-	seedExpenseAccounts(t, db, fundUserID(23), "200.00", "120.00")
-	seedGiftUsed(t, db, fundUserID(23), "BR-GIFT-4", 4, model.BalanceRequestApprovedPassed, "200.00", "150.00")
-	if got, err := svc.RefundableAmount(ctx, fundUserID(23), 1, 10); err != nil || got != 0 {
-		t.Fatalf("截断后扣赠送仍为负 → 应返回 0,实得 (%d, %v)", got, err)
+	// 越界口径提醒:可退额只做"查询",不因赠送额而改动账户(只读断言)
+	if got := readSchool(t, db, fundUserID(201), inst, camp).Balance; cents(t, got) != 20000 {
+		t.Fatalf("RefundableAmount 必须只读(②用例校区余额应仍 20000 分),实得 %s", got)
 	}
 }
 
+// strPtr 取字符串指针(表驱动里表达"该账户存在且余额为 X")。
+func strPtr(s string) *string { return &s }
+
 // ---------------------------------------------------------------- 坏账(SP7,双加)
 
 // TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
@@ -776,11 +891,11 @@ func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount {
 
 // classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
 //
-// 判别式(**唯一、可机械执行**):取 108 流水的 `logid`(= `request_id`)——
-//   - **同 logid 存在 type=207 流水** → 差额型(`CustomRefund` 的退费差额):
+// 判别式(**唯一、可机械执行**,设计 §3.8-b (f) A18):取 108 流水的 `logid`(= `request_id`)——
+//   - **同 logid 存在 type=207 且 logid 非空(`<> ”`)** → 差额型(`CustomRefund` 的退费差额):
 //     该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
-//   - **不存在** → 独立坏账(`FundService.BadDebt`):balance 与 special_expenses 双加,
-//     该 108 **就是**一次余额增加。
+//   - **不存在(含 logid 为空/NULL)** → 独立坏账(`FundService.BadDebt`):balance 与
+//     special_expenses 双加,该 108 **就是**一次余额增加。
 func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
 	independent, differential []model.UserExpenseLog,
 ) {
@@ -788,16 +903,18 @@ func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
 	all := balanceLogs(t, db, userID)
 	refundKeys := make(map[string]bool)
 	for i := range all {
-		if all[i].Type != model.ExpenseLogTypeRefund || all[i].LogID == nil {
+		if all[i].Type != model.ExpenseLogTypeRefund {
 			continue
 		}
-		refundKeys[*all[i].LogID] = true
+		if key := logKeyOf(all[i]); key != "" {
+			refundKeys[key] = true
+		}
 	}
 	for i := range all {
 		if all[i].Type != model.ExpenseLogTypeBadDebt {
 			continue
 		}
-		if all[i].LogID != nil && refundKeys[*all[i].LogID] {
+		if key := logKeyOf(all[i]); key != "" && refundKeys[key] {
 			differential = append(differential, all[i])
 			continue
 		}
@@ -806,6 +923,14 @@ func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
 	return independent, differential
 }
 
+// logKeyOf 取流水的 logid(= request_id)原文;NULL/空 → ""(与 A18 判定 SQL 的 `log_id <> ”` 同口径)。
+func logKeyOf(row model.UserExpenseLog) string {
+	if row.LogID == nil {
+		return ""
+	}
+	return strings.TrimSpace(*row.LogID)
+}
+
 // TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
 // 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
 func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {