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