balance_request_repo.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. // 余额变更工单仓储(t_user_balance_request)——AC-03 资金工单(充值/退费/坏账/取消活动/转结)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §2「4. t_user_balance_request」/ §3(审批驱动)
  4. // / §10.3(事务边界只在 Service)。
  5. //
  6. // 工单是**资金变动唯一入口**:`approved` 由 0(未处理)→ 终态的审批动作才触发余额变动;
  7. // 因此本仓储的三个写方法都是"资金事务的参与者",一律接收调用方 tx(本文件不开事务)。
  8. //
  9. // 并发防线:
  10. // - `UpdateApproved` 是 **CAS**(`WHERE approved = 0`)——重复审批/双人同时点通过时,
  11. // 第二次 affected=0,Service 据此返回幂等结果而不是重复入账;
  12. // - `GetForUpdate` 提供 `SELECT ... FOR UPDATE` 加锁读(审批前读旧值/校验金额用)。
  13. //
  14. // 作用域:表带 institution_id + campus_id(作用域列)→ 继承 BaseRepo,普通角色强制本机构。
  15. package repository
  16. import (
  17. "context"
  18. "strings"
  19. "time"
  20. "gorm.io/gorm"
  21. "gorm.io/gorm/clause"
  22. "yuansiwei/unified-backend/internal/academic/model"
  23. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  24. appscope "yuansiwei/unified-backend/internal/infra/scope"
  25. )
  26. // ErrDuplicateBizNo 工单业务单号重复哨兵(撞 uk_biz_no)——属幂等命中,不是 5002。
  27. //
  28. // 判定方式:`stderrors.Is(err, repository.ErrDuplicateBizNo)`。
  29. var ErrDuplicateBizNo = infraerrors.New(infraerrors.CodeUnique, "重复的业务单号(biz_no 已存在)")
  30. // BalanceRequestRepo 余额变更工单仓储(多租户表,继承 BaseRepo 强制机构作用域)。
  31. type BalanceRequestRepo struct {
  32. *BaseRepo
  33. }
  34. // NewBalanceRequestRepo 构造工单仓储。
  35. //
  36. // 入参:db *gorm.DB(连接池句柄)/ sc *appscope.Scope(请求作用域;系统任务可 nil,一查询即 panic)。
  37. // 出参:*BalanceRequestRepo(CampusScoped=true:表带 campus_id)。
  38. func NewBalanceRequestRepo(db *gorm.DB, sc *appscope.Scope) *BalanceRequestRepo {
  39. r := &BalanceRequestRepo{BaseRepo: NewBaseRepo(db, sc)}
  40. r.CampusScoped = true
  41. return r
  42. }
  43. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  44. func (r *BalanceRequestRepo) db(ctx context.Context) (*gorm.DB, error) {
  45. if r == nil || r.DB == nil {
  46. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: BalanceRequestRepo.DB 为 nil"))
  47. }
  48. if tx := TxFromContext(ctx); tx != nil {
  49. return tx, nil
  50. }
  51. if ctx == nil {
  52. return r.DB, nil
  53. }
  54. return r.DB.WithContext(ctx), nil
  55. }
  56. // GetForUpdate 按主键取工单并加行锁(审批前读旧值)。
  57. //
  58. // 入参:ctx 请求上下文;tx 事务句柄(**必传**);id 工单 ID(<=0 → ErrBadParam)。
  59. // 出参:*model.UserBalanceRequest + ErrNotFound(不存在或跨机构)/ ErrBadParam / ErrDBError。
  60. //
  61. // SQL:`SELECT * FROM t_user_balance_request WHERE id=? [AND institution_id=?] LIMIT 1 FOR UPDATE`。
  62. func (r *BalanceRequestRepo) GetForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.UserBalanceRequest, error) {
  63. if err := requireTx(tx, "GetForUpdate(t_user_balance_request)"); err != nil {
  64. return nil, err
  65. }
  66. if id <= 0 {
  67. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request id 必须 > 0"))
  68. }
  69. var out model.UserBalanceRequest
  70. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)))
  71. err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  72. Where("id = ?", id).Take(&out).Error
  73. if err != nil {
  74. return nil, wrapQueryErr(err, "GetForUpdate(t_user_balance_request)")
  75. }
  76. return &out, nil
  77. }
  78. // Insert 创建工单(幂等键 biz_no)。
  79. //
  80. // 入参:
  81. // - ctx 请求上下文;
  82. // - tx 事务句柄(**必传**);
  83. // - row 工单行(BizNo 非空、Type != 0、UserID 非空;InstitutionID=0 时用作用域补齐)。
  84. //
  85. // 出参:
  86. // - nil 成功;
  87. // - ErrDuplicateBizNo(biz_no 已存在 → 幂等命中);
  88. // - ErrBadParam / ErrUnscoped / ErrDBError。
  89. //
  90. // 红线:`approved` 一律由 DB 默认 0(未处理)落库,**禁止**创建即通过(资金变动必须走审批链)。
  91. func (r *BalanceRequestRepo) Insert(ctx context.Context, tx *gorm.DB, row *model.UserBalanceRequest) error {
  92. if err := requireTx(tx, "Insert(t_user_balance_request)"); err != nil {
  93. return err
  94. }
  95. if row == nil {
  96. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request 为 nil"))
  97. }
  98. if strings.TrimSpace(row.BizNo) == "" || strings.TrimSpace(row.UserID) == "" || row.Type == 0 {
  99. return infraerrors.WithCause(infraerrors.ErrBadParam,
  100. errString("repository: balance request 需要 biz_no/user_id/type"))
  101. }
  102. if row.InstitutionID == 0 {
  103. inst, campus := r.GetScopedID()
  104. if inst <= 0 {
  105. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  106. errString("repository: balance request 缺少 institution_id 且作用域无机构"))
  107. }
  108. row.InstitutionID = inst
  109. row.CampusID = campus
  110. }
  111. if err := normalizeExpenseAmounts(&row.Money); err != nil {
  112. return err
  113. }
  114. // approved 由调用方显式置 0(零值即未处理),此处不做覆盖以便"迁移搬入历史终态工单"
  115. err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error
  116. if err != nil {
  117. if IsDupKey(err) {
  118. return infraerrors.WithCause(ErrDuplicateBizNo, err)
  119. }
  120. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  121. }
  122. return nil
  123. }
  124. // UpdateApproved 审批状态 CAS 推进(`WHERE approved = 0`,0→终态唯一入口)。
  125. //
  126. // 入参:
  127. // - ctx 请求上下文;
  128. // - tx 事务句柄(**必传**:资金变动 + 工单回写必须同事务);
  129. // - id 工单 ID(<=0 → ErrBadParam);
  130. // - to 目标状态(model.BalanceRequestApproved*:-2 取消 / -1 不通过 / 1 已通过);
  131. // - responseUserID 审批人(ULID;nil = 系统自动审批,落 NULL);
  132. // - responseTime 审批时间(nil = 不写,由调用方决定;资金口径要求非 nil 见 Service 校验)。
  133. //
  134. // 出参:
  135. // - int64:受影响行数(**1=本次抢到审批权**;0=已被处理过/不存在/跨机构 → Service 做幂等返回);
  136. // - ErrBadParam / ErrDBError。
  137. //
  138. // SQL(单语句 CAS):`UPDATE t_user_balance_request SET approved=?, response_user_id=?, response_time=?
  139. // WHERE id=? AND approved=0 [AND institution_id=?]`。
  140. //
  141. // 红线:CAS 的 `approved = 0` 条件**不可省**(否则双人同时点"通过"会重复入账);
  142. // 金额/类型**不允许**在本方法内变更(改金额须新建工单)。
  143. func (r *BalanceRequestRepo) UpdateApproved(ctx context.Context, tx *gorm.DB, id int64, to int8,
  144. responseUserID *string, responseTime *time.Time) (int64, error) {
  145. if err := requireTx(tx, "UpdateApproved"); err != nil {
  146. return 0, err
  147. }
  148. if id <= 0 {
  149. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateApproved 需要 id"))
  150. }
  151. switch to {
  152. case model.BalanceRequestApprovedCancelled, model.BalanceRequestApprovedRejected,
  153. model.BalanceRequestApprovedPassed:
  154. default:
  155. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  156. errString("repository: UpdateApproved 目标状态非法(只允许 -2/-1/1)"))
  157. }
  158. updates := map[string]any{
  159. "approved": to,
  160. "response_user_id": responseUserID, // nil → 写 NULL
  161. }
  162. if responseTime != nil {
  163. updates["response_time"] = *responseTime
  164. }
  165. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserBalanceRequest{}))
  166. res := q.Where("id = ? AND approved = ?", id, model.BalanceRequestApprovedPending).
  167. Updates(updates)
  168. if res.Error != nil {
  169. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  170. }
  171. return res.RowsAffected, nil
  172. }
  173. // ListByUser 分页列出某用户的工单(按 id 倒序,最新在前)。
  174. //
  175. // 入参:
  176. // - ctx 请求上下文;
  177. // - userID 用户(ULID;空 → ErrBadParam);
  178. // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
  179. //
  180. // 出参:
  181. // - []model.UserBalanceRequest:**空结果返回长度 0 的非 nil 切片**;
  182. // - ErrBadParam / ErrDBError。
  183. //
  184. // 作用域:经 Scoped(普通角色只见本机构工单;超管全量)。
  185. func (r *BalanceRequestRepo) ListByUser(ctx context.Context, userID string, page PageQuery) ([]model.UserBalanceRequest, error) {
  186. if strings.TrimSpace(userID) == "" {
  187. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByUser 需要 user_id"))
  188. }
  189. db, err := r.db(ctx)
  190. if err != nil {
  191. return nil, err
  192. }
  193. pageSize := page.Limit()
  194. rows := make([]model.UserBalanceRequest, 0, pageSize)
  195. q := r.Scoped(db.Model(&model.UserBalanceRequest{}))
  196. err = q.Where("user_id = ?", userID).
  197. Order("id DESC").Limit(pageSize).Offset(page.Offset()).
  198. Find(&rows).Error
  199. if err != nil {
  200. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  201. }
  202. if rows == nil {
  203. rows = make([]model.UserBalanceRequest, 0)
  204. }
  205. return rows, nil
  206. }