activity_discount_repo.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. // 活动赠送金额池仓储(t_courses_activity_user_discount / _user_discount_log / _used_log)——AC-06。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.2 #14~#19(赠送金额池 4 SP)/ §1.5(金额)
  4. // / §1.7(scoping)。
  5. //
  6. // 覆盖 SP 映射:
  7. //
  8. // user_discount_usable → SumUsable(dump 3 参汇总口径:schoolid + campusid + userid)
  9. // user_discount_use → ListUsable(ASC) + IncUsed/IncUsedBatch + InsertDiscountLog(负值)
  10. // user_discount_refund → ListUsable(DESC) + IncUsed(负 delta) + InsertDiscountLog(正值)
  11. // user_discount_refund_user_activity → ListUsable(DESC)(同活动池优先退回)
  12. // use_activity_recommend → IncUsed + InsertUsedLog(老带新结算写负值)
  13. //
  14. // 金额口径(红线):库里 discount_amount / discount_amount_used 是 decimal(12,2) **字符串列**,
  15. // Go 侧一律转 money.Amount(int64 分)再运算;SQL 侧只用字符串字面量做加减(decimal 精确),
  16. // **绝不**把金额转 float64(sqlite 的 SUM() 返回 REAL,会让分位失真——见 SumUsable 实现说明)。
  17. package repository
  18. import (
  19. "context"
  20. "strings"
  21. "gorm.io/gorm"
  22. "yuansiwei/unified-backend/internal/academic/model"
  23. "yuansiwei/unified-backend/internal/academic/money"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. appscope "yuansiwei/unified-backend/internal/infra/scope"
  26. )
  27. // UsedDelta 批量累加「已使用赠送金额」的单元素(id + 增量)。
  28. //
  29. // 字段:
  30. // - DiscountID int64:优惠账户 ID(t_courses_activity_user_discount.id;<=0 的条目被跳过)。
  31. // - Delta money.Amount:discount_amount_used 的增量(正=消费/占用,负=退回冲正;单位:分)。
  32. type UsedDelta struct {
  33. // DiscountID 优惠账户 ID(DB 自增主键)。
  34. DiscountID int64
  35. // Delta 已使用金额增量(正=消费,负=退回;单位:分)。
  36. Delta money.Amount
  37. }
  38. // ActivityDiscountRepo 活动赠送金额池仓储。
  39. //
  40. // 表:
  41. // - t_courses_activity_user_discount(池:可用 = discount_amount − discount_amount_used);
  42. // - t_courses_activity_user_discount_log(使用/退回流水,只增,负=消费/正=退回);
  43. // - t_courses_activity_used_log(老带新获得/使用记录,结算写负值)。
  44. //
  45. // 作用域:三表均带 institution_id + campus_id(campus_id=0 = 机构所有自营校区)→ scopedCampusRows。
  46. type ActivityDiscountRepo struct {
  47. *BaseRepo
  48. }
  49. // NewActivityDiscountRepo 构造赠送金额池仓储。
  50. //
  51. // 入参:db *gorm.DB / sc *appscope.Scope(语义同 NewActivityRepo)。
  52. // 出参:*ActivityDiscountRepo(CampusScoped=false,校区维度走 IN (campus,0) 超集语义)。
  53. func NewActivityDiscountRepo(db *gorm.DB, sc *appscope.Scope) *ActivityDiscountRepo {
  54. r := &ActivityDiscountRepo{BaseRepo: NewBaseRepo(db, sc)}
  55. r.CampusScoped = false
  56. return r
  57. }
  58. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  59. func (r *ActivityDiscountRepo) db(ctx context.Context) (*gorm.DB, error) {
  60. if r == nil || r.DB == nil {
  61. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityDiscountRepo.DB 为 nil"))
  62. }
  63. if tx := TxFromContext(ctx); tx != nil {
  64. return tx, nil
  65. }
  66. if ctx == nil {
  67. return r.DB, nil
  68. }
  69. return r.DB.WithContext(ctx), nil
  70. }
  71. // ListUsable 列出用户可用赠送金额池(按游标方向取用/退回顺序)。
  72. //
  73. // 入参:
  74. // - ctx 请求上下文;
  75. // - userID 学生(canonical_user_id ULID;空 → ErrBadParam);
  76. // - order 游标方向(**顺序不可改**,源自 dump):
  77. // OrderAsc = user_discount_use 使用顺序(先入先出);
  78. // OrderDesc = user_discount_refund 退回顺序(后入先退)。
  79. //
  80. // 出参:
  81. // - []model.CoursesActivityUserDiscount:可用行(`discount_amount > discount_amount_used`),
  82. // 金额为字符串原文,调用方用 money.Parse 转 Amount;
  83. // - ErrBadParam / ErrDBError。
  84. //
  85. // 可用口径(dump user_discount_usable 一致):仅累加 discount_amount > discount_amount_used 的行
  86. // (负赠送/已用尽的行不参与),故本方法只返回正可用行。
  87. func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, order string) ([]model.CoursesActivityUserDiscount, error) {
  88. if strings.TrimSpace(userID) == "" {
  89. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable 需要 user_id"))
  90. }
  91. dir, err := normalizeOrder(order)
  92. if err != nil {
  93. return nil, err
  94. }
  95. db, err := r.db(ctx)
  96. if err != nil {
  97. return nil, err
  98. }
  99. q := scopedCampusRows(db.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  100. q = q.Where("user_id = ?", userID).
  101. Where("discount_amount > discount_amount_used").
  102. Order("id " + dir)
  103. var rows []model.CoursesActivityUserDiscount
  104. if err := q.Find(&rows).Error; err != nil {
  105. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  106. }
  107. return rows, nil
  108. }
  109. // SumUsable 汇总用户可用赠送金额(**对齐 dump `usable` 的 3 参口径**:机构 + 校区 + 用户)。
  110. //
  111. // 入参:
  112. // - ctx 请求上下文;
  113. // - userID 学生(ULID;空 → ErrBadParam);
  114. // - institutionID 机构 ID(原 schoolid;<=0 → ErrBadParam);
  115. // - campusID 校区 ID(原 campusid;0 = 机构级口径,即不限定校区)。
  116. //
  117. // 出参:
  118. // - money.Amount:可用额度合计(单位:分;无可用行为 0);
  119. // - ErrBadParam / ErrUnscoped(1004,"报表参数越权")/ ErrDBError。
  120. //
  121. // 作用域(两条例外之外的**显式参数口径**,安全红线):
  122. //
  123. // dump 的 usable(schoolid, campusid, userid) 由调用方传参(报表/退费需要"指定机构校区"视角),
  124. // 与请求作用域可能不同;此处**不叠加**请求作用域过滤,但做一次防越权断言:
  125. // 非超管且 institutionID != Sc.InstitutionID → ErrUnscoped(禁止借参数跨机构读他人赠送金额)。
  126. //
  127. // 校区过滤:campusID > 0 → `campus_id IN (campusID, 0)`(0=机构所有自营校区,与 dump 一致);
  128. // campusID = 0 → 只按机构汇总(机构级报表视角)。
  129. //
  130. // 实现说明(金额精度):**不用 SQL SUM()**。sqlite 把 decimal 存 NUMERIC 时 SUM 返回 REAL,
  131. // 会把分位带进浮点;MySQL 返回 DECIMAL(字符串) 虽精确,但两库行为不一致。
  132. // 因此这里只让 DB 做过滤,**逐行在 Go 侧用 money.Amount(int64 分)累加**(用户池行数极小,
  133. // 性能可忽略),换取"零浮点"的确定性。
  134. func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, institutionID, campusID int64) (money.Amount, error) {
  135. if strings.TrimSpace(userID) == "" || institutionID <= 0 {
  136. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumUsable 需要 user_id/institution_id"))
  137. }
  138. if r != nil && r.Sc != nil && !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
  139. return 0, infraerrors.WithCause(infraerrors.ErrUnscoped,
  140. errString("repository: SumUsable 机构参数越权(作用域机构与入参不一致)"))
  141. }
  142. db, err := r.db(ctx)
  143. if err != nil {
  144. return 0, err
  145. }
  146. q := db.Model(&model.CoursesActivityUserDiscount{}).
  147. Where("user_id = ?", userID).
  148. Where("institution_id = ?", institutionID).
  149. Where("discount_amount > discount_amount_used")
  150. if campusID > 0 {
  151. // campus_id=0 = 机构所有自营校区:与指定校区同属可见范围(dump IN (x,0))
  152. q = q.Where("campus_id IN ?", []int64{campusID, 0})
  153. }
  154. var rows []model.CoursesActivityUserDiscount
  155. if err := q.Find(&rows).Error; err != nil {
  156. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  157. }
  158. total := money.Amount(0)
  159. for i := range rows {
  160. totalAmount, perr := money.Parse(rows[i].DiscountAmount)
  161. if perr != nil {
  162. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  163. }
  164. usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed)
  165. if perr != nil {
  166. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  167. }
  168. total = total.Add(totalAmount.Sub(usedAmount))
  169. }
  170. return total, nil
  171. }
  172. // IncUsed 累加优惠账户的「已使用赠送金额」(单账户原子增减)。
  173. //
  174. // 入参:
  175. // - ctx 请求上下文;
  176. // - tx 事务句柄(**必传**);
  177. // - id 优惠账户 ID(<=0 → ErrBadParam);
  178. // - delta 增量(正=消费,负=退回冲正;单位:分;0 → 空操作)。
  179. //
  180. // 出参:
  181. // - int64:受影响行数(0 = 账户不存在/不在作用域内);
  182. // - ErrBadParam / ErrDBError。
  183. //
  184. // 红线:
  185. // - `discount_amount_used <= discount_amount` 的业务约束由 **Service** 在扣减前用可用额校验
  186. // (不足 → 3109),本方法**不做截断**——静默截断会破坏"池余额 = 流水累计"的对账恒等式;
  187. // - 与流水写入(InsertDiscountLog)必须同事务。
  188. func (r *ActivityDiscountRepo) IncUsed(ctx context.Context, tx *gorm.DB, id int64, delta money.Amount) (int64, error) {
  189. if err := requireTx(tx, "IncUsed"); err != nil {
  190. return 0, err
  191. }
  192. if id <= 0 {
  193. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncUsed 需要 discount id"))
  194. }
  195. if delta.IsZero() {
  196. return 0, nil
  197. }
  198. if err := delta.Validate(); err != nil {
  199. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  200. }
  201. q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  202. res := q.Where("id = ?", id).
  203. UpdateColumn("discount_amount_used", gorm.Expr("discount_amount_used + ?", delta.String()))
  204. if res.Error != nil {
  205. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  206. }
  207. return res.RowsAffected, nil
  208. }
  209. // IncUsedBatch 批量累加多个优惠账户的「已使用赠送金额」(**单语句 CASE WHEN**,禁止逐行 UPDATE)。
  210. //
  211. // 入参:
  212. // - ctx 请求上下文;
  213. // - tx 事务句柄(**必传**);
  214. // - items 增量集合(同一 id 出现多次时**后者覆盖前者**;<=0 的 id 与零增量条目被忽略)。
  215. //
  216. // 出参:
  217. // - int64:受影响行数;
  218. // - ErrBadParam / ErrDBError(空集合 → 0, nil,不发 SQL)。
  219. //
  220. // 为什么必须单语句(性能/正确性双约束):
  221. // - 一次跨多个池扣减若逐行 UPDATE,N 个账户 = N 次往返 + N 个独立执行计划;
  222. // - 单语句在事务内原子生效,减少锁持有时间(并发退费场景)。
  223. //
  224. // SQL:
  225. //
  226. // UPDATE t_courses_activity_user_discount
  227. // SET discount_amount_used = discount_amount_used + CASE id WHEN ? THEN ? ... ELSE 0 END
  228. // WHERE id IN (...) [AND institution_id = ? AND campus_id IN (?,0)]
  229. //
  230. // 红线:金额以**字符串**字面量入场(decimal 精确),禁止 float。
  231. func (r *ActivityDiscountRepo) IncUsedBatch(ctx context.Context, tx *gorm.DB, items []UsedDelta) (int64, error) {
  232. if err := requireTx(tx, "IncUsedBatch"); err != nil {
  233. return 0, err
  234. }
  235. // 归一:去重(后者覆盖)+ 过滤非法 id/零增量,保持入参顺序(CASE 分支顺序可预期,便于断言)
  236. order := make([]int64, 0, len(items))
  237. deltaByID := make(map[int64]money.Amount, len(items))
  238. for _, it := range items {
  239. if it.DiscountID <= 0 || it.Delta.IsZero() {
  240. continue
  241. }
  242. if err := it.Delta.Validate(); err != nil {
  243. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  244. }
  245. if _, ok := deltaByID[it.DiscountID]; !ok {
  246. order = append(order, it.DiscountID)
  247. }
  248. deltaByID[it.DiscountID] = it.Delta
  249. }
  250. if len(order) == 0 {
  251. return 0, nil
  252. }
  253. var sqlText strings.Builder
  254. sqlText.WriteString("discount_amount_used + CASE id")
  255. caseArgs := make([]any, 0, len(order)*2)
  256. for _, id := range order {
  257. sqlText.WriteString(" WHEN ? THEN ?")
  258. caseArgs = append(caseArgs, id, deltaByID[id].String())
  259. }
  260. sqlText.WriteString(" ELSE 0 END")
  261. q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  262. res := q.Where("id IN ?", order).UpdateColumn("discount_amount_used", gorm.Expr(sqlText.String(), caseArgs...))
  263. if res.Error != nil {
  264. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  265. }
  266. return res.RowsAffected, nil
  267. }
  268. // InsertDiscountLog 写入赠送金额使用/退回流水(**只增**)。
  269. //
  270. // 入参:
  271. // - ctx 请求上下文;
  272. // - tx 事务句柄(**必传**);
  273. // - row 流水行(ActivityUserDiscountID>0;DiscountAmountUsed 负=消费/正=退回)。
  274. //
  275. // 出参:nil 成功;ErrBadParam / ErrDBError。
  276. //
  277. // 红线:流水与池余额变动(IncUsed)必须同事务;流水禁止 UPDATE/DELETE(审计底线)。
  278. func (r *ActivityDiscountRepo) InsertDiscountLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUserDiscountLog) error {
  279. if err := requireTx(tx, "InsertDiscountLog"); err != nil {
  280. return err
  281. }
  282. if row == nil {
  283. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 为 nil"))
  284. }
  285. if row.ActivityUserDiscountID <= 0 {
  286. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 缺少 activity_user_discount_id"))
  287. }
  288. ensureCreateTime(&row.CreateTime) // 审计时间兜底(见 activity_repo.go ensureCreateTime 说明)
  289. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  290. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  291. }
  292. return nil
  293. }
  294. // InsertUsedLog 写入老带新活动金额获得/使用记录(**只增**)。
  295. //
  296. // 入参:
  297. // - ctx 请求上下文;
  298. // - tx 事务句柄(**必传**);
  299. // - row 记录行(ActivityID>0、UserID 非空;老带新结算写**负值** activity_money_used)。
  300. //
  301. // 出参:nil 成功;ErrBadParam / ErrDBError。
  302. //
  303. // 红线:符号方向固定(结算负值);撤销报名前置校验按 (activity_id, user_id) 判存在(3108),
  304. // 故本表写入必须与 signed 状态推进同事务。
  305. func (r *ActivityDiscountRepo) InsertUsedLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUsedLog) error {
  306. if err := requireTx(tx, "InsertUsedLog"); err != nil {
  307. return err
  308. }
  309. if row == nil {
  310. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 为 nil"))
  311. }
  312. if row.ActivityID <= 0 || strings.TrimSpace(row.UserID) == "" {
  313. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 缺少 activity_id/user_id"))
  314. }
  315. ensureCreateTime(&row.CreateTime) // 审计时间兜底(见 activity_repo.go ensureCreateTime 说明)
  316. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  317. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  318. }
  319. return nil
  320. }