ソースを参照

feat(academic): AC-07 活动服务 A(报名/取消 + 充值赠送与代金券使用退回)

覆盖 SP(dump 行号逐条对齐):
- signed_add 762908 → ActivityService.Sign
  · 3105 判**报名行** status(取消后可重报)/3106 判**活动** status、不判报名 status(取消也永久消耗次数);
    两条判据**不合并**(TestSignCancelResignMatrix 一条用例同时钉住两边);
  · activity_money = max(money,0);DELETE+INSERT 幂等;number_sign+1 原子;
  · 3141 人数上限标注为 **Go 增强**(dump 无超卖保护),事务内 GetByIDForUpdate 串行判定。
- signed_cancel 762982 → ActivityService.CancelSign
  · 3107(type∈{2,3} 且已审核)/3108(type=4 且 used_log 存在);守卫命中**零副作用**;
  · 三列同置 0(CancelSigned)+ number_sign-1(下限 0,dump 会减成负数)。
- use_activity_recharge 763256 → UseRechargeGift(A9 修正:temp=min(剩余,请求额) 且逐笔递减)
- refund_activity_recharge 762841 → RefundRechargeGift(used_log id **DESC**、逐行盖帽)
- user_discount_usable 763176 → UsableAmount(schoolid<=0 不加作用域;campusid 等值)
- user_discount_use 763204 → UseVoucher(单活动取池 + 行锁 + 3109;type=3 按券面额,dump 缺陷不修正)
- user_discount_refund 763046 → RefundByStuCourse(discount_log **DESC**、退 120 → 先 50 再 70、退 40 只退 40)
- user_discount_refund_user_activity 763106 → RefundByActivity(代金券一次全额退 + A13 显式盖帽)

仓储增量(AC-07 前置原语):ExistsSigned / GetSigned / IncSignedActivityMoneyUsed /
SetSignedSettled / ListRecommendSignedByUser / GetByIDForUpdate;ExistsUsedLog / GetUsedLog /
ListUsedLogsByOrder / GetLastDiscountLogByUserActivity / ListUsableForUpdate / GetDiscountForUpdate。

单测(横切优先,自证失败式):signed.used == -Σ(used_log)、pool.used == -Σ(discount_log);
退回顺序与逐条金额、盖帽、守卫零副作用、number_sign 下限。
Q 18 時間 前
親
コミット
b438c3aa0c

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

@@ -19,9 +19,11 @@ package repository
 
 import (
 	"context"
+	"errors"
 	"strings"
 
 	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
 
 	"yuansiwei/unified-backend/internal/academic/model"
 	"yuansiwei/unified-backend/internal/academic/money"
@@ -379,6 +381,232 @@ func (r *ActivityDiscountRepo) InsertDiscountLog(ctx context.Context, tx *gorm.D
 	return nil
 }
 
+// ---------------------------------------------------------------- AC-07 原语
+
+// ExistsUsedLog 判断用户在某活动上是否已有「老带新/充值赠送」记录(dump `signed_cancel` 762994 → **3108**)。
+//
+// SQL(dump 原文):
+//
+//	SELECT (SELECT id FROM t_courses_activity_used_log WHERE activityid=? AND userid=?) INTO logid;
+//	-- IF logid > 0 THEN LEAVE(不可取消)
+//
+// 语义:**判存在性**(不看金额正负、不看 order_no)——只要产生过消费/结算记录,
+// 老带新报名就永久不可撤销(奖励已发出)。
+//
+// 入参:ctx / activityID(<=0 → ErrBadParam)/ userID(空 → ErrBadParam)。
+// 出参:(是否存在, error);ErrBadParam / ErrDBError。
+func (r *ActivityDiscountRepo) ExistsUsedLog(ctx context.Context, activityID int64, userID string) (bool, error) {
+	if activityID <= 0 || strings.TrimSpace(userID) == "" {
+		return false, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ExistsUsedLog 需要 activity_id>0 且 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return false, err
+	}
+	var count int64
+	if err := db.Model(&model.CoursesActivityUsedLog{}).
+		Where("activity_id = ? AND user_id = ?", activityID, userID).
+		Count(&count).Error; err != nil {
+		return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return count > 0, nil
+}
+
+// GetUsedLog 取用户在某活动上的老带新/赠送记录(幂等回读用)。
+//
+// 与 `ExistsUsedLog` 的区别:本方法返回**整行**(含 id / activity_money_used / recommend_user_id),
+// 供幂等命中分支"从库回读"(§5.1 纪律 5:replay 不得复用首执行的中间变量)。
+//
+// 入参:ctx / activityID(<=0 → ErrBadParam)/ userID(空 → ErrBadParam)。
+// 出参:(*model.CoursesActivityUsedLog, nil) 命中;(**nil**, nil) 无记录;ErrBadParam / ErrDBError。
+func (r *ActivityDiscountRepo) GetUsedLog(
+	ctx context.Context, activityID int64, userID string,
+) (*model.CoursesActivityUsedLog, error) {
+	if activityID <= 0 || strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetUsedLog 需要 activity_id>0 且 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursesActivityUsedLog
+	if err := db.Model(&model.CoursesActivityUsedLog{}).
+		Where("activity_id = ? AND user_id = ?", activityID, userID).
+		Order("id ASC").
+		Take(&out).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, nil
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return &out, nil
+}
+
+// ListUsedLogsByOrder 取某报课单产生的**消费方向**(负值)活动金额流水(dump 762854 游标)。
+//
+// SQL(dump 原文):
+//
+//	SELECT activityid,userid,activity_money_used FROM t_courses_activity_used_log
+//	 WHERE orderid = ? AND activity_money_used < 0 ORDER BY id DESC;
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - orderNo 报课单号(t_courses_student.id 的字符串形式;空 → ErrBadParam);
+//   - order 方向(OrderAsc/OrderDesc;**退回路径必须 DESC**,非法值 → 2002)。
+//
+// 出参:[]model.CoursesActivityUsedLog(空 → 非 nil 切片);ErrBadParam / ErrDBError。
+//
+// 为什么只取负值行:正值行是**退回动作本身**(不能拿来回冲,否则同一笔可退两次)。
+func (r *ActivityDiscountRepo) ListUsedLogsByOrder(
+	ctx context.Context, orderNo string, order string,
+) ([]model.CoursesActivityUsedLog, error) {
+	if strings.TrimSpace(orderNo) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListUsedLogsByOrder 需要 order_no"))
+	}
+	dir, err := normalizeOrder(order)
+	if err != nil {
+		return nil, err
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.CoursesActivityUsedLog, 0, 8)
+	q := db.Model(&model.CoursesActivityUsedLog{}).
+		Where("order_no = ?", orderNo).
+		Where("activity_money_used < 0").
+		Order("id " + dir)
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// DiscountLogWithPool 最近一条赠送流水 + 其所属池行(dump `refund_user_activity` 游标的展开,763122)。
+//
+// 字段:
+//   - PoolID 池行 ID(更新目标);
+//   - LogAmountUsed 流水金额原文(负=消费);
+//   - CoursesStudentID 该流水关联的报课记录(退回时原样带回);
+//   - DiscountAmount 池面额原文(type=3 代金券"一次全退"的取值来源);
+//   - DiscountAmountUsed 池已用原文。
+type DiscountLogWithPool struct {
+	// PoolID 优惠账户 ID。
+	PoolID int64 `gorm:"column:pool_id"`
+	// LogAmountUsed 流水金额(元字符串,负值)。
+	LogAmountUsed string `gorm:"column:log_amount_used"`
+	// CoursesStudentID 报课记录 ID(可为 NULL)。
+	CoursesStudentID *int64 `gorm:"column:courses_student_id"`
+	// DiscountAmount 池赠送面额(元字符串)。
+	DiscountAmount string `gorm:"column:discount_amount"`
+	// DiscountAmountUsed 池已使用(元字符串)。
+	DiscountAmountUsed string `gorm:"column:discount_amount_used"`
+}
+
+// GetLastDiscountLogByUserActivity 取用户在**某活动**上最近一条消费流水(dump 763122 `LIMIT 0,1`)。
+//
+// SQL(dump 原文):
+//
+//	SELECT t1.activity_user_discountid, t1.discount_amount_used, t1.courses_studentid,
+//	       t2.discount_amount, t2.discount_amount_used
+//	  FROM t_courses_activity_user_discount_log t1
+//	  LEFT JOIN t_courses_activity_user_discount t2 ON t1.activity_user_discountid = t2.id
+//	 WHERE t2.userid = ? AND t2.activityid = ? AND t1.discount_amount_used < 0
+//	 ORDER BY t1.id DESC LIMIT 0,1;
+//
+// 语义:`refund_user_activity` **只处理最近一条**(dump 的 LIMIT 1)——多张券时不会一次退完。
+//
+// 入参:ctx / userID(空 → ErrBadParam)/ activityID(<=0 → ErrBadParam)。
+// 出参:(*DiscountLogWithPool, nil) 命中;(**nil**, nil) 无消费流水(dump 游标空 → no-op);
+// ErrBadParam / ErrDBError。
+func (r *ActivityDiscountRepo) GetLastDiscountLogByUserActivity(
+	ctx context.Context, userID string, activityID int64,
+) (*DiscountLogWithPool, error) {
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetLastDiscountLogByUserActivity 需要 user_id 且 activity_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out DiscountLogWithPool
+	q := db.Table("t_courses_activity_user_discount_log AS dlog").
+		Select("dlog.activity_user_discount_id AS pool_id, dlog.discount_amount_used AS log_amount_used, "+
+			"dlog.courses_student_id, pool.discount_amount, pool.discount_amount_used").
+		Joins("JOIN t_courses_activity_user_discount AS pool ON pool.id = dlog.activity_user_discount_id").
+		Where("pool.user_id = ? AND pool.activity_id = ?", userID, activityID).
+		Where("dlog.discount_amount_used < 0").
+		Order("dlog.id DESC").
+		Limit(1)
+	if err := q.Take(&out).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, nil // dump:游标无行 → 整个 SP no-op(不是错误)
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return &out, nil
+}
+
+// ListUsableForUpdate 同 `ListUsable`,但对取出的池行加**行锁**(赠送金额扣减前必调)。
+//
+// 为什么需要独立方法而不是给 ListUsable 加锁:`ListUsable` 也被只读汇总路径使用(不该加锁)。
+// 加锁语义:先 SELECT ... FOR UPDATE 固定候选行的已用额,再据可用额判定 3109,
+// 避免"两笔并发使用同一张券 → used 超过 face"的超用(dump 无锁,属 Go 增强)。
+//
+// 入参:ctx / tx(**必传**)/ userID / activityID(**>0**,扣减必须单活动)/ order。
+// 出参:[]model.CoursesActivityUserDiscount(可用行,id 方向由 order 决定);ErrBadParam / ErrDBError。
+func (r *ActivityDiscountRepo) ListUsableForUpdate(
+	ctx context.Context, tx *gorm.DB, userID string, activityID int64, order string,
+) ([]model.CoursesActivityUserDiscount, error) {
+	if err := requireTx(tx, "ListUsableForUpdate"); err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListUsableForUpdate 需要 user_id 且 activity_id>0"))
+	}
+	dir, err := normalizeOrder(order)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.CoursesActivityUserDiscount, 0, 8)
+	q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
+	q = q.Where("user_id = ? AND activity_id = ?", userID, activityID).
+		Where("discount_amount > discount_amount_used").
+		Order("id " + dir).
+		Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// GetDiscountForUpdate 按主键取池行并加行锁(退回路径更新前必调)。
+//
+// 入参:ctx / tx(必传)/ id(<=0 → ErrBadParam)。
+// 出参:*model.CoursesActivityUserDiscount;ErrNotFound / ErrBadParam / ErrDBError。
+func (r *ActivityDiscountRepo) GetDiscountForUpdate(
+	ctx context.Context, tx *gorm.DB, id int64,
+) (*model.CoursesActivityUserDiscount, error) {
+	if err := requireTx(tx, "GetDiscountForUpdate"); err != nil {
+		return nil, err
+	}
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetDiscountForUpdate 需要 id>0"))
+	}
+	var out model.CoursesActivityUserDiscount
+	q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
+	if err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
+		Where("id = ?", id).Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetDiscountForUpdate(t_courses_activity_user_discount)")
+	}
+	return &out, nil
+}
+
 // InsertUsedLog 写入老带新活动金额获得/使用记录(**只增**)。
 //
 // 入参:

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

@@ -31,8 +31,10 @@ import (
 	"strings"
 
 	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
 
 	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
 	appscope "yuansiwei/unified-backend/internal/infra/scope"
 )
@@ -575,6 +577,267 @@ func wrapQueryErr(err error, op string) error {
 	return infraerrors.WithCause(infraerrors.ErrDBError, fmt.Errorf("%s: %w", op, err))
 }
 
+// ---------------------------------------------------------------- AC-07 / AC-08 原语
+
+// SignedWithType 报名行 + 活动类型的联合视图(dump `signed_cancel` 游标等价物,762982)。
+//
+// 字段:
+//   - SignedID 报名行主键(取消时按它定位,dump 的 `WHERE id=userSignedid`);
+//   - Status 报名状态(model.ActivitySigned*);
+//   - ResponseUserID 审核人(非空/非 "0" = 已审核 → 3107 判据);
+//   - ActivityType 活动类型(model.ActivityType*;决定 3107 还是 3108 分支)。
+type SignedWithType struct {
+	// SignedID 报名行 ID。
+	SignedID int64 `gorm:"column:signed_id"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `gorm:"column:activity_id"`
+	// UserID 报名用户(ULID)。
+	UserID string `gorm:"column:user_id"`
+	// Status 报名状态。
+	Status int32 `gorm:"column:status"`
+	// ResponseUserID 审核人(NULL/空串/"0" = 未审核)。
+	ResponseUserID *string `gorm:"column:response_user_id"`
+	// ActivityType 活动类型。
+	ActivityType int8 `gorm:"column:activity_type"`
+	// ActivityMoney 活动金额快照(元字符串)。
+	ActivityMoney string `gorm:"column:activity_money"`
+	// ActivityMoneyUsed 已使用活动金额(元字符串)。
+	ActivityMoneyUsed string `gorm:"column:activity_money_used"`
+	// InstitutionID 报名行冗余机构(作用域列)。
+	InstitutionID int64 `gorm:"column:institution_id"`
+	// CampusID 报名行冗余校区(作用域列)。
+	CampusID int64 `gorm:"column:campus_id"`
+}
+
+// ExistsSigned 判断用户对该活动是否**已经报过名**(dump 762928 → **3105**)。
+//
+// SQL(dump 原文,**逐字**):
+//
+//	SELECT COUNT(1) FROM t_courses_activity_signed
+//	 WHERE userid = ? AND activityid = ? AND status != 0
+//
+// 语义(与 3106 相反,两条判据不得互换):判的是**报名行**状态 ⇒ `status=0`(已取消)
+// 不算"已报名" ⇒ **取消后可以重新报名同一活动**。
+//
+// 为什么不叠作用域过滤:`(user_id, activity_id)` 是全局 UK,报名行的归属已由调用方
+// `GetByID`(带作用域)确认;此处若叠 `campus_id IN (?,0)` 而历史行的冗余校区列脏,
+// 会漏判 → INSERT 撞 UK 直接 5002(比 3105 更难解释)。
+//
+// 入参:ctx / userID(空 → ErrBadParam)/ activityID(<=0 → ErrBadParam)。
+// 出参:(是否已报名, error);ErrBadParam / ErrDBError。
+func (r *ActivityRepo) ExistsSigned(ctx context.Context, userID string, activityID int64) (bool, error) {
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return false, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ExistsSigned 需要 user_id 且 activity_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return false, err
+	}
+	var count int64
+	if err := db.Model(&model.CoursesActivitySigned{}).
+		Where("user_id = ? AND activity_id = ? AND status <> ?",
+			userID, activityID, model.ActivitySignedCancelled).
+		Count(&count).Error; err != nil {
+		return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return count > 0, nil
+}
+
+// GetSigned 取用户在某活动上的**有效**报名行 + 活动类型(dump `signed_cancel` 游标等价物)。
+//
+// SQL(dump 762982 原文):
+//
+//	SELECT t1.id,t1.status,t1.response_userid,t2.type FROM t_courses_activity_signed t1
+//	  INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id
+//	 WHERE t1.userid=? AND t1.activityid=? AND t1.status!=0
+//
+// 入参:ctx / userID(空 → ErrBadParam)/ activityID(<=0 → ErrBadParam)。
+// 出参:*SignedWithType(命中行);ErrNotFound(无有效报名行 → 调用方映射 3001);ErrDBError。
+//
+// 作用域:与 dump 一致**不叠机构/校区过滤**(活动归属已由 `GetByID` 校验;叠 signed 的
+// 冗余校区列会在 `campus_id=0` 的活动上漏判)。
+func (r *ActivityRepo) GetSigned(ctx context.Context, userID string, activityID int64) (*SignedWithType, error) {
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: GetSigned 需要 user_id 且 activity_id>0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out SignedWithType
+	q := db.Table("t_courses_activity_signed AS signed").
+		Select("signed.id AS signed_id, signed.activity_id, signed.user_id, signed.status, "+
+			"signed.response_user_id, signed.activity_money, signed.activity_money_used, "+
+			"signed.institution_id, signed.campus_id, activity.type AS activity_type").
+		Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
+		Where("signed.user_id = ? AND signed.activity_id = ? AND signed.status <> ?",
+			userID, activityID, model.ActivitySignedCancelled)
+	if err := q.Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetSigned(t_courses_activity_signed)")
+	}
+	return &out, nil
+}
+
+// IncSignedActivityMoneyUsed 原子增减报名行的「已使用活动金额」(dump 763269 / 762854)。
+//
+// 入参:
+//   - ctx / tx(**必传**:与 used_log 写入同事务);
+//   - userID / activityID 定位报名行(dump 的 `WHERE activityid=? AND userid=?`);
+//   - delta 增量(分:使用=+,退回=−;0 → no-op)。
+//
+// 出参:(受影响行数, error);0 = 报名行不存在(dump 静默 0 行更新);ErrBadParam / ErrDBError。
+//
+// SQL:`UPDATE t_courses_activity_signed SET activity_money_used = activity_money_used + ?
+// WHERE user_id = ? AND activity_id = ?`(UK 唯一命中一行,等价于 dump 的按 id 更新)。
+//
+// 红线:**不做截断**(`used <= money` 的业务约束由 Service 用剩余额校验后给定 temp);
+// 静默截断会破坏 `activity_money_used == |Σ used_log|` 的对账恒等式。
+func (r *ActivityRepo) IncSignedActivityMoneyUsed(
+	ctx context.Context, tx *gorm.DB, userID string, activityID int64, delta money.Amount,
+) (int64, error) {
+	if err := requireTx(tx, "IncSignedActivityMoneyUsed"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: IncSignedActivityMoneyUsed 需要 user_id 且 activity_id>0"))
+	}
+	if delta.IsZero() {
+		return 0, nil
+	}
+	if err := delta.Validate(); err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
+		Where("user_id = ? AND activity_id = ?", userID, activityID).
+		UpdateColumn("activity_money_used", gorm.Expr("activity_money_used + ?", delta.String()))
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// SetSignedSettled 老带新结算落库(dump 763366:`SET activity_money_used=activityMoney, status=2`)。
+//
+// 入参:ctx / tx(必传)/ signedID(>0)/ used 已使用活动金额(= 活动金额)/ status 目标状态。
+// 出参:(受影响行数, error);0 = 行不存在(调用方按 3001 处理)。
+//
+// 为什么两列同一条 UPDATE:dump 就是一条语句(`SET activity_money_used=...,status=...`),
+// 拆开会出现"金额已登、状态未推进"的中间态被并发对账读到。
+func (r *ActivityRepo) SetSignedSettled(
+	ctx context.Context, tx *gorm.DB, signedID int64, used money.Amount, status int32,
+) (int64, error) {
+	if err := requireTx(tx, "SetSignedSettled"); err != nil {
+		return 0, err
+	}
+	if signedID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetSignedSettled 需要 signed_id"))
+	}
+	if err := used.Validate(); err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
+		Where("id = ?", signedID).
+		UpdateColumns(map[string]any{
+			"activity_money_used": used.String(),
+			"status":              status,
+		})
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// RecommendSignedRow 老带新结算的候选行(dump 763329 游标等价物)。
+//
+// 字段:SignedID 报名行;ActivityID/ActivityTitle/ActivityMoney/InstitutionID/CampusID 取**活动**;
+// RecommendUserID 取报名行(NULL = 没填推荐人 → 跳过)。
+type RecommendSignedRow struct {
+	// SignedID 报名行 ID。
+	SignedID int64 `gorm:"column:signed_id"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `gorm:"column:activity_id"`
+	// ActivityTitle 活动标题(拼 remark 用;NULL → 空串)。
+	ActivityTitle *string `gorm:"column:activity_title"`
+	// RecommendUserID 推荐人(NULL/空串/"0" = 无推荐人)。
+	RecommendUserID *string `gorm:"column:recommend_user_id"`
+	// ActivityMoney 活动金额快照(元字符串)。
+	ActivityMoney string `gorm:"column:activity_money"`
+	// InstitutionID 活动机构 ID(0 → 调用方回查 t_courses_student)。
+	InstitutionID int64 `gorm:"column:institution_id"`
+	// CampusID 活动校区 ID(0 → 调用方回查 t_courses_student)。
+	CampusID int64 `gorm:"column:campus_id"`
+}
+
+// ListRecommendSignedByUser 取用户全部**老带新**报名行(dump `use_activity_recommend` 游标,763329)。
+//
+// SQL(dump 原文,**逐字**):
+//
+//	SELECT t1.id,t1.activityid,t1.recommend_userid,t1.activity_money,t2.schoolid,t2.campusid,t2.title
+//	  FROM t_courses_activity_signed t1
+//	  INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id AND t2.type=4
+//	 WHERE t1.userid = ?
+//
+// ★ 与 3106 判据的区别(**不得合并**):这里**不过滤 `signed.status`**(取消过也算)。
+//
+// 排序:dump 游标无 `ORDER BY`(InnoDB 等值扫描≈主键序),Go 显式 `ORDER BY signed.id ASC`
+// 保跨引擎确定性;Service 只取**第一行**(dump 的 `LEAVE readLoop` 语义)。
+//
+// 作用域:**不叠机构/校区过滤**(dump 无;学生可能在多机构参加过老带新)。
+// 多租户防线:写在 `activity.institution_id` 目标账户上(机构取自活动行,不由入参决定)。
+//
+// 入参:ctx / userID(空 → ErrBadParam)。
+// 出参:[]RecommendSignedRow(空 → 长度 0 的非 nil 切片);ErrBadParam / ErrDBError。
+func (r *ActivityRepo) ListRecommendSignedByUser(ctx context.Context, userID string) ([]RecommendSignedRow, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListRecommendSignedByUser 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]RecommendSignedRow, 0, 4)
+	q := db.Table("t_courses_activity_signed AS signed").
+		Select("signed.id AS signed_id, signed.activity_id, signed.recommend_user_id, "+
+			"signed.activity_money, activity.title AS activity_title, "+
+			"activity.institution_id, activity.campus_id").
+		Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id AND activity.type = ?",
+			model.ActivityTypeRecommend).
+		Where("signed.user_id = ?", userID).
+		Order("signed.id ASC")
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// GetByIDForUpdate 按主键取活动并**加行锁**(报名人数上限判定用,3141 是 Go 增强)。
+//
+// 为什么需要:`number_sign >= number_max` 的判定必须在事务内且与 `number_sign+1` 串行,
+// 否则并发报名会一起通过校验(超卖)。dump **没有**这条校验(§3.8 未登记该项),
+// 故这是纯 Go 增强,只影响"人数已满"场景,不改变 dump 已有分支的输出。
+//
+// 入参:ctx / tx(**必传**)/ id(<=0 → ErrBadParam)。
+// 出参:*model.CoursesActivity(作用域强制,跨机构 → ErrNotFound);ErrBadParam / ErrNotFound / ErrDBError。
+func (r *ActivityRepo) GetByIDForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.CoursesActivity, error) {
+	if err := requireTx(tx, "GetByIDForUpdate"); err != nil {
+		return nil, err
+	}
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetByIDForUpdate 需要 id>0"))
+	}
+	var out model.CoursesActivity
+	q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivity{}), r.Sc, "")
+	if err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
+		Where("id = ?", id).Take(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetByIDForUpdate(t_courses_activity)")
+	}
+	return &out, nil
+}
+
 // ctxOrBackground 保证传给 gorm 的 ctx 非 nil(gorm.WithContext(nil) 会 panic)。
 func ctxOrBackground(ctx context.Context) context.Context {
 	if ctx != nil {

+ 795 - 0
internal/academic/service/activity_discount_service.go

@@ -0,0 +1,795 @@
+// 活动赠送金额服务(充值赠送使用/退回 + 代金券使用/退回 + 可用额汇总)——AC-07 活动服务 A。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.2 #12 `sp_courses_activity_use_activity_recharge`(dump **763256**)→ `UseRechargeGift`
+//   - §3.2 #13 `sp_courses_activity_refund_activity_recharge`(dump **762841**)→ `RefundRechargeGift`
+//   - §3.2 #14 `sp_courses_activity_user_discount_usable`(dump **763176**)→ `UsableAmount`
+//   - §3.2 #15 `sp_courses_activity_user_discount_use`(dump **763204**)→ `UseVoucher`
+//   - §3.2 #16 `sp_courses_activity_user_discount_refund`(dump **763046**)→ `RefundByStuCourse`
+//   - §3.2 #17 `sp_courses_activity_user_discount_refund_user_activity`(dump **763106**)→ `RefundByActivity`
+//   - §3.8-a **A9**(`use_activity_recharge` 不递减 `inputAmount` → 重复占用;Go 修正为逐行递减)
+//   - §3.8-a **A13**(`refund_user_activity` 的 `SELECT x = y` 是布尔比较,截断从未生效 → 可超退;Go 显式盖帽)
+//   - §5.1 纪律 4「横切优先」:每条路径都有"池余额 == 流水累计"的跨表恒等式断言(见单测)
+//
+// 三条**顺序性**红线(写错不会报错、只会让金额分配的来源不同,双跑/对账才抓得住):
+//
+//	退回方向 = `id DESC`(后入先退):`discount_log` 与 `used_log` 两条退回路径**都必须倒序**。
+//	  两次用券 100/50、退 120 ⇒ **先退 50 再退 70**(若误写 ASC 会变成先 100 再 20,
+//	  总额相同、但"哪张券被冲回"全错 ⇒ 单券核销记录与账单永远对不上)。
+//	退回金额必须**逐行递减剩余额**(A9 修正):否则同一笔退款会按全额冲回多行 ⇒ 超退。
+//	每一行冲回额必须 **min(行绝对值, 剩余应退)**(A13 修正):否则单行可被超额冲回。
+package service
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"time"
+
+	"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/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 活动赠送相关默认备注(逐字对齐 dump 的 `IF inputMemo=''`)。
+const (
+	// defaultUseRechargeMemo SP12 默认备注(dump 763259 起:`'使用充值赠送金额'`)。
+	defaultUseRechargeMemo = "使用充值赠送金额"
+	// defaultRefundRechargeMemo SP13 默认备注(dump:`'退回活动金额'`)。
+	defaultRefundRechargeMemo = "退回活动金额"
+	// defaultRefundDiscountMemo SP16 默认备注(dump:`'退回赠送金额'`)。
+	defaultRefundDiscountMemo = "退回赠送金额"
+	// defaultRefundUserActivityMemo SP17 默认备注(dump:`'退回活动金额'`)。
+	defaultRefundUserActivityMemo = "退回活动金额"
+)
+
+// ActivityDiscountService 活动赠送金额服务。
+//
+// 字段:
+//   - disc 赠送池/流水仓储(池行、used_log、discount_log);
+//   - act 活动/报名仓储(活动类型判定、报名行已用额);
+//   - runner 事务运行器(每个 SP 一个事务;失败整体回滚)。
+type ActivityDiscountService struct {
+	disc   *repository.ActivityDiscountRepo
+	act    *repository.ActivityRepo
+	runner *repository.TxRunner
+	now    func() time.Time
+}
+
+// NewActivityDiscountService 构造活动赠送金额服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
+// 出参:*ActivityDiscountService。
+func NewActivityDiscountService(db *gorm.DB, sc *appscope.Scope) *ActivityDiscountService {
+	return &ActivityDiscountService{
+		disc:   repository.NewActivityDiscountRepo(db, sc),
+		act:    repository.NewActivityRepo(db, sc),
+		runner: repository.NewTxRunner(db),
+		now:    time.Now,
+	}
+}
+
+// ---------------------------------------------------------------- 入参 / 响应 DTO
+
+// UsableAmountInput 可用赠送金额汇总入参(dump `usable` **3 参**:userid + schoolid + campusid)。
+type UsableAmountInput struct {
+	// UserID 学生(必填)。
+	UserID string
+	// InstitutionID 机构 ID(原 schoolid;**0 = 平台视角,不加任何作用域条件**)。
+	InstitutionID int64
+	// CampusID 校区 ID(原 campusid;仅在 InstitutionID>0 时生效,且为**等值**)。
+	CampusID int64
+}
+
+// UsableAmountResult 可用赠送金额汇总响应(端点 26 的 `{usable}`)。
+type UsableAmountResult struct {
+	// Usable 可用赠送金额合计(分;无可用行 → 0)。
+	Usable money.Amount `json:"usable"`
+}
+
+// UseRechargeGiftInput 使用充值赠送金额入参(dump SP12 **5 参**)。
+type UseRechargeGiftInput struct {
+	// UserID 学生(必填)。
+	UserID string
+	// ActivityID 活动 ID(必填 >0;**单活动**,不得跨活动扣减)。
+	ActivityID int64
+	// StudentCourseID 报课记录 ID(→ used_log.order_no;0 → 不写单号)。
+	StudentCourseID int64
+	// Amount 本次使用金额(分;<=0 → no-op,dump 口径)。
+	Amount money.Amount
+	// Memo 备注(空 → "使用充值赠送金额")。
+	Memo string
+}
+
+// UseRechargeGiftResult 使用充值赠送金额响应。
+type UseRechargeGiftResult struct {
+	// SignedID 报名行 ID。
+	SignedID int64 `json:"signedId"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// UsedAmount 本次实际使用额(= min(活动剩余额, 请求额),分)。
+	UsedAmount money.Amount `json:"usedAmount"`
+	// ActivityMoneyUsedAfter 使用后报名行已用额(分)。
+	ActivityMoneyUsedAfter money.Amount `json:"activityMoneyUsedAfter"`
+	// Remaining 活动金额剩余可用额(分;= 活动金额 − 已用额,下限 0)。
+	Remaining money.Amount `json:"remaining"`
+}
+
+// RechargeGiftRefundItem 充值赠送退回的单条明细(哪笔活动被冲回多少)。
+type RechargeGiftRefundItem struct {
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// UserID 学生。
+	UserID string `json:"userId"`
+	// Amount 本次冲回金额(分,正数)。
+	Amount money.Amount `json:"amount"`
+}
+
+// RefundRechargeGiftInput 退回充值赠送金额入参(dump SP13 **3 参**)。
+type RefundRechargeGiftInput struct {
+	// StudentCourseID 报课记录 ID(游标:`used_log.order_no = ?`)。
+	StudentCourseID int64
+	// Amount 本次应退金额(分;<=0 → no-op)。
+	Amount money.Amount
+	// Memo 备注(空 → "退回活动金额")。
+	Memo string
+}
+
+// RefundRechargeGiftResult 退回充值赠送金额响应。
+type RefundRechargeGiftResult struct {
+	// Requested 请求退回额(分,回显)。
+	Requested money.Amount `json:"requested"`
+	// RefundedTotal 实际冲回合计(分;≤ Requested)。
+	RefundedTotal money.Amount `json:"refundedTotal"`
+	// Items 逐活动明细(按 `id DESC` 顺序)。
+	Items []RechargeGiftRefundItem `json:"items"`
+}
+
+// UseVoucherInput 使用代金券/赠送金额入参(dump SP15 **5 参**)。
+type UseVoucherInput struct {
+	// UserID 学生(必填)。
+	UserID string
+	// ActivityID 活动 ID(必填 >0;**单活动**取池)。
+	ActivityID int64
+	// StudentCourseID 报课记录 ID(→ discount_log.courses_student_id)。
+	StudentCourseID int64
+	// Amount 本次使用金额(分;<=0 → no-op)。
+	Amount money.Amount
+	// Memo 备注(dump 该 SP **无默认备注**,空 → NULL)。
+	Memo string
+}
+
+// UseVoucherResult 使用代金券/赠送金额响应。
+type UseVoucherResult struct {
+	// DiscountID 被扣减的优惠账户 ID。
+	DiscountID int64 `json:"discountId"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// Applied 本次实际占用额(分;type=3 时 = 池面额,见方法注释)。
+	Applied money.Amount `json:"applied"`
+	// DiscountAmountUsedAfter 扣减后池已用额(分)。
+	DiscountAmountUsedAfter money.Amount `json:"discountAmountUsedAfter"`
+	// ActivityType 活动类型(1/2/3/4)。
+	ActivityType int8 `json:"activityType"`
+}
+
+// VoucherRefundItem 赠送金额退回的单条明细。
+type VoucherRefundItem struct {
+	// DiscountID 优惠账户 ID。
+	DiscountID int64 `json:"discountId"`
+	// Amount 冲回金额(分,正数)。
+	Amount money.Amount `json:"amount"`
+}
+
+// RefundByStuCourseInput 按报课记录退回赠送金额入参(dump SP16 **3 参**)。
+type RefundByStuCourseInput struct {
+	// StudentCourseID 报课记录 ID(游标:`discount_log.courses_student_id = ?`)。
+	StudentCourseID int64
+	// Amount 本次应退金额(分;<=0 → no-op)。
+	Amount money.Amount
+	// Memo 备注(空 → "退回赠送金额")。
+	Memo string
+}
+
+// RefundByStuCourseResult 按报课记录退回赠送金额响应。
+type RefundByStuCourseResult struct {
+	// Requested 请求退回额(分)。
+	Requested money.Amount `json:"requested"`
+	// RefundedTotal 实际冲回合计(分)。
+	RefundedTotal money.Amount `json:"refundedTotal"`
+	// Items 逐池明细(按 `discount_log.id DESC` 顺序)。
+	Items []VoucherRefundItem `json:"items"`
+}
+
+// RefundByActivityInput 按活动退回赠送金额入参(dump SP17 **4 参**)。
+type RefundByActivityInput struct {
+	// UserID 学生(必填)。
+	UserID string
+	// ActivityID 活动 ID(必填 >0)。
+	ActivityID int64
+	// Amount 本次应退金额(分;代金券活动会被置为池面额)。
+	Amount money.Amount
+	// Memo 备注(空 → "退回活动金额")。
+	Memo string
+}
+
+// RefundByActivityResult 按活动退回赠送金额响应。
+type RefundByActivityResult struct {
+	// DiscountID 优惠账户 ID。
+	DiscountID int64 `json:"discountId"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// Refunded 本次冲回额(分;已按 A13 盖帽 = min(应退, 最近一条消费额))。
+	Refunded money.Amount `json:"refunded"`
+	// DiscountAmountUsedAfter 冲回后池已用额(分)。
+	DiscountAmountUsedAfter money.Amount `json:"discountAmountUsedAfter"`
+}
+
+// ---------------------------------------------------------------- 可用额汇总(SP14)
+
+// UsableAmount 汇总用户可用赠送金额(SP14,dump 763176)。
+//
+// dump 语义:`SUM(discount_amount - discount_amount_used)`,条件 `discount_amount > discount_amount_used`;
+// 动态 SQL 拼 `schoolid/campusid` 两个**等值**条件(`schoolid<=0` 时一个都不加)。
+//
+// 入参:ctx / in。出参:*UsableAmountResult;错误码 2001/2002/5002。
+func (s *ActivityDiscountService) UsableAmount(
+	ctx context.Context, in UsableAmountInput,
+) (*UsableAmountResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UsableAmount 需要 user_id"))
+	}
+	if in.InstitutionID < 0 || in.CampusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: UsableAmount 机构/校区参数不得为负(0 的语义见 repository.SumUsable)"))
+	}
+	if s == nil || s.disc == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
+	}
+	usable, err := s.disc.SumUsable(ctx, in.UserID, in.InstitutionID, in.CampusID)
+	if err != nil {
+		return nil, err
+	}
+	return &UsableAmountResult{Usable: usable}, nil
+}
+
+// ---------------------------------------------------------------- 充值赠送:使用(SP12)
+
+// UseRechargeGift 使用充值赠送活动的金额(SP12,dump 763256)。
+//
+// dump 语义:
+//
+//	763262  游标:signed JOIN activity ON type=2 WHERE user/activity AND **signed.status=2**
+//	763268  IF inputAmount<=0 → LEAVE(no-op)
+//	763278  IF activity_money_used >= activity_money → LEAVE(额度用完)
+//	763284  UPDATE signed SET activity_money_used += inputAmount
+//	763287  INSERT used_log(activityid, userid, orderid=stuCourseId, activity_money_used=**-inputAmount**)
+//
+// **A9 修正(登记 §3.8-a,`DUMP-BUGFIX`)**:dump 在循环里从不递减 `inputAmount`,
+// 有多行 signed 时每行都按全额扣减(重复占用)。Go 改为
+// `temp = min(活动剩余额, 请求额)`、扣完即止 —— 单行 signed(UK 保证)下结果与 dump 一致,
+// 多行/脏数据下不会超额占用。
+//
+// 前置:报名行必须 `status=2`(已参加)——这是 dump 游标条件,**不是**可选的宽松化。
+//
+// 入参:ctx / in。出参:*UseRechargeGiftResult(no-op 时 UsedAmount=0);错误码 2001/2002/5002。
+func (s *ActivityDiscountService) UseRechargeGift(
+	ctx context.Context, in UseRechargeGiftInput,
+) (*UseRechargeGiftResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UseRechargeGift 需要 user_id"))
+	}
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: UseRechargeGift 需要 activity_id>0"))
+	}
+	if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
+	}
+	memo := strings.TrimSpace(in.Memo)
+	if memo == "" {
+		memo = defaultUseRechargeMemo
+	}
+	userID := strings.TrimSpace(in.UserID)
+	out := &UseRechargeGiftResult{ActivityID: in.ActivityID}
+
+	if !in.Amount.GreaterThan(0) {
+		// dump 763268:`IF inputAmount<=0 THEN LEAVE`(no-op,不写流水也不报错)
+		return out, nil
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		signed, e := s.act.GetSigned(txCtx, userID, in.ActivityID)
+		if e != nil {
+			if errors.Is(e, infraerrors.ErrNotFound) {
+				return nil // dump:游标无行 → no-op
+			}
+			return e
+		}
+		// 游标条件:活动 type=2 且报名行 status=2(已参加活动)
+		if signed.ActivityType != model.ActivityTypeRecharge || signed.Status != model.ActivitySignedAttended {
+			return nil
+		}
+		total, e := parseAccountAmount(signed.ActivityMoney, "t_courses_activity_signed.activity_money")
+		if e != nil {
+			return e
+		}
+		used, e := parseAccountAmount(signed.ActivityMoneyUsed, "t_courses_activity_signed.activity_money_used")
+		if e != nil {
+			return e
+		}
+		remaining := total.Sub(used)
+		if remaining <= 0 {
+			return nil // dump 763278:额度已用完 → no-op
+		}
+		temp := in.Amount
+		if remaining.LessThan(temp) {
+			temp = remaining // A9 修正:扣到剩余额为止
+		}
+		if !temp.GreaterThan(0) {
+			return nil
+		}
+		if _, ue := s.act.IncSignedActivityMoneyUsed(txCtx, tx, userID, in.ActivityID, temp); ue != nil {
+			return ue
+		}
+		log := &model.CoursesActivityUsedLog{
+			ActivityID:        in.ActivityID,
+			UserID:            userID,
+			OrderNo:           optString(orderNoOf(in.StudentCourseID)),
+			ActivityMoneyUsed: temp.Neg().String(), // 负值 = 使用(符号方向固定)
+			Memo:              optString(memo),
+		}
+		if ie := s.disc.InsertUsedLog(txCtx, tx, log); ie != nil {
+			return ie
+		}
+		out.SignedID = signed.SignedID
+		out.UsedAmount = temp
+		out.ActivityMoneyUsedAfter = used.Add(temp)
+		out.Remaining = remaining.Sub(temp)
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------- 充值赠送:退回(SP13)
+
+// RefundRechargeGift 退回充值赠送活动的金额(SP13,dump 762841)。
+//
+// dump 语义:
+//
+//	762853  IF inputAmount<0 → LEAVE(注意是 `<0`,0 会在循环首轮 `inputAmount<=0` 处退出)
+//	762854  游标:`used_log WHERE orderid=? AND activity_money_used<0 ORDER BY id DESC`(**降序**)
+//	762869  逐行取活动 type;**仅 type=2** 参与冲回(代金券/老带新流水在此路径不动)
+//	762874  temp = min(|行金额|, 剩余应退)
+//	762880  UPDATE signed SET activity_money_used -= temp(按 activityid+userid)
+//	762882  INSERT used_log(..., activity_money_used=**+temp**)
+//	762885  剩余应退 -= temp
+//
+// 顺序红线:**必须 `id DESC`**(后入先退)。若改成 ASC,冲回的"哪一笔"完全不同(金额总数可能相同),
+// 单笔 used_log 的核销链立刻错位——这类错误在总额断言下自证通过,只有逐行明细断言才抓得住。
+//
+// 入参:ctx / in。出参:*RefundRechargeGiftResult;错误码 2001/2002/5002。
+func (s *ActivityDiscountService) RefundRechargeGift(
+	ctx context.Context, in RefundRechargeGiftInput,
+) (*RefundRechargeGiftResult, error) {
+	if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
+	}
+	memo := strings.TrimSpace(in.Memo)
+	if memo == "" {
+		memo = defaultRefundRechargeMemo
+	}
+	out := &RefundRechargeGiftResult{
+		Requested: in.Amount,
+		Items:     make([]RechargeGiftRefundItem, 0, 4),
+	}
+	if !in.Amount.GreaterThan(0) {
+		return out, nil // dump 762853 / 762869:无正数应退额 → no-op
+	}
+	orderNo := orderNoOf(in.StudentCourseID)
+	if orderNo == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: RefundRechargeGift 需要 student_course_id>0"))
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		rows, e := s.disc.ListUsedLogsByOrder(txCtx, orderNo, repository.OrderDesc)
+		if e != nil {
+			return e
+		}
+		remaining := in.Amount
+		refunded := money.Amount(0)
+		for i := range rows {
+			if !remaining.GreaterThan(0) {
+				break // dump:金额用完即停
+			}
+			row := &rows[i]
+			actType, te := s.activityType(txCtx, row.ActivityID)
+			if te != nil {
+				return te
+			}
+			if actType != model.ActivityTypeRecharge {
+				continue // 只处理充值赠送活动(dump 762869 的 IF type=2)
+			}
+			rowAmount, pe := parseAccountAmount(row.ActivityMoneyUsed, "t_courses_activity_used_log.activity_money_used")
+			if pe != nil {
+				return pe
+			}
+			absUsed := absAmount(rowAmount)
+			temp := absUsed
+			if remaining.LessThan(temp) {
+				temp = remaining // A9 修正:逐行递减,绝不按全额冲回多行
+			}
+			if !temp.GreaterThan(0) {
+				continue
+			}
+			if _, ue := s.act.IncSignedActivityMoneyUsed(txCtx, tx, row.UserID, row.ActivityID, temp.Neg()); ue != nil {
+				return ue
+			}
+			log := &model.CoursesActivityUsedLog{
+				ActivityID:        row.ActivityID,
+				UserID:            row.UserID,
+				OrderNo:           optString(orderNo),
+				ActivityMoneyUsed: temp.String(), // 正值 = 退回
+				Memo:              optString(memo),
+			}
+			if ie := s.disc.InsertUsedLog(txCtx, tx, log); ie != nil {
+				return ie
+			}
+			remaining = remaining.Sub(temp)
+			refunded = refunded.Add(temp)
+			out.Items = append(out.Items, RechargeGiftRefundItem{
+				ActivityID: row.ActivityID, UserID: row.UserID, Amount: temp,
+			})
+		}
+		out.RefundedTotal = refunded
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------- 代金券/赠送:使用(SP15)
+
+// UseVoucher 使用赠送金额/代金券(SP15,dump 763204)。
+//
+// dump 语义:
+//
+//	763210  游标:`t_courses_activity_user_discount WHERE userid=? AND activityid=?`(**单活动**,无 ORDER BY)
+//	763226  IF discount_amount_used < discount_amount THEN
+//	763229    IF 活动 type=3 THEN **SET inputAmount = discount_amount**(券面额,不是剩余额)
+//	763232    UPDATE pool SET discount_amount_used += inputAmount
+//	763233    INSERT discount_log(..., discount_amount_used=**-inputAmount**)
+//
+// 两处 Go 处理:
+//  1. **游标 → 单行**:dump 的循环在多池行时会把 `inputAmount` 加**每一行**(重复占用);设计 §3.2 #15
+//     裁定"无游标(一次扣减)",Go 按 `ORDER BY id ASC` 取**第一张可用券**,只扣一次。
+//  2. **3109 增强**:`available < amount` → 3109(dump 静默 LEAVE,前端看到"成功但没扣")。
+//
+// ⚠ 已知 dump 口径(**不得"修正"**):type=3 时 `inputAmount` 被置为**券面额**(`discount_amount`),
+// 部分已用的券再使用会**超额占用**(`used > face`)。Go 按 dump 对齐并注释;
+// 该超额由"可用额校验"在**下一次**使用/退回时体现(available 变负 → 3109 / 盖帽)。
+//
+// 入参:ctx / in。出参:*UseVoucherResult(no-op 时 Applied=0);错误码 2001/2002/3109/5002。
+func (s *ActivityDiscountService) UseVoucher(
+	ctx context.Context, in UseVoucherInput,
+) (*UseVoucherResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UseVoucher 需要 user_id"))
+	}
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: UseVoucher 需要 activity_id>0"))
+	}
+	if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
+	}
+	userID := strings.TrimSpace(in.UserID)
+	out := &UseVoucherResult{ActivityID: in.ActivityID}
+	if !in.Amount.GreaterThan(0) {
+		return out, nil
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		actType, te := s.activityType(txCtx, in.ActivityID)
+		if te != nil {
+			return te
+		}
+		// 池行加锁(Go 增强:dump 无锁,并发两笔可把 used 打过 face)
+		rows, e := s.disc.ListUsableForUpdate(txCtx, tx, userID, in.ActivityID, repository.OrderAsc)
+		if e != nil {
+			return e
+		}
+		if len(rows) == 0 {
+			return infraerrors.WithCause(apperr.ErrGiftAmountShort,
+				errString("service: UseVoucher 可用赠送金额不足(3109;无可用池行)"))
+		}
+		row := &rows[0]
+		face, e := parseAccountAmount(row.DiscountAmount, "t_courses_activity_user_discount.discount_amount")
+		if e != nil {
+			return e
+		}
+		usedBefore, e := parseAccountAmount(row.DiscountAmountUsed, "t_courses_activity_user_discount.discount_amount_used")
+		if e != nil {
+			return e
+		}
+		available := face.Sub(usedBefore)
+		if available.LessThan(in.Amount) {
+			return infraerrors.WithCause(apperr.ErrGiftAmountShort,
+				errString("service: UseVoucher 可用赠送金额不足(3109;dump 静默 LEAVE,Go 显式拒绝)"))
+		}
+		apply := in.Amount
+		if actType == model.ActivityTypeVoucher {
+			// dump 763229:代金券"一次用完一张"——直接取券面额(**不是剩余额**;部分已用会超额占用)
+			apply = face
+		}
+		if !apply.GreaterThan(0) {
+			return nil
+		}
+		if _, ue := s.disc.IncUsed(txCtx, tx, row.ID, apply); ue != nil {
+			return ue
+		}
+		log := &model.CoursesActivityUserDiscountLog{
+			ActivityUserDiscountID: row.ID,
+			CoursesStudentID:       idPtr(in.StudentCourseID),
+			DiscountAmountUsed:     apply.Neg().String(), // 负值 = 消费
+			Memo:                   optString(in.Memo),
+		}
+		if ie := s.disc.InsertDiscountLog(txCtx, tx, log); ie != nil {
+			return ie
+		}
+		out.DiscountID = row.ID
+		out.Applied = apply
+		out.DiscountAmountUsedAfter = usedBefore.Add(apply)
+		out.ActivityType = actType
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------- 代金券/赠送:按报课记录退回(SP16)
+
+// RefundByStuCourse 按报课记录退回赠送金额(SP16,dump 763046)。
+//
+// dump 语义:
+//
+//	763052  IF inputAmount<0 THEN inputAmount=0
+//	763057  游标:`discount_log WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id **DESC**`
+//	763070  temp = min(|行金额|, 剩余应退);剩余应退 -= temp
+//	763077  UPDATE pool SET discount_amount_used -= temp
+//	763079  INSERT discount_log(..., +temp)
+//
+// Go 修正(与 A9/A13 同族,`DUMP-BUGFIX`):
+//   - dump 在循环内又套了一个 `WHILE done=0 DO FETCH`(把剩余行一次读空、不做处理)⇒ 实际**只冲回最近一条**;
+//     Go 按"逐行递减剩余额"正常遍历(退 120、行 50/100 ⇒ 先 50 再 70)。
+//   - 每行冲回额**显式盖帽** `min(|行金额|, 剩余应退)`(退 40 只退 40,不超退)。
+//
+// 入参:ctx / in。出参:*RefundByStuCourseResult;错误码 2001/2002/5002。
+func (s *ActivityDiscountService) RefundByStuCourse(
+	ctx context.Context, in RefundByStuCourseInput,
+) (*RefundByStuCourseResult, error) {
+	if s == nil || s.disc == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
+	}
+	if in.StudentCourseID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: RefundByStuCourse 需要 student_course_id>0"))
+	}
+	memo := strings.TrimSpace(in.Memo)
+	if memo == "" {
+		memo = defaultRefundDiscountMemo
+	}
+	amount := in.Amount
+	if amount < 0 {
+		amount = 0 // dump 763052:负值归 0
+	}
+	out := &RefundByStuCourseResult{
+		Requested: in.Amount,
+		Items:     make([]VoucherRefundItem, 0, 4),
+	}
+	if !amount.GreaterThan(0) {
+		return out, nil
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		rows, e := s.disc.ListDiscountLogsByStudentCourse(txCtx, in.StudentCourseID)
+		if e != nil {
+			return e
+		}
+		remaining := amount
+		refunded := money.Amount(0)
+		for i := range rows {
+			if !remaining.GreaterThan(0) {
+				break
+			}
+			row := &rows[i]
+			rowAmount, pe := parseAccountAmount(row.DiscountAmountUsed, "t_courses_activity_user_discount_log.discount_amount_used")
+			if pe != nil {
+				return pe
+			}
+			absUsed := absAmount(rowAmount)
+			temp := absUsed
+			if remaining.LessThan(temp) {
+				temp = remaining // A13 同族:显式盖帽
+			}
+			if !temp.GreaterThan(0) {
+				continue
+			}
+			// 锁池行(Go 增强)后原子冲回
+			if _, le := s.disc.GetDiscountForUpdate(txCtx, tx, row.ActivityUserDiscountID); le != nil {
+				return le
+			}
+			if _, ue := s.disc.IncUsed(txCtx, tx, row.ActivityUserDiscountID, temp.Neg()); ue != nil {
+				return ue
+			}
+			log := &model.CoursesActivityUserDiscountLog{
+				ActivityUserDiscountID: row.ActivityUserDiscountID,
+				CoursesStudentID:       idPtr(in.StudentCourseID),
+				DiscountAmountUsed:     temp.String(), // 正值 = 退回
+				Memo:                   optString(memo),
+			}
+			if ie := s.disc.InsertDiscountLog(txCtx, tx, log); ie != nil {
+				return ie
+			}
+			remaining = remaining.Sub(temp)
+			refunded = refunded.Add(temp)
+			out.Items = append(out.Items, VoucherRefundItem{
+				DiscountID: row.ActivityUserDiscountID, Amount: temp,
+			})
+		}
+		out.RefundedTotal = refunded
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------- 代金券/赠送:按活动退回(SP17)
+
+// RefundByActivity 针对用户的某一个活动退回赠送金额(SP17,dump 763106)。
+//
+// dump 语义:
+//
+//	763110  IF inputAmount<0 THEN inputAmount=0
+//	763122  游标:`discount_log JOIN pool WHERE pool.userid=? AND pool.activityid=? AND log.used<0
+//	                ORDER BY log.id DESC LIMIT 0,1`(**只处理最近一条**)
+//	763133  IF 活动 type=3 THEN **inputAmount = discount_amount**(代金券一次全额退)
+//	763137  IF inputAmount > |log.used| THEN `SELECT inputAmount = logDiscountAmountUsed`(**布尔比较,
+//	        赋值从未生效** ⇒ dump 实际不截断、可超退,A13)
+//	763140  IF inputAmount>0 THEN UPDATE pool SET used -= inputAmount; INSERT log(..., +inputAmount)
+//
+// Go 修正(A13,`DUMP-BUGFIX`):`amount = min(amount, |最近一条消费额|)` **显式盖帽**;
+// 代金券仍按 dump **一次全额退**(`amount = 券面额`),再盖帽到该券最近一笔消费额。
+//
+// 入参:ctx / in。出参:*RefundByActivityResult(no-op 时 Refunded=0);错误码 2001/2002/5002。
+func (s *ActivityDiscountService) RefundByActivity(
+	ctx context.Context, in RefundByActivityInput,
+) (*RefundByActivityResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: RefundByActivity 需要 user_id"))
+	}
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("service: RefundByActivity 需要 activity_id>0"))
+	}
+	if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
+	}
+	memo := strings.TrimSpace(in.Memo)
+	if memo == "" {
+		memo = defaultRefundUserActivityMemo
+	}
+	amount := in.Amount
+	if amount < 0 {
+		amount = 0
+	}
+	userID := strings.TrimSpace(in.UserID)
+	out := &RefundByActivityResult{ActivityID: in.ActivityID}
+	if !amount.GreaterThan(0) {
+		return out, nil
+	}
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		actType, te := s.activityType(txCtx, in.ActivityID)
+		if te != nil {
+			return te
+		}
+		last, e := s.disc.GetLastDiscountLogByUserActivity(txCtx, userID, in.ActivityID)
+		if e != nil {
+			return e
+		}
+		if last == nil {
+			return nil // dump:LIMIT 1 无行 → no-op
+		}
+		apply := amount
+		if actType == model.ActivityTypeVoucher {
+			face, pe := parseAccountAmount(last.DiscountAmount, "t_courses_activity_user_discount.discount_amount")
+			if pe != nil {
+				return pe
+			}
+			apply = face // dump 763133:代金券一次全额退
+		}
+		logAmount, pe := parseAccountAmount(last.LogAmountUsed, "t_courses_activity_user_discount_log.discount_amount_used")
+		if pe != nil {
+			return pe
+		}
+		absUsed := absAmount(logAmount)
+		if absUsed.LessThan(apply) {
+			apply = absUsed // A13 修正:显式盖帽(dump 的 `SELECT x = y` 从未赋值)
+		}
+		if !apply.GreaterThan(0) {
+			return nil
+		}
+		if _, le := s.disc.GetDiscountForUpdate(txCtx, tx, last.PoolID); le != nil {
+			return le
+		}
+		if _, ue := s.disc.IncUsed(txCtx, tx, last.PoolID, apply.Neg()); ue != nil {
+			return ue
+		}
+		log := &model.CoursesActivityUserDiscountLog{
+			ActivityUserDiscountID: last.PoolID,
+			CoursesStudentID:       last.CoursesStudentID, // dump 原样带回日志行上的报课记录
+			DiscountAmountUsed:     apply.String(),
+			Memo:                   optString(memo),
+		}
+		if ie := s.disc.InsertDiscountLog(txCtx, tx, log); ie != nil {
+			return ie
+		}
+		poolAfter, e := s.disc.GetDiscountForUpdate(txCtx, tx, last.PoolID)
+		if e != nil {
+			return e
+		}
+		usedAfter, e := parseAccountAmount(poolAfter.DiscountAmountUsed, "t_courses_activity_user_discount.discount_amount_used")
+		if e != nil {
+			return e
+		}
+		out.DiscountID = last.PoolID
+		out.Refunded = apply
+		out.DiscountAmountUsedAfter = usedAfter
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ---------------------------------------------------------------- 内部助手
+
+// activityType 只读活动类型(dump 的 `SELECT type FROM t_courses_activity WHERE id=?`)。
+//
+// 语义:活动不存在 → 返回 0(= 非 2/3/4,所有分支都不命中),与 dump 的
+// `SELECT ... INTO` 取不到行时变量保持默认 0 一致;DB 异常才报错。
+func (s *ActivityDiscountService) activityType(ctx context.Context, activityID int64) (int8, error) {
+	act, err := s.act.GetByID(ctx, activityID)
+	if err != nil {
+		if errors.Is(err, infraerrors.ErrNotFound) {
+			return 0, nil
+		}
+		return 0, err
+	}
+	return act.Type, nil
+}
+
+// idPtr 把数值主键转成指针(0 → nil,表示"未关联报课记录")。
+func idPtr(id int64) *int64 {
+	if id <= 0 {
+		return nil
+	}
+	return &id
+}

+ 545 - 0
internal/academic/service/activity_discount_service_test.go

@@ -0,0 +1,545 @@
+// ActivityDiscountService 单测(AC-07):充值赠送使用/退回 + 代金券使用/退回 + 可用额。
+//
+// 断言纪律(对照 §5.1 第 4 条「横切优先」——**不是**数 case 数量):
+//  1. **退回分配顺序(降序)是阻断级**:两次用券 100/50、退 120 ⇒ **先 50 再 70**。
+//     若把 `id DESC` 写成 `ASC`,总额同样是 120,只有"逐条明细"能证明分配错误 ⇒
+//     本文件断言 `Items` 的顺序与逐条金额,而不只断言合计。
+//  2. **盖帽**:退 40 只能退 40(单行冲回额 = min(|行金额|, 剩余应退))。
+//  3. **账务恒等式(自证失败式)**:`signed.activity_money_used == -Σ(used_log)`、
+//     `pool.discount_amount_used == -Σ(discount_log)`——任何"少写一条流水/金额算错"都会立刻破式。
+//  4. **代金券一次全额退**:type=3 的退回额 = 券面额,再按最近一条消费额盖帽(A13 修正)。
+package service
+
+import (
+	"context"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+)
+
+// ---------------------------------------------------------------- 辅助
+
+// seedSignedFull 落一行报名行(可指定状态与两列金额)。
+func seedSignedFull(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32,
+	activityMoney, activityMoneyUsed string) {
+	t.Helper()
+	row := &model.CoursesActivitySigned{
+		ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
+		Status: status, ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 报名行失败: %v", err)
+	}
+}
+
+// seedPool 落一行赠送池(返回池 ID)。
+func seedPool(t *testing.T, db *gorm.DB, userID string, activityID int64,
+	discountAmount, discountAmountUsed string) int64 {
+	t.Helper()
+	row := &model.CoursesActivityUserDiscount{
+		ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID,
+		DiscountAmount: discountAmount, DiscountAmountUsed: discountAmountUsed,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 赠送池失败: %v", err)
+	}
+	return row.ID
+}
+
+// readSignedUsed 读报名行已用额(分)。
+func readSignedUsed(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 {
+	t.Helper()
+	row := readSignedRow(t, db, userID, activityID)
+	if row == nil {
+		t.Fatalf("报名行不存在(user=%s,activity=%d)", userID, activityID)
+	}
+	return cents(t, row.ActivityMoneyUsed)
+}
+
+// sumUsedLogAmount 汇总某活动某用户的 used_log 金额(分;消费为负、退回为正)。
+func sumUsedLogAmount(t *testing.T, db *gorm.DB, activityID int64, userID string) int64 {
+	t.Helper()
+	var rows []model.CoursesActivityUsedLog
+	if err := db.Where("activity_id = ? AND user_id = ?", activityID, userID).Find(&rows).Error; err != nil {
+		t.Fatalf("读 used_log 失败: %v", err)
+	}
+	total := int64(0)
+	for i := range rows {
+		total += cents(t, rows[i].ActivityMoneyUsed)
+	}
+	return total
+}
+
+// readPoolUsed 读池已用额(分)。
+func readPoolUsed(t *testing.T, db *gorm.DB, poolID int64) int64 {
+	t.Helper()
+	var row model.CoursesActivityUserDiscount
+	if err := db.Where("id = ?", poolID).Take(&row).Error; err != nil {
+		t.Fatalf("读赠送池(%d) 失败: %v", poolID, err)
+	}
+	return cents(t, row.DiscountAmountUsed)
+}
+
+// sumDiscountLogAmount 汇总某池的 discount_log 金额(分;消费为负、退回为正)。
+func sumDiscountLogAmount(t *testing.T, db *gorm.DB, poolID int64) int64 {
+	t.Helper()
+	var rows []model.CoursesActivityUserDiscountLog
+	if err := db.Where("activity_user_discount_id = ?", poolID).Find(&rows).Error; err != nil {
+		t.Fatalf("读 discount_log 失败: %v", err)
+	}
+	total := int64(0)
+	for i := range rows {
+		total += cents(t, rows[i].DiscountAmountUsed)
+	}
+	return total
+}
+
+// ---------------------------------------------------------------- 充值赠送:使用(SP12)
+
+// TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity
+// 使用额不得超过活动剩余额(A9 修正),且 `signed.used == -Σ used_log` 恒成立。
+func TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(20)
+
+	seedActivityFull(t, db, 400, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 400, model.ActivitySignedAttended, "100.00", "0.00")
+
+	// 第一次:用 30(小于剩余 100)
+	first, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
+		UserID: user, ActivityID: 400, StudentCourseID: 5001, Amount: mustAmount(t, "30.00"),
+	})
+	if err != nil {
+		t.Fatalf("使用充值赠送失败: %v", err)
+	}
+	if first.UsedAmount.Cents() != 3000 || first.Remaining.Cents() != 7000 {
+		t.Fatalf("首用应 3000 分、剩余 7000 分,实得 %d/%d",
+			first.UsedAmount.Cents(), first.Remaining.Cents())
+	}
+
+	// 第二次:请求 90,但只剩 70 → 只能扣 70(A9:逐行递减到剩余额为止)
+	second, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
+		UserID: user, ActivityID: 400, StudentCourseID: 5002, Amount: mustAmount(t, "90.00"),
+	})
+	if err != nil {
+		t.Fatalf("第二次使用失败: %v", err)
+	}
+	if second.UsedAmount.Cents() != 7000 || second.Remaining.Cents() != 0 {
+		t.Fatalf("第二次应被截到 7000 分、剩余 0,实得 %d/%d",
+			second.UsedAmount.Cents(), second.Remaining.Cents())
+	}
+
+	// 第三次:额度已用完 → no-op(不写流水)
+	third, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
+		UserID: user, ActivityID: 400, StudentCourseID: 5003, Amount: mustAmount(t, "10.00"),
+	})
+	if err != nil {
+		t.Fatalf("第三次使用失败: %v", err)
+	}
+	if third.UsedAmount.Cents() != 0 {
+		t.Fatalf("额度用完后应为 no-op,实得 %d 分", third.UsedAmount.Cents())
+	}
+
+	// 横切恒等式:报名行已用额 == -(Σ used_log)
+	used := readSignedUsed(t, db, user, 400)
+	if used != 10000 {
+		t.Fatalf("报名行已用额应 10000 分,实得 %d", used)
+	}
+	if sum := sumUsedLogAmount(t, db, 400, user); sum != -used {
+		t.Fatalf("恒等式破式:signed.used=%d 应等于 -Σ(used_log)=%d", used, -sum)
+	}
+}
+
+// TestUseRechargeGiftRequiresAttendedStatus 报名行状态非 2(未参加)→ no-op(dump 游标条件)。
+func TestUseRechargeGiftRequiresAttendedStatus(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(21)
+
+	seedActivityFull(t, db, 401, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 401, model.ActivitySignedSigned, "100.00", "0.00")
+
+	res, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
+		UserID: user, ActivityID: 401, StudentCourseID: 5004, Amount: mustAmount(t, "10.00"),
+	})
+	if err != nil {
+		t.Fatalf("使用充值赠送失败: %v", err)
+	}
+	if res.UsedAmount.Cents() != 0 {
+		t.Fatalf("报名行 status!=2 时必须 no-op,实得 %d 分", res.UsedAmount.Cents())
+	}
+	if used := readSignedUsed(t, db, user, 401); used != 0 {
+		t.Fatalf("报名行已用额应保持 0,实得 %d", used)
+	}
+	if sum := sumUsedLogAmount(t, db, 401, user); sum != 0 {
+		t.Fatalf("不得写 used_log,实得合计 %d", sum)
+	}
+}
+
+// ---------------------------------------------------------------- 充值赠送:退回(SP13)
+
+// TestRefundRechargeGiftAllocatesDescending **降序分配**:先冲回最近一笔(50),再 70。
+func TestRefundRechargeGiftAllocatesDescending(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(22)
+
+	seedActivityFull(t, db, 402, model.ActivityTypeRecharge, "200.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 402, model.ActivitySignedAttended, "200.00", "150.00")
+	// id 小的是**较早**的一笔(-100),id 大的是最近一笔(-50)
+	seedUsedLogRow(t, db, 402, user, "6001", "-100.00")
+	seedUsedLogRow(t, db, 402, user, "6001", "-50.00")
+
+	res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
+		StudentCourseID: 6001, Amount: mustAmount(t, "120.00"),
+	})
+	if err != nil {
+		t.Fatalf("退回充值赠送失败: %v", err)
+	}
+	if res.RefundedTotal.Cents() != 12000 {
+		t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents())
+	}
+	if len(res.Items) != 2 {
+		t.Fatalf("应命中 2 笔,实得 %d 笔(%+v)", len(res.Items), res.Items)
+	}
+	// ★ 顺序断言:先 50(最近一笔),再 70(差额补到较早那笔)
+	if res.Items[0].Amount.Cents() != 5000 {
+		t.Fatalf("第一笔应为 5000 分(后入先退),实得 %d", res.Items[0].Amount.Cents())
+	}
+	if res.Items[1].Amount.Cents() != 7000 {
+		t.Fatalf("第二笔应为 7000 分,实得 %d", res.Items[1].Amount.Cents())
+	}
+
+	// 横切恒等式:use(+150) 与 refund(-120) 之后 signed.used 应为 30
+	if used := readSignedUsed(t, db, user, 402); used != 3000 {
+		t.Fatalf("退回后报名行已用额应 3000 分,实得 %d", used)
+	}
+	if sum := sumUsedLogAmount(t, db, 402, user); sum != -3000 {
+		t.Fatalf("恒等式破式:signed.used=3000 应等于 -Σ(used_log)=%d", -sum)
+	}
+}
+
+// TestRefundRechargeGiftCapsAtRequested 退 40 只退 40(盖上"请求额")。
+func TestRefundRechargeGiftCapsAtRequested(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(23)
+
+	seedActivityFull(t, db, 403, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 403, model.ActivitySignedAttended, "100.00", "100.00")
+	seedUsedLogRow(t, db, 403, user, "6002", "-100.00")
+
+	res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
+		StudentCourseID: 6002, Amount: mustAmount(t, "40.00"),
+	})
+	if err != nil {
+		t.Fatalf("退回充值赠送失败: %v", err)
+	}
+	if res.RefundedTotal.Cents() != 4000 {
+		t.Fatalf("只应退 4000 分,实得 %d", res.RefundedTotal.Cents())
+	}
+	if len(res.Items) != 1 || res.Items[0].Amount.Cents() != 4000 {
+		t.Fatalf("应 1 笔 4000 分,实得 %+v", res.Items)
+	}
+	if used := readSignedUsed(t, db, user, 403); used != 6000 {
+		t.Fatalf("退回 40 后已用额应 6000 分,实得 %d", used)
+	}
+}
+
+// TestRefundRechargeGiftSkipsNonRechargeActivities 非 type=2 活动的流水不参与冲回(dump `IF type=2`)。
+func TestRefundRechargeGiftSkipsNonRechargeActivities(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(24)
+
+	seedActivityFull(t, db, 404, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedSignedFull(t, db, user, 404, model.ActivitySignedAttended, "100.00", "100.00")
+	seedUsedLogRow(t, db, 404, user, "6003", "-100.00")
+
+	res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
+		StudentCourseID: 6003, Amount: mustAmount(t, "100.00"),
+	})
+	if err != nil {
+		t.Fatalf("退回充值赠送失败: %v", err)
+	}
+	if res.RefundedTotal.Cents() != 0 || len(res.Items) != 0 {
+		t.Fatalf("非充值赠送活动不得冲回,实得 %+v", res.Items)
+	}
+	if used := readSignedUsed(t, db, user, 404); used != 10000 {
+		t.Fatalf("已用额应保持 10000 分,实得 %d", used)
+	}
+}
+
+// ---------------------------------------------------------------- 代金券:使用(SP15)
+
+// TestUseVoucherType3UsesFaceValue 代金券按 dump 口径取**券面额**(不是请求额、也不是剩余额)。
+func TestUseVoucherType3UsesFaceValue(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(25)
+
+	seedActivityFull(t, db, 405, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 405, "100.00", "0.00")
+
+	res, err := svc.UseVoucher(ctx, UseVoucherInput{
+		UserID: user, ActivityID: 405, StudentCourseID: 7001, Amount: mustAmount(t, "30.00"),
+	})
+	if err != nil {
+		t.Fatalf("使用代金券失败: %v", err)
+	}
+	if res.Applied.Cents() != 10000 {
+		t.Fatalf("代金券应按面额 10000 分占用,实得 %d", res.Applied.Cents())
+	}
+	if got := readPoolUsed(t, db, poolID); got != 10000 {
+		t.Fatalf("池已用额应 10000 分,实得 %d", got)
+	}
+	// 横切恒等式:pool.used == -Σ(discount_log)
+	if sum := sumDiscountLogAmount(t, db, poolID); sum != -10000 {
+		t.Fatalf("恒等式破式:pool.used=10000 应等于 -Σ(discount_log)=%d", -sum)
+	}
+}
+
+// TestUseVoucherRejectsInsufficientWith3109 可用额不足 → 3109(dump 静默 LEAVE,Go 显式)。
+func TestUseVoucherRejectsInsufficientWith3109(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(26)
+
+	seedActivityFull(t, db, 406, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 406, "100.00", "90.00") // 只剩 10
+
+	_, err := svc.UseVoucher(ctx, UseVoucherInput{
+		UserID: user, ActivityID: 406, StudentCourseID: 7002, Amount: mustAmount(t, "20.00"),
+	})
+	wantAppErr(t, err, apperr.CodeGiftAmountShort, "可用赠送金额不足")
+
+	if got := readPoolUsed(t, db, poolID); got != 9000 {
+		t.Fatalf("被 3109 拒绝后池已用额不得变,实得 %d", got)
+	}
+	// 被拒后**零副作用**:池已用额与流水条数都不变(种子池本身没有流水行 → 合计应为 0)
+	if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
+		t.Fatalf("被 3109 拒绝后不得写 discount_log,实得合计 %d", sum)
+	}
+}
+
+// ---------------------------------------------------------------- 代金券/赠送:按报课记录退回(SP16)
+
+// TestRefundByStuCourseDescendingWithCap 降序(50 → 70)+ 盖帽(退 40 只退 40)。
+func TestRefundByStuCourseDescendingWithCap(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(27)
+
+	seedActivityFull(t, db, 407, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 407, "150.00", "150.00")
+	// 两次用券:先 100、后 50(同一报课记录)→ 池 used = 150
+	seedDiscountLogRow(t, db, poolID, 8001, "-100.00")
+	seedDiscountLogRow(t, db, poolID, 8001, "-50.00")
+
+	res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{
+		StudentCourseID: 8001, Amount: mustAmount(t, "120.00"),
+	})
+	if err != nil {
+		t.Fatalf("按报课记录退回失败: %v", err)
+	}
+	if res.RefundedTotal.Cents() != 12000 {
+		t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents())
+	}
+	if len(res.Items) != 2 || res.Items[0].Amount.Cents() != 5000 || res.Items[1].Amount.Cents() != 7000 {
+		t.Fatalf("降序分配应为 5000→7000,实得 %+v", res.Items)
+	}
+	if got := readPoolUsed(t, db, poolID); got != 3000 {
+		t.Fatalf("退回后池已用额应 3000 分,实得 %d", got)
+	}
+	if sum := sumDiscountLogAmount(t, db, poolID); sum != -3000 {
+		t.Fatalf("恒等式破式:pool.used=3000 应等于 -Σ(discount_log)=%d", -sum)
+	}
+}
+
+// TestRefundByStuCourseCapsAtRequested 退 40 只退 40。
+func TestRefundByStuCourseCapsAtRequested(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(28)
+
+	seedActivityFull(t, db, 408, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 408, "100.00", "100.00")
+	seedDiscountLogRow(t, db, poolID, 8002, "-100.00")
+
+	res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{
+		StudentCourseID: 8002, Amount: mustAmount(t, "40.00"),
+	})
+	if err != nil {
+		t.Fatalf("按报课记录退回失败: %v", err)
+	}
+	if res.RefundedTotal.Cents() != 4000 || len(res.Items) != 1 {
+		t.Fatalf("应退 4000 分 1 笔,实得 %d 分 %d 笔", res.RefundedTotal.Cents(), len(res.Items))
+	}
+	if got := readPoolUsed(t, db, poolID); got != 6000 {
+		t.Fatalf("池已用额应 6000 分,实得 %d", got)
+	}
+}
+
+// ---------------------------------------------------------------- 代金券:按活动退回(SP17)
+
+// TestRefundByActivityVoucherFullRefund 代金券一次全额退(amount 置为券面额)。
+func TestRefundByActivityVoucherFullRefund(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(29)
+
+	seedActivityFull(t, db, 409, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 409, "100.00", "100.00")
+	seedDiscountLogRow(t, db, poolID, 9001, "-100.00")
+
+	// 请求 30,但代金券一次全额退 → 实际退 100(再按最近一条消费额 100 盖帽)
+	res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
+		UserID: user, ActivityID: 409, Amount: mustAmount(t, "30.00"),
+	})
+	if err != nil {
+		t.Fatalf("按活动退回失败: %v", err)
+	}
+	if res.Refunded.Cents() != 10000 {
+		t.Fatalf("代金券应一次全额退 10000 分,实得 %d", res.Refunded.Cents())
+	}
+	if got := readPoolUsed(t, db, poolID); got != 0 {
+		t.Fatalf("全额退后池已用额应 0,实得 %d", got)
+	}
+	if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
+		t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum)
+	}
+}
+
+// TestRefundByActivityCappedByLastLog A13 修正:非代金券按"最近一条消费额"盖帽,不得超退。
+func TestRefundByActivityCappedByLastLog(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(30)
+
+	seedActivityFull(t, db, 410, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 410, "100.00", "50.00")
+	seedDiscountLogRow(t, db, poolID, 9002, "-50.00")
+
+	// 请求 80 > 最近一条消费 50 → 只能退 50(dump 的 `SELECT x = y` 从未赋值 ⇒ 会退 80,A13 修正)
+	res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
+		UserID: user, ActivityID: 410, Amount: mustAmount(t, "80.00"),
+	})
+	if err != nil {
+		t.Fatalf("按活动退回失败: %v", err)
+	}
+	if res.Refunded.Cents() != 5000 {
+		t.Fatalf("应盖帽到 5000 分,实得 %d", res.Refunded.Cents())
+	}
+	if got := readPoolUsed(t, db, poolID); got != 0 {
+		t.Fatalf("池已用额应 0,实得 %d", got)
+	}
+	if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
+		t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum)
+	}
+}
+
+// TestRefundByActivityNoLogIsNoop 无消费流水 → no-op(dump LIMIT 1 空)。
+func TestRefundByActivityNoLogIsNoop(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(31)
+
+	seedActivityFull(t, db, 411, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	poolID := seedPool(t, db, user, 411, "100.00", "0.00")
+
+	res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
+		UserID: user, ActivityID: 411, Amount: mustAmount(t, "100.00"),
+	})
+	if err != nil {
+		t.Fatalf("按活动退回失败: %v", err)
+	}
+	if res.Refunded.Cents() != 0 {
+		t.Fatalf("无消费流水应 no-op,实得 %d", res.Refunded.Cents())
+	}
+	if got := readPoolUsed(t, db, poolID); got != 0 {
+		t.Fatalf("池已用额应保持 0,实得 %d", got)
+	}
+}
+
+// ---------------------------------------------------------------- 可用额汇总(SP14)
+
+// TestUsableAmountAggregatesAndRespectsScope 可用额 = Σ(face−used)(仅 face>used 的行),
+// 且 `institutionID>0` 时校区条件是**等值**(campus_id=0 的机构级池只在 campusID=0 时命中)。
+func TestUsableAmountAggregatesAndRespectsScope(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityDiscountService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(32)
+
+	seedActivityFull(t, db, 412, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedPool(t, db, user, 412, "100.00", "30.00") // 本校区:可用 70
+	// 机构级池(campus_id=0):可用 10
+	if err := db.Create(&model.CoursesActivityUserDiscount{
+		ActivityID: 412, InstitutionID: 1, CampusID: 0, UserID: user,
+		DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 机构级池失败: %v", err)
+	}
+	// 已用尽的行不参与(face == used)
+	seedPool(t, db, user, 412, "50.00", "50.00")
+
+	// campusID=10:只命中本校区池 → 70
+	res, err := svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 10})
+	if err != nil {
+		t.Fatalf("汇总可用额失败: %v", err)
+	}
+	if res.Usable.Cents() != 7000 {
+		t.Fatalf("本校区可用额应 7000 分(等值过滤,不含机构级池),实得 %d", res.Usable.Cents())
+	}
+
+	// campusID=0:只命中机构级池 → 10
+	res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 0})
+	if err != nil {
+		t.Fatalf("汇总可用额失败: %v", err)
+	}
+	if res.Usable.Cents() != 1000 {
+		t.Fatalf("机构级池可用额应 1000 分,实得 %d", res.Usable.Cents())
+	}
+
+	// institutionID=0:平台视角,不加任何作用域条件 → 70+10=80
+	res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user})
+	if err != nil {
+		t.Fatalf("汇总可用额失败: %v", err)
+	}
+	if res.Usable.Cents() != 8000 {
+		t.Fatalf("平台视角可用额应 8000 分,实得 %d", res.Usable.Cents())
+	}
+}
+
+// seedDiscountLogRow 落一行 discount_log(负=消费/正=退回)。
+func seedDiscountLogRow(t *testing.T, db *gorm.DB, poolID int64, stuCourseID int64, amount string) {
+	t.Helper()
+	row := &model.CoursesActivityUserDiscountLog{
+		ActivityUserDiscountID: poolID,
+		CoursesStudentID:       &stuCourseID,
+		DiscountAmountUsed:     amount,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed discount_log 失败: %v", err)
+	}
+}
+
+// 编译期断言:避免 money 包被"只用类型"时误删导入。
+var _ = money.FromCents

+ 414 - 0
internal/academic/service/activity_service.go

@@ -0,0 +1,414 @@
+// 活动报名服务(报名 / 取消报名)——AC-07 活动服务 A(主链路部分)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.2 #19 `sp_courses_activity_signed_add`(dump **762908**)→ `Sign`
+//   - §3.2 #20 `sp_courses_activity_signed_cancel`(dump **762982**)→ `CancelSign`
+//   - §8 AC-07 前置输入(10 原语)、§5.1 五条纪律(第 4 条横切优先 / 第 5 条幂等回放等价)
+//   - §10.1 错误码 3105/3106/3107/3108/3141、§10.3 事务模板、§10.7 金额
+//
+// 两条**反直觉**判据(dump 口径,**禁止按直觉互换,也禁止合并**):
+//
+//	3105 已报名   ← dump 762928:`COUNT(*) FROM signed WHERE user_id=? AND activity_id=? AND status != 0`
+//	               判的是**报名行**状态 ⇒ 取消(status=0)后可**重新报名**同一活动;
+//	3106 老带新限一次 ← dump 762947:`signed JOIN activity ON type=4 WHERE user_id=? AND activity.status != 0`
+//	               判的是**活动**状态、**不看报名行 status** ⇒ 参加过就**永久消耗次数**,取消也不退。
+//
+// 于是必然出现"取消报名后 → 可重报(3105 放行)但不能再参加老带新(3106 拒)"的组合,
+// 这正是两条判据**不得合并**的直接证据(合并任一方都会让另一半失真)。
+package service
+
+import (
+	"context"
+	"errors"
+	"strconv"
+	"strings"
+	"time"
+
+	"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 inputStatus<1 THEN SET inputStatus=1`)。
+const (
+	// defaultSignStatus 报名初始状态(dump 的 status<1 → 1)。
+	defaultSignStatus int32 = model.ActivitySignedSigned
+)
+
+// ActivityService 活动报名服务(depends: 活动仓储 + 事务运行器 + 成员校验器)。
+//
+// 字段:
+//   - repo 活动/报名仓储(含 3105/3106 判据与人数增减原语);
+//   - runner 事务运行器(顶层 BEGIN;事务内透传同一 tx);
+//   - member 成员关系校验器(可 nil:系统任务/割接脚本模式);
+//   - now 时钟(单测注入;dump 用 NOW())。
+type ActivityService struct {
+	repo   *repository.ActivityRepo
+	runner *repository.TxRunner
+	member port.MembershipChecker
+	now    func() time.Time
+}
+
+// NewActivityService 构造活动报名服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域,多租户过滤的唯一来源)。
+// 出参:*ActivityService。
+func NewActivityService(db *gorm.DB, sc *appscope.Scope) *ActivityService {
+	return &ActivityService{
+		repo:   repository.NewActivityRepo(db, sc),
+		runner: repository.NewTxRunner(db),
+		now:    time.Now,
+	}
+}
+
+// WithMembershipChecker 注入成员校验器(返自身,链式装配)。
+//
+// 入参:member port.MembershipChecker(可 nil)。
+// 出参:*ActivityService(同一个实例)。
+func (s *ActivityService) WithMembershipChecker(member port.MembershipChecker) *ActivityService {
+	s.member = member
+	return s
+}
+
+// ---------------------------------------------------------------- 入参 / 响应 DTO
+
+// SignInput 活动报名入参(dump `signed_add` **5 参**,签名逐字对齐)。
+type SignInput struct {
+	// UserID 报名用户(必填,ULID)。
+	UserID string
+	// ActivityID 活动 ID(必填 >0)。
+	ActivityID int64
+	// Status 报名状态(<1 → 1,dump 762921 同口径)。
+	Status int32
+	// RecommendUserID 推荐人(ULID;空/"0" → NULL;老带新活动才有意义)。
+	RecommendUserID string
+	// CreateUserID 操作人(→ create_user_id;空 → NULL)。
+	CreateUserID string
+}
+
+// SignResult 活动报名响应。
+//
+// 字段语义:
+//   - Status 实际写入的报名状态(dump 的 `inputStatus`,已按 `<1 → 1` 归一);
+//   - ActivityMoney 快照进报名行的活动金额(= `max(activity.money, 0)`);
+//   - NumberSign 本次报名后的活动报名人数(`number_sign+1` 之后的值)。
+type SignResult struct {
+	// SignedID 报名行 ID(DELETE+INSERT 后的新行)。
+	SignedID int64 `json:"signedId"`
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// UserID 报名用户。
+	UserID string `json:"userId"`
+	// Status 报名状态(归一后的值)。
+	Status int32 `json:"status"`
+	// ActivityMoney 报名行快照的活动金额(分)。
+	ActivityMoney money.Amount `json:"activityMoney"`
+	// NumberSign 报名后的活动报名人数。
+	NumberSign int32 `json:"numberSign"`
+	// ActivityType 活动类型(1/2/3/4)。
+	ActivityType int8 `json:"activityType"`
+}
+
+// CancelSignInput 取消报名入参(dump `signed_cancel` **2 参**)。
+type CancelSignInput struct {
+	// UserID 报名用户(必填)。
+	UserID string
+	// ActivityID 活动 ID(必填 >0)。
+	ActivityID int64
+}
+
+// CancelSignResult 取消报名响应。
+//
+// 字段语义:`Status` 恒为 0(取消);金额三列已同语句归零(见 `repository.CancelSigned`)。
+type CancelSignResult struct {
+	// ActivityID 活动 ID。
+	ActivityID int64 `json:"activityId"`
+	// UserID 报名用户。
+	UserID string `json:"userId"`
+	// Status 取消后的报名状态(恒 0)。
+	Status int32 `json:"status"`
+	// NumberSign 取消后的活动报名人数(`number_sign-1`,**下限 0**)。
+	NumberSign int32 `json:"numberSign"`
+	// ActivityType 活动类型(1/2/3/4)。
+	ActivityType int8 `json:"activityType"`
+}
+
+// ---------------------------------------------------------------- 报名(SP19)
+
+// Sign 用户报名活动(SP19 `sp_courses_activity_signed_add`,dump 762908)。
+//
+// dump 语义(逐行对齐,行号见括号):
+//
+//	762921  IF inputStatus<1 THEN inputStatus=1
+//	762928  IF COUNT(signed WHERE user=? AND activity=? AND status!=0) > 0 → 返回 success=0(**3105**)
+//	762939  游标取 activity 的 (type, money)(**无行 → success=0**,见下"偏差")
+//	762947  IF type=4 AND COUNT(signed JOIN activity ON type=4 WHERE user=?) > 0 → success=0(**3106**)
+//	762955  IF activityMoney<0 THEN activityMoney=0(→ `max(money, 0)`)
+//	762965  DELETE signed WHERE user/activity(清旧行,含 status=0)+ INSERT + number_sign+1
+//
+// Go 的三处差异(均已在本方法注释与交付报告中登记):
+//  1. **3141 人数上限是 Go 增强**:dump 完全没有超卖保护(`number_sign` 只加不查),
+//     事务内用 `GetByIDForUpdate` + 比 `number_max` 判定(`number_max=0` = 不限);只影响
+//     "人数已满"这一新分支,dump 已有分支输出不变。
+//  2. **活动不存在 → 3001**:dump 游标取不到行时 `success=0` 静默返回(等价于"没报名"),
+//     Go 显式 3001 —— 目标态 signed 对 activity 有外键,静默成功会让前端误判。
+//  3. **3106 判据保留 `institution_id` 过滤**(多租户红线,Design §3.2 #19 唯一保留的 Go 偏差):
+//     只放宽 `signed.status` 与 `campus_id` 维度,机构过滤不删。
+//
+// 幂等语义:DELETE+INSERT 保证 `(user, activity)` 只留一行(UK 兜底);**重复报名不会产生第二行**,
+// 但按 dump 口径第二次会被 3105 拒(`status!=0` 命中原行)——故"重复报名"的可观测行为是 3105,
+// 不是 UPSERT 覆盖。
+//
+// 入参:ctx / in。出参:*SignResult;错误码 2001/2002/3001/3105/3106/3141/5002。
+func (s *ActivityService) Sign(ctx context.Context, in SignInput) (*SignResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Sign 需要 user_id"))
+	}
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: Sign 需要 activity_id>0"))
+	}
+	if s == nil || s.repo == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityService 未装配"))
+	}
+	status := in.Status
+	if status < 1 {
+		status = defaultSignStatus // dump 762921
+	}
+	userID := strings.TrimSpace(in.UserID)
+
+	var out SignResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if e := s.assertInScope(txCtx, userID); e != nil {
+			return e
+		}
+
+		// ① 3105 已报名(判**报名行** status ⇒ 取消后可重报)
+		exists, e := s.repo.ExistsSigned(txCtx, userID, in.ActivityID)
+		if e != nil {
+			return e
+		}
+		if exists {
+			return infraerrors.WithCause(apperr.ErrActivityAlreadySigned,
+				errString("service: Sign 已报名该活动(3105,dump 762928)"))
+		}
+
+		// ② 取活动 + 行锁(3141 判定需要与 number_sign+1 串行)
+		act, e := s.repo.GetByIDForUpdate(txCtx, tx, in.ActivityID)
+		if e != nil {
+			if errors.Is(e, infraerrors.ErrNotFound) {
+				return infraerrors.WithCause(infraerrors.ErrNotFound,
+					errString("service: Sign 活动不存在或不在作用域内(dump:游标无行 → success=0)"))
+			}
+			return e
+		}
+
+		// ③ 3106 老带新限一次(判**活动** status、**不判**报名行 status)
+		if act.Type == model.ActivityTypeRecommend {
+			count, ce := s.repo.CountSignedByUser(txCtx, userID, true)
+			if ce != nil {
+				return ce
+			}
+			if count > 0 {
+				return infraerrors.WithCause(apperr.ErrRecommendLimited,
+					errString("service: Sign 老带新每人限参加一次(3106,dump 762947;取消报名也不退次数)"))
+			}
+		}
+
+		// ④ 3141 人数上限(**Go 增强**:dump 无此校验)
+		if act.NumberMax > 0 && act.NumberSign >= act.NumberMax {
+			return infraerrors.WithCause(apperr.ErrActivityFull,
+				errString("service: Sign 报名人数已满(3141;dump 无此校验,属 Go 有意增强)"))
+		}
+
+		// ⑤ activity_money = max(activity.money, 0)(dump 762955)
+		actMoney, e := parseAccountAmount(act.Money, "t_courses_activity.money")
+		if e != nil {
+			return e
+		}
+		if actMoney < 0 {
+			actMoney = 0
+		}
+
+		// ⑥ DELETE + INSERT(幂等重置全字段)
+		row := &model.CoursesActivitySigned{
+			ActivityID:        in.ActivityID,
+			UserID:            userID,
+			InstitutionID:     act.InstitutionID,
+			CampusID:          act.CampusID, // 快照活动 campus_id(0 有"全自营校区"语义,不用请求作用域值覆盖)
+			Status:            status,
+			ActivityMoney:     actMoney.String(),
+			ActivityMoneyUsed: "0.00",
+			RecommendUserID:   optString(in.RecommendUserID),
+			CreateUserID:      optString(in.CreateUserID),
+		}
+		if e := s.repo.UpsertSigned(txCtx, tx, row); e != nil {
+			return e
+		}
+
+		// ⑦ 报名人数 +1(原子,与插入同事务)
+		if _, e := s.repo.IncreaseNumberSign(txCtx, tx, in.ActivityID, 1); e != nil {
+			return e
+		}
+
+		out = SignResult{
+			SignedID:      row.ID,
+			ActivityID:    in.ActivityID,
+			UserID:        userID,
+			Status:        status,
+			ActivityMoney: actMoney,
+			NumberSign:    act.NumberSign + 1,
+			ActivityType:  act.Type,
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- 取消报名(SP20)
+
+// CancelSign 用户取消报名(SP20 `sp_courses_activity_signed_cancel`,dump 762982)。
+//
+// dump 语义(逐行对齐):
+//
+//	762994  游标:signed JOIN activity WHERE user/activity AND signed.status!=0
+//	763007  IF type∈{2,3} AND response_userid>0 → LEAVE(**3107** 已审核不可取消)
+//	763013  ELSEIF type=4 AND EXISTS(used_log WHERE activityid/userid) → LEAVE(**3108** 已消费不可取消)
+//	763028  UPDATE signed SET status=0, activity_money=0, activity_money_used=0(**三列同置**)
+//	763030  UPDATE activity SET number_sign=number_sign-1(Go 加**下限 0**保护)
+//
+// 守卫命中时**不减人数**(`LEAVE` 在两条 UPDATE 之前)——这是"拒绝必须零副作用"的判据:
+// 若把 -1 写进守卫之前,就会留下"取消被拒但人数少了"的脏数据。
+//
+// 入参:ctx / in。出参:*CancelSignResult;错误码 2001/2002/3001/3107/3108/5002。
+func (s *ActivityService) CancelSign(ctx context.Context, in CancelSignInput) (*CancelSignResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CancelSign 需要 user_id"))
+	}
+	if in.ActivityID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: CancelSign 需要 activity_id>0"))
+	}
+	if s == nil || s.repo == nil || s.runner == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityService 未装配"))
+	}
+	userID := strings.TrimSpace(in.UserID)
+
+	var out CancelSignResult
+	err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if e := s.assertInScope(txCtx, userID); e != nil {
+			return e
+		}
+
+		// ① 游标等价物:该用户在该活动上的**有效**报名行(status!=0)
+		signed, e := s.repo.GetSigned(txCtx, userID, in.ActivityID)
+		if e != nil {
+			if errors.Is(e, infraerrors.ErrNotFound) {
+				return infraerrors.WithCause(infraerrors.ErrNotFound,
+					errString("service: CancelSign 报名记录不存在或已取消(dump:游标无行 → success=0)"))
+			}
+			return e
+		}
+
+		// ② 3107:充值赠送 / 代金券活动审核通过后不可取消
+		if signed.ActivityType == model.ActivityTypeRecharge || signed.ActivityType == model.ActivityTypeVoucher {
+			if hasUserRef(signed.ResponseUserID) {
+				return infraerrors.WithCause(apperr.ErrActivitySignedAudited,
+					errString("service: CancelSign 活动已审核通过不可取消(3107,dump 763007)"))
+			}
+		} else if signed.ActivityType == model.ActivityTypeRecommend {
+			// ③ 3108:老带新已有消费/结算记录 → 不可取消(**判存在性**,不看金额)
+			used, ue := s.discountRepo().ExistsUsedLog(txCtx, in.ActivityID, userID)
+			if ue != nil {
+				return ue
+			}
+			if used {
+				return infraerrors.WithCause(apperr.ErrRecommendUsed,
+					errString("service: CancelSign 老带新已产生消费不可取消(3108,dump 763013)"))
+			}
+		}
+
+		// ④ 三列同置 0(dump 763028:一条 UPDATE)
+		affected, e := s.repo.CancelSigned(txCtx, tx, userID, in.ActivityID)
+		if e != nil {
+			return e
+		}
+		if affected == 0 {
+			return infraerrors.WithCause(infraerrors.ErrNotFound,
+				errString("service: CancelSign 报名行不可写(并发删除/作用域变化)"))
+		}
+
+		// ⑤ 报名人数 -1(dump 763030;Go 加下限 0,防并发把人数减成负数)
+		if _, e := s.repo.IncreaseNumberSign(txCtx, tx, in.ActivityID, -1); e != nil {
+			return e
+		}
+
+		// ⑥ 回读人数(响应体必须是**当刻库值**,不复用中间变量)
+		after, e := s.repo.GetByID(txCtx, in.ActivityID)
+		if e != nil {
+			return e
+		}
+		out = CancelSignResult{
+			ActivityID:   in.ActivityID,
+			UserID:       userID,
+			Status:       model.ActivitySignedCancelled,
+			NumberSign:   after.NumberSign,
+			ActivityType: signed.ActivityType,
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &out, nil
+}
+
+// ---------------------------------------------------------------- 内部助手
+
+// discountRepo 按需构造赠送池仓储(复用同一 DB/作用域;取消报名的 3108 判据需要它)。
+//
+// 为什么不在结构体里固定持有:AC-07 的取消路径只有 type=4 一支需要它,
+// 按需构造可让 `ActivityService` 的装配依赖保持最小(同一 DB 句柄,无额外连接)。
+func (s *ActivityService) discountRepo() *repository.ActivityDiscountRepo {
+	return repository.NewActivityDiscountRepo(s.repo.DB, s.repo.Sc)
+}
+
+// assertInScope 对象级越权断言(语义同 FundService;未注入 checker 的系统任务模式直接放行)。
+func (s *ActivityService) assertInScope(ctx context.Context, userID string) error {
+	if s == nil || s.member == nil {
+		return nil
+	}
+	return s.member.AssertInScope(ctx, userID, 0, 0)
+}
+
+// hasUserRef 判断目标态的用户引用列是否"有值"(旧 int 型的 `>0` 在 ULID 列上的等价判据)。
+//
+// 口径:NULL / 空串 / "0" 都视为无值(与 §3.8-A17 的 `NOT IN ('', '0')` 同源)——
+// 迁移期可能把旧的 `0` 物化成字符串,只用 `IS NOT NULL` 会把它当"已审核"。
+func hasUserRef(ref *string) bool {
+	if ref == nil {
+		return false
+	}
+	v := strings.TrimSpace(*ref)
+	return v != "" && v != "0"
+}
+
+// orderNoOf 把报课记录 ID 转成流水 `order_no`/`log_id` 的字符串口径(dump 的 VARCHAR(50) 入参)。
+//
+// 入参:id 数值主键(<=0 → 空串,调用方按"无单号"处理)。
+// 出参:十进制字符串(id<=0 → "")。
+func orderNoOf(id int64) string {
+	if id <= 0 {
+		return ""
+	}
+	return strconv.FormatInt(id, 10)
+}

+ 433 - 0
internal/academic/service/activity_service_test.go

@@ -0,0 +1,433 @@
+// ActivityService 单测(AC-07):报名 / 取消报名。
+//
+// 断言纪律(本文件的核心价值,对照 §5.1 五条纪律):
+//  1. **3105 与 3106 不得合并**(纪律 4「横切优先」):
+//     `TestSignCancelResignMatrix` 一条用例同时证明两件事——
+//     取消报名后**可以重报同一活动**(3105 判**报名行** status)、
+//     但**不能再参加另一个老带新活动**(3106 判**活动** status、不看报名行 status)。
+//     两个分支里任意一个被"按直觉"改成另一个,这条用例必红。
+//  2. **守卫命中零副作用**:3107/3108 被拒后 `number_sign` 与三列金额**一字不动**。
+//  3. **数值边界**:`number_sign` 不得减成负数(下限 0);`activity_money = max(money,0)`。
+//  4. **人数上限 3141 是 Go 增强**:用例名与注释显式标注"dump 无此校验"。
+package service
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+	"testing"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试基础设施
+
+// newActivityDomainDB 活动域真实库(活动/报名/赠送池/两条流水 + 资金账户 + 报课/排班 + t_campus)。
+//
+// 复用 `migrateFundTables`(与 fund_service_test.go 同一 package):逐表 AutoMigrate 后删除
+// 该表索引——SQLite 的索引名是库级唯一,academic model 多表共用 `idx_inst_campus` 等名字,
+// 一次性迁移会报 "index already exists"。
+func newActivityDomainDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_activity_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(活动域文件库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	// 单连接:让"并发"用例在 sqlite 上退化为串行(生产 MySQL 用行锁串行,语义等价)
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+
+	migrateFundTables(t, db,
+		&model.CoursesActivity{}, &model.CoursesActivitySigned{},
+		&model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
+		&model.CoursesActivityUsedLog{},
+		&model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
+		&model.CoursesStudent{}, &model.CoursesClass{},
+	)
+	// 跨域只读表(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
+}
+
+// activityScopeFor 指定机构/校区的作用域(普通角色,非超管)。
+func activityScopeFor(institutionID, campusID int64) *appscope.Scope {
+	return &appscope.Scope{
+		InstitutionID: institutionID, CampusID: campusID,
+		UserID: "01HOPERATOR0000000000001", Role: "academic",
+	}
+}
+
+// seedActivityFull 落一行活动(可指定类型/金额/人数上限/状态)。
+func seedActivityFull(t *testing.T, db *gorm.DB, id int64, typ int8, money string,
+	institutionID, campusID int64, numberMax int32, status int8) {
+	t.Helper()
+	row := &model.CoursesActivity{
+		ID: id, Type: typ, InstitutionID: institutionID, CampusID: campusID,
+		Money: money, NumberMax: numberMax, Status: status,
+		NumberSign: 0,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 活动(%d) 失败: %v", id, err)
+	}
+}
+
+// readActivityRow 读活动行(断言 number_sign)。
+func readActivityRow(t *testing.T, db *gorm.DB, id int64) model.CoursesActivity {
+	t.Helper()
+	var row model.CoursesActivity
+	if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+		t.Fatalf("读活动(%d) 失败: %v", id, err)
+	}
+	return row
+}
+
+// readSignedRow 读报名行(不存在 → 返回 nil)。
+func readSignedRow(t *testing.T, db *gorm.DB, userID string, activityID int64) *model.CoursesActivitySigned {
+	t.Helper()
+	var rows []model.CoursesActivitySigned
+	if err := db.Where("user_id = ? AND activity_id = ?", userID, activityID).Find(&rows).Error; err != nil {
+		t.Fatalf("读报名行(user=%s,activity=%d) 失败: %v", userID, activityID, err)
+	}
+	if len(rows) == 0 {
+		return nil
+	}
+	if len(rows) > 1 {
+		t.Fatalf("报名行不唯一(%d 行)——UK(user_id,activity_id) 失效", len(rows))
+	}
+	return &rows[0]
+}
+
+// countSignedRows 统计报名行条数(幂等断言:重复报名不得产生第二行)。
+func countSignedRows(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 {
+	t.Helper()
+	var n int64
+	if err := db.Model(&model.CoursesActivitySigned{}).
+		Where("user_id = ? AND activity_id = ?", userID, activityID).Count(&n).Error; err != nil {
+		t.Fatalf("统计报名行失败: %v", err)
+	}
+	return n
+}
+
+// seedUsedLogRow 落一行 used_log(`activity_money_used` 按方向传:使用/结算为负、退回为正)。
+//
+// orderNo 是 dump 的 `orderid`(报课单号),退回游标(SP13)按它定位;不需要单号时传 ""。
+func seedUsedLogRow(t *testing.T, db *gorm.DB, activityID int64, userID, orderNo, amount string) {
+	t.Helper()
+	row := &model.CoursesActivityUsedLog{
+		ActivityID: activityID, UserID: userID, ActivityMoneyUsed: amount,
+	}
+	if orderNo != "" {
+		no := orderNo
+		row.OrderNo = &no
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed used_log 失败: %v", err)
+	}
+}
+
+// wantAppErr 断言错误码(不匹配 → 直接失败并打印实际值)。
+func wantAppErr(t *testing.T, err error, code int, what string) {
+	t.Helper()
+	if err == nil {
+		t.Fatalf("%s:期望错误码 %d,实得 nil(本应被拒)", what, code)
+	}
+	var appErr *infraerrors.AppError
+	if !errors.As(err, &appErr) {
+		t.Fatalf("%s:期望 *AppError(%d),实得 %T(%v)", what, code, err, err)
+	}
+	if appErr.Code != code {
+		t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg)
+	}
+}
+
+// ---------------------------------------------------------------- 报名
+
+// TestSignCreatesSignedAndIncrementsNumber 正常报名:写报名行 + 人数 +1 + 金额快照。
+func TestSignCreatesSignedAndIncrementsNumber(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(1)
+
+	seedActivityFull(t, db, 100, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+
+	res, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 100, Status: 0})
+	if err != nil {
+		t.Fatalf("Sign 失败: %v", err)
+	}
+	if res.Status != model.ActivitySignedSigned || res.SignedID <= 0 {
+		t.Fatalf("报名状态应归一为 1 且回填 signedId,实得 status=%d signedId=%d", res.Status, res.SignedID)
+	}
+	if res.ActivityMoney.Cents() != 10000 {
+		t.Fatalf("报名行金额应 10000 分,实得 %d", res.ActivityMoney.Cents())
+	}
+
+	row := readSignedRow(t, db, user, 100)
+	if row == nil {
+		t.Fatalf("报名行未落库")
+	}
+	// 金额列在 sqlite 上按 NUMERIC 亲和回读("100" 而非 "100.00")→ 一律用 cents() 按分值比较
+	if row.Status != model.ActivitySignedSigned || cents(t, row.ActivityMoney) != 10000 || cents(t, row.ActivityMoneyUsed) != 0 {
+		t.Fatalf("报名行字段不符:status=%d money=%s used=%s", row.Status, row.ActivityMoney, row.ActivityMoneyUsed)
+	}
+	// 冗余作用域列必须是**活动上的**快照(campus_id=0 有"全机构自营校区"语义)
+	if row.InstitutionID != 1 || row.CampusID != 10 {
+		t.Fatalf("报名行作用域列应取活动值(1/10),实得 %d/%d", row.InstitutionID, row.CampusID)
+	}
+	if act := readActivityRow(t, db, 100); act.NumberSign != 1 {
+		t.Fatalf("报名人数应 +1 到 1,实得 %d", act.NumberSign)
+	}
+}
+
+// TestSignClampsNegativeActivityMoney 活动金额为负 → 报名行金额归 0(dump 762955)。
+func TestSignClampsNegativeActivityMoney(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	user := fundUserID(2)
+
+	seedActivityFull(t, db, 101, model.ActivityTypeRecharge, "-5.00", 1, 10, 0, model.ActivityStatusEnabled)
+	res, err := svc.Sign(context.Background(), SignInput{UserID: user, ActivityID: 101})
+	if err != nil {
+		t.Fatalf("Sign 失败: %v", err)
+	}
+	if res.ActivityMoney.Cents() != 0 {
+		t.Fatalf("负金额应归 0,实得 %d 分", res.ActivityMoney.Cents())
+	}
+	if row := readSignedRow(t, db, user, 101); row == nil || cents(t, row.ActivityMoney) != 0 {
+		t.Fatalf("报名行金额应为 0,实得 %+v", row)
+	}
+}
+
+// TestSignRejectsDuplicateWith3105 重复报名 → 3105,且**不产生第二行、人数不再 +1**。
+func TestSignRejectsDuplicateWith3105(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(3)
+
+	seedActivityFull(t, db, 102, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 102}); err != nil {
+		t.Fatalf("首次报名失败: %v", err)
+	}
+	_, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 102})
+	wantAppErr(t, err, apperr.CodeActivityAlreadySigned, "重复报名")
+
+	if n := countSignedRows(t, db, user, 102); n != 1 {
+		t.Fatalf("重复报名不得产生第二行,实得 %d 行", n)
+	}
+	if act := readActivityRow(t, db, 102); act.NumberSign != 1 {
+		t.Fatalf("被 3105 拒绝后人数不得再 +1,实得 %d", act.NumberSign)
+	}
+}
+
+// TestSignRejectsMissingActivityWith3001 活动不存在 → 3001(dump 此处静默 success=0,Go 显式化)。
+func TestSignRejectsMissingActivityWith3001(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	_, err := svc.Sign(context.Background(), SignInput{UserID: fundUserID(4), ActivityID: 999})
+	wantAppErr(t, err, 3001, "报名不存在活动")
+	if n := countSignedRows(t, db, fundUserID(4), 999); n != 0 {
+		t.Fatalf("活动不存在时不得写报名行,实得 %d 行", n)
+	}
+}
+
+// TestSignRespectsNumberMax 人数上限 → 3141(**Go 增强:dump 无此校验**,见方法注释)。
+func TestSignRespectsNumberMax(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+
+	seedActivityFull(t, db, 103, model.ActivityTypeRecharge, "100.00", 1, 10, 1, model.ActivityStatusEnabled)
+	if _, err := svc.Sign(ctx, SignInput{UserID: fundUserID(5), ActivityID: 103}); err != nil {
+		t.Fatalf("第 1 个报名应成功: %v", err)
+	}
+	_, err := svc.Sign(ctx, SignInput{UserID: fundUserID(6), ActivityID: 103})
+	wantAppErr(t, err, apperr.CodeActivityFull, "超人数上限报名")
+	if n := countSignedRows(t, db, fundUserID(6), 103); n != 0 {
+		t.Fatalf("超上限被拒后不得写报名行,实得 %d 行", n)
+	}
+	if act := readActivityRow(t, db, 103); act.NumberSign != 1 {
+		t.Fatalf("被 3141 拒绝后人数应保持 1,实得 %d", act.NumberSign)
+	}
+}
+
+// TestSignCancelResignMatrix **横切断言(纪律 4)**:3105 与 3106 是两个不能合并的分支。
+//
+// 步骤与判据:
+//
+//	a) 报名**充值赠送** A(type=2)→ 成功;取消 A → 三列归零、人数回到 0
+//	b) **重报 A → 必须成功**(3105 判**报名行** status,status=0 不算已报名)
+//	c) 用户已有一条**已取消**的老带新报名(type=4,status=0)→ 报名**另一个**老带新 B
+//	   → **必须被 3106 拒**(3106 判**活动** status、不看报名行 status ⇒ 取消也永久消耗次数)
+//
+// 若把两条判据互换口径:
+//   - 3105 改成"判活动" → b) 会被误拒(A 的活动 status != 0);
+//   - 3106 改成"判报名行 status" → c) 会被误放行(那条老带新报名 status=0)⇒ 可反复领奖励。
+func TestSignCancelResignMatrix(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(7)
+
+	seedActivityFull(t, db, 200, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedActivityFull(t, db, 201, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	seedActivityFull(t, db, 202, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+
+	// a) 报名 + 取消(type=2,未审核 → 可取消)
+	if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 200}); err != nil {
+		t.Fatalf("报名 A 失败: %v", err)
+	}
+	if _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 200}); err != nil {
+		t.Fatalf("取消报名失败: %v", err)
+	}
+	row := readSignedRow(t, db, user, 200)
+	if row == nil || row.Status != model.ActivitySignedCancelled {
+		t.Fatalf("取消后报名行应保留且 status=0,实得 %+v", row)
+	}
+	if cents(t, row.ActivityMoney) != 0 || cents(t, row.ActivityMoneyUsed) != 0 {
+		t.Fatalf("取消后两列金额应同置 0,实得 money=%s used=%s", row.ActivityMoney, row.ActivityMoneyUsed)
+	}
+	if act := readActivityRow(t, db, 200); act.NumberSign != 0 {
+		t.Fatalf("取消后人数应回到 0(下限保护),实得 %d", act.NumberSign)
+	}
+
+	// b) 重报 A —— 3105 放行(判报名行 status)
+	if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 200}); err != nil {
+		t.Fatalf("取消后可重报同一活动(3105 判报名行 status),实得错误: %v", err)
+	}
+	if n := countSignedRows(t, db, user, 200); n != 1 {
+		t.Fatalf("重报后仍应只有 1 行(DELETE+INSERT 幂等),实得 %d", n)
+	}
+
+	// c) 已取消的老带新报名(status=0)→ 报名另一个老带新 B 必须被 3106 拒
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 201, UserID: user, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedCancelled, ActivityMoney: "0", ActivityMoneyUsed: "0",
+	}).Error; err != nil {
+		t.Fatalf("seed 已取消的老带新报名失败: %v", err)
+	}
+	_, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 202})
+	wantAppErr(t, err, apperr.CodeRecommendLimited, "老带新限一次(已取消也算参加过)")
+	if n := countSignedRows(t, db, user, 202); n != 0 {
+		t.Fatalf("被 3106 拒绝后不得写 B 的报名行,实得 %d 行", n)
+	}
+}
+
+// ---------------------------------------------------------------- 取消报名
+
+// TestCancelSignRejectsAuditedWith3107 type∈{2,3} 且已审核 → 3107,且**零副作用**。
+func TestCancelSignRejectsAuditedWith3107(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(8)
+
+	seedActivityFull(t, db, 300, model.ActivityTypeVoucher, "50.00", 1, 10, 0, model.ActivityStatusEnabled)
+	auditor := "01HAUDITOR000000000000001"
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 300, UserID: user, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "50.00", ResponseUserID: &auditor,
+	}).Error; err != nil {
+		t.Fatalf("seed 已审核报名行失败: %v", err)
+	}
+	if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 300).
+		UpdateColumn("number_sign", 1).Error; err != nil {
+		t.Fatalf("seed 人数失败: %v", err)
+	}
+
+	_, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 300})
+	wantAppErr(t, err, apperr.CodeActivitySignedAudited, "取消已审核报名")
+
+	row := readSignedRow(t, db, user, 300)
+	if row.Status != model.ActivitySignedSigned || cents(t, row.ActivityMoney) != 5000 {
+		t.Fatalf("被 3107 拒绝后报名行必须一字不动,实得 status=%d money=%s", row.Status, row.ActivityMoney)
+	}
+	if act := readActivityRow(t, db, 300); act.NumberSign != 1 {
+		t.Fatalf("被 3107 拒绝后人数不得减少,实得 %d", act.NumberSign)
+	}
+}
+
+// TestCancelSignRejectsRecommendUsedWith3108 type=4 且 used_log 存在 → 3108,且**零副作用**。
+func TestCancelSignRejectsRecommendUsedWith3108(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(9)
+
+	seedActivityFull(t, db, 301, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
+	if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 301}); err != nil {
+		t.Fatalf("报名失败: %v", err)
+	}
+	seedUsedLogRow(t, db, 301, user, "", "-100.00") // 已有结算记录(奖励已发出)
+
+	_, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 301})
+	wantAppErr(t, err, apperr.CodeRecommendUsed, "取消已消费的老带新报名")
+
+	row := readSignedRow(t, db, user, 301)
+	if row.Status != model.ActivitySignedSigned {
+		t.Fatalf("被 3108 拒绝后报名状态不得变,实得 %d", row.Status)
+	}
+	if act := readActivityRow(t, db, 301); act.NumberSign != 1 {
+		t.Fatalf("被 3108 拒绝后人数不得减少,实得 %d", act.NumberSign)
+	}
+}
+
+// TestCancelSignNumberSignFloorAtZero 人数已是 0 时取消 → 保持 0(不下穿)。
+func TestCancelSignNumberSignFloorAtZero(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	ctx := context.Background()
+	user := fundUserID(10)
+
+	// type=1(试听):既不走 3107 也不走 3108
+	seedActivityFull(t, db, 302, model.ActivityTypeTrial, "0.00", 1, 10, 0, model.ActivityStatusEnabled)
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 302, UserID: user, InstitutionID: 1, CampusID: 10,
+		Status: model.ActivitySignedSigned, ActivityMoney: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 报名行失败: %v", err)
+	}
+	// number_sign 故意留 0(脏数据/并发取消后的状态)
+
+	res, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 302})
+	if err != nil {
+		t.Fatalf("取消报名失败: %v", err)
+	}
+	if res.NumberSign != 0 {
+		t.Fatalf("人数下限应为 0,实得 %d", res.NumberSign)
+	}
+	if act := readActivityRow(t, db, 302); act.NumberSign != 0 {
+		t.Fatalf("库中人数不得为负,实得 %d", act.NumberSign)
+	}
+}
+
+// TestCancelSignMissingSignedWith3001 无有效报名行 → 3001(dump:游标空 → success=0)。
+func TestCancelSignMissingSignedWith3001(t *testing.T) {
+	db := newActivityDomainDB(t)
+	svc := NewActivityService(db, fundSuperScope())
+	seedActivityFull(t, db, 303, model.ActivityTypeRecharge, "10.00", 1, 10, 0, model.ActivityStatusEnabled)
+
+	_, err := svc.CancelSign(context.Background(), CancelSignInput{UserID: fundUserID(11), ActivityID: 303})
+	wantAppErr(t, err, 3001, "取消不存在的报名")
+	if act := readActivityRow(t, db, 303); act.NumberSign != 0 {
+		t.Fatalf("无报名行时人数不得变化,实得 %d", act.NumberSign)
+	}
+}