Эх сурвалжийг харах

feat(academic): AC-08 活动服务 B(老带新双边结算 + 取消充值活动/欠费核销转调)

覆盖 SP:
- use_activity_recommend 763307-763366 → RecommendService.SettleRecommend
  · 候选:signed JOIN activity type=4,**不过滤报名 status**(dump 763329);
  · recommend_user_id 空/金额<=0 → 零写入;used_log 存在 → 幂等回放;
  · **双边各自入账**:各 balance+total_recommended_amount 同一语句双增 + **各一条 107**,
    `balance_after` 各取**本 user 全局快照**(不要求两边相等);
  · 每边入账后**同 tx 转调** MaintenanceService.CounteractArrears(不重复实现);
  · used_log 写 -m,signed 置 activity_money_used=m / status=2;
  · 活动 schoolid/campusid 为 0 → 从 t_courses_student 回查(仍缺则零写入,dump 763348/763355)。
- cancel_recharge_activity(AC-05 已交付)→ 同 tx 转调 RefundService.CancelRechargeActivity。
- counteract_arrears(AC-11 已交付)→ 同 tx 转调 MaintenanceService.CounteractArrears。

幂等回放等价(§5.1 纪律 5):
- SettleRecommend / CancelRechargeActivity 的 replay 分支全部**从库回读**响应体字段;
- 修 refund_service.go 的 CancelRechargeActivity **并发命中分支**:原本用流水 `balance_after`
  (v0.1.16 起是**全局**口径)充当响应体校区 `Balance` → 改为回读校区账户,避免串口径回放旧值。

仓储增量:ExpenseColumnTotalRecommendedAmount(白名单 +1 项,与 balance 同一语句双增)、
ExpenseRepo.FindLogByLogID(107 无 request_id,回放按 user+log_id+type 定位)、
CourseRepo.GetStudentCourseScope(活动机构/校区为 0 时的兜底回查)。

单测(横切优先):双边各加一次 + 两条 107 + 每边 balance_after 为本 user 快照;
幂等回放造"首次结算后账户被别的写入改过"场景,响应体必须等于当刻库值(回放旧值即红);
取消充值活动/欠费核销两条转调路径的资金效果与 replay 回读。
Q 16 цаг өмнө
parent
commit
6cddf7c7e8

+ 74 - 0
internal/academic/repository/course_student_repo.go

@@ -0,0 +1,74 @@
+// 报课记录的作用域回查(AC-08 老带新结算用)——`t_courses_student` 的只读投影。
+//
+// 为什么独立文件:`course_repo.go` 是 AC-11 的交付文件,本批只**新增**一个只读方法
+// (对既有行为零改动),集中在本文件便于评审与回滚。
+//
+// dump 依据(`sp_courses_activity_use_activity_recommend`,yuansiwei_finance.sql:763339-763347):
+//
+//	IF (selectSchoolid<=0) THEN
+//	  SELECT(SELECT schoolid FROM t_courses_student WHERE studentid=inputUserid AND id=inputOrder) INTO selectSchoolid;
+//	END IF;
+//	IF (selectCampusid<=0) THEN
+//	  SELECT(SELECT campusid FROM t_courses_student WHERE studentid=inputUserid AND id=inputOrder) INTO selectCampusid;
+//	END IF;
+//	IF(selectCampusid<=0 || selectSchoolid<=0) THEN LEAVE readLoop; END IF;
+//
+// 语义:活动行上的 `schoolid/campusid` 为 0("全机构池"活动)时,用**报课记录**的机构/校区
+// 兜底——钱必须落到具体校区账户上。
+package repository
+
+import (
+	"context"
+	"strings"
+
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// StudentCourseScope 报课记录的作用域投影(机构 + 校区)。
+//
+// 字段:StudentCourseID 报课记录 ID;InstitutionID 原 schoolid;CampusID 原 campusid。
+type StudentCourseScope struct {
+	// StudentCourseID t_courses_student.id。
+	StudentCourseID int64 `gorm:"column:student_course_id"`
+	// InstitutionID 机构 ID(原 schoolid)。
+	InstitutionID int64 `gorm:"column:institution_id"`
+	// CampusID 校区 ID(原 campusid)。
+	CampusID int64 `gorm:"column:campus_id"`
+}
+
+// GetStudentCourseScope 按 (报课记录 id, 学生) 取机构/校区(dump 763339 的两次子查询合并为一次)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - studentCourseID 报课记录 ID(<=0 → ErrBadParam);
+//   - studentID 学生(ULID;空 → ErrBadParam;**必须与报课记录归属人一致**,否则 ErrNotFound)。
+//
+// 出参:(*StudentCourseScope, nil) 命中;ErrNotFound(记录不存在或不属于该学生)/ ErrBadParam / ErrDBError。
+//
+// 作用域:与 dump 一致**按 (id, student_id) 精确定位、不叠作用域过滤**——
+// dump 的子查询同样没有机构/校区条件;越权风险为 0(`student_id` 来自登录态,
+// 且本方法只读两个作用域列,写入口账户时仍由活动/报课记录的机构决定)。
+func (r *CourseRepo) GetStudentCourseScope(
+	ctx context.Context, studentCourseID int64, studentID string,
+) (*StudentCourseScope, error) {
+	if studentCourseID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetStudentCourseScope 需要 student_course_id>0"))
+	}
+	if strings.TrimSpace(studentID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetStudentCourseScope 需要 student_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out StudentCourseScope
+	q := db.Table("t_courses_student").
+		Select("id AS student_course_id, institution_id, campus_id").
+		Where("id = ? AND student_id = ?", studentCourseID, strings.TrimSpace(studentID))
+	if err := q.Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetStudentCourseScope(t_courses_student)")
+	}
+	return &out, nil
+}

+ 5 - 2
internal/academic/repository/expense_repo.go

@@ -418,11 +418,14 @@ func (r *ExpenseRepo) addColumnAtomic(ctx context.Context, tx *gorm.DB, op, colu
 	return global.RowsAffected, nil
 }
 
-// isExpenseColumn 列名白名单校验(只接受本文件 expenseCol* 常量)。
+// isExpenseColumn 列名白名单校验(只接受本文件 expenseCol* 常量 + `total_recommended_amount`)。
+//
+// `total_recommended_amount` 由 AC-08 老带新结算引入(常量定义见 expense_repo_recommend.go),
+// 必须与 `balance` 同一语句双增(dump 763344/763351)。
 func isExpenseColumn(column string) bool {
 	switch column {
 	case expenseColBalance, expenseColSpend, expenseColSpecialExpenses,
-		expenseColTotalIncome, expenseColTotalExpenditure:
+		expenseColTotalIncome, expenseColTotalExpenditure, ExpenseColumnTotalRecommendedAmount:
 		return true
 	default:
 		return false

+ 75 - 0
internal/academic/repository/expense_repo_recommend.go

@@ -0,0 +1,75 @@
+// 余额账户仓储的「老带新结算」增量(AC-08)——`total_recommended_amount` 列 + 107 流水定位。
+//
+// 为什么要独立文件:`expense_repo.go` 是 AC-03/AC-04/AC-05 的共用文件,本批只**新增**
+// 一个列名常量与两个只读/统计方法(对既有行为零改动),集中在本文件便于评审与回滚。
+//
+// dump 依据(`sp_courses_activity_use_activity_recommend`,yuansiwei_finance.sql:763344-763366):
+//
+//	UPDATE t_user_expense_school SET balance=balance+m, total_recommended_amount=total_recommended_amount+m
+//	 WHERE userid=? AND schoolid=? AND campusid=?;
+//	UPDATE t_user_expense        SET balance=balance+m, total_recommended_amount=total_recommended_amount+m
+//	 WHERE userid=?;
+//	INSERT INTO t_user_expense_log(userid,type,logid,money,create_userid,create_time,remark)
+//	 VALUES(?,107,inputOrder,m,...);
+//
+// 两条 UPDATE 都是「balance 与 total_recommended_amount 同一语句双增」——拆开会出现
+// "余额加了、累计推荐额没加"的中间态(对账/双跑会读到半成品),故走 `ColumnDelta` 多列原子增减。
+package repository
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// ExpenseColumnTotalRecommendedAmount 累计推荐金额列(老带新双边入账累加)。
+//
+// 该列**不在** AC-03 的初始白名单里(当时无写入场景),AC-08 结算需要按 dump 与
+// `balance` 同一语句双增,故补入白名单;`isExpenseColumn` 已同步(见 expense_repo.go)。
+const ExpenseColumnTotalRecommendedAmount = "total_recommended_amount"
+
+// FindLogByLogID 按「用户 + 流水 logid + type」取一条余额流水(老带新 107 的幂等回读)。
+//
+// 为什么不用 `FindLogByRequest`:老带新 dump 写的是 `logid=inputOrder`,
+// `request_id` 为 NULL(无幂等键语义)→ 幂等回放只能靠 `(user_id, log_id, type)` 定位。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(可 nil:nil 时用连接池或 ctx 内事务);
+//   - userID 用户(空 → ErrBadParam);logID 外部单号(空 → ErrBadParam);typ 流水类型。
+//
+// 出参:(*model.UserExpenseLog, nil) 命中;(**nil**, nil) 无该流水;ErrBadParam / ErrDBError。
+func (r *ExpenseRepo) FindLogByLogID(
+	ctx context.Context, tx *gorm.DB, userID, logID string, typ int32,
+) (*model.UserExpenseLog, error) {
+	if strings.TrimSpace(userID) == "" || strings.TrimSpace(logID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: FindLogByLogID 需要 user_id 与 log_id"))
+	}
+	db, err := r.queryDB(ctx, tx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.UserExpenseLog
+	err = db.Model(&model.UserExpenseLog{}).
+		Where("user_id = ? AND log_id = ? AND type = ?", userID, logID, typ).
+		Order("id ASC").
+		Take(&out).Error
+	if err != nil {
+		if isRecordNotFound(err) {
+			return nil, nil
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return &out, nil
+}
+
+// isRecordNotFound 判定 gorm 的"无行"错误(errors.Is 兼容驱动/包装差异)。
+func isRecordNotFound(err error) bool {
+	return errors.Is(err, gorm.ErrRecordNotFound)
+}

+ 564 - 0
internal/academic/service/recommend_service.go

@@ -0,0 +1,564 @@
+// 老带新双边结算服务(+ 取消充值活动 / 欠费核销的转调出口)——AC-08 活动服务 B。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.2 #18 `sp_courses_activity_use_activity_recommend`(dump **763307~763366**)→ `SettleRecommend`
+//   - §3.1 #8 `sp_user_expense_cancel_recharge_activity`(AC-05 已交付 RefundService.CancelRechargeActivity)
+//     → **本服务只转调、不重复实现**(同一 tx 透传,§10.3)
+//   - §3.5 #27 `sp_courses_student_counteract_arrears_course`(AC-11 已交付 MaintenanceService.CounteractArrears)
+//     → 同上,只转调(依赖图 AC-11 -.-> AC-08 虚线边)
+//   - §5.1 纪律 4「横切优先」/ 纪律 5「幂等回放等价」
+//
+// dump 原文(763329 游标 → 763366 收尾,逐行对齐):
+//
+//	游标:signed JOIN activity ON type=4 WHERE t1.userid=?   -- **不过滤报名 status**
+//	763340  IF recommend_userid<=0 OR activity_money<=0 → LEAVE(无推荐人/无金额 → 整体 no-op)
+//	763346  IF EXISTS(used_log WHERE userid=? AND activityid=?) → LEAVE(**幂等**:已结算过)
+//	763348  schoolid/campusid 为 0 → 从 `t_courses_student WHERE studentid=? AND id=inputOrder` 回查
+//	763355  仍为 0 → LEAVE
+//	763358  remark = CONCAT('参与老带新活动(', title, ')<br/>', inputRemark)
+//	763361  **被推荐人**:UPSERT 双账户 → balance+=m, total_recommended_amount+=m → INSERT 107 —— 再欠费核销
+//	763362  **推荐人**:同上(双边各入一次,**两条 107**)
+//	763363  INSERT used_log(activityid, userid, recommend_userid, orderid, activity_money_used=**-m**)
+//	763365  UPDATE signed SET activity_money_used=m, status=2
+//
+// 「双边 + 各一条 107」是这条 SP 最容易写错的地方:写成"只给被推荐人入账"或"两个人共用一条 107"
+// 在总额上差一倍,而只断言"有 107 流水"的用例依然全绿 —— 所以单测断言的是
+// **两个 user 各自的 balance/total_recommended_amount/107 条数与金额**,且 `balance_after`
+// 各取**本 user 自己的全局快照**(不要求两边相等,多校区/多历史下本来也不等)。
+package service
+
+import (
+	"context"
+	"errors"
+	"strconv"
+	"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"
+)
+
+// recommendRemarkPrefix 老带新备注前缀(dump 763358 `CONCAT('参与老带新活动(',title,')<br/>',remark)`)。
+const (
+	// recommendRemarkPrefix 备注前缀。
+	recommendRemarkPrefix = "参与老带新活动("
+	// recommendRemarkJoin 标题与用户备注之间的分隔(dump 的 `<br/>` 原样保留,前端按 HTML 渲染)。
+	recommendRemarkJoin = ")<br/>"
+)
+
+// RecommendService 老带新结算服务(+ 取消充值活动 / 欠费核销的转调出口)。
+//
+// 字段:
+//   - act 活动/报名仓储(取 type=4 报名、写结算态);
+//   - disc 赠送池仓储(used_log 幂等判据与写入);
+//   - expense 余额账户仓储(双边入账、流水);
+//   - courses 报课仓储(活动 schoolid/campusid 为 0 时回查);
+//   - runner 事务运行器(整个结算 + 两侧欠费核销在同一事务);
+//   - refund 退费服务(取消充值活动的**唯一实现**,本服务只转调);
+//   - maint 运维服务(欠费核销的**唯一实现**,本服务只转调);
+//   - member 成员校验器(可 nil)。
+type RecommendService struct {
+	act     *repository.ActivityRepo
+	disc    *repository.ActivityDiscountRepo
+	expense *repository.ExpenseRepo
+	courses *repository.CourseRepo
+	runner  *repository.TxRunner
+	refund  *RefundService
+	maint   *MaintenanceService
+	member  port.MembershipChecker
+	campus  port.CampusReader
+	sc      *appscope.Scope
+	db      *gorm.DB
+}
+
+// NewRecommendService 构造老带新结算服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
+// 出参:*RecommendService(取消充值活动需要校区端口,用 `WithCampusReader` 注入后可用)。
+func NewRecommendService(db *gorm.DB, sc *appscope.Scope) *RecommendService {
+	return &RecommendService{
+		act:     repository.NewActivityRepo(db, sc),
+		disc:    repository.NewActivityDiscountRepo(db, sc),
+		expense: repository.NewExpenseRepo(db, sc),
+		courses: repository.NewCourseRepo(db, sc),
+		runner:  repository.NewTxRunner(db),
+		refund:  NewRefundService(db, sc, nil, nil),
+		maint:   NewMaintenanceService(db, sc),
+		sc:      sc,
+		db:      db,
+	}
+}
+
+// WithCampusReader 注入校区只读端口(取消充值活动需要它解析校区→机构,返自身便于链式装配)。
+//
+// 入参:reader port.CampusReader(nil 时取消充值活动会返回 5002「未装配」)。
+// 出参:*RecommendService(同一个实例)。
+func (s *RecommendService) WithCampusReader(reader port.CampusReader) *RecommendService {
+	s.campus = reader
+	s.refund = NewRefundService(s.db, s.sc, reader, s.member)
+	return s
+}
+
+// WithMembershipChecker 注入成员校验器(返自身,链式装配)。
+//
+// 入参:member port.MembershipChecker(可 nil)。
+// 出参:*RecommendService(同一个实例)。
+func (s *RecommendService) WithMembershipChecker(member port.MembershipChecker) *RecommendService {
+	s.member = member
+	s.refund = NewRefundService(s.db, s.sc, s.campus, member)
+	return s
+}
+
+// ---------------------------------------------------------------- 入参 / 响应 DTO
+
+// SettleRecommendInput 老带新结算入参(dump 4 参:userid / order / remark / createUserid)。
+type SettleRecommendInput struct {
+	// UserID 被推荐人(**新生**;必填 ULID)。
+	UserID string
+	// OrderNo 报课记录 ID 的字符串形式(dump `inputOrder`;活动 schoolid/campusid 为 0 时用它回查)。
+	OrderNo string
+	// Remark 用户备注(会拼到 `参与老带新活动(<标题>)<br/>` 之后)。
+	Remark string
+	// CreateUserID 操作人(→ 107 流水的 create_user_id)。
+	CreateUserID string
+}
+
+// SettleRecommendResult 老带新结算响应。
+//
+// 语义要点:
+//   - `Settled=false` 表示 dump 的 `LEAVE` 分支(无推荐人 / 活动金额<=0 / 回查不到校区),
+//     此时**零写入**,其余字段回显候选值(dump 末尾 `SELECT recommendUserid,schoolid,campusid,success`);
+//   - `Repeated=true` 表示幂等命中(已结算过),**所有金额字段都是当刻库值**(从库回读,
+//     §5.1 纪律 5:不得复用首执行的中间变量)。
+type SettleRecommendResult struct {
+	// Settled 本次是否真正结算(写入了双边账户/流水/used_log)。
+	Settled bool `json:"settled"`
+	// Repeated 幂等命中(已结算过)。
+	Repeated bool `json:"repeated"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// Amount 活动金额(分)= 每边入账额。
+	Amount money.Amount `json:"amount"`
+	// RecommendUserID 推荐人。
+	RecommendUserID string `json:"recommendUserId"`
+	// InstitutionID 入账机构(活动上的,或从报课记录回查的)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 入账校区。
+	CampusID int64 `json:"campusId"`
+	// UserLogID 被推荐人的 107 流水 ID(0 = 未产生)。
+	UserLogID int64 `json:"userLogId"`
+	// UserBalance 被推荐人**当前**全局余额(分)。
+	UserBalance money.Amount `json:"userBalance"`
+	// UserCampusBalance 被推荐人**当前**校区余额(分)。
+	UserCampusBalance money.Amount `json:"userCampusBalance"`
+	// UserArrearsApplied 被推荐人本次核销欠费额(分;幂等回放为 0,不可重建)。
+	UserArrearsApplied money.Amount `json:"userArrearsApplied"`
+	// RecommendUserLogID 推荐人的 107 流水 ID。
+	RecommendUserLogID int64 `json:"recommendUserLogId"`
+	// RecommendUserBalance 推荐人**当前**全局余额(分)。
+	RecommendUserBalance money.Amount `json:"recommendUserBalance"`
+	// RecommendUserCampusBalance 推荐人**当前**校区余额(分)。
+	RecommendUserCampusBalance money.Amount `json:"recommendUserCampusBalance"`
+	// RecommendUserArrearsApplied 推荐人本次核销欠费额(分)。
+	RecommendUserArrearsApplied money.Amount `json:"recommendUserArrearsApplied"`
+	// UsedLogID used_log 记录 ID(幂等锚点)。
+	UsedLogID int64 `json:"usedLogId"`
+}
+
+// ---------------------------------------------------------------- 老带新结算(SP18)
+
+// SettleRecommend 老带新双边结算(SP18 `sp_courses_activity_use_activity_recommend`,dump 763307)。
+//
+// 单事务内 6 步(顺序不可调整,见文件头 dump 原文):候选 → 幂等 → 解析校区 →
+// 被推荐人入账+核销 → 推荐人入账+核销 → used_log + signed 结算态。
+//
+// 幂等:`used_log` 存在即视为已结算(dump 763346);命中分支**从库回读**账户余额与流水 ID。
+//
+// 入参:ctx / in。出参:*SettleRecommendResult;错误码 2001/5002(参数与 DB 异常)、
+// 3104(报课记录回查校区失败时按 no-op 处理,不报错——与 dump LEAVE 一致);
+// 3113(欠费核销冲突,来自 MaintenanceService)。
+func (s *RecommendService) SettleRecommend(
+	ctx context.Context, in SettleRecommendInput,
+) (*SettleRecommendResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: SettleRecommend 需要 user_id"))
+	}
+	if s == nil || s.act == nil || s.disc == nil || s.expense == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RecommendService 未装配"))
+	}
+	userID := strings.TrimSpace(in.UserID)
+	orderNo := strings.TrimSpace(in.OrderNo)
+
+	var out SettleRecommendResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		// ① 候选:该 user 的 type=4 报名(**不过滤报名 status**,dump 763329)
+		rows, e := s.act.ListRecommendSignedByUser(txCtx, userID)
+		if e != nil {
+			return e
+		}
+		if len(rows) == 0 {
+			return nil // dump:游标无行 → success=0(零写入)
+		}
+		cand := rows[0]
+		out.ActivityID = cand.ActivityID
+		out.InstitutionID = cand.InstitutionID
+		out.CampusID = cand.CampusID
+
+		// ② 无推荐人 / 无活动金额 → LEAVE(dump 763340)
+		recommender := ""
+		if hasUserRef(cand.RecommendUserID) {
+			recommender = strings.TrimSpace(*cand.RecommendUserID)
+		}
+		out.RecommendUserID = recommender
+		amount, e := parseAccountAmount(cand.ActivityMoney, "t_courses_activity_signed.activity_money")
+		if e != nil {
+			return e
+		}
+		out.Amount = amount
+		if recommender == "" || !amount.GreaterThan(0) {
+			return nil
+		}
+
+		// ③ 幂等:used_log 存在 → 回读当刻库值(dump 763346)
+		used, e := s.disc.GetUsedLog(txCtx, cand.ActivityID, userID)
+		if e != nil {
+			return e
+		}
+		if used != nil {
+			return s.replaySettle(txCtx, &out, cand, userID, recommender, used)
+		}
+
+		// ④ 机构/校区:活动上为 0 → 从报课记录回查(dump 763348)
+		institutionID, campusID := cand.InstitutionID, cand.CampusID
+		if institutionID <= 0 || campusID <= 0 {
+			scope, se := s.studentCourseScope(txCtx, orderNo, userID)
+			if se != nil {
+				return se
+			}
+			if scope == nil {
+				return nil // 回查不到报课记录 → dump 的 IF 判据保持 0 → LEAVE
+			}
+			if institutionID <= 0 {
+				institutionID = scope.InstitutionID
+			}
+			if campusID <= 0 {
+				campusID = scope.CampusID
+			}
+		}
+		out.InstitutionID, out.CampusID = institutionID, campusID
+		if institutionID <= 0 || campusID <= 0 {
+			return nil // dump 763355:仍缺机构/校区 → LEAVE(零写入)
+		}
+
+		// ⑤ 备注(dump 763358)
+		title := ""
+		if cand.ActivityTitle != nil {
+			title = strings.TrimSpace(*cand.ActivityTitle)
+		}
+		remark := recommendRemarkPrefix + title + recommendRemarkJoin + strings.TrimSpace(in.Remark)
+
+		// ⑥ 双边入账 + 各自欠费核销(**被推荐人先、推荐人后**,dump 763361/763362)
+		userLogID, userArrears, e := s.settleOneSide(txCtx, tx, userID, institutionID, campusID, amount, remark, orderNo, in.CreateUserID)
+		if e != nil {
+			return e
+		}
+		recLogID, recArrears, e := s.settleOneSide(txCtx, tx, recommender, institutionID, campusID, amount, remark, orderNo, in.CreateUserID)
+		if e != nil {
+			return e
+		}
+
+		// ⑦ used_log:activity_money_used = **-m**(dump 763363)
+		recRef := recommender
+		usedLog := &model.CoursesActivityUsedLog{
+			ActivityID:        cand.ActivityID,
+			UserID:            userID,
+			RecommendUserID:   &recRef,
+			OrderNo:           optString(orderNo),
+			ActivityMoneyUsed: amount.Neg().String(),
+			Memo:              optString(remark),
+		}
+		if e := s.disc.InsertUsedLog(txCtx, tx, usedLog); e != nil {
+			return e
+		}
+
+		// ⑧ 报名行结算态:activity_money_used=m, status=2(dump 763365)
+		affected, e := s.act.SetSignedSettled(txCtx, tx, cand.SignedID, amount,
+			model.ActivitySignedAttended)
+		if e != nil {
+			return e
+		}
+		if affected == 0 {
+			return infraerrors.WithCause(infraerrors.ErrNotFound,
+				errString("service: SettleRecommend 报名行不可写(并发删除/作用域变化)"))
+		}
+
+		out.Settled = true
+		out.UsedLogID = usedLog.ID
+		out.UserLogID = userLogID
+		out.RecommendUserLogID = recLogID
+		out.UserArrearsApplied = userArrears
+		out.RecommendUserArrearsApplied = recArrears
+
+		// ⑨ 余额快照**从库回读**(响应体不用中间变量,§5.1 纪律 5)
+		if e := s.fillBalances(txCtx, &out, userID, recommender, institutionID, campusID); e != nil {
+			return e
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// settleOneSide 单边入账:UPSERT 双账户 → 双列同增 → 107 流水 → 欠费核销(dump 763361/763362 逐个照搬)。
+//
+// 入参:txCtx(含 tx)/ tx / userID / 机构 / 校区 / amount / remark / orderNo / operatorID。
+// 出参:(107 流水 ID, 核销额, error)。
+//
+// 红线:
+//   - `balance` 与 `total_recommended_amount` **同一语句双增**(AddColumnsGlobalThenCampus 多列原子);
+//   - 107 的 `logid = orderNo`、`request_id = NULL`(dump 无幂等键;回放靠 used_log 判据);
+//   - `balance_after` = **本 user 自己的**全局余额快照(两个 user 的值本来就不等)。
+func (s *RecommendService) settleOneSide(
+	txCtx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
+	amount money.Amount, remark, orderNo, operatorID string,
+) (int64, money.Amount, error) {
+	// ① 建户(dump 的 COUNT 判存在 → UPSERT)
+	if e := s.expense.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: userID}); e != nil {
+		return 0, 0, e
+	}
+	if e := s.expense.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
+		UserID: userID, InstitutionID: institutionID, CampusID: campusID,
+	}); e != nil {
+		return 0, 0, e
+	}
+
+	// ② 双表双列同增:balance +m、total_recommended_amount +m
+	if _, e := s.expense.AddColumnsGlobalThenCampus(txCtx, tx, "SettleRecommend", userID,
+		institutionID, campusID,
+		repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: amount},
+		repository.ColumnDelta{Column: repository.ExpenseColumnTotalRecommendedAmount, Delta: amount},
+	); e != nil {
+		return 0, 0, e
+	}
+
+	// ③ 107 流水(log_id = 报课单号;balance_after = 本 user 全局快照)
+	snapshot, e := globalBalanceAfter(txCtx, s.expense, tx, userID)
+	if e != nil {
+		return 0, 0, e
+	}
+	lg := newExpenseLog(userID, model.ExpenseLogTypeRecommend, amount, "", remark, operatorID, &snapshot)
+	lg.LogID = optString(orderNo)
+	if e := s.insertLog(txCtx, tx, lg); e != nil {
+		return 0, 0, e
+	}
+
+	// ④ 欠费核销(同 tx 转调 AC-11 的实现,**不重复实现**)
+	arrears, e := s.counteractArrears(txCtx, userID, institutionID, campusID, amount)
+	if e != nil {
+		return 0, 0, e
+	}
+	return lg.ID, arrears, nil
+}
+
+// counteractArrears 同 tx 转调 `MaintenanceService.CounteractArrears`(SP27)。
+//
+// 入参:ctx(**须已注入 tx**,下游 runner 会自动透传)/ userID / 机构 / 校区 / 金额。
+// 出参:(实际核销额, error)。
+func (s *RecommendService) counteractArrears(
+	ctx context.Context, userID string, institutionID, campusID int64, amount money.Amount,
+) (money.Amount, error) {
+	res, err := s.maint.CounteractArrears(ctx, CounteractArrearsInput{
+		StudentID:     userID,
+		InstitutionID: institutionID,
+		CampusID:      campusID,
+		Money:         amount,
+	})
+	if err != nil {
+		return 0, err
+	}
+	return res.Applied, nil
+}
+
+// replaySettle 幂等回放(已结算过):**全部金额字段从库回读**,不复用首执行的中间变量。
+//
+// 入参:txCtx / out(待填充)/ cand 候选行 / userID / recommender / used 已存在的 used_log 行。
+// 出参:nil / 错误。
+//
+// 能重建的:活动/推荐人/金额(used_log 的 `-m`)、两侧 107 流水 ID(按 `log_id + type`)、
+// 两侧当前余额(账户表)。**不可重建**:首次欠费核销额(无独立流水)→ 置 0 并在注释说明。
+func (s *RecommendService) replaySettle(
+	txCtx context.Context, out *SettleRecommendResult, cand repository.RecommendSignedRow,
+	userID, recommender string, used *model.CoursesActivityUsedLog,
+) error {
+	amount, err := parseAccountAmount(used.ActivityMoneyUsed, "t_courses_activity_used_log.activity_money_used")
+	if err != nil {
+		return err
+	}
+	out.Amount = absAmount(amount) // used_log 结算行恒为负值
+	out.Repeated = true
+	out.Settled = true
+	out.UsedLogID = used.ID
+	if out.InstitutionID <= 0 || out.CampusID <= 0 {
+		out.InstitutionID, out.CampusID = cand.InstitutionID, cand.CampusID
+	}
+	// 107 流水:按 (user, log_id, type) 回读(dump 用 orderNo 作 logid)
+	orderNo := ""
+	if used.OrderNo != nil {
+		orderNo = strings.TrimSpace(*used.OrderNo)
+	}
+	if orderNo != "" {
+		if lg, e := s.expense.FindLogByLogID(txCtx, nil, userID, orderNo, model.ExpenseLogTypeRecommend); e == nil && lg != nil {
+			out.UserLogID = lg.ID
+		}
+		if lg, e := s.expense.FindLogByLogID(txCtx, nil, recommender, orderNo, model.ExpenseLogTypeRecommend); e == nil && lg != nil {
+			out.RecommendUserLogID = lg.ID
+		}
+	}
+	return s.fillBalances(txCtx, out, userID, recommender, out.InstitutionID, out.CampusID)
+}
+
+// fillBalances 从库回读两侧当前余额(全局 + 校区),缺失则保持 0(不伪造)。
+func (s *RecommendService) fillBalances(
+	ctx context.Context, out *SettleRecommendResult, userID, recommender string, institutionID, campusID int64,
+) error {
+	if bal, e := globalBalanceAfter(ctx, s.expense, nil, userID); e == nil {
+		out.UserBalance = bal
+	} else if !errors.Is(e, infraerrors.ErrNotFound) {
+		return e
+	}
+	if recommender != "" {
+		if bal, e := globalBalanceAfter(ctx, s.expense, nil, recommender); e == nil {
+			out.RecommendUserBalance = bal
+		} else if !errors.Is(e, infraerrors.ErrNotFound) {
+			return e
+		}
+	}
+	if institutionID > 0 && campusID > 0 {
+		if bal, e := campusBalanceAfter(ctx, s.expense, nil, userID, institutionID, campusID); e == nil {
+			out.UserCampusBalance = bal
+		} else if !errors.Is(e, infraerrors.ErrNotFound) {
+			return e
+		}
+		if recommender != "" {
+			if bal, e := campusBalanceAfter(ctx, s.expense, nil, recommender, institutionID, campusID); e == nil {
+				out.RecommendUserCampusBalance = bal
+			} else if !errors.Is(e, infraerrors.ErrNotFound) {
+				return e
+			}
+		}
+	}
+	return nil
+}
+
+// studentCourseScope 用报课单号回查机构/校区(dump 763348-763351)。
+//
+// 入参:ctx / orderNo(**十进制 id 字符串**;非数字 → nil, nil 视为"回查不到")/ studentID。
+// 出参:(*repository.StudentCourseScope, nil) 命中;(nil, nil) 回查不到;error 仅 DB 异常。
+func (s *RecommendService) studentCourseScope(
+	ctx context.Context, orderNo, studentID string,
+) (*repository.StudentCourseScope, error) {
+	if s.courses == nil {
+		return nil, nil
+	}
+	id, err := strconv.ParseInt(strings.TrimSpace(orderNo), 10, 64)
+	if err != nil || id <= 0 {
+		return nil, nil // dump:`id=inputOrder` 非数值时子查询无行 → 变量保持 0 → LEAVE
+	}
+	scope, err := s.courses.GetStudentCourseScope(ctx, id, studentID)
+	if err != nil {
+		if errors.Is(err, infraerrors.ErrNotFound) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return scope, nil
+}
+
+// insertLog 写余额流水(撞 `uk_request_type` → 内部幂等哨兵,语义同 FundService.insertLog)。
+//
+// 说明:107 的 `request_id` 为 NULL(dump 无幂等键),正常不会撞唯一键;
+// 保留该分支只为与其它资金方法保持同一错误映射。
+func (s *RecommendService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
+	err := s.expense.InsertLog(txCtx, tx, lg)
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, repository.ErrDuplicateLog) {
+		return apperr.ErrRepeated
+	}
+	return err
+}
+
+// ---------------------------------------------------------------- 取消充值活动(SP8 转调)
+
+// CancelRechargeActivity 取消充值活动——**同 tx 转调** `RefundService.CancelRechargeActivity`。
+//
+// 为什么不在本服务重新实现:AC-05 已交付同一资金动作 + 同一句活动状态回写(`signed.status=0`)+
+// 幂等键 `(request_id, type=209)`;写两份必然漂移(Design §3.1 #8 归属说明)。
+//
+// 事务:本方法先开(或透传)一个事务并把 tx 注入 ctx,下游 `RefundService.runner` 命中透传分支
+// (§10.3 ②「ctx 携带 tx」),因此**两侧写在同一事务里**,任一步失败整体回滚。
+//
+// 入参:ctx / in(与 RefundService 同一入参结构)。出参:*CancelRechargeActivityResult(含 Repeated)。
+func (s *RecommendService) CancelRechargeActivity(
+	ctx context.Context, in CancelRechargeActivityInput,
+) (*CancelRechargeActivityResult, error) {
+	if s == nil || s.refund == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RecommendService 未装配"))
+	}
+	var out *CancelRechargeActivityResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		res, e := s.refund.CancelRechargeActivity(txCtx, in)
+		if e != nil {
+			return e
+		}
+		out = res
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------- 欠费核销(SP27 转调)
+
+// CounteractArrears 欠费核销——**同 tx 转调** `MaintenanceService.CounteractArrears`。
+//
+// 为什么不另建 `arrears_service.go`:Design §8 AC-08 明确「以同一 tx 调用 AC-11 已交付的
+// `MaintenanceService.CounteractArrears`」,依赖图 AC-11 -.-> AC-08;本方法只是端点出口 + 事务边界。
+//
+// 入参:ctx / in(与 MaintenanceService 同一入参结构)。
+// 出参:*CounteractArrearsResult(Applied/Remaining/Items);3113(核销冲突)。
+func (s *RecommendService) CounteractArrears(
+	ctx context.Context, in CounteractArrearsInput,
+) (*CounteractArrearsResult, error) {
+	if s == nil || s.maint == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RecommendService 未装配"))
+	}
+	var out *CounteractArrearsResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		res, e := s.maint.CounteractArrears(txCtx, in)
+		if e != nil {
+			return e
+		}
+		out = res
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}

+ 453 - 0
internal/academic/service/recommend_service_test.go

@@ -0,0 +1,453 @@
+// RecommendService 单测(AC-08):老带新双边结算 + 取消充值活动 / 欠费核销的转调。
+//
+// 断言纪律(对照 §5.1 第 4/5 条):
+//  1. **双边各自入账**(不是"总额对了就行"):被推荐人与推荐人的
+//     `balance` / `total_recommended_amount` **各加一次**、**各一条 107**。
+//     只断言"存在 107 流水"或"某一边余额增加"的用例,在"只给一边入账"的实现下依然全绿。
+//  2. **`balance_after` 各取本 user 全局快照**(两个值不要求相等,多校区/多历史下本来不等)。
+//  3. **幂等回放等价(纪律 5)**:造"首次结算后账户又被别的写入改过"的场景,
+//     再次结算必须 `repeated=true` 且响应体余额 == **当刻库值**(回放旧值即红)。
+//  4. **转调不重复实现**:取消充值活动与欠费核销分别只经 RefundService / MaintenanceService,
+//     用例同时证明其资金效果与幂等回读。
+package service
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/port"
+)
+
+// ---------------------------------------------------------------- 辅助
+
+// seedStudentCourse 落一行报课记录(老带新的机构/校区兜底来源)。
+func seedStudentCourse(t *testing.T, db *gorm.DB, id int64, studentID string,
+	institutionID, campusID int64, remainderReceivable string) {
+	t.Helper()
+	confirmUser := "01HCONFIRM000000000000001"
+	row := &model.CoursesStudent{
+		ID: id, BizNo: "BIZ-" + time.Now().Format("150405.000000"), CoursesID: 1, ClassID: 1,
+		InstitutionID: institutionID, CampusID: campusID, StudentID: studentID,
+		RemainderReceivable: remainderReceivable, NetReceipts: "0.00",
+		ConfirmDiscountUserID: &confirmUser,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 报课记录(%d) 失败: %v", id, err)
+	}
+}
+
+// seedCoursesClass 落一行排班规则(欠费核销候选集的 INNER JOIN 对象)。
+func seedCoursesClass(t *testing.T, db *gorm.DB, coursesID, classID int64, startTime time.Time) {
+	t.Helper()
+	row := &model.CoursesClass{
+		CoursesID: coursesID, ClassID: classID, InstitutionID: 1, CampusID: 10,
+		StartTime: &startTime,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 排班规则失败: %v", err)
+	}
+}
+
+// usedLogsOfActivity 取某活动的 used_log 行(按 id ASC)。
+func usedLogsOfActivity(t *testing.T, db *gorm.DB, activityID int64) []model.CoursesActivityUsedLog {
+	t.Helper()
+	var rows []model.CoursesActivityUsedLog
+	if err := db.Where("activity_id = ?", activityID).Order("id ASC").Find(&rows).Error; err != nil {
+		t.Fatalf("读 used_log 失败: %v", err)
+	}
+	return rows
+}
+
+// ---------------------------------------------------------------- 老带新结算(SP18)
+
+// TestSettleRecommendDoubleSided 核心用例:双边各入一次账、各一条 107、used_log 负值、signed 结算态。
+func TestSettleRecommendDoubleSided(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	newUser := fundUserID(40)
+	recUser := fundUserID(41)
+
+	seedActivityFull(t, db, 500, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	rec := recUser
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 500, UserID: newUser, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
+		RecommendUserID: &rec,
+	}).Error; err != nil {
+		t.Fatalf("seed 老带新报名失败: %v", err)
+	}
+
+	res, err := svc.SettleRecommend(ctx, SettleRecommendInput{
+		UserID: newUser, OrderNo: "7001", Remark: "报名成功",
+	})
+	if err != nil {
+		t.Fatalf("老带新结算失败: %v", err)
+	}
+	if !res.Settled || res.Repeated {
+		t.Fatalf("首次结算应 Settled=true/Repeated=false,实得 %+v", res)
+	}
+	if res.RecommendUserID != recUser || res.Amount.Cents() != 10000 {
+		t.Fatalf("响应应回显推荐人与金额,实得 %+v", res)
+	}
+
+	// ① 双边 **各自** 加 balance + total_recommended_amount(不是"合计对就行")
+	for _, uid := range []string{newUser, recUser} {
+		global := readGlobal(t, db, uid)
+		if cents(t, global.Balance) != 10000 {
+			t.Fatalf("user %s 全局余额应 10000 分,实得 %d", uid, cents(t, global.Balance))
+		}
+		if cents(t, global.TotalRecommendedAmount) != 10000 {
+			t.Fatalf("user %s 累计推荐额应 10000 分,实得 %d", uid, cents(t, global.TotalRecommendedAmount))
+		}
+		campus := readSchool(t, db, uid, 1, 10)
+		if cents(t, campus.Balance) != 10000 {
+			t.Fatalf("user %s 校区余额应 10000 分,实得 %d", uid, cents(t, campus.Balance))
+		}
+		if cents(t, campus.TotalRecommendedAmount) != 10000 {
+			t.Fatalf("user %s 校区累计推荐额应 10000 分,实得 %d", uid, cents(t, campus.TotalRecommendedAmount))
+		}
+	}
+
+	// ② **两条** 107,各属一个 user,金额都是 +m,logid = 报课单号
+	for _, uid := range []string{newUser, recUser} {
+		logs := logsByType(t, db, uid, model.ExpenseLogTypeRecommend)
+		if len(logs) != 1 {
+			t.Fatalf("user %s 应有 1 条 107,实得 %d 条", uid, len(logs))
+		}
+		if cents(t, logs[0].Money) != 10000 {
+			t.Fatalf("user %s 的 107 金额应 10000 分,实得 %d", uid, cents(t, logs[0].Money))
+		}
+		if logs[0].LogID == nil || *logs[0].LogID != "7001" {
+			t.Fatalf("user %s 的 107 logid 应为报课单号 7001,实得 %v", uid, logs[0].LogID)
+		}
+		if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
+			t.Fatalf("user %s 的 balance_after 应为本 user 全局快照 10000 分,实得 %v", uid, logs[0].BalanceAfter)
+		}
+	}
+
+	// ③ used_log:**负值** -m + 推荐人 + 单号
+	used := usedLogsOfActivity(t, db, 500)
+	if len(used) != 1 {
+		t.Fatalf("used_log 应 1 条,实得 %d 条", len(used))
+	}
+	if cents(t, used[0].ActivityMoneyUsed) != -10000 {
+		t.Fatalf("used_log 金额应为 -10000 分,实得 %d", cents(t, used[0].ActivityMoneyUsed))
+	}
+	if used[0].RecommendUserID == nil || *used[0].RecommendUserID != recUser {
+		t.Fatalf("used_log 推荐人应为 %s,实得 %v", recUser, used[0].RecommendUserID)
+	}
+
+	// ④ signed:结算态(status=2、activity_money_used=+m)
+	signed := readSignedRow(t, db, newUser, 500)
+	if signed.Status != model.ActivitySignedAttended || cents(t, signed.ActivityMoneyUsed) != 10000 {
+		t.Fatalf("报名行应结算为 status=2/used=10000,实得 status=%d used=%d",
+			signed.Status, cents(t, signed.ActivityMoneyUsed))
+	}
+}
+
+// TestSettleRecommendReplayReadsLiveState 幂等回放等价(纪律 5):
+// 首次结算后**别的写入改过账户**,再次结算的响应体必须是当刻库值(回放旧值即红)。
+func TestSettleRecommendReplayReadsLiveState(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	newUser := fundUserID(42)
+	recUser := fundUserID(43)
+
+	seedActivityFull(t, db, 501, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	rec := recUser
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 501, UserID: newUser, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
+		RecommendUserID: &rec,
+	}).Error; err != nil {
+		t.Fatalf("seed 老带新报名失败: %v", err)
+	}
+
+	first, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
+	if err != nil {
+		t.Fatalf("首次结算失败: %v", err)
+	}
+
+	// 造"首次执行后账户被其它写入改过":被推荐人又充了 55 元
+	if err := db.Model(&model.UserExpense{}).Where("user_id = ?", newUser).
+		UpdateColumn("balance", "155.00").Error; err != nil {
+		t.Fatalf("模拟外部写入失败: %v", err)
+	}
+	if err := db.Model(&model.UserExpenseSchool{}).Where("user_id = ?", newUser).
+		UpdateColumn("balance", "155.00").Error; err != nil {
+		t.Fatalf("模拟外部写入失败: %v", err)
+	}
+
+	second, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
+	if err != nil {
+		t.Fatalf("二次结算失败: %v", err)
+	}
+	if !second.Repeated {
+		t.Fatalf("二次结算应 repeated=true(used_log 已存在)")
+	}
+	// ★ 回读断言:响应体每个金额字段 == 当刻库值
+	if second.UserBalance.Cents() != 15500 {
+		t.Fatalf("回放必须回读当刻余额 15500 分(旧值 10000 → 红),实得 %d", second.UserBalance.Cents())
+	}
+	if second.UserCampusBalance.Cents() != 15500 {
+		t.Fatalf("回放必须回读当刻校区余额 15500 分,实得 %d", second.UserCampusBalance.Cents())
+	}
+	if second.UserLogID != first.UserLogID || second.UsedLogID != first.UsedLogID {
+		t.Fatalf("回放应命中同一条 107/used_log(first=%d/%d second=%d/%d)",
+			first.UserLogID, first.UsedLogID, second.UserLogID, second.UsedLogID)
+	}
+	// 幂等不得再写一笔钱
+	if global := readGlobal(t, db, newUser); cents(t, global.Balance) != 15500 {
+		t.Fatalf("幂等命中不得再次入账,实得余额 %d", cents(t, global.Balance))
+	}
+	if logs := logsByType(t, db, newUser, model.ExpenseLogTypeRecommend); len(logs) != 1 {
+		t.Fatalf("幂等命中不得再写 107,实得 %d 条", len(logs))
+	}
+}
+
+// TestSettleRecommendSkipsWithoutRecommender 无推荐人 → 零写入(dump 763340 LEAVE)。
+func TestSettleRecommendSkipsWithoutRecommender(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(44)
+
+	seedActivityFull(t, db, 502, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 502, UserID: user, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "100.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 报名失败: %v", err)
+	}
+
+	res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7003"})
+	if err != nil {
+		t.Fatalf("结算失败: %v", err)
+	}
+	if res.Settled {
+		t.Fatalf("无推荐人时应零写入(Settled=false),实得 %+v", res)
+	}
+	if len(usedLogsOfActivity(t, db, 502)) != 0 {
+		t.Fatalf("无推荐人时不得写 used_log")
+	}
+	if len(logsByType(t, db, user, model.ExpenseLogTypeRecommend)) != 0 {
+		t.Fatalf("无推荐人时不得写 107")
+	}
+}
+
+// TestSettleRecommendResolvesScopeFromStudentCourse 活动 schoolid/campusid=0 → 从报课记录回查(dump 763348)。
+func TestSettleRecommendResolvesScopeFromStudentCourse(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	newUser := fundUserID(45)
+	recUser := fundUserID(46)
+
+	// 活动为"全机构池"(campus_id=0)且 institution_id=0 → 必须回查报课记录
+	seedActivityFull(t, db, 503, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
+	rec := recUser
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 503, UserID: newUser, InstitutionID: 0, CampusID: 0,
+		Status: model.ActivitySignedSigned, ActivityMoney: "80.00",
+		RecommendUserID: &rec,
+	}).Error; err != nil {
+		t.Fatalf("seed 报名失败: %v", err)
+	}
+	seedStudentCourse(t, db, 7100, newUser, 1, 10, "0.00")
+
+	res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7100"})
+	if err != nil {
+		t.Fatalf("结算失败: %v", err)
+	}
+	if !res.Settled || res.InstitutionID != 1 || res.CampusID != 10 {
+		t.Fatalf("应从报课记录回查机构/校区 (1/10),实得 settled=%v %d/%d",
+			res.Settled, res.InstitutionID, res.CampusID)
+	}
+	if got := cents(t, readGlobal(t, db, newUser).Balance); got != 8000 {
+		t.Fatalf("被推荐人余额应 8000 分,实得 %d", got)
+	}
+	if got := cents(t, readSchool(t, db, recUser, 1, 10).Balance); got != 8000 {
+		t.Fatalf("推荐人校区余额应 8000 分,实得 %d", got)
+	}
+}
+
+// TestSettleRecommendSkipsWhenScopeUnresolvable 活动与报课记录都给不出机构/校区 → 零写入(dump 763355)。
+func TestSettleRecommendSkipsWhenScopeUnresolvable(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(47)
+
+	seedActivityFull(t, db, 504, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
+	rec := fundUserID(48)
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 504, UserID: user, InstitutionID: 0, CampusID: 0,
+		Status: model.ActivitySignedSigned, ActivityMoney: "80.00", RecommendUserID: &rec,
+	}).Error; err != nil {
+		t.Fatalf("seed 报名失败: %v", err)
+	}
+
+	res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "999999"})
+	if err != nil {
+		t.Fatalf("结算失败: %v", err)
+	}
+	if res.Settled || len(usedLogsOfActivity(t, db, 504)) != 0 {
+		t.Fatalf("机构/校区无法解析时应零写入,实得 %+v", res)
+	}
+}
+
+// TestSettleRecommendSkipsOtherActivityTypes 非 type=4 的报名不进入老带新结算。
+func TestSettleRecommendSkipsOtherActivityTypes(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(49)
+
+	seedActivityFull(t, db, 505, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	rec := fundUserID(50)
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 505, UserID: user, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "100.00", RecommendUserID: &rec,
+	}).Error; err != nil {
+		t.Fatalf("seed 报名失败: %v", err)
+	}
+
+	res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7004"})
+	if err != nil {
+		t.Fatalf("结算失败: %v", err)
+	}
+	if res.Settled {
+		t.Fatalf("非老带新活动不得结算,实得 %+v", res)
+	}
+}
+
+// ---------------------------------------------------------------- 取消充值活动(SP8 转调)
+
+// TestRecommendCancelRechargeActivityDelegates 转调 RefundService:209 流水 -m、signed 归 0、余额减少。
+func TestRecommendCancelRechargeActivityDelegates(t *testing.T) {
+	db := newActivityDomainDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
+	ctx := context.Background()
+	user := fundUserID(51)
+
+	seedCampus(t, db, 10, 1, true)
+	seedActivityFull(t, db, 506, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 506, model.ActivitySignedSigned, "100.00", "0.00")
+	if _, err := fund.Recharge(ctx, RechargeInput{
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-1",
+	}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+
+	res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
+		UserID: user, ActivityID: 506, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-1",
+	})
+	if err != nil {
+		t.Fatalf("取消充值活动失败: %v", err)
+	}
+	if res.Repeated || res.InstitutionID != 1 || res.Balance.Cents() != 20000 {
+		t.Fatalf("首次取消应 non-repeated/机构 1/余额 20000,实得 %+v", res)
+	}
+	if logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct); len(logs) != 1 ||
+		cents(t, logs[0].Money) != -10000 {
+		t.Fatalf("应写 1 条 209 且金额 -10000 分,实得 %+v", logs)
+	}
+	if signed := readSignedRow(t, db, user, 506); signed.Status != model.ActivitySignedCancelled {
+		t.Fatalf("报名状态应归 0,实得 %d", signed.Status)
+	}
+}
+
+// TestRecommendCancelRechargeReplayReadsLiveState 转调路径的幂等回放也必须回读当刻库值(纪律 5)。
+func TestRecommendCancelRechargeReplayReadsLiveState(t *testing.T) {
+	db := newActivityDomainDB(t)
+	fund := NewFundService(db, fundSuperScope(), nil)
+	svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
+	ctx := context.Background()
+	user := fundUserID(52)
+
+	seedCampus(t, db, 10, 1, true)
+	seedActivityFull(t, db, 507, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 507, model.ActivitySignedSigned, "100.00", "0.00")
+	if _, err := fund.Recharge(ctx, RechargeInput{
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-2",
+	}); err != nil {
+		t.Fatalf("准备余额失败: %v", err)
+	}
+	first, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
+		UserID: user, ActivityID: 507, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
+	})
+	if err != nil {
+		t.Fatalf("首次取消失败: %v", err)
+	}
+	if first.Balance.Cents() != 20000 {
+		t.Fatalf("首次取消后校区余额应 20000 分,实得 %d", first.Balance.Cents())
+	}
+
+	// 首次执行后被别的写入改过(再充 50)
+	if _, err := fund.Recharge(ctx, RechargeInput{
+		UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "50.00"), RequestID: "REQ-SEED-3",
+	}); err != nil {
+		t.Fatalf("模拟外部写入失败: %v", err)
+	}
+
+	second, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
+		UserID: user, ActivityID: 507, CampusID: 10,
+		Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
+	})
+	if err != nil {
+		t.Fatalf("二次取消失败: %v", err)
+	}
+	if !second.Repeated {
+		t.Fatalf("二次取消应 repeated=true")
+	}
+	if second.Balance.Cents() != 25000 {
+		t.Fatalf("回放必须回读当刻校区余额 25000 分(旧值 20000 → 红),实得 %d", second.Balance.Cents())
+	}
+	if second.LogID != first.LogID {
+		t.Fatalf("回放应命中同一条 209(first=%d second=%d)", first.LogID, second.LogID)
+	}
+}
+
+// ---------------------------------------------------------------- 欠费核销(SP27 转调)
+
+// TestRecommendCounteractArrearsDelegates 转调 MaintenanceService:按剩余应收核销并写回两列。
+func TestRecommendCounteractArrearsDelegates(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewRecommendService(db, fundSuperScope())
+	ctx := context.Background()
+	student := fundUserID(53)
+
+	startAt := time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC)
+	seedCoursesClass(t, db, 1, 1, startAt)
+	seedStudentCourse(t, db, 7200, student, 1, 10, "60.00")
+
+	res, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
+		StudentID: student, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"),
+	})
+	if err != nil {
+		t.Fatalf("欠费核销失败: %v", err)
+	}
+	if res.Applied.Cents() != 6000 {
+		t.Fatalf("应核销 6000 分(截到剩余应收),实得 %d", res.Applied.Cents())
+	}
+	if res.Remaining.Cents() != 4000 {
+		t.Fatalf("未用完部分应 4000 分留在账户,实得 %d", res.Remaining.Cents())
+	}
+	var row model.CoursesStudent
+	if err := db.Where("id = ?", 7200).Take(&row).Error; err != nil {
+		t.Fatalf("读报课记录失败: %v", err)
+	}
+	if cents(t, row.NetReceipts) != 6000 || cents(t, row.RemainderReceivable) != 0 {
+		t.Fatalf("核销后 net_receipts 应 6000/remainder 0,实得 %d/%d",
+			cents(t, row.NetReceipts), cents(t, row.RemainderReceivable))
+	}
+}

+ 13 - 4
internal/academic/service/refund_service.go

@@ -813,11 +813,20 @@ func (s *RefundService) CancelRechargeActivity(
 				return nil, infraerrors.WithCause(infraerrors.ErrInternal,
 					errString("service: CancelRechargeActivity 幂等命中但首次流水已不可见"))
 			}
-			bal, pe := balanceAfterOf(hit)
-			if pe != nil {
-				return nil, pe
+			// ⚠ §5.1 纪律 5「幂等回放等价」:响应体必须**从库回读**(当刻真实状态),
+			// 不得复用首次执行的中间变量、也不得拿流水上的 `balance_after` 充当校区余额
+			// (`balance_after` 自 v0.1.16 起语义是**全局**账户快照,与响应体 Balance 的校区口径不同)。
+			// 只有"首次执行后账户又被别的写入改过"的场景才能区分"回读"与"回放旧值"。
+			out := CancelRechargeActivityResult{LogID: hit.ID, Repeated: true}
+			if inst, ie := s.readCampus(ctx, nil, in.CampusID); ie == nil {
+				out.InstitutionID = inst.SchoolID
+				if inst.SchoolID > 0 {
+					if bal, be := campusBalanceAfter(ctx, s.repo, nil, in.UserID, inst.SchoolID, in.CampusID); be == nil {
+						out.Balance = bal
+					}
+				}
 			}
-			return &CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}, nil
+			return &out, nil
 		}
 		return nil, err
 	}