balance_request_repo.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  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. "yuansiwei/unified-backend/internal/academic/money"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. appscope "yuansiwei/unified-backend/internal/infra/scope"
  26. )
  27. // ErrDuplicateBizNo 工单业务单号重复哨兵(撞 uk_biz_no)——属幂等命中,不是 5002。
  28. //
  29. // 判定方式:`stderrors.Is(err, repository.ErrDuplicateBizNo)`。
  30. var ErrDuplicateBizNo = infraerrors.New(infraerrors.CodeUnique, "重复的业务单号(biz_no 已存在)")
  31. // BalanceRequestRepo 余额变更工单仓储(多租户表,继承 BaseRepo 强制机构作用域)。
  32. type BalanceRequestRepo struct {
  33. *BaseRepo
  34. }
  35. // NewBalanceRequestRepo 构造工单仓储。
  36. //
  37. // 入参:db *gorm.DB(连接池句柄)/ sc *appscope.Scope(请求作用域;系统任务可 nil,一查询即 panic)。
  38. // 出参:*BalanceRequestRepo(CampusScoped=true:表带 campus_id)。
  39. func NewBalanceRequestRepo(db *gorm.DB, sc *appscope.Scope) *BalanceRequestRepo {
  40. r := &BalanceRequestRepo{BaseRepo: NewBaseRepo(db, sc)}
  41. r.CampusScoped = true
  42. return r
  43. }
  44. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  45. func (r *BalanceRequestRepo) db(ctx context.Context) (*gorm.DB, error) {
  46. if r == nil || r.DB == nil {
  47. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: BalanceRequestRepo.DB 为 nil"))
  48. }
  49. if tx := TxFromContext(ctx); tx != nil {
  50. return tx, nil
  51. }
  52. if ctx == nil {
  53. return r.DB, nil
  54. }
  55. return r.DB.WithContext(ctx), nil
  56. }
  57. // GetForUpdate 按主键取工单并加行锁(审批前读旧值)。
  58. //
  59. // 入参:ctx 请求上下文;tx 事务句柄(**必传**);id 工单 ID(<=0 → ErrBadParam)。
  60. // 出参:*model.UserBalanceRequest + ErrNotFound(不存在或跨机构)/ ErrBadParam / ErrDBError。
  61. //
  62. // SQL:`SELECT * FROM t_user_balance_request WHERE id=? [AND institution_id=?] LIMIT 1 FOR UPDATE`。
  63. func (r *BalanceRequestRepo) GetForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.UserBalanceRequest, error) {
  64. if err := requireTx(tx, "GetForUpdate(t_user_balance_request)"); err != nil {
  65. return nil, err
  66. }
  67. if id <= 0 {
  68. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request id 必须 > 0"))
  69. }
  70. var out model.UserBalanceRequest
  71. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)))
  72. err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  73. Where("id = ?", id).Take(&out).Error
  74. if err != nil {
  75. return nil, wrapQueryErr(err, "GetForUpdate(t_user_balance_request)")
  76. }
  77. return &out, nil
  78. }
  79. // Insert 创建工单(幂等键 biz_no)。
  80. //
  81. // 入参:
  82. // - ctx 请求上下文;
  83. // - tx 事务句柄(**必传**);
  84. // - row 工单行(BizNo 非空、Type != 0、UserID 非空;InstitutionID=0 时用作用域补齐)。
  85. //
  86. // 出参:
  87. // - nil 成功;
  88. // - ErrDuplicateBizNo(biz_no 已存在 → 幂等命中);
  89. // - ErrBadParam / ErrUnscoped / ErrDBError。
  90. //
  91. // 红线:`approved` 一律由 DB 默认 0(未处理)落库,**禁止**创建即通过(资金变动必须走审批链)。
  92. func (r *BalanceRequestRepo) Insert(ctx context.Context, tx *gorm.DB, row *model.UserBalanceRequest) error {
  93. if err := requireTx(tx, "Insert(t_user_balance_request)"); err != nil {
  94. return err
  95. }
  96. if row == nil {
  97. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: balance request 为 nil"))
  98. }
  99. if strings.TrimSpace(row.BizNo) == "" || strings.TrimSpace(row.UserID) == "" || row.Type == 0 {
  100. return infraerrors.WithCause(infraerrors.ErrBadParam,
  101. errString("repository: balance request 需要 biz_no/user_id/type"))
  102. }
  103. if row.InstitutionID == 0 {
  104. inst, campus := r.GetScopedID()
  105. if inst <= 0 {
  106. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  107. errString("repository: balance request 缺少 institution_id 且作用域无机构"))
  108. }
  109. row.InstitutionID = inst
  110. row.CampusID = campus
  111. }
  112. if err := normalizeExpenseAmounts(&row.Money); err != nil {
  113. return err
  114. }
  115. // approved 由调用方显式置 0(零值即未处理),此处不做覆盖以便"迁移搬入历史终态工单"
  116. err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error
  117. if err != nil {
  118. if IsDupKey(err) {
  119. return infraerrors.WithCause(ErrDuplicateBizNo, err)
  120. }
  121. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  122. }
  123. return nil
  124. }
  125. // UpdateApproved 审批状态 CAS 推进(`WHERE approved = 0`,0→终态唯一入口)。
  126. //
  127. // 入参:
  128. // - ctx 请求上下文;
  129. // - tx 事务句柄(**必传**:资金变动 + 工单回写必须同事务);
  130. // - id 工单 ID(<=0 → ErrBadParam);
  131. // - to 目标状态(model.BalanceRequestApproved*:-2 取消 / -1 不通过 / 1 已通过);
  132. // - responseUserID 审批人(ULID;nil = 系统自动审批,落 NULL);
  133. // - responseTime 审批时间(nil = 不写,由调用方决定;资金口径要求非 nil 见 Service 校验)。
  134. //
  135. // 出参:
  136. // - int64:受影响行数(**1=本次抢到审批权**;0=已被处理过/不存在/跨机构 → Service 做幂等返回);
  137. // - ErrBadParam / ErrDBError。
  138. //
  139. // SQL(单语句 CAS):`UPDATE t_user_balance_request SET approved=?, response_user_id=?, response_time=?
  140. // WHERE id=? AND approved=0 [AND institution_id=?]`。
  141. //
  142. // 红线:CAS 的 `approved = 0` 条件**不可省**(否则双人同时点"通过"会重复入账);
  143. // 金额/类型**不允许**在本方法内变更(改金额须新建工单)。
  144. func (r *BalanceRequestRepo) UpdateApproved(ctx context.Context, tx *gorm.DB, id int64, to int8,
  145. responseUserID *string, responseTime *time.Time) (int64, error) {
  146. if err := requireTx(tx, "UpdateApproved"); err != nil {
  147. return 0, err
  148. }
  149. if id <= 0 {
  150. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateApproved 需要 id"))
  151. }
  152. switch to {
  153. case model.BalanceRequestApprovedCancelled, model.BalanceRequestApprovedRejected,
  154. model.BalanceRequestApprovedPassed:
  155. default:
  156. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  157. errString("repository: UpdateApproved 目标状态非法(只允许 -2/-1/1)"))
  158. }
  159. updates := map[string]any{
  160. "approved": to,
  161. "response_user_id": responseUserID, // nil → 写 NULL
  162. }
  163. if responseTime != nil {
  164. updates["response_time"] = *responseTime
  165. }
  166. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserBalanceRequest{}))
  167. res := q.Where("id = ? AND approved = ?", id, model.BalanceRequestApprovedPending).
  168. Updates(updates)
  169. if res.Error != nil {
  170. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  171. }
  172. return res.RowsAffected, nil
  173. }
  174. // SumRechargeActivityUsedGift 汇总用户在某机构某校区「已使用的充值赠送金额」(SP5 第二步修正)。
  175. //
  176. // 用途:`sp_user_expense_school_get_refundable_amount` 的可退额计算**第二步**——
  177. // `refundable = max(min(校区余额, 全局余额) − 已用赠送额, 0)`(第一步的截断在 Service 层)。
  178. //
  179. // dump 原文(yuansiwei_finance.sql:764421 起,游标段被 `/* */` 注释掉,实际只生效这条 SUM):
  180. //
  181. // SELECT SUM(t2.activity_money_used)
  182. // FROM t_user_balance_request t1
  183. // LEFT JOIN t_courses_activity_signed t2 ON t1.id = t2.requestid
  184. // WHERE t1.type = 100 AND t1.userid = ? AND t1.schoolid = ? AND t1.campusid = ?
  185. // AND t1.aproved = 1
  186. // AND t2.activity_money > 0 AND t2.activity_money_used < t2.activity_money;
  187. //
  188. // 目标态列映射(**必须按映射改写,不能照抄 dump 的列名**):
  189. //
  190. // t1.id (旧库 UUID 主键) → t_user_balance_request.biz_no
  191. // (设计 §2.2 / §11-10:旧 UUID 原样搬进 biz_no,新 id 是自增 int64)
  192. // t2.requestid (旧库字符串申请单号) → t_courses_activity_signed.request_no
  193. // (DDL 注释:「申请单号(原 requestid,关联 t_user_balance_request.biz_no)」)
  194. // t1.userid / t1.schoolid / t1.campusid / t1.aproved → user_id / institution_id / campus_id / approved
  195. //
  196. // → JOIN 条件因此是 **`br.biz_no = cas.request_no`**,不是 `br.id = cas.request_no`:
  197. // 新 `id` 是自增整数,与字符串单号**永不等值**(照抄 dump 会静默恒不命中 → 可退额永远不少扣,属资金口径 BUG);
  198. // `t_courses_activity_signed` 的 model 红线亦已写明「字符串保留(非 int64 FK),勿 JOIN id」。
  199. //
  200. // 两个过滤条件的语义(照抄 dump,**不得"顺手简化"**):
  201. // - `cas.activity_money > 0`:只统计赠送额为正的活动行(负数=抵扣,不进赠送池);
  202. // - `cas.activity_money_used < cas.activity_money`:只统计赠送池**尚未用尽**的行——
  203. // 用尽的行其赠送额已全部转为可用余额且不可再退,故不参与本次扣减。
  204. //
  205. // 作用域:**显式**按 `br.institution_id` / `br.campus_id` 过滤,**不**用 `BaseRepo.Scoped`——
  206. // Scoped 生成的是裸列名 `institution_id = ?`,在 JOIN 下两表同名列会二义、SQL 直接报错;
  207. // 这里把等价过滤写成别名限定列,安全边界不变(普通角色同样被钉在本机构 + 本校区,超管由调用方
  208. // 的 campusID 入参钉住,与 Scoped 的语义差异仅"不因超管而放宽",属于更严而非更宽)。
  209. //
  210. // 精度:**Go 侧定点累加**(本仓红线:禁止 `SQL SUM(decimal)` 回 REAL 再转 Amount)。
  211. //
  212. // 入参:
  213. // - ctx 请求上下文;
  214. // - userID 用户(ULID;空 → ErrBadParam);
  215. // - institutionID 机构 ID(**必须 > 0**:与 dump 的 `schoolid` 等值);
  216. // - campusID 校区 ID(**必须 > 0**:与 dump 的 `campusid` 等值)。
  217. //
  218. // 出参:
  219. // - money.Amount:已用赠送额合计(分;无匹配行 → 0,**不返回 ErrNotFound**);
  220. // - ErrBadParam / ErrDBError。
  221. func (r *BalanceRequestRepo) SumRechargeActivityUsedGift(
  222. ctx context.Context, userID string, institutionID, campusID int64,
  223. ) (money.Amount, error) {
  224. if strings.TrimSpace(userID) == "" {
  225. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  226. errString("repository: SumRechargeActivityUsedGift 需要 user_id"))
  227. }
  228. if institutionID <= 0 || campusID <= 0 {
  229. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  230. errString("repository: SumRechargeActivityUsedGift 需要 institution_id>0 且 campus_id>0"))
  231. }
  232. db, err := r.db(ctx)
  233. if err != nil {
  234. return 0, err
  235. }
  236. // 扫描目标用 *string:LEFT JOIN 未命中时该列为 NULL,string 直接接会扫描报错。
  237. type usedGiftRow struct {
  238. ActivityMoneyUsed *string `gorm:"column:activity_money_used"`
  239. }
  240. rows := make([]usedGiftRow, 0, 8)
  241. scanErr := db.Table("t_user_balance_request AS br").
  242. Select("cas.activity_money_used AS activity_money_used").
  243. Joins("LEFT JOIN t_courses_activity_signed AS cas ON br.biz_no = cas.request_no").
  244. Where("br.type = ? AND br.user_id = ? AND br.institution_id = ? AND br.campus_id = ? AND br.approved = ?",
  245. model.BalanceRequestTypeRecharge, userID, institutionID, campusID,
  246. model.BalanceRequestApprovedPassed).
  247. Where("cas.activity_money > 0 AND cas.activity_money_used < cas.activity_money").
  248. Scan(&rows).Error
  249. if scanErr != nil {
  250. return 0, infraerrors.WithCause(infraerrors.ErrDBError, scanErr)
  251. }
  252. total := money.Amount(0)
  253. for i := range rows {
  254. if rows[i].ActivityMoneyUsed == nil {
  255. continue // LEFT JOIN 未命中的占位行(不可能进入 where,防御性跳过)
  256. }
  257. amount, perr := money.Parse(*rows[i].ActivityMoneyUsed)
  258. if perr != nil {
  259. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  260. }
  261. total = total.Add(amount)
  262. }
  263. return total, nil
  264. }
  265. // ListByUser 分页列出某用户的工单(按 id 倒序,最新在前)。
  266. //
  267. // 入参:
  268. // - ctx 请求上下文;
  269. // - userID 用户(ULID;空 → ErrBadParam);
  270. // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
  271. //
  272. // 出参:
  273. // - []model.UserBalanceRequest:**空结果返回长度 0 的非 nil 切片**;
  274. // - ErrBadParam / ErrDBError。
  275. //
  276. // 作用域:经 Scoped(普通角色只见本机构工单;超管全量)。
  277. func (r *BalanceRequestRepo) ListByUser(ctx context.Context, userID string, page PageQuery) ([]model.UserBalanceRequest, error) {
  278. if strings.TrimSpace(userID) == "" {
  279. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByUser 需要 user_id"))
  280. }
  281. db, err := r.db(ctx)
  282. if err != nil {
  283. return nil, err
  284. }
  285. pageSize := page.Limit()
  286. rows := make([]model.UserBalanceRequest, 0, pageSize)
  287. q := r.Scoped(db.Model(&model.UserBalanceRequest{}))
  288. err = q.Where("user_id = ?", userID).
  289. Order("id DESC").Limit(pageSize).Offset(page.Offset()).
  290. Find(&rows).Error
  291. if err != nil {
  292. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  293. }
  294. if rows == nil {
  295. rows = make([]model.UserBalanceRequest, 0)
  296. }
  297. return rows, nil
  298. }