activity_discount_service.go 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. // 活动赠送金额服务(充值赠送使用/退回 + 代金券使用/退回 + 可用额汇总)——AC-07 活动服务 A。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §3.2 #12 `sp_courses_activity_use_activity_recharge`(dump **763256**)→ `UseRechargeGift`
  5. // - §3.2 #13 `sp_courses_activity_refund_activity_recharge`(dump **762841**)→ `RefundRechargeGift`
  6. // - §3.2 #14 `sp_courses_activity_user_discount_usable`(dump **763176**)→ `UsableAmount`
  7. // - §3.2 #15 `sp_courses_activity_user_discount_use`(dump **763204**)→ `UseVoucher`
  8. // - §3.2 #16 `sp_courses_activity_user_discount_refund`(dump **763046**)→ `RefundByStuCourse`
  9. // - §3.2 #17 `sp_courses_activity_user_discount_refund_user_activity`(dump **763106**)→ `RefundByActivity`
  10. // - §3.8-a **A9**(`use_activity_recharge` 不递减 `inputAmount` → 重复占用;Go 修正为逐行递减)
  11. // - §3.8-a **A13**(`refund_user_activity` 的 `SELECT x = y` 是布尔比较,截断从未生效 → 可超退;Go 显式盖帽)
  12. // - §5.1 纪律 4「横切优先」:每条路径都有"池余额 == 流水累计"的跨表恒等式断言(见单测)
  13. //
  14. // 三条**顺序性**红线(写错不会报错、只会让金额分配的来源不同,双跑/对账才抓得住):
  15. //
  16. // 退回方向 = `id DESC`(后入先退):`discount_log` 与 `used_log` 两条退回路径**都必须倒序**。
  17. // 两次用券 100/50、退 120 ⇒ **先退 50 再退 70**(若误写 ASC 会变成先 100 再 20,
  18. // 总额相同、但"哪张券被冲回"全错 ⇒ 单券核销记录与账单永远对不上)。
  19. // 退回金额必须**逐行递减剩余额**(A9 修正):否则同一笔退款会按全额冲回多行 ⇒ 超退。
  20. // 每一行冲回额必须 **min(行绝对值, 剩余应退)**(A13 修正):否则单行可被超额冲回。
  21. package service
  22. import (
  23. "context"
  24. "errors"
  25. "strings"
  26. "time"
  27. "gorm.io/gorm"
  28. "yuansiwei/unified-backend/internal/academic/apperr"
  29. "yuansiwei/unified-backend/internal/academic/model"
  30. "yuansiwei/unified-backend/internal/academic/money"
  31. "yuansiwei/unified-backend/internal/academic/repository"
  32. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  33. appscope "yuansiwei/unified-backend/internal/infra/scope"
  34. )
  35. // 活动赠送相关默认备注(逐字对齐 dump 的 `IF inputMemo=''`)。
  36. const (
  37. // defaultUseRechargeMemo SP12 默认备注(dump 763259 起:`'使用充值赠送金额'`)。
  38. defaultUseRechargeMemo = "使用充值赠送金额"
  39. // defaultRefundRechargeMemo SP13 默认备注(dump:`'退回活动金额'`)。
  40. defaultRefundRechargeMemo = "退回活动金额"
  41. // defaultRefundDiscountMemo SP16 默认备注(dump:`'退回赠送金额'`)。
  42. defaultRefundDiscountMemo = "退回赠送金额"
  43. // defaultRefundUserActivityMemo SP17 默认备注(dump:`'退回活动金额'`)。
  44. defaultRefundUserActivityMemo = "退回活动金额"
  45. )
  46. // ActivityDiscountService 活动赠送金额服务。
  47. //
  48. // 字段:
  49. // - disc 赠送池/流水仓储(池行、used_log、discount_log);
  50. // - act 活动/报名仓储(活动类型判定、报名行已用额);
  51. // - runner 事务运行器(每个 SP 一个事务;失败整体回滚)。
  52. type ActivityDiscountService struct {
  53. disc *repository.ActivityDiscountRepo
  54. act *repository.ActivityRepo
  55. runner *repository.TxRunner
  56. now func() time.Time
  57. }
  58. // NewActivityDiscountService 构造活动赠送金额服务。
  59. //
  60. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
  61. // 出参:*ActivityDiscountService。
  62. func NewActivityDiscountService(db *gorm.DB, sc *appscope.Scope) *ActivityDiscountService {
  63. return &ActivityDiscountService{
  64. disc: repository.NewActivityDiscountRepo(db, sc),
  65. act: repository.NewActivityRepo(db, sc),
  66. runner: repository.NewTxRunner(db),
  67. now: time.Now,
  68. }
  69. }
  70. // ---------------------------------------------------------------- 入参 / 响应 DTO
  71. // UsableAmountInput 可用赠送金额汇总入参(dump `usable` **3 参**:userid + schoolid + campusid)。
  72. type UsableAmountInput struct {
  73. // UserID 学生(必填)。
  74. UserID string
  75. // InstitutionID 机构 ID(原 schoolid;**0 = 平台视角,不加任何作用域条件**)。
  76. InstitutionID int64
  77. // CampusID 校区 ID(原 campusid;仅在 InstitutionID>0 时生效,且为**等值**)。
  78. CampusID int64
  79. }
  80. // UsableAmountResult 可用赠送金额汇总响应(端点 26 的 `{usable}`)。
  81. type UsableAmountResult struct {
  82. // Usable 可用赠送金额合计(分;无可用行 → 0)。
  83. Usable money.Amount `json:"usable"`
  84. }
  85. // UseRechargeGiftInput 使用充值赠送金额入参(dump SP12 **5 参**)。
  86. type UseRechargeGiftInput struct {
  87. // UserID 学生(必填)。
  88. UserID string
  89. // ActivityID 活动 ID(必填 >0;**单活动**,不得跨活动扣减)。
  90. ActivityID int64
  91. // StudentCourseID 报课记录 ID(→ used_log.order_no;0 → 不写单号)。
  92. StudentCourseID int64
  93. // Amount 本次使用金额(分;<=0 → no-op,dump 口径)。
  94. Amount money.Amount
  95. // Memo 备注(空 → "使用充值赠送金额")。
  96. Memo string
  97. }
  98. // UseRechargeGiftResult 使用充值赠送金额响应。
  99. type UseRechargeGiftResult struct {
  100. // SignedID 报名行 ID。
  101. SignedID int64 `json:"signedId"`
  102. // ActivityID 活动 ID。
  103. ActivityID int64 `json:"activityId"`
  104. // UsedAmount 本次实际使用额(= min(活动剩余额, 请求额),分)。
  105. UsedAmount money.Amount `json:"usedAmount"`
  106. // ActivityMoneyUsedAfter 使用后报名行已用额(分)。
  107. ActivityMoneyUsedAfter money.Amount `json:"activityMoneyUsedAfter"`
  108. // Remaining 活动金额剩余可用额(分;= 活动金额 − 已用额,下限 0)。
  109. Remaining money.Amount `json:"remaining"`
  110. }
  111. // RechargeGiftRefundItem 充值赠送退回的单条明细(哪笔活动被冲回多少)。
  112. type RechargeGiftRefundItem struct {
  113. // ActivityID 活动 ID。
  114. ActivityID int64 `json:"activityId"`
  115. // UserID 学生。
  116. UserID string `json:"userId"`
  117. // Amount 本次冲回金额(分,正数)。
  118. Amount money.Amount `json:"amount"`
  119. }
  120. // RefundRechargeGiftInput 退回充值赠送金额入参(dump SP13 **3 参**)。
  121. type RefundRechargeGiftInput struct {
  122. // StudentCourseID 报课记录 ID(游标:`used_log.order_no = ?`)。
  123. StudentCourseID int64
  124. // Amount 本次应退金额(分;<=0 → no-op)。
  125. Amount money.Amount
  126. // Memo 备注(空 → "退回活动金额")。
  127. Memo string
  128. }
  129. // RefundRechargeGiftResult 退回充值赠送金额响应。
  130. type RefundRechargeGiftResult struct {
  131. // Requested 请求退回额(分,回显)。
  132. Requested money.Amount `json:"requested"`
  133. // RefundedTotal 实际冲回合计(分;≤ Requested)。
  134. RefundedTotal money.Amount `json:"refundedTotal"`
  135. // Items 逐活动明细(按 `id DESC` 顺序)。
  136. Items []RechargeGiftRefundItem `json:"items"`
  137. }
  138. // UseVoucherInput 使用代金券/赠送金额入参(dump SP15 **5 参**)。
  139. type UseVoucherInput struct {
  140. // UserID 学生(必填)。
  141. UserID string
  142. // ActivityID 活动 ID(必填 >0;**单活动**取池)。
  143. ActivityID int64
  144. // StudentCourseID 报课记录 ID(→ discount_log.courses_student_id)。
  145. StudentCourseID int64
  146. // Amount 本次使用金额(分;<=0 → no-op)。
  147. Amount money.Amount
  148. // Memo 备注(dump 该 SP **无默认备注**,空 → NULL)。
  149. Memo string
  150. }
  151. // UseVoucherResult 使用代金券/赠送金额响应。
  152. type UseVoucherResult struct {
  153. // DiscountID 被扣减的优惠账户 ID。
  154. DiscountID int64 `json:"discountId"`
  155. // ActivityID 活动 ID。
  156. ActivityID int64 `json:"activityId"`
  157. // Applied 本次实际占用额(分;type=3 时 = 池面额,见方法注释)。
  158. Applied money.Amount `json:"applied"`
  159. // DiscountAmountUsedAfter 扣减后池已用额(分)。
  160. DiscountAmountUsedAfter money.Amount `json:"discountAmountUsedAfter"`
  161. // ActivityType 活动类型(1/2/3/4)。
  162. ActivityType int8 `json:"activityType"`
  163. }
  164. // VoucherRefundItem 赠送金额退回的单条明细。
  165. type VoucherRefundItem struct {
  166. // DiscountID 优惠账户 ID。
  167. DiscountID int64 `json:"discountId"`
  168. // Amount 冲回金额(分,正数)。
  169. Amount money.Amount `json:"amount"`
  170. }
  171. // RefundByStuCourseInput 按报课记录退回赠送金额入参(dump SP16 **3 参**)。
  172. type RefundByStuCourseInput struct {
  173. // StudentCourseID 报课记录 ID(游标:`discount_log.courses_student_id = ?`)。
  174. StudentCourseID int64
  175. // Amount 本次应退金额(分;<=0 → no-op)。
  176. Amount money.Amount
  177. // Memo 备注(空 → "退回赠送金额")。
  178. Memo string
  179. }
  180. // RefundByStuCourseResult 按报课记录退回赠送金额响应。
  181. type RefundByStuCourseResult struct {
  182. // Requested 请求退回额(分)。
  183. Requested money.Amount `json:"requested"`
  184. // RefundedTotal 实际冲回合计(分)。
  185. RefundedTotal money.Amount `json:"refundedTotal"`
  186. // Items 逐池明细(按 `discount_log.id DESC` 顺序)。
  187. Items []VoucherRefundItem `json:"items"`
  188. }
  189. // RefundByActivityInput 按活动退回赠送金额入参(dump SP17 **4 参**)。
  190. type RefundByActivityInput struct {
  191. // UserID 学生(必填)。
  192. UserID string
  193. // ActivityID 活动 ID(必填 >0)。
  194. ActivityID int64
  195. // Amount 本次应退金额(分;代金券活动会被置为池面额)。
  196. Amount money.Amount
  197. // Memo 备注(空 → "退回活动金额")。
  198. Memo string
  199. }
  200. // RefundByActivityResult 按活动退回赠送金额响应。
  201. type RefundByActivityResult struct {
  202. // DiscountID 优惠账户 ID。
  203. DiscountID int64 `json:"discountId"`
  204. // ActivityID 活动 ID。
  205. ActivityID int64 `json:"activityId"`
  206. // Refunded 本次冲回额(分;已按 A13 盖帽 = min(应退, 最近一条消费额))。
  207. Refunded money.Amount `json:"refunded"`
  208. // DiscountAmountUsedAfter 冲回后池已用额(分)。
  209. DiscountAmountUsedAfter money.Amount `json:"discountAmountUsedAfter"`
  210. }
  211. // ---------------------------------------------------------------- 可用额汇总(SP14)
  212. // UsableAmount 汇总用户可用赠送金额(SP14,dump 763176)。
  213. //
  214. // dump 语义:`SUM(discount_amount - discount_amount_used)`,条件 `discount_amount > discount_amount_used`;
  215. // 动态 SQL 拼 `schoolid/campusid` 两个**等值**条件(`schoolid<=0` 时一个都不加)。
  216. //
  217. // 入参:ctx / in。出参:*UsableAmountResult;错误码 2001/2002/5002。
  218. func (s *ActivityDiscountService) UsableAmount(
  219. ctx context.Context, in UsableAmountInput,
  220. ) (*UsableAmountResult, error) {
  221. if strings.TrimSpace(in.UserID) == "" {
  222. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UsableAmount 需要 user_id"))
  223. }
  224. if in.InstitutionID < 0 || in.CampusID < 0 {
  225. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  226. errString("service: UsableAmount 机构/校区参数不得为负(0 的语义见 repository.SumUsable)"))
  227. }
  228. if s == nil || s.disc == nil {
  229. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
  230. }
  231. usable, err := s.disc.SumUsable(ctx, in.UserID, in.InstitutionID, in.CampusID)
  232. if err != nil {
  233. return nil, err
  234. }
  235. return &UsableAmountResult{Usable: usable}, nil
  236. }
  237. // ---------------------------------------------------------------- 充值赠送:使用(SP12)
  238. // UseRechargeGift 使用充值赠送活动的金额(SP12,dump 763256)。
  239. //
  240. // dump 语义:
  241. //
  242. // 763262 游标:signed JOIN activity ON type=2 WHERE user/activity AND **signed.status=2**
  243. // 763268 IF inputAmount<=0 → LEAVE(no-op)
  244. // 763278 IF activity_money_used >= activity_money → LEAVE(额度用完)
  245. // 763284 UPDATE signed SET activity_money_used += inputAmount
  246. // 763287 INSERT used_log(activityid, userid, orderid=stuCourseId, activity_money_used=**-inputAmount**)
  247. //
  248. // **A9 修正(登记 §3.8-a,`DUMP-BUGFIX`)**:dump 在循环里从不递减 `inputAmount`,
  249. // 有多行 signed 时每行都按全额扣减(重复占用)。Go 改为
  250. // `temp = min(活动剩余额, 请求额)`、扣完即止 —— 单行 signed(UK 保证)下结果与 dump 一致,
  251. // 多行/脏数据下不会超额占用。
  252. //
  253. // 前置:报名行必须 `status=2`(已参加)——这是 dump 游标条件,**不是**可选的宽松化。
  254. //
  255. // 入参:ctx / in。出参:*UseRechargeGiftResult(no-op 时 UsedAmount=0);错误码 2001/2002/5002。
  256. func (s *ActivityDiscountService) UseRechargeGift(
  257. ctx context.Context, in UseRechargeGiftInput,
  258. ) (*UseRechargeGiftResult, error) {
  259. if strings.TrimSpace(in.UserID) == "" {
  260. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UseRechargeGift 需要 user_id"))
  261. }
  262. if in.ActivityID <= 0 {
  263. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  264. errString("service: UseRechargeGift 需要 activity_id>0"))
  265. }
  266. if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
  267. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
  268. }
  269. memo := strings.TrimSpace(in.Memo)
  270. if memo == "" {
  271. memo = defaultUseRechargeMemo
  272. }
  273. userID := strings.TrimSpace(in.UserID)
  274. out := &UseRechargeGiftResult{ActivityID: in.ActivityID}
  275. if !in.Amount.GreaterThan(0) {
  276. // dump 763268:`IF inputAmount<=0 THEN LEAVE`(no-op,不写流水也不报错)
  277. return out, nil
  278. }
  279. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  280. txCtx := repository.WithTxContext(ctx, tx)
  281. signed, e := s.act.GetSigned(txCtx, userID, in.ActivityID)
  282. if e != nil {
  283. if errors.Is(e, infraerrors.ErrNotFound) {
  284. return nil // dump:游标无行 → no-op
  285. }
  286. return e
  287. }
  288. // 游标条件:活动 type=2 且报名行 status=2(已参加活动)
  289. if signed.ActivityType != model.ActivityTypeRecharge || signed.Status != model.ActivitySignedAttended {
  290. return nil
  291. }
  292. total, e := parseAccountAmount(signed.ActivityMoney, "t_courses_activity_signed.activity_money")
  293. if e != nil {
  294. return e
  295. }
  296. used, e := parseAccountAmount(signed.ActivityMoneyUsed, "t_courses_activity_signed.activity_money_used")
  297. if e != nil {
  298. return e
  299. }
  300. remaining := total.Sub(used)
  301. if remaining <= 0 {
  302. return nil // dump 763278:额度已用完 → no-op
  303. }
  304. temp := in.Amount
  305. if remaining.LessThan(temp) {
  306. temp = remaining // A9 修正:扣到剩余额为止
  307. }
  308. if !temp.GreaterThan(0) {
  309. return nil
  310. }
  311. if _, ue := s.act.IncSignedActivityMoneyUsed(txCtx, tx, userID, in.ActivityID, temp); ue != nil {
  312. return ue
  313. }
  314. log := &model.CoursesActivityUsedLog{
  315. ActivityID: in.ActivityID,
  316. UserID: userID,
  317. OrderNo: optString(orderNoOf(in.StudentCourseID)),
  318. ActivityMoneyUsed: temp.Neg().String(), // 负值 = 使用(符号方向固定)
  319. Memo: optString(memo),
  320. }
  321. if ie := s.disc.InsertUsedLog(txCtx, tx, log); ie != nil {
  322. return ie
  323. }
  324. out.SignedID = signed.SignedID
  325. out.UsedAmount = temp
  326. out.ActivityMoneyUsedAfter = used.Add(temp)
  327. out.Remaining = remaining.Sub(temp)
  328. return nil
  329. })
  330. if err != nil {
  331. return nil, err
  332. }
  333. return out, nil
  334. }
  335. // ---------------------------------------------------------------- 充值赠送:退回(SP13)
  336. // RefundRechargeGift 退回充值赠送活动的金额(SP13,dump 762841)。
  337. //
  338. // dump 语义:
  339. //
  340. // 762853 IF inputAmount<0 → LEAVE(注意是 `<0`,0 会在循环首轮 `inputAmount<=0` 处退出)
  341. // 762854 游标:`used_log WHERE orderid=? AND activity_money_used<0 ORDER BY id DESC`(**降序**)
  342. // 762869 逐行取活动 type;**仅 type=2** 参与冲回(代金券/老带新流水在此路径不动)
  343. // 762874 temp = min(|行金额|, 剩余应退)
  344. // 762880 UPDATE signed SET activity_money_used -= temp(按 activityid+userid)
  345. // 762882 INSERT used_log(..., activity_money_used=**+temp**)
  346. // 762885 剩余应退 -= temp
  347. //
  348. // 顺序红线:**必须 `id DESC`**(后入先退)。若改成 ASC,冲回的"哪一笔"完全不同(金额总数可能相同),
  349. // 单笔 used_log 的核销链立刻错位——这类错误在总额断言下自证通过,只有逐行明细断言才抓得住。
  350. //
  351. // 入参:ctx / in。出参:*RefundRechargeGiftResult;错误码 2001/2002/5002。
  352. func (s *ActivityDiscountService) RefundRechargeGift(
  353. ctx context.Context, in RefundRechargeGiftInput,
  354. ) (*RefundRechargeGiftResult, error) {
  355. if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
  356. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
  357. }
  358. memo := strings.TrimSpace(in.Memo)
  359. if memo == "" {
  360. memo = defaultRefundRechargeMemo
  361. }
  362. out := &RefundRechargeGiftResult{
  363. Requested: in.Amount,
  364. Items: make([]RechargeGiftRefundItem, 0, 4),
  365. }
  366. if !in.Amount.GreaterThan(0) {
  367. return out, nil // dump 762853 / 762869:无正数应退额 → no-op
  368. }
  369. orderNo := orderNoOf(in.StudentCourseID)
  370. if orderNo == "" {
  371. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  372. errString("service: RefundRechargeGift 需要 student_course_id>0"))
  373. }
  374. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  375. txCtx := repository.WithTxContext(ctx, tx)
  376. rows, e := s.disc.ListUsedLogsByOrder(txCtx, orderNo, repository.OrderDesc)
  377. if e != nil {
  378. return e
  379. }
  380. remaining := in.Amount
  381. refunded := money.Amount(0)
  382. for i := range rows {
  383. if !remaining.GreaterThan(0) {
  384. break // dump:金额用完即停
  385. }
  386. row := &rows[i]
  387. actType, te := s.activityType(txCtx, row.ActivityID)
  388. if te != nil {
  389. return te
  390. }
  391. if actType != model.ActivityTypeRecharge {
  392. continue // 只处理充值赠送活动(dump 762869 的 IF type=2)
  393. }
  394. rowAmount, pe := parseAccountAmount(row.ActivityMoneyUsed, "t_courses_activity_used_log.activity_money_used")
  395. if pe != nil {
  396. return pe
  397. }
  398. absUsed := absAmount(rowAmount)
  399. temp := absUsed
  400. if remaining.LessThan(temp) {
  401. temp = remaining // A9 修正:逐行递减,绝不按全额冲回多行
  402. }
  403. if !temp.GreaterThan(0) {
  404. continue
  405. }
  406. if _, ue := s.act.IncSignedActivityMoneyUsed(txCtx, tx, row.UserID, row.ActivityID, temp.Neg()); ue != nil {
  407. return ue
  408. }
  409. log := &model.CoursesActivityUsedLog{
  410. ActivityID: row.ActivityID,
  411. UserID: row.UserID,
  412. OrderNo: optString(orderNo),
  413. ActivityMoneyUsed: temp.String(), // 正值 = 退回
  414. Memo: optString(memo),
  415. }
  416. if ie := s.disc.InsertUsedLog(txCtx, tx, log); ie != nil {
  417. return ie
  418. }
  419. remaining = remaining.Sub(temp)
  420. refunded = refunded.Add(temp)
  421. out.Items = append(out.Items, RechargeGiftRefundItem{
  422. ActivityID: row.ActivityID, UserID: row.UserID, Amount: temp,
  423. })
  424. }
  425. out.RefundedTotal = refunded
  426. return nil
  427. })
  428. if err != nil {
  429. return nil, err
  430. }
  431. return out, nil
  432. }
  433. // ---------------------------------------------------------------- 代金券/赠送:使用(SP15)
  434. // UseVoucher 使用赠送金额/代金券(SP15,dump 763204)。
  435. //
  436. // dump 语义:
  437. //
  438. // 763210 游标:`t_courses_activity_user_discount WHERE userid=? AND activityid=?`(**单活动**,无 ORDER BY)
  439. // 763226 IF discount_amount_used < discount_amount THEN
  440. // 763229 IF 活动 type=3 THEN **SET inputAmount = discount_amount**(券面额,不是剩余额)
  441. // 763232 UPDATE pool SET discount_amount_used += inputAmount
  442. // 763233 INSERT discount_log(..., discount_amount_used=**-inputAmount**)
  443. //
  444. // 两处 Go 处理:
  445. // 1. **游标 → 单行**:dump 的循环在多池行时会把 `inputAmount` 加**每一行**(重复占用);设计 §3.2 #15
  446. // 裁定"无游标(一次扣减)",Go 按 `ORDER BY id ASC` 取**第一张可用券**,只扣一次。
  447. // 2. **3109 增强**:`available < amount` → 3109(dump 静默 LEAVE,前端看到"成功但没扣")。
  448. //
  449. // ⚠ 已知 dump 口径(**不得"修正"**):type=3 时 `inputAmount` 被置为**券面额**(`discount_amount`),
  450. // 部分已用的券再使用会**超额占用**(`used > face`)。Go 按 dump 对齐并注释;
  451. // 该超额由"可用额校验"在**下一次**使用/退回时体现(available 变负 → 3109 / 盖帽)。
  452. //
  453. // 入参:ctx / in。出参:*UseVoucherResult(no-op 时 Applied=0);错误码 2001/2002/3109/5002。
  454. func (s *ActivityDiscountService) UseVoucher(
  455. ctx context.Context, in UseVoucherInput,
  456. ) (*UseVoucherResult, error) {
  457. if strings.TrimSpace(in.UserID) == "" {
  458. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UseVoucher 需要 user_id"))
  459. }
  460. if in.ActivityID <= 0 {
  461. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: UseVoucher 需要 activity_id>0"))
  462. }
  463. if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
  464. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
  465. }
  466. userID := strings.TrimSpace(in.UserID)
  467. out := &UseVoucherResult{ActivityID: in.ActivityID}
  468. if !in.Amount.GreaterThan(0) {
  469. return out, nil
  470. }
  471. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  472. txCtx := repository.WithTxContext(ctx, tx)
  473. actType, te := s.activityType(txCtx, in.ActivityID)
  474. if te != nil {
  475. return te
  476. }
  477. // 池行加锁(Go 增强:dump 无锁,并发两笔可把 used 打过 face)
  478. rows, e := s.disc.ListUsableForUpdate(txCtx, tx, userID, in.ActivityID, repository.OrderAsc)
  479. if e != nil {
  480. return e
  481. }
  482. if len(rows) == 0 {
  483. return infraerrors.WithCause(apperr.ErrGiftAmountShort,
  484. errString("service: UseVoucher 可用赠送金额不足(3109;无可用池行)"))
  485. }
  486. row := &rows[0]
  487. face, e := parseAccountAmount(row.DiscountAmount, "t_courses_activity_user_discount.discount_amount")
  488. if e != nil {
  489. return e
  490. }
  491. usedBefore, e := parseAccountAmount(row.DiscountAmountUsed, "t_courses_activity_user_discount.discount_amount_used")
  492. if e != nil {
  493. return e
  494. }
  495. available := face.Sub(usedBefore)
  496. if available.LessThan(in.Amount) {
  497. return infraerrors.WithCause(apperr.ErrGiftAmountShort,
  498. errString("service: UseVoucher 可用赠送金额不足(3109;dump 静默 LEAVE,Go 显式拒绝)"))
  499. }
  500. apply := in.Amount
  501. if actType == model.ActivityTypeVoucher {
  502. // dump 763229:代金券"一次用完一张"——直接取券面额(**不是剩余额**;部分已用会超额占用)
  503. apply = face
  504. }
  505. if !apply.GreaterThan(0) {
  506. return nil
  507. }
  508. if _, ue := s.disc.IncUsed(txCtx, tx, row.ID, apply); ue != nil {
  509. return ue
  510. }
  511. log := &model.CoursesActivityUserDiscountLog{
  512. ActivityUserDiscountID: row.ID,
  513. CoursesStudentID: idPtr(in.StudentCourseID),
  514. DiscountAmountUsed: apply.Neg().String(), // 负值 = 消费
  515. Memo: optString(in.Memo),
  516. }
  517. if ie := s.disc.InsertDiscountLog(txCtx, tx, log); ie != nil {
  518. return ie
  519. }
  520. out.DiscountID = row.ID
  521. out.Applied = apply
  522. out.DiscountAmountUsedAfter = usedBefore.Add(apply)
  523. out.ActivityType = actType
  524. return nil
  525. })
  526. if err != nil {
  527. return nil, err
  528. }
  529. return out, nil
  530. }
  531. // ---------------------------------------------------------------- 代金券/赠送:按报课记录退回(SP16)
  532. // RefundByStuCourse 按报课记录退回赠送金额(SP16,dump 763046)。
  533. //
  534. // dump 语义:
  535. //
  536. // 763052 IF inputAmount<0 THEN inputAmount=0
  537. // 763057 游标:`discount_log WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id **DESC**`
  538. // 763070 temp = min(|行金额|, 剩余应退);剩余应退 -= temp
  539. // 763077 UPDATE pool SET discount_amount_used -= temp
  540. // 763079 INSERT discount_log(..., +temp)
  541. //
  542. // Go 修正(与 A9/A13 同族,`DUMP-BUGFIX`):
  543. // - dump 在循环内又套了一个 `WHILE done=0 DO FETCH`(把剩余行一次读空、不做处理)⇒ 实际**只冲回最近一条**;
  544. // Go 按"逐行递减剩余额"正常遍历(退 120、行 50/100 ⇒ 先 50 再 70)。
  545. // - 每行冲回额**显式盖帽** `min(|行金额|, 剩余应退)`(退 40 只退 40,不超退)。
  546. //
  547. // 入参:ctx / in。出参:*RefundByStuCourseResult;错误码 2001/2002/5002。
  548. func (s *ActivityDiscountService) RefundByStuCourse(
  549. ctx context.Context, in RefundByStuCourseInput,
  550. ) (*RefundByStuCourseResult, error) {
  551. if s == nil || s.disc == nil || s.runner == nil {
  552. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
  553. }
  554. if in.StudentCourseID <= 0 {
  555. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  556. errString("service: RefundByStuCourse 需要 student_course_id>0"))
  557. }
  558. memo := strings.TrimSpace(in.Memo)
  559. if memo == "" {
  560. memo = defaultRefundDiscountMemo
  561. }
  562. amount := in.Amount
  563. if amount < 0 {
  564. amount = 0 // dump 763052:负值归 0
  565. }
  566. out := &RefundByStuCourseResult{
  567. Requested: in.Amount,
  568. Items: make([]VoucherRefundItem, 0, 4),
  569. }
  570. if !amount.GreaterThan(0) {
  571. return out, nil
  572. }
  573. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  574. txCtx := repository.WithTxContext(ctx, tx)
  575. rows, e := s.disc.ListDiscountLogsByStudentCourse(txCtx, in.StudentCourseID)
  576. if e != nil {
  577. return e
  578. }
  579. remaining := amount
  580. refunded := money.Amount(0)
  581. for i := range rows {
  582. if !remaining.GreaterThan(0) {
  583. break
  584. }
  585. row := &rows[i]
  586. rowAmount, pe := parseAccountAmount(row.DiscountAmountUsed, "t_courses_activity_user_discount_log.discount_amount_used")
  587. if pe != nil {
  588. return pe
  589. }
  590. absUsed := absAmount(rowAmount)
  591. temp := absUsed
  592. if remaining.LessThan(temp) {
  593. temp = remaining // A13 同族:显式盖帽
  594. }
  595. if !temp.GreaterThan(0) {
  596. continue
  597. }
  598. // 锁池行(Go 增强)后原子冲回
  599. if _, le := s.disc.GetDiscountForUpdate(txCtx, tx, row.ActivityUserDiscountID); le != nil {
  600. return le
  601. }
  602. if _, ue := s.disc.IncUsed(txCtx, tx, row.ActivityUserDiscountID, temp.Neg()); ue != nil {
  603. return ue
  604. }
  605. log := &model.CoursesActivityUserDiscountLog{
  606. ActivityUserDiscountID: row.ActivityUserDiscountID,
  607. CoursesStudentID: idPtr(in.StudentCourseID),
  608. DiscountAmountUsed: temp.String(), // 正值 = 退回
  609. Memo: optString(memo),
  610. }
  611. if ie := s.disc.InsertDiscountLog(txCtx, tx, log); ie != nil {
  612. return ie
  613. }
  614. remaining = remaining.Sub(temp)
  615. refunded = refunded.Add(temp)
  616. out.Items = append(out.Items, VoucherRefundItem{
  617. DiscountID: row.ActivityUserDiscountID, Amount: temp,
  618. })
  619. }
  620. out.RefundedTotal = refunded
  621. return nil
  622. })
  623. if err != nil {
  624. return nil, err
  625. }
  626. return out, nil
  627. }
  628. // ---------------------------------------------------------------- 代金券/赠送:按活动退回(SP17)
  629. // RefundByActivity 针对用户的某一个活动退回赠送金额(SP17,dump 763106)。
  630. //
  631. // dump 语义:
  632. //
  633. // 763110 IF inputAmount<0 THEN inputAmount=0
  634. // 763122 游标:`discount_log JOIN pool WHERE pool.userid=? AND pool.activityid=? AND log.used<0
  635. // ORDER BY log.id DESC LIMIT 0,1`(**只处理最近一条**)
  636. // 763133 IF 活动 type=3 THEN **inputAmount = discount_amount**(代金券一次全额退)
  637. // 763137 IF inputAmount > |log.used| THEN `SELECT inputAmount = logDiscountAmountUsed`(**布尔比较,
  638. // 赋值从未生效** ⇒ dump 实际不截断、可超退,A13)
  639. // 763140 IF inputAmount>0 THEN UPDATE pool SET used -= inputAmount; INSERT log(..., +inputAmount)
  640. //
  641. // Go 修正(A13,`DUMP-BUGFIX`):`amount = min(amount, |最近一条消费额|)` **显式盖帽**;
  642. // 代金券仍按 dump **一次全额退**(`amount = 券面额`),再盖帽到该券最近一笔消费额。
  643. //
  644. // 入参:ctx / in。出参:*RefundByActivityResult(no-op 时 Refunded=0);错误码 2001/2002/5002。
  645. func (s *ActivityDiscountService) RefundByActivity(
  646. ctx context.Context, in RefundByActivityInput,
  647. ) (*RefundByActivityResult, error) {
  648. if strings.TrimSpace(in.UserID) == "" {
  649. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: RefundByActivity 需要 user_id"))
  650. }
  651. if in.ActivityID <= 0 {
  652. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  653. errString("service: RefundByActivity 需要 activity_id>0"))
  654. }
  655. if s == nil || s.disc == nil || s.act == nil || s.runner == nil {
  656. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配"))
  657. }
  658. memo := strings.TrimSpace(in.Memo)
  659. if memo == "" {
  660. memo = defaultRefundUserActivityMemo
  661. }
  662. amount := in.Amount
  663. if amount < 0 {
  664. amount = 0
  665. }
  666. userID := strings.TrimSpace(in.UserID)
  667. out := &RefundByActivityResult{ActivityID: in.ActivityID}
  668. if !amount.GreaterThan(0) {
  669. return out, nil
  670. }
  671. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  672. txCtx := repository.WithTxContext(ctx, tx)
  673. actType, te := s.activityType(txCtx, in.ActivityID)
  674. if te != nil {
  675. return te
  676. }
  677. last, e := s.disc.GetLastDiscountLogByUserActivity(txCtx, userID, in.ActivityID)
  678. if e != nil {
  679. return e
  680. }
  681. if last == nil {
  682. return nil // dump:LIMIT 1 无行 → no-op
  683. }
  684. apply := amount
  685. if actType == model.ActivityTypeVoucher {
  686. face, pe := parseAccountAmount(last.DiscountAmount, "t_courses_activity_user_discount.discount_amount")
  687. if pe != nil {
  688. return pe
  689. }
  690. apply = face // dump 763133:代金券一次全额退
  691. }
  692. logAmount, pe := parseAccountAmount(last.LogAmountUsed, "t_courses_activity_user_discount_log.discount_amount_used")
  693. if pe != nil {
  694. return pe
  695. }
  696. absUsed := absAmount(logAmount)
  697. if absUsed.LessThan(apply) {
  698. apply = absUsed // A13 修正:显式盖帽(dump 的 `SELECT x = y` 从未赋值)
  699. }
  700. if !apply.GreaterThan(0) {
  701. return nil
  702. }
  703. if _, le := s.disc.GetDiscountForUpdate(txCtx, tx, last.PoolID); le != nil {
  704. return le
  705. }
  706. if _, ue := s.disc.IncUsed(txCtx, tx, last.PoolID, apply.Neg()); ue != nil {
  707. return ue
  708. }
  709. log := &model.CoursesActivityUserDiscountLog{
  710. ActivityUserDiscountID: last.PoolID,
  711. CoursesStudentID: last.CoursesStudentID, // dump 原样带回日志行上的报课记录
  712. DiscountAmountUsed: apply.String(),
  713. Memo: optString(memo),
  714. }
  715. if ie := s.disc.InsertDiscountLog(txCtx, tx, log); ie != nil {
  716. return ie
  717. }
  718. poolAfter, e := s.disc.GetDiscountForUpdate(txCtx, tx, last.PoolID)
  719. if e != nil {
  720. return e
  721. }
  722. usedAfter, e := parseAccountAmount(poolAfter.DiscountAmountUsed, "t_courses_activity_user_discount.discount_amount_used")
  723. if e != nil {
  724. return e
  725. }
  726. out.DiscountID = last.PoolID
  727. out.Refunded = apply
  728. out.DiscountAmountUsedAfter = usedAfter
  729. return nil
  730. })
  731. if err != nil {
  732. return nil, err
  733. }
  734. return out, nil
  735. }
  736. // ---------------------------------------------------------------- 内部助手
  737. // activityType 只读活动类型(dump 的 `SELECT type FROM t_courses_activity WHERE id=?`)。
  738. //
  739. // 语义:活动不存在 → 返回 0(= 非 2/3/4,所有分支都不命中),与 dump 的
  740. // `SELECT ... INTO` 取不到行时变量保持默认 0 一致;DB 异常才报错。
  741. func (s *ActivityDiscountService) activityType(ctx context.Context, activityID int64) (int8, error) {
  742. act, err := s.act.GetByID(ctx, activityID)
  743. if err != nil {
  744. if errors.Is(err, infraerrors.ErrNotFound) {
  745. return 0, nil
  746. }
  747. return 0, err
  748. }
  749. return act.Type, nil
  750. }
  751. // idPtr 把数值主键转成指针(0 → nil,表示"未关联报课记录")。
  752. func idPtr(id int64) *int64 {
  753. if id <= 0 {
  754. return nil
  755. }
  756. return &id
  757. }