// 活动赠送金额服务(充值赠送使用/退回 + 代金券使用/退回 + 可用额汇总)——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 }