expense_repo.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. // 余额账户仓储(t_user_expense / t_user_expense_school / t_user_expense_log)——AC-03 资金主账。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §1.5(幂等键 uk_request_type)/ §1.7(两条例外)
  4. // / §10.2(type 编码与方向口径)/ §10.3(事务边界只在 Service)/ §10.6(分页钳制)。
  5. //
  6. // 三张表的性质差异(**决定了作用域处理方式,读代码前必看**):
  7. //
  8. // t_user_expense 全局钱包,**无** institution_id/campus_id(§1.7 例外一)
  9. // → 本仓储的"全局"方法**不吃** Scoped(叠作用域会 Unknown column);
  10. // 越权断言由 Service 经 port.MembershipChecker 完成,仓储只按 user_id 精确取数。
  11. // t_user_expense_school 校区账户,带作用域列 + UK(user_id, institution_id, campus_id)
  12. // → "校区"方法**一律经 Scoped**(普通角色强制本机构,超管放行)。
  13. // t_user_expense_log 流水,只增不改,**无**作用域列(同 t_user_expense)
  14. // → 按 user_id 取数;幂等由 UNIQUE(request_id, type) 兜底。
  15. //
  16. // 金额纪律(红线,代码评审逐处检查):
  17. // - 入参出参一律 `money.Amount`(分,int64),**禁止 float64**;
  18. // - 写库一律 `col = col + ?` **原子表达式**(禁读-改-写,防丢更新),金额以 decimal
  19. // **字符串字面量**入场(`money.Amount.String()`,精确无浮点);
  20. // - 汇总(SumLogMoney)在 **Go 侧定点累加**,禁止 `SQL SUM(decimal)` 回 REAL 再转(精度漂移)。
  21. //
  22. // 事务纪律:本文件**不开事务**,所有写方法必须接收调用方的 `tx *gorm.DB`(设计 §10.3);
  23. // 全局 + 校区双写必须由**同一个 tx** 串起来,且固定顺序「全局 → 校区」(model 红线:防死锁)。
  24. package repository
  25. import (
  26. "context"
  27. "strings"
  28. "time"
  29. "gorm.io/gorm"
  30. "gorm.io/gorm/clause"
  31. "yuansiwei/unified-backend/internal/academic/model"
  32. "yuansiwei/unified-backend/internal/academic/money"
  33. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  34. appscope "yuansiwei/unified-backend/internal/infra/scope"
  35. )
  36. // ErrDuplicateLog 幂等重复哨兵:t_user_expense_log 撞 `uk_request_type`(request_id+type)。
  37. //
  38. // 语义(设计 §1.5):同一资金请求号 + 同一 type 的第二次写入**不是错误**,而是幂等命中——
  39. // Service 捕获本 sentinel 后返回**首次结果**(code 仍 200,Repeated=true),不得抛 5002。
  40. //
  41. // 判定方式:`stderrors.Is(err, repository.ErrDuplicateLog)`。
  42. var ErrDuplicateLog = infraerrors.New(infraerrors.CodeUnique, "重复的余额变更请求(request_id+type 已存在)")
  43. // ErrAccountNotFound 账户行缺失哨兵:原子增减命中 0 行(账户未创建)。
  44. //
  45. // 语义:资金方法要求账户**先存在**(Service 在事务开头 UpsertGlobal/UpsertCampus 建户),
  46. // 命中 0 行说明调用顺序错误或账户被误删 → 必须报错回滚,禁止静默跳过(否则出现"流水有、余额没变")。
  47. var ErrAccountNotFound = infraerrors.New(infraerrors.CodeNotFound, "余额账户不存在(须先 Upsert 建户)")
  48. // 余额账户列名白名单(原子增减的目标列,禁止调用方透传字符串 → 防列名注入)。
  49. const (
  50. // expenseColBalance 余额列(可正可负,负数=欠费)。
  51. expenseColBalance = "balance"
  52. // expenseColSpend 上课消费课费单价之和(只增)。
  53. expenseColSpend = "spend"
  54. // expenseColSpecialExpenses 特殊费用(坏账/清零/提前退费累加,只增)。
  55. expenseColSpecialExpenses = "special_expenses"
  56. // expenseColTotalIncome 总充值。
  57. expenseColTotalIncome = "total_income"
  58. // expenseColTotalExpenditure 总消费。
  59. expenseColTotalExpenditure = "total_expenditure"
  60. )
  61. // ExpenseRepo 余额账户仓储(全局钱包 + 校区账户 + 流水)。
  62. //
  63. // 继承 BaseRepo 的目的:**校区账户**(t_user_expense_school)强制机构/校区作用域;
  64. // 全局钱包与流水(无作用域列)的方法显式标注「平台级数据,不吃 Scoped」(§1.7 例外一)。
  65. type ExpenseRepo struct {
  66. *BaseRepo
  67. }
  68. // NewExpenseRepo 构造余额账户仓储。
  69. //
  70. // 入参:
  71. // - db *gorm.DB:连接池句柄(必填);
  72. // - sc *appscope.Scope:请求作用域(校区账户读写必需;系统任务传 nil 时仅全局/流水方法可用)。
  73. //
  74. // 出参:*ExpenseRepo(CampusScoped=true:t_user_expense_school 带 campus_id)。
  75. func NewExpenseRepo(db *gorm.DB, sc *appscope.Scope) *ExpenseRepo {
  76. r := &ExpenseRepo{BaseRepo: NewBaseRepo(db, sc)}
  77. r.CampusScoped = true
  78. return r
  79. }
  80. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  81. //
  82. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  83. func (r *ExpenseRepo) db(ctx context.Context) (*gorm.DB, error) {
  84. if r == nil || r.DB == nil {
  85. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenseRepo.DB 为 nil"))
  86. }
  87. if tx := TxFromContext(ctx); tx != nil {
  88. return tx, nil
  89. }
  90. if ctx == nil {
  91. return r.DB, nil
  92. }
  93. return r.DB.WithContext(ctx), nil
  94. }
  95. // amountLiteral 把金额压成 SQL 用的 decimal 字符串字面量(越界 → ErrBadParam)。
  96. //
  97. // 入参:delta money.Amount(分)。出参:string("12.34"/"-0.01")+ error。
  98. //
  99. // 为什么用字符串而不是 int64:列类型是 decimal(12,2),直接传 int64 会被当成"元"的整数写入;
  100. // 传两位小数字符串由 DB 精确解析,全程不经过 float64(红线)。
  101. func amountLiteral(delta money.Amount) (string, error) {
  102. if err := delta.Validate(); err != nil {
  103. return "", infraerrors.WithCause(infraerrors.ErrBadParam, err)
  104. }
  105. return delta.String(), nil
  106. }
  107. // UpsertGlobal 建全局钱包账户(**不动已有余额**,UPSERT 语义)。
  108. //
  109. // 入参:
  110. // - ctx 请求上下文;
  111. // - tx 事务句柄(**必传**,nil → ErrInternal);
  112. // - row 账户行(UserID 非空;金额列留空则补 "0.00")。
  113. //
  114. // 出参:nil 成功;ErrBadParam(缺 user_id/金额非法)/ ErrDBError。
  115. //
  116. // 语义(关键):`ON CONFLICT(user_id) DO NOTHING` —— 冲突时**绝不**覆盖 balance/spend 等列
  117. // (UPSERT 只负责"建户",资金变动一律走 Add* 原子表达式,否则并发充值会丢更新)。
  118. //
  119. // 平台级数据(t_user_expense 无作用域列,§1.7 例外一):越权断言在 Service 层。
  120. func (r *ExpenseRepo) UpsertGlobal(ctx context.Context, tx *gorm.DB, row *model.UserExpense) error {
  121. if err := requireTx(tx, "UpsertGlobal"); err != nil {
  122. return err
  123. }
  124. if row == nil || strings.TrimSpace(row.UserID) == "" {
  125. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpsertGlobal 需要 user_id"))
  126. }
  127. if err := normalizeExpenseAmounts(&row.Balance, &row.Spend, &row.TotalIncome,
  128. &row.TotalExpenditure, &row.TotalRecommendedAmount, &row.SpecialExpenses); err != nil {
  129. return err
  130. }
  131. err := tx.WithContext(ctxOrBackground(ctx)).
  132. Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoNothing: true}).
  133. Create(row).Error
  134. if err != nil {
  135. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  136. }
  137. return nil
  138. }
  139. // UpsertCampus 建校区账户(**不动已有余额**,UPSERT 语义;服务端补齐作用域机构)。
  140. //
  141. // 入参:
  142. // - ctx 请求上下文;
  143. // - tx 事务句柄(**必传**);
  144. // - row 账户行(UserID 非空;InstitutionID=0 时用请求作用域补齐;CampusID 允许 0=机构级)。
  145. //
  146. // 出参:nil 成功;ErrBadParam / ErrUnscoped(作用域缺失无法补齐机构)/ ErrDBError。
  147. //
  148. // SQL:`INSERT ... ON CONFLICT(user_id, institution_id, campus_id) DO NOTHING`
  149. // (UK uk_user_inst_campus 兜底并发建户;冲突不覆盖余额,理由同 UpsertGlobal)。
  150. func (r *ExpenseRepo) UpsertCampus(ctx context.Context, tx *gorm.DB, row *model.UserExpenseSchool) error {
  151. if err := requireTx(tx, "UpsertCampus"); err != nil {
  152. return err
  153. }
  154. if row == nil || strings.TrimSpace(row.UserID) == "" {
  155. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpsertCampus 需要 user_id"))
  156. }
  157. if row.InstitutionID == 0 {
  158. inst, _ := r.GetScopedID()
  159. if inst <= 0 {
  160. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  161. errString("repository: UpsertCampus 缺少 institution_id 且作用域无机构"))
  162. }
  163. row.InstitutionID = inst
  164. }
  165. if row.CampusID < 0 {
  166. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: campus_id 不得为负(0=机构级)"))
  167. }
  168. if err := normalizeExpenseAmounts(&row.Balance, &row.Spend, &row.TotalIncome,
  169. &row.TotalExpenditure, &row.TotalRecommendedAmount, &row.SpecialExpenses); err != nil {
  170. return err
  171. }
  172. err := tx.WithContext(ctxOrBackground(ctx)).
  173. Clauses(clause.OnConflict{
  174. Columns: []clause.Column{{Name: "user_id"}, {Name: "institution_id"}, {Name: "campus_id"}},
  175. DoNothing: true,
  176. }).
  177. Create(row).Error
  178. if err != nil {
  179. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  180. }
  181. return nil
  182. }
  183. // normalizeExpenseAmounts 把空金额列补成 "0.00",非法金额 → ErrBadParam。
  184. //
  185. // 入参:待写入行的 6 个金额列地址(全局/校区两张表列名一致,复用同一归一路径)。
  186. // 出参:nil 合法;ErrBadParam(格式非法)。
  187. //
  188. // 说明:仅做**格式校验 + 空值补齐**,不做四舍五入/截断(金额语义由 Service 负责)。
  189. func normalizeExpenseAmounts(fields ...*string) error {
  190. for _, f := range fields {
  191. if f == nil {
  192. continue
  193. }
  194. if strings.TrimSpace(*f) == "" {
  195. *f = "0.00"
  196. continue
  197. }
  198. if _, err := money.Parse(*f); err != nil {
  199. return infraerrors.WithCause(infraerrors.ErrBadParam, err)
  200. }
  201. }
  202. return nil
  203. }
  204. // AddBalance 余额原子增减(`balance = balance + ?`)。
  205. //
  206. // 入参:
  207. // - ctx 请求上下文;
  208. // - tx 事务句柄(**必传**:全局 + 校区双写必须同事务);
  209. // - userID 用户(ULID;空 → ErrBadParam);
  210. // - campusID 校区 ID:**>0 → 全局账户 + 该校区账户双写**(固定顺序「全局→校区」防死锁);
  211. // **==0 → 只更新全局账户**(全局钱包主口径);
  212. // - delta 增量(正=入账,负=出账;0 → 空操作返回 0)。
  213. //
  214. // 出参:
  215. // - int64:**全局账户**受影响行数(1=成功;0 且无错=delta 为 0);
  216. // - ErrAccountNotFound(账户未建户)/ ErrBadParam / ErrDBError。
  217. //
  218. // 红线:
  219. // - 一律 `balance = balance + ?` 原子表达式,**禁止**先 SELECT 再 UPDATE(并发丢更新);
  220. // - 与流水写入(InsertLog)必须同事务,否则出现"流水有、余额没变";
  221. // - 机构级校区账户(campus_id=0 行)不在本方法范围内(与全局账户的 0 值语义冲突,
  222. // 需要时由 Service 走独立的校区账户增减接口,已在 AC-03 交接中标注)。
  223. func (r *ExpenseRepo) AddBalance(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  224. return r.addColumnAtomic(ctx, tx, "AddBalance", expenseColBalance, userID, campusID, delta)
  225. }
  226. // AddSpend 上课消费课费单价之和原子累加(`spend = spend + ?`,只增)。
  227. //
  228. // 入参/出参/事务语义同 AddBalance。红线:**本列不动 balance**(dump 的 spend SP 只统计口径),
  229. // 真正扣费走 AddBalance,混用会造成余额与统计双错。
  230. func (r *ExpenseRepo) AddSpend(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  231. return r.addColumnAtomic(ctx, tx, "AddSpend", expenseColSpend, userID, campusID, delta)
  232. }
  233. // AddSpecialExpense 特殊费用原子累加(`special_expenses = special_expenses + ?`)。
  234. //
  235. // 使用场景:坏账处理 / 余额清零 / 充值活动提前退费差额(只增不减,对账口径列)。
  236. // 入参/出参/事务语义同 AddBalance。
  237. func (r *ExpenseRepo) AddSpecialExpense(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  238. return r.addColumnAtomic(ctx, tx, "AddSpecialExpense", expenseColSpecialExpenses, userID, campusID, delta)
  239. }
  240. // AddTotalIncome 总充值原子累加(`total_income = total_income + ?`)。
  241. //
  242. // 使用场景:充值 +、退费 −、清零不动、转结双向。入参/出参/事务语义同 AddBalance。
  243. func (r *ExpenseRepo) AddTotalIncome(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  244. return r.addColumnAtomic(ctx, tx, "AddTotalIncome", expenseColTotalIncome, userID, campusID, delta)
  245. }
  246. // AddTotalExpenditure 总消费原子累加(`total_expenditure = total_expenditure + ?`)。
  247. //
  248. // 使用场景:消费 +、退费 −、转结双向调整(与 t_user_expense_school 同名列须同事务,本方法已双写)。
  249. // 入参/出参/事务语义同 AddBalance。
  250. func (r *ExpenseRepo) AddTotalExpenditure(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  251. return r.addColumnAtomic(ctx, tx, "AddTotalExpenditure", expenseColTotalExpenditure, userID, campusID, delta)
  252. }
  253. // addColumnAtomic 单列原子增减收口(5 个 Add* 方法的唯一实现,column 来自白名单常量)。
  254. //
  255. // 入参:
  256. // - ctx/tx:请求上下文 + **必传**事务句柄;
  257. // - op:调用方方法名(仅用于错误文案定位);
  258. // - column:列名(**只接受本文件 expenseCol* 常量**,内部白名单校验,防列名注入);
  259. // - userID/campusID/delta:同 AddBalance。
  260. //
  261. // 出参:(全局受影响行数, error)。campusID>0 时:先全局后校区,任一 0 行 → ErrAccountNotFound。
  262. //
  263. // SQL 形状(必须保持"表达式 + 参数",不得退化为读-改-写):
  264. //
  265. // UPDATE t_user_expense SET <col> = <col> + ? WHERE user_id = ?
  266. // UPDATE t_user_expense_school SET <col> = <col> + ? WHERE user_id = ? AND campus_id = ?
  267. // [AND institution_id = ?] -- Scoped 注入(普通角色)
  268. func (r *ExpenseRepo) addColumnAtomic(ctx context.Context, tx *gorm.DB, op, column, userID string, campusID int64, delta money.Amount) (int64, error) {
  269. if err := requireTx(tx, op); err != nil {
  270. return 0, err
  271. }
  272. if !isExpenseColumn(column) {
  273. return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: 非法列名 "+column))
  274. }
  275. if strings.TrimSpace(userID) == "" {
  276. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id"))
  277. }
  278. if delta.IsZero() {
  279. return 0, nil
  280. }
  281. lit, err := amountLiteral(delta)
  282. if err != nil {
  283. return 0, err
  284. }
  285. db := tx.WithContext(ctxOrBackground(ctx))
  286. // ① 全局钱包(固定先全局,后校区:两事务并发时锁顺序一致,避免死锁)
  287. global := db.Model(&model.UserExpense{}).
  288. Where("user_id = ?", userID).
  289. UpdateColumn(column, gorm.Expr(column+" + ?", lit))
  290. if global.Error != nil {
  291. return 0, infraerrors.WithCause(infraerrors.ErrDBError, global.Error)
  292. }
  293. if global.RowsAffected == 0 {
  294. return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 全局账户未建户"))
  295. }
  296. // ② 校区账户(仅 campusID>0;普通角色的机构/校区过滤由 Scoped 强制)
  297. if campusID > 0 {
  298. campus := r.Scoped(db.Model(&model.UserExpenseSchool{})).
  299. Where("user_id = ? AND campus_id = ?", userID, campusID).
  300. UpdateColumn(column, gorm.Expr(column+" + ?", lit))
  301. if campus.Error != nil {
  302. return 0, infraerrors.WithCause(infraerrors.ErrDBError, campus.Error)
  303. }
  304. if campus.RowsAffected == 0 {
  305. return 0, infraerrors.WithCause(ErrAccountNotFound,
  306. errString("repository: "+op+" 校区账户未建户(user_id/campus_id 不匹配)"))
  307. }
  308. }
  309. return global.RowsAffected, nil
  310. }
  311. // isExpenseColumn 列名白名单校验(只接受本文件 expenseCol* 常量)。
  312. func isExpenseColumn(column string) bool {
  313. switch column {
  314. case expenseColBalance, expenseColSpend, expenseColSpecialExpenses,
  315. expenseColTotalIncome, expenseColTotalExpenditure:
  316. return true
  317. default:
  318. return false
  319. }
  320. }
  321. // GetForUpdate 取全局钱包账户并加行锁(**退费/清零前必调**,防超退)。
  322. //
  323. // 入参:
  324. // - ctx 请求上下文;
  325. // - tx 事务句柄(**必传**:行锁只在事务内有效);
  326. // - userID 用户(ULID;空 → ErrBadParam)。
  327. //
  328. // 出参:
  329. // - *model.UserExpense:账户行(金额为字符串原文,调用方用 money.Parse 转 Amount);
  330. // - ErrNotFound(账户不存在)/ ErrBadParam / ErrDBError。
  331. //
  332. // SQL:`SELECT * FROM t_user_expense WHERE user_id = ? LIMIT 1 FOR UPDATE`(MySQL 渲染;
  333. // sqlite 单测栈不渲染行锁,但 clause 已挂载,见单测断言口径)。
  334. //
  335. // 平台级数据(无作用域列),越权断言在 Service。
  336. func (r *ExpenseRepo) GetForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.UserExpense, error) {
  337. if err := requireTx(tx, "GetForUpdate"); err != nil {
  338. return nil, err
  339. }
  340. if strings.TrimSpace(userID) == "" {
  341. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetForUpdate 需要 user_id"))
  342. }
  343. var out model.UserExpense
  344. err := tx.WithContext(ctxOrBackground(ctx)).
  345. Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  346. Where("user_id = ?", userID).
  347. Take(&out).Error
  348. if err != nil {
  349. return nil, wrapQueryErr(err, "GetForUpdate(t_user_expense)")
  350. }
  351. return &out, nil
  352. }
  353. // GetCampusForUpdate 取校区账户并加行锁(校区退费/清零前必调)。
  354. //
  355. // 入参:ctx / tx(**必传**)/ userID / campusID(>=0;0=机构级账户)。
  356. // 出参:*model.UserExpenseSchool + ErrNotFound/ErrBadParam/ErrDBError;
  357. // 普通角色跨机构 → ErrNotFound(Scoped 过滤,不泄露存在性)。
  358. //
  359. // SQL:`SELECT * FROM t_user_expense_school WHERE user_id=? AND campus_id=? [AND institution_id=?]
  360. // LIMIT 1 FOR UPDATE`。
  361. func (r *ExpenseRepo) GetCampusForUpdate(ctx context.Context, tx *gorm.DB, userID string, campusID int64) (*model.UserExpenseSchool, error) {
  362. if err := requireTx(tx, "GetCampusForUpdate"); err != nil {
  363. return nil, err
  364. }
  365. if strings.TrimSpace(userID) == "" || campusID < 0 {
  366. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  367. errString("repository: GetCampusForUpdate 需要 user_id 且 campus_id >= 0"))
  368. }
  369. var out model.UserExpenseSchool
  370. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)))
  371. err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  372. Where("user_id = ? AND campus_id = ?", userID, campusID).
  373. Take(&out).Error
  374. if err != nil {
  375. return nil, wrapQueryErr(err, "GetCampusForUpdate(t_user_expense_school)")
  376. }
  377. return &out, nil
  378. }
  379. // GetBalance 只读查询余额(**无行锁**,列表/详情展示用)。
  380. //
  381. // 入参:ctx 请求上下文;userID(空 → ErrBadParam);campusID(**0 = 查全局钱包**,>0 = 查该校区账户)。
  382. // 出参:
  383. // - money.Amount:余额(分;账户不存在返回 0 且 err = ErrNotFound);
  384. // - ErrNotFound / ErrBadParam / ErrDBError。
  385. //
  386. // 红线:**不得**用本方法的结果做"够不够扣"的判断再扣费(读-改-写竞态),
  387. // 资金判定必须在事务内用 GetForUpdate / GetCampusForUpdate 的加锁读。
  388. func (r *ExpenseRepo) GetBalance(ctx context.Context, userID string, campusID int64) (money.Amount, error) {
  389. if strings.TrimSpace(userID) == "" {
  390. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetBalance 需要 user_id"))
  391. }
  392. db, err := r.db(ctx)
  393. if err != nil {
  394. return 0, err
  395. }
  396. var raw string
  397. if campusID > 0 {
  398. var row model.UserExpenseSchool
  399. q := r.Scoped(db.Model(&model.UserExpenseSchool{}))
  400. if err := q.Where("user_id = ? AND campus_id = ?", userID, campusID).
  401. Select("balance").Take(&row).Error; err != nil {
  402. return 0, wrapQueryErr(err, "GetBalance(t_user_expense_school)")
  403. }
  404. raw = row.Balance
  405. } else {
  406. var row model.UserExpense
  407. if err := db.Model(&model.UserExpense{}).Where("user_id = ?", userID).
  408. Select("balance").Take(&row).Error; err != nil {
  409. return 0, wrapQueryErr(err, "GetBalance(t_user_expense)")
  410. }
  411. raw = row.Balance
  412. }
  413. amount, perr := money.Parse(raw)
  414. if perr != nil {
  415. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  416. }
  417. return amount, nil
  418. }
  419. // InsertLog 写入余额变更流水(**只增不改**;幂等键 request_id+type)。
  420. //
  421. // 入参:
  422. // - ctx 请求上下文;
  423. // - tx 事务句柄(**必传**:必须与余额增减同事务);
  424. // - log 流水行(UserID 非空、Type != 0、Money 合法;RequestID 为幂等键,历史数据可 NULL)。
  425. //
  426. // 出参:
  427. // - nil:首次写入成功;
  428. // - ErrDuplicateLog(CodeUnique 3003):撞 uk_request_type,**属幂等命中**,
  429. // Service 捕获 `stderrors.Is(err, repository.ErrDuplicateLog)` 后返回首次结果(不得抛 5002);
  430. // - ErrBadParam / ErrDBError。
  431. //
  432. // 平台级数据(流水无作用域列),越权断言在 Service。
  433. func (r *ExpenseRepo) InsertLog(ctx context.Context, tx *gorm.DB, log *model.UserExpenseLog) error {
  434. if err := requireTx(tx, "InsertLog"); err != nil {
  435. return err
  436. }
  437. if log == nil || strings.TrimSpace(log.UserID) == "" {
  438. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 user_id"))
  439. }
  440. if log.Type == 0 {
  441. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 type"))
  442. }
  443. if err := normalizeExpenseAmounts(&log.Money); err != nil {
  444. return err
  445. }
  446. if log.BalanceAfter != nil {
  447. if err := normalizeExpenseAmounts(log.BalanceAfter); err != nil {
  448. return err
  449. }
  450. }
  451. err := tx.WithContext(ctxOrBackground(ctx)).Create(log).Error
  452. if err != nil {
  453. if IsDupKey(err) {
  454. // 幂等命中:同一 request_id + type 重复提交 → 返回 sentinel,由 Service 决定"返回首次结果"
  455. return infraerrors.WithCause(ErrDuplicateLog, err)
  456. }
  457. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  458. }
  459. return nil
  460. }
  461. // ListLogs 分页查流水(按创建时间倒序,时间窗可空)。
  462. //
  463. // 入参:
  464. // - ctx 请求上下文;
  465. // - userID 用户(ULID;空 → ErrBadParam;流水表无作用域列 → 越权断言在 Service);
  466. // - campusIDs **必须为空**:t_user_expense_log 无 campus_id 列(DDL 第二部分「3.」),
  467. // 传非空值一律 ErrBadParam —— 明确拒绝优于"看起来筛了其实没筛"的静默越权;
  468. // 校区维度流水视图需 Service 侧 join t_user_expense_school 后叠加(不在本方法职责内);
  469. // - start/end 创建时间窗(零值 = 不限;两端**闭区间**);
  470. // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
  471. //
  472. // 出参:
  473. // - []model.UserExpenseLog:**空结果返回长度 0 的非 nil 切片**(JSON 序列化为 [] 而非 null);
  474. // - ErrBadParam / ErrDBError。
  475. func (r *ExpenseRepo) ListLogs(ctx context.Context, userID string, campusIDs []int64, start, end time.Time, page PageQuery) ([]model.UserExpenseLog, error) {
  476. if strings.TrimSpace(userID) == "" {
  477. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListLogs 需要 user_id"))
  478. }
  479. if len(campusIDs) > 0 {
  480. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  481. errString("repository: ListLogs 不支持 campus_id 过滤(t_user_expense_log 无该列)"))
  482. }
  483. db, err := r.db(ctx)
  484. if err != nil {
  485. return nil, err
  486. }
  487. q := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID)
  488. if !start.IsZero() {
  489. q = q.Where("create_time >= ?", start)
  490. }
  491. if !end.IsZero() {
  492. q = q.Where("create_time <= ?", end)
  493. }
  494. pageSize := page.Limit()
  495. rows := make([]model.UserExpenseLog, 0, pageSize)
  496. err = q.Order("create_time DESC").Order("id DESC").
  497. Limit(pageSize).Offset(page.Offset()).
  498. Find(&rows).Error
  499. if err != nil {
  500. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  501. }
  502. if rows == nil {
  503. rows = make([]model.UserExpenseLog, 0)
  504. }
  505. return rows, nil
  506. }
  507. // SumLogMoney 汇总流水金额(对账用;**Go 侧定点累加**)。
  508. //
  509. // 入参:
  510. // - ctx 请求上下文;
  511. // - userID 用户(ULID;空 → ErrBadParam);
  512. // - campusIDs **必须为空**(理由同 ListLogs:流水表无 campus_id 列);
  513. // - types 类型白名单(空 = 全部类型;非空时 `type IN (...)`)。
  514. //
  515. // 出参:
  516. // - money.Amount:金额合计(分;正负按流水方向自然累加;无数据为 0);
  517. // - ErrBadParam / ErrDBError。
  518. //
  519. // 红线:**禁止** `SELECT SUM(money)` —— MySQL 会把 decimal 求和结果转 REAL(浮点),
  520. // 与 money.Amount 的定点口径冲突;本方法 Sql 只取原始字符串,累加在 Go 侧 `money.Parse` 后完成。
  521. func (r *ExpenseRepo) SumLogMoney(ctx context.Context, userID string, campusIDs []int64, types []int) (money.Amount, error) {
  522. if strings.TrimSpace(userID) == "" {
  523. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumLogMoney 需要 user_id"))
  524. }
  525. if len(campusIDs) > 0 {
  526. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  527. errString("repository: SumLogMoney 不支持 campus_id 过滤(t_user_expense_log 无该列)"))
  528. }
  529. db, err := r.db(ctx)
  530. if err != nil {
  531. return 0, err
  532. }
  533. q := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID)
  534. if len(types) > 0 {
  535. q = q.Where("type IN ?", types)
  536. }
  537. var rows []model.UserExpenseLog
  538. if err := q.Select("money").Find(&rows).Error; err != nil {
  539. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  540. }
  541. total := money.Amount(0)
  542. for i := range rows {
  543. amount, perr := money.Parse(rows[i].Money)
  544. if perr != nil {
  545. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  546. }
  547. total = total.Add(amount)
  548. }
  549. return total, nil
  550. }