expense_repo.go 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  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. "errors"
  28. "strings"
  29. "time"
  30. "gorm.io/gorm"
  31. "gorm.io/gorm/clause"
  32. "yuansiwei/unified-backend/internal/academic/model"
  33. "yuansiwei/unified-backend/internal/academic/money"
  34. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  35. appscope "yuansiwei/unified-backend/internal/infra/scope"
  36. )
  37. // ErrDuplicateLog 幂等重复哨兵:t_user_expense_log 撞 `uk_request_type`(request_id+type)。
  38. //
  39. // 语义(设计 §1.5):同一资金请求号 + 同一 type 的第二次写入**不是错误**,而是幂等命中——
  40. // Service 捕获本 sentinel 后返回**首次结果**(code 仍 200,Repeated=true),不得抛 5002。
  41. //
  42. // 判定方式:`stderrors.Is(err, repository.ErrDuplicateLog)`。
  43. var ErrDuplicateLog = infraerrors.New(infraerrors.CodeUnique, "重复的余额变更请求(request_id+type 已存在)")
  44. // ErrAccountNotFound 账户行缺失哨兵:原子增减命中 0 行(账户未创建)。
  45. //
  46. // 语义:资金方法要求账户**先存在**(Service 在事务开头 UpsertGlobal/UpsertCampus 建户),
  47. // 命中 0 行说明调用顺序错误或账户被误删 → 必须报错回滚,禁止静默跳过(否则出现"流水有、余额没变")。
  48. var ErrAccountNotFound = infraerrors.New(infraerrors.CodeNotFound, "余额账户不存在(须先 Upsert 建户)")
  49. // 余额账户列名白名单(原子增减的目标列,禁止调用方透传字符串 → 防列名注入)。
  50. const (
  51. // expenseColBalance 余额列(可正可负,负数=欠费)。
  52. expenseColBalance = "balance"
  53. // expenseColSpend 上课消费课费单价之和(只增)。
  54. expenseColSpend = "spend"
  55. // expenseColSpecialExpenses 特殊费用(坏账/清零/提前退费累加,只增)。
  56. expenseColSpecialExpenses = "special_expenses"
  57. // expenseColTotalIncome 总充值。
  58. expenseColTotalIncome = "total_income"
  59. // expenseColTotalExpenditure 总消费。
  60. expenseColTotalExpenditure = "total_expenditure"
  61. )
  62. // 列名常量的**导出别名**(AC-04/AC-05 的 Service 层构造 `ColumnDelta` 时使用)。
  63. //
  64. // 为什么导出:批量增减 primitive(AddColumnsGlobalThenCampus / AddCampusColumns)需要调用方
  65. // 声明"改哪几个列",而列名必须来自白名单(禁止字符串透传,防列名注入)。
  66. // 白名单校验仍由 `isExpenseColumn` 单点收口,导出别名只是让 service 不必复制字面量。
  67. const (
  68. // ExpenseColumnBalance 余额列。
  69. ExpenseColumnBalance = expenseColBalance
  70. // ExpenseColumnSpend 上课消费课费单价之和列(只增)。
  71. ExpenseColumnSpend = expenseColSpend
  72. // ExpenseColumnSpecialExpenses 特殊费用列(坏账/清零累加)。
  73. ExpenseColumnSpecialExpenses = expenseColSpecialExpenses
  74. // ExpenseColumnTotalIncome 总充值列。
  75. ExpenseColumnTotalIncome = expenseColTotalIncome
  76. // ExpenseColumnTotalExpenditure 总消费列。
  77. ExpenseColumnTotalExpenditure = expenseColTotalExpenditure
  78. )
  79. // ColumnDelta 单列增量(原子表达式 `col = col + ?` 的目标列 + 增量)。
  80. //
  81. // 用途:一次 UPDATE 同时改多列(dump 的 SP 每条 UPDATE 都同时改 2~3 列),
  82. // 避免"多列分多条语句"产生的中间态(对账/双跑会读到半成品)。
  83. //
  84. // 字段:
  85. // - Column string:列名(**必须**取自 ExpenseColumn* 常量,白名单校验);
  86. // - Delta money.Amount:增量(分;正=加,负=减,0=该列不参与本次更新)。
  87. type ColumnDelta struct {
  88. // Column 目标列名(ExpenseColumn* 常量)。
  89. Column string
  90. // Delta 增量(分)。
  91. Delta money.Amount
  92. }
  93. // buildColumnExprs 把 ColumnDelta 列表编译成 GORM 的"列 → 原子表达式"映射。
  94. //
  95. // 入参:op 操作名(错误文案定位);deltas 列增量列表(空 → ErrInternal,属装配错误)。
  96. // 出参:
  97. // - map[string]any:可直接交给 `UpdateColumns` 的更新映射(值为 gorm.Expr);
  98. // 所有 Delta 均为 0 时返回 **空 map**(调用方据此判定 no-op);
  99. // - ErrBadParam(非法列名/金额越界)/ ErrInternal。
  100. //
  101. // 红线:金额以 decimal **字符串字面量**入场(`Amount.String()`),全程不经 float64(仓库包注释)。
  102. func buildColumnExprs(op string, deltas []ColumnDelta) (map[string]any, error) {
  103. if len(deltas) == 0 {
  104. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  105. errString("repository: "+op+" 未指定任何列(属装配错误)"))
  106. }
  107. updates := make(map[string]any, len(deltas))
  108. for _, d := range deltas {
  109. if !isExpenseColumn(d.Column) {
  110. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  111. errString("repository: "+op+" 非法列名 "+d.Column))
  112. }
  113. if d.Delta.IsZero() {
  114. continue
  115. }
  116. lit, err := amountLiteral(d.Delta)
  117. if err != nil {
  118. return nil, err
  119. }
  120. updates[d.Column] = gorm.Expr(d.Column+" + ?", lit)
  121. }
  122. return updates, nil
  123. }
  124. // institutionScoped 只叠「机构」作用域(**故意不叠 `Sc.CampusID` 等值过滤**)。
  125. //
  126. // 为什么不能用 Scoped:多校区写操作(课费转结 = 两个校区一进一出)一旦叠加
  127. // `campus_id = Sc.CampusID`,第二个校区必然查不到 → 转结永远失败;
  128. // 而 `t_courses_activity` 系列还存在 `campus_id = 0`(全机构池)语义,等值过滤同样会漏行。
  129. //
  130. // 安全边界(不放松):机构过滤仍强制(超管除外),且**调用方必须显式传 campus_id**,
  131. // 二者一起把写入范围钉死在「本机构的指定校区」(比 Scoped 的等值更精确,不是更宽)。
  132. func (r *ExpenseRepo) institutionScoped(db *gorm.DB) *gorm.DB {
  133. if db == nil {
  134. return nil
  135. }
  136. if r == nil || r.Sc == nil {
  137. // 纵深防御:无作用域上下文禁止进入数据访问层(与 BaseRepo.Scoped 同口径 fail-fast)
  138. panic(errNoScope.Error())
  139. }
  140. if r.Sc.SuperAdmin {
  141. return db
  142. }
  143. return db.Where("institution_id = ?", r.Sc.InstitutionID)
  144. }
  145. // ExpenseRepo 余额账户仓储(全局钱包 + 校区账户 + 流水)。
  146. //
  147. // 继承 BaseRepo 的目的:**校区账户**(t_user_expense_school)强制机构/校区作用域;
  148. // 全局钱包与流水(无作用域列)的方法显式标注「平台级数据,不吃 Scoped」(§1.7 例外一)。
  149. type ExpenseRepo struct {
  150. *BaseRepo
  151. }
  152. // NewExpenseRepo 构造余额账户仓储。
  153. //
  154. // 入参:
  155. // - db *gorm.DB:连接池句柄(必填);
  156. // - sc *appscope.Scope:请求作用域(校区账户读写必需;系统任务传 nil 时仅全局/流水方法可用)。
  157. //
  158. // 出参:*ExpenseRepo(CampusScoped=true:t_user_expense_school 带 campus_id)。
  159. func NewExpenseRepo(db *gorm.DB, sc *appscope.Scope) *ExpenseRepo {
  160. r := &ExpenseRepo{BaseRepo: NewBaseRepo(db, sc)}
  161. r.CampusScoped = true
  162. return r
  163. }
  164. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  165. //
  166. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  167. func (r *ExpenseRepo) db(ctx context.Context) (*gorm.DB, error) {
  168. if r == nil || r.DB == nil {
  169. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenseRepo.DB 为 nil"))
  170. }
  171. if tx := TxFromContext(ctx); tx != nil {
  172. return tx, nil
  173. }
  174. if ctx == nil {
  175. return r.DB, nil
  176. }
  177. return r.DB.WithContext(ctx), nil
  178. }
  179. // amountLiteral 把金额压成 SQL 用的 decimal 字符串字面量(越界 → ErrBadParam)。
  180. //
  181. // 入参:delta money.Amount(分)。出参:string("12.34"/"-0.01")+ error。
  182. //
  183. // 为什么用字符串而不是 int64:列类型是 decimal(12,2),直接传 int64 会被当成"元"的整数写入;
  184. // 传两位小数字符串由 DB 精确解析,全程不经过 float64(红线)。
  185. func amountLiteral(delta money.Amount) (string, error) {
  186. if err := delta.Validate(); err != nil {
  187. return "", infraerrors.WithCause(infraerrors.ErrBadParam, err)
  188. }
  189. return delta.String(), nil
  190. }
  191. // UpsertGlobal 建全局钱包账户(**不动已有余额**,UPSERT 语义)。
  192. //
  193. // 入参:
  194. // - ctx 请求上下文;
  195. // - tx 事务句柄(**必传**,nil → ErrInternal);
  196. // - row 账户行(UserID 非空;金额列留空则补 "0.00")。
  197. //
  198. // 出参:nil 成功;ErrBadParam(缺 user_id/金额非法)/ ErrDBError。
  199. //
  200. // 语义(关键):`ON CONFLICT(user_id) DO NOTHING` —— 冲突时**绝不**覆盖 balance/spend 等列
  201. // (UPSERT 只负责"建户",资金变动一律走 Add* 原子表达式,否则并发充值会丢更新)。
  202. //
  203. // 平台级数据(t_user_expense 无作用域列,§1.7 例外一):越权断言在 Service 层。
  204. func (r *ExpenseRepo) UpsertGlobal(ctx context.Context, tx *gorm.DB, row *model.UserExpense) error {
  205. if err := requireTx(tx, "UpsertGlobal"); err != nil {
  206. return err
  207. }
  208. if row == nil || strings.TrimSpace(row.UserID) == "" {
  209. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpsertGlobal 需要 user_id"))
  210. }
  211. if err := normalizeExpenseAmounts(&row.Balance, &row.Spend, &row.TotalIncome,
  212. &row.TotalExpenditure, &row.TotalRecommendedAmount, &row.SpecialExpenses); err != nil {
  213. return err
  214. }
  215. err := tx.WithContext(ctxOrBackground(ctx)).
  216. Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoNothing: true}).
  217. Create(row).Error
  218. if err != nil {
  219. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  220. }
  221. return nil
  222. }
  223. // UpsertCampus 建校区账户(**不动已有余额**,UPSERT 语义;服务端补齐作用域机构)。
  224. //
  225. // 入参:
  226. // - ctx 请求上下文;
  227. // - tx 事务句柄(**必传**);
  228. // - row 账户行(UserID 非空;InstitutionID=0 时用请求作用域补齐;CampusID 允许 0=机构级)。
  229. //
  230. // 出参:nil 成功;ErrBadParam / ErrUnscoped(作用域缺失无法补齐机构)/ ErrDBError。
  231. //
  232. // SQL:`INSERT ... ON CONFLICT(user_id, institution_id, campus_id) DO NOTHING`
  233. // (UK uk_user_inst_campus 兜底并发建户;冲突不覆盖余额,理由同 UpsertGlobal)。
  234. func (r *ExpenseRepo) UpsertCampus(ctx context.Context, tx *gorm.DB, row *model.UserExpenseSchool) error {
  235. if err := requireTx(tx, "UpsertCampus"); err != nil {
  236. return err
  237. }
  238. if row == nil || strings.TrimSpace(row.UserID) == "" {
  239. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpsertCampus 需要 user_id"))
  240. }
  241. if row.InstitutionID == 0 {
  242. inst, _ := r.GetScopedID()
  243. if inst <= 0 {
  244. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  245. errString("repository: UpsertCampus 缺少 institution_id 且作用域无机构"))
  246. }
  247. row.InstitutionID = inst
  248. }
  249. if row.CampusID < 0 {
  250. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: campus_id 不得为负(0=机构级)"))
  251. }
  252. if err := normalizeExpenseAmounts(&row.Balance, &row.Spend, &row.TotalIncome,
  253. &row.TotalExpenditure, &row.TotalRecommendedAmount, &row.SpecialExpenses); err != nil {
  254. return err
  255. }
  256. err := tx.WithContext(ctxOrBackground(ctx)).
  257. Clauses(clause.OnConflict{
  258. Columns: []clause.Column{{Name: "user_id"}, {Name: "institution_id"}, {Name: "campus_id"}},
  259. DoNothing: true,
  260. }).
  261. Create(row).Error
  262. if err != nil {
  263. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  264. }
  265. return nil
  266. }
  267. // normalizeExpenseAmounts 把空金额列补成 "0.00",非法金额 → ErrBadParam。
  268. //
  269. // 入参:待写入行的 6 个金额列地址(全局/校区两张表列名一致,复用同一归一路径)。
  270. // 出参:nil 合法;ErrBadParam(格式非法)。
  271. //
  272. // 说明:仅做**格式校验 + 空值补齐**,不做四舍五入/截断(金额语义由 Service 负责)。
  273. func normalizeExpenseAmounts(fields ...*string) error {
  274. for _, f := range fields {
  275. if f == nil {
  276. continue
  277. }
  278. if strings.TrimSpace(*f) == "" {
  279. *f = "0.00"
  280. continue
  281. }
  282. if _, err := money.Parse(*f); err != nil {
  283. return infraerrors.WithCause(infraerrors.ErrBadParam, err)
  284. }
  285. }
  286. return nil
  287. }
  288. // AddBalance 余额原子增减(`balance = balance + ?`)。
  289. //
  290. // 入参:
  291. // - ctx 请求上下文;
  292. // - tx 事务句柄(**必传**:全局 + 校区双写必须同事务);
  293. // - userID 用户(ULID;空 → ErrBadParam);
  294. // - campusID 校区 ID:**>0 → 全局账户 + 该校区账户双写**(固定顺序「全局→校区」防死锁);
  295. // **==0 → 只更新全局账户**(全局钱包主口径);
  296. // - delta 增量(正=入账,负=出账;0 → 空操作返回 0)。
  297. //
  298. // 出参:
  299. // - int64:**全局账户**受影响行数(1=成功;0 且无错=delta 为 0);
  300. // - ErrAccountNotFound(账户未建户)/ ErrBadParam / ErrDBError。
  301. //
  302. // 红线:
  303. // - 一律 `balance = balance + ?` 原子表达式,**禁止**先 SELECT 再 UPDATE(并发丢更新);
  304. // - 与流水写入(InsertLog)必须同事务,否则出现"流水有、余额没变";
  305. // - 机构级校区账户(campus_id=0 行)不在本方法范围内(与全局账户的 0 值语义冲突,
  306. // 需要时由 Service 走独立的校区账户增减接口,已在 AC-03 交接中标注)。
  307. func (r *ExpenseRepo) AddBalance(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  308. return r.addColumnAtomic(ctx, tx, "AddBalance", expenseColBalance, userID, campusID, delta)
  309. }
  310. // AddSpend 上课消费课费单价之和原子累加(`spend = spend + ?`,只增)。
  311. //
  312. // 入参/出参/事务语义同 AddBalance。红线:**本列不动 balance**(dump 的 spend SP 只统计口径),
  313. // 真正扣费走 AddBalance,混用会造成余额与统计双错。
  314. func (r *ExpenseRepo) AddSpend(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  315. return r.addColumnAtomic(ctx, tx, "AddSpend", expenseColSpend, userID, campusID, delta)
  316. }
  317. // AddSpecialExpense 特殊费用原子累加(`special_expenses = special_expenses + ?`)。
  318. //
  319. // 使用场景:坏账处理 / 余额清零 / 充值活动提前退费差额(只增不减,对账口径列)。
  320. // 入参/出参/事务语义同 AddBalance。
  321. func (r *ExpenseRepo) AddSpecialExpense(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  322. return r.addColumnAtomic(ctx, tx, "AddSpecialExpense", expenseColSpecialExpenses, userID, campusID, delta)
  323. }
  324. // AddTotalIncome 总充值原子累加(`total_income = total_income + ?`)。
  325. //
  326. // 使用场景:充值 +、退费 −、清零不动、转结双向。入参/出参/事务语义同 AddBalance。
  327. func (r *ExpenseRepo) AddTotalIncome(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  328. return r.addColumnAtomic(ctx, tx, "AddTotalIncome", expenseColTotalIncome, userID, campusID, delta)
  329. }
  330. // AddTotalExpenditure 总消费原子累加(`total_expenditure = total_expenditure + ?`)。
  331. //
  332. // 使用场景:消费 +、退费 −、转结双向调整(与 t_user_expense_school 同名列须同事务,本方法已双写)。
  333. // 入参/出参/事务语义同 AddBalance。
  334. func (r *ExpenseRepo) AddTotalExpenditure(ctx context.Context, tx *gorm.DB, userID string, campusID int64, delta money.Amount) (int64, error) {
  335. return r.addColumnAtomic(ctx, tx, "AddTotalExpenditure", expenseColTotalExpenditure, userID, campusID, delta)
  336. }
  337. // addColumnAtomic 单列原子增减收口(5 个 Add* 方法的唯一实现,column 来自白名单常量)。
  338. //
  339. // 入参:
  340. // - ctx/tx:请求上下文 + **必传**事务句柄;
  341. // - op:调用方方法名(仅用于错误文案定位);
  342. // - column:列名(**只接受本文件 expenseCol* 常量**,内部白名单校验,防列名注入);
  343. // - userID/campusID/delta:同 AddBalance。
  344. //
  345. // 出参:(全局受影响行数, error)。campusID>0 时:先全局后校区,任一 0 行 → ErrAccountNotFound。
  346. //
  347. // SQL 形状(必须保持"表达式 + 参数",不得退化为读-改-写):
  348. //
  349. // UPDATE t_user_expense SET <col> = <col> + ? WHERE user_id = ?
  350. // UPDATE t_user_expense_school SET <col> = <col> + ? WHERE user_id = ? AND campus_id = ?
  351. // [AND institution_id = ?] -- Scoped 注入(普通角色)
  352. func (r *ExpenseRepo) addColumnAtomic(ctx context.Context, tx *gorm.DB, op, column, userID string, campusID int64, delta money.Amount) (int64, error) {
  353. if err := requireTx(tx, op); err != nil {
  354. return 0, err
  355. }
  356. if !isExpenseColumn(column) {
  357. return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: 非法列名 "+column))
  358. }
  359. if strings.TrimSpace(userID) == "" {
  360. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id"))
  361. }
  362. if delta.IsZero() {
  363. return 0, nil
  364. }
  365. lit, err := amountLiteral(delta)
  366. if err != nil {
  367. return 0, err
  368. }
  369. db := tx.WithContext(ctxOrBackground(ctx))
  370. // ① 全局钱包(固定先全局,后校区:两事务并发时锁顺序一致,避免死锁)
  371. global := db.Model(&model.UserExpense{}).
  372. Where("user_id = ?", userID).
  373. UpdateColumn(column, gorm.Expr(column+" + ?", lit))
  374. if global.Error != nil {
  375. return 0, infraerrors.WithCause(infraerrors.ErrDBError, global.Error)
  376. }
  377. if global.RowsAffected == 0 {
  378. return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 全局账户未建户"))
  379. }
  380. // ② 校区账户(仅 campusID>0;普通角色的机构/校区过滤由 Scoped 强制)
  381. if campusID > 0 {
  382. campus := r.Scoped(db.Model(&model.UserExpenseSchool{})).
  383. Where("user_id = ? AND campus_id = ?", userID, campusID).
  384. UpdateColumn(column, gorm.Expr(column+" + ?", lit))
  385. if campus.Error != nil {
  386. return 0, infraerrors.WithCause(infraerrors.ErrDBError, campus.Error)
  387. }
  388. if campus.RowsAffected == 0 {
  389. return 0, infraerrors.WithCause(ErrAccountNotFound,
  390. errString("repository: "+op+" 校区账户未建户(user_id/campus_id 不匹配)"))
  391. }
  392. }
  393. return global.RowsAffected, nil
  394. }
  395. // isExpenseColumn 列名白名单校验(只接受本文件 expenseCol* 常量 + `total_recommended_amount`)。
  396. //
  397. // `total_recommended_amount` 由 AC-08 老带新结算引入(常量定义见 expense_repo_recommend.go),
  398. // 必须与 `balance` 同一语句双增(dump 763344/763351)。
  399. func isExpenseColumn(column string) bool {
  400. switch column {
  401. case expenseColBalance, expenseColSpend, expenseColSpecialExpenses,
  402. expenseColTotalIncome, expenseColTotalExpenditure, ExpenseColumnTotalRecommendedAmount:
  403. return true
  404. default:
  405. return false
  406. }
  407. }
  408. // GetForUpdate 取全局钱包账户并加行锁(**退费/清零前必调**,防超退)。
  409. //
  410. // 入参:
  411. // - ctx 请求上下文;
  412. // - tx 事务句柄(**必传**:行锁只在事务内有效);
  413. // - userID 用户(ULID;空 → ErrBadParam)。
  414. //
  415. // 出参:
  416. // - *model.UserExpense:账户行(金额为字符串原文,调用方用 money.Parse 转 Amount);
  417. // - ErrNotFound(账户不存在)/ ErrBadParam / ErrDBError。
  418. //
  419. // SQL:`SELECT * FROM t_user_expense WHERE user_id = ? LIMIT 1 FOR UPDATE`(MySQL 渲染;
  420. // sqlite 单测栈不渲染行锁,但 clause 已挂载,见单测断言口径)。
  421. //
  422. // 平台级数据(无作用域列),越权断言在 Service。
  423. func (r *ExpenseRepo) GetForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.UserExpense, error) {
  424. if err := requireTx(tx, "GetForUpdate"); err != nil {
  425. return nil, err
  426. }
  427. if strings.TrimSpace(userID) == "" {
  428. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetForUpdate 需要 user_id"))
  429. }
  430. var out model.UserExpense
  431. err := tx.WithContext(ctxOrBackground(ctx)).
  432. Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  433. Where("user_id = ?", userID).
  434. Take(&out).Error
  435. if err != nil {
  436. return nil, wrapQueryErr(err, "GetForUpdate(t_user_expense)")
  437. }
  438. return &out, nil
  439. }
  440. // GetCampusForUpdate 取校区账户并加行锁(校区退费/清零前必调)。
  441. //
  442. // 入参:ctx / tx(**必传**)/ userID / campusID(>=0;0=机构级账户)。
  443. // 出参:*model.UserExpenseSchool + ErrNotFound/ErrBadParam/ErrDBError;
  444. // 普通角色跨机构 → ErrNotFound(Scoped 过滤,不泄露存在性)。
  445. //
  446. // SQL:`SELECT * FROM t_user_expense_school WHERE user_id=? AND campus_id=? [AND institution_id=?]
  447. // LIMIT 1 FOR UPDATE`。
  448. func (r *ExpenseRepo) GetCampusForUpdate(ctx context.Context, tx *gorm.DB, userID string, campusID int64) (*model.UserExpenseSchool, error) {
  449. if err := requireTx(tx, "GetCampusForUpdate"); err != nil {
  450. return nil, err
  451. }
  452. if strings.TrimSpace(userID) == "" || campusID < 0 {
  453. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  454. errString("repository: GetCampusForUpdate 需要 user_id 且 campus_id >= 0"))
  455. }
  456. var out model.UserExpenseSchool
  457. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)))
  458. err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  459. Where("user_id = ? AND campus_id = ?", userID, campusID).
  460. Take(&out).Error
  461. if err != nil {
  462. return nil, wrapQueryErr(err, "GetCampusForUpdate(t_user_expense_school)")
  463. }
  464. return &out, nil
  465. }
  466. // GetBalance 只读查询余额(**无行锁**,列表/详情展示用)。
  467. //
  468. // 入参:ctx 请求上下文;userID(空 → ErrBadParam);campusID(**0 = 查全局钱包**,>0 = 查该校区账户)。
  469. // 出参:
  470. // - money.Amount:余额(分;账户不存在返回 0 且 err = ErrNotFound);
  471. // - ErrNotFound / ErrBadParam / ErrDBError。
  472. //
  473. // 红线:**不得**用本方法的结果做"够不够扣"的判断再扣费(读-改-写竞态),
  474. // 资金判定必须在事务内用 GetForUpdate / GetCampusForUpdate 的加锁读。
  475. func (r *ExpenseRepo) GetBalance(ctx context.Context, userID string, campusID int64) (money.Amount, error) {
  476. if strings.TrimSpace(userID) == "" {
  477. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetBalance 需要 user_id"))
  478. }
  479. db, err := r.db(ctx)
  480. if err != nil {
  481. return 0, err
  482. }
  483. var raw string
  484. if campusID > 0 {
  485. var row model.UserExpenseSchool
  486. q := r.Scoped(db.Model(&model.UserExpenseSchool{}))
  487. if err := q.Where("user_id = ? AND campus_id = ?", userID, campusID).
  488. Select("balance").Take(&row).Error; err != nil {
  489. return 0, wrapQueryErr(err, "GetBalance(t_user_expense_school)")
  490. }
  491. raw = row.Balance
  492. } else {
  493. var row model.UserExpense
  494. if err := db.Model(&model.UserExpense{}).Where("user_id = ?", userID).
  495. Select("balance").Take(&row).Error; err != nil {
  496. return 0, wrapQueryErr(err, "GetBalance(t_user_expense)")
  497. }
  498. raw = row.Balance
  499. }
  500. amount, perr := money.Parse(raw)
  501. if perr != nil {
  502. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  503. }
  504. return amount, nil
  505. }
  506. // InsertLog 写入余额变更流水(**只增不改**;幂等键 request_id+type)。
  507. //
  508. // 入参:
  509. // - ctx 请求上下文;
  510. // - tx 事务句柄(**必传**:必须与余额增减同事务);
  511. // - log 流水行(UserID 非空、Type != 0、Money 合法;RequestID 为幂等键,历史数据可 NULL)。
  512. //
  513. // 出参:
  514. // - nil:首次写入成功;
  515. // - ErrDuplicateLog(CodeUnique 3003):撞 uk_request_type,**属幂等命中**,
  516. // Service 捕获 `stderrors.Is(err, repository.ErrDuplicateLog)` 后返回首次结果(不得抛 5002);
  517. // - ErrBadParam / ErrDBError。
  518. //
  519. // 平台级数据(流水无作用域列),越权断言在 Service。
  520. func (r *ExpenseRepo) InsertLog(ctx context.Context, tx *gorm.DB, log *model.UserExpenseLog) error {
  521. if err := requireTx(tx, "InsertLog"); err != nil {
  522. return err
  523. }
  524. if log == nil || strings.TrimSpace(log.UserID) == "" {
  525. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 user_id"))
  526. }
  527. if log.Type == 0 {
  528. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 type"))
  529. }
  530. if err := normalizeExpenseAmounts(&log.Money); err != nil {
  531. return err
  532. }
  533. if log.BalanceAfter != nil {
  534. if err := normalizeExpenseAmounts(log.BalanceAfter); err != nil {
  535. return err
  536. }
  537. }
  538. err := tx.WithContext(ctxOrBackground(ctx)).Create(log).Error
  539. if err != nil {
  540. if IsDupKey(err) {
  541. // 幂等命中:同一 request_id + type 重复提交 → 返回 sentinel,由 Service 决定"返回首次结果"
  542. return infraerrors.WithCause(ErrDuplicateLog, err)
  543. }
  544. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  545. }
  546. return nil
  547. }
  548. // AddColumnsGlobalThenCampus 多列原子增减(**一张表一条 UPDATE**,固定顺序「全局 → 校区」)。
  549. //
  550. // 入参:
  551. // - ctx 请求上下文;
  552. // - tx 事务句柄(**必传**:两张账户表必须同事务,否则出现"全局扣了、校区没扣");
  553. // - op 操作名(错误文案定位,如 "Recharge");
  554. // - userID 用户(ULID;空 → ErrBadParam);
  555. // - institutionID 机构 ID(**必填 >0**:校区账户必须按机构精确过滤,防跨机构同 campus_id 误更新
  556. // ——设计 §3.1 #4 BUG#2 的同类缺陷,本方法把机构过滤做成硬参数);
  557. // - campusID 校区 ID(>0 时同时更新校区账户;==0 只更新全局钱包);
  558. // - deltas 列增量(如 `{balance: -refunded}, {total_income: -refunded}`)。
  559. //
  560. // 出参:
  561. // - int64:全局账户受影响行数(>0 正常;0 且无错 = 全部增量为 0 的 no-op);
  562. // - ErrAccountNotFound(账户未建户,调用顺序错误)/ ErrBadParam / ErrInternal / ErrDBError。
  563. //
  564. // SQL 形状(**必须保持"表达式 + 参数"**,禁读-改-写):
  565. //
  566. // UPDATE t_user_expense SET balance = balance + ?, total_income = total_income + ?
  567. // WHERE user_id = ?
  568. // UPDATE t_user_expense_school SET balance = balance + ?, total_income = total_income + ?
  569. // WHERE user_id = ? AND institution_id = ? AND campus_id = ?
  570. //
  571. // 为什么两列合成一条 UPDATE:dump 的 SP 每条 UPDATE 都同时改 2~3 列,
  572. // 拆成多条会让"余额已减、总充值未减"的中间态被并发对账/双跑读到。
  573. func (r *ExpenseRepo) AddColumnsGlobalThenCampus(
  574. ctx context.Context, tx *gorm.DB, op, userID string,
  575. institutionID, campusID int64, deltas ...ColumnDelta,
  576. ) (int64, error) {
  577. if err := requireTx(tx, op); err != nil {
  578. return 0, err
  579. }
  580. if strings.TrimSpace(userID) == "" {
  581. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id"))
  582. }
  583. if institutionID <= 0 || campusID < 0 {
  584. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  585. errString("repository: "+op+" 需要 institution_id>0 且 campus_id>=0"))
  586. }
  587. updates, err := buildColumnExprs(op, deltas)
  588. if err != nil {
  589. return 0, err
  590. }
  591. if len(updates) == 0 {
  592. return 0, nil
  593. }
  594. db := tx.WithContext(ctxOrBackground(ctx))
  595. // ① 全局钱包(固定先全局,后校区:并发时锁顺序一致,避免死循环等待)
  596. global := db.Model(&model.UserExpense{}).
  597. Where("user_id = ?", userID).
  598. UpdateColumns(updates)
  599. if global.Error != nil {
  600. return 0, infraerrors.WithCause(infraerrors.ErrDBError, global.Error)
  601. }
  602. if global.RowsAffected == 0 {
  603. return 0, infraerrors.WithCause(ErrAccountNotFound, errString("repository: "+op+" 全局账户未建户"))
  604. }
  605. // ② 校区账户(campusID>0 才写;机构显式过滤,不叠 Sc.CampusID 等值——见 institutionScoped)
  606. if campusID > 0 {
  607. campus := r.institutionScoped(db.Model(&model.UserExpenseSchool{})).
  608. Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
  609. UpdateColumns(updates)
  610. if campus.Error != nil {
  611. return 0, infraerrors.WithCause(infraerrors.ErrDBError, campus.Error)
  612. }
  613. if campus.RowsAffected == 0 {
  614. return 0, infraerrors.WithCause(ErrAccountNotFound,
  615. errString("repository: "+op+" 校区账户未建户(user_id/institution_id/campus_id 不匹配)"))
  616. }
  617. }
  618. return global.RowsAffected, nil
  619. }
  620. // AddCampusColumns **只**更新校区账户的多列原子增减(不动全局钱包)。
  621. //
  622. // 使用场景(唯一):`sp_user_expense_school_transfer_expenditure` 课费转结——
  623. // dump 明确**不动 balance**,只在两个校区账户上调整 `total_expenditure` / `total_income`
  624. // (转出方各减、转入方各加);全局钱包不参与,故不能用 AddColumnsGlobalThenCampus。
  625. //
  626. // 入参/出参:同 AddColumnsGlobalThenCampus(institutionID 必填 >0,campusID 必填 >0)。
  627. //
  628. // SQL:`UPDATE t_user_expense_school SET <cols> WHERE user_id=? AND institution_id=? AND campus_id=?`
  629. func (r *ExpenseRepo) AddCampusColumns(
  630. ctx context.Context, tx *gorm.DB, op, userID string,
  631. institutionID, campusID int64, deltas ...ColumnDelta,
  632. ) (int64, error) {
  633. if err := requireTx(tx, op); err != nil {
  634. return 0, err
  635. }
  636. if strings.TrimSpace(userID) == "" {
  637. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: "+op+" 需要 user_id"))
  638. }
  639. if institutionID <= 0 || campusID <= 0 {
  640. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  641. errString("repository: "+op+" 需要 institution_id>0 且 campus_id>0"))
  642. }
  643. updates, err := buildColumnExprs(op, deltas)
  644. if err != nil {
  645. return 0, err
  646. }
  647. if len(updates) == 0 {
  648. return 0, nil
  649. }
  650. db := tx.WithContext(ctxOrBackground(ctx))
  651. res := r.institutionScoped(db.Model(&model.UserExpenseSchool{})).
  652. Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
  653. UpdateColumns(updates)
  654. if res.Error != nil {
  655. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  656. }
  657. if res.RowsAffected == 0 {
  658. return 0, infraerrors.WithCause(ErrAccountNotFound,
  659. errString("repository: "+op+" 校区账户未建户(user_id/institution_id/campus_id 不匹配)"))
  660. }
  661. return res.RowsAffected, nil
  662. }
  663. // GetGlobalAccount 读全局钱包账户整行(**无行锁**,只用于写后快照/结果回读)。
  664. //
  665. // 入参:ctx / tx(可 nil:nil 时用连接池或 ctx 内事务)/ userID(空 → ErrBadParam)。
  666. // 出参:*model.UserExpense(金额为 decimal 字符串原文)+ ErrNotFound / ErrBadParam / ErrDBError。
  667. //
  668. // 红线:**不得**用本方法的余额做"够不够扣"的判断(读-改-写竞态),
  669. // 资金判定必须用 GetForUpdate(加锁读)。
  670. func (r *ExpenseRepo) GetGlobalAccount(ctx context.Context, tx *gorm.DB, userID string) (*model.UserExpense, error) {
  671. if strings.TrimSpace(userID) == "" {
  672. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetGlobalAccount 需要 user_id"))
  673. }
  674. db, err := r.queryDB(ctx, tx)
  675. if err != nil {
  676. return nil, err
  677. }
  678. var out model.UserExpense
  679. if err := db.Model(&model.UserExpense{}).Where("user_id = ?", userID).Take(&out).Error; err != nil {
  680. return nil, wrapQueryErr(err, "GetGlobalAccount(t_user_expense)")
  681. }
  682. return &out, nil
  683. }
  684. // GetCampusAccount 读校区账户整行(**无行锁**,写后快照/结果回读用)。
  685. //
  686. // 入参:ctx / tx(可 nil)/ userID / institutionID(必填 >0)/ campusID(>=0;0=机构级账户)。
  687. // 出参:*model.UserExpenseSchool + ErrNotFound / ErrBadParam / ErrDBError。
  688. //
  689. // 作用域:机构过滤强制(institutionScoped,超管除外);**不叠 Sc.CampusID 等值**,
  690. // 因为本方法用于"结果快照",入参已精确指定机构 + 校区(比叠加更严格,见 institutionScoped)。
  691. func (r *ExpenseRepo) GetCampusAccount(
  692. ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
  693. ) (*model.UserExpenseSchool, error) {
  694. if strings.TrimSpace(userID) == "" || institutionID <= 0 || campusID < 0 {
  695. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  696. errString("repository: GetCampusAccount 需要 user_id 且 institution_id>0 且 campus_id>=0"))
  697. }
  698. db, err := r.queryDB(ctx, tx)
  699. if err != nil {
  700. return nil, err
  701. }
  702. var out model.UserExpenseSchool
  703. q := r.institutionScoped(db.Model(&model.UserExpenseSchool{}))
  704. if err := q.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
  705. Take(&out).Error; err != nil {
  706. return nil, wrapQueryErr(err, "GetCampusAccount(t_user_expense_school)")
  707. }
  708. return &out, nil
  709. }
  710. // GetCampusForUpdateExact 取指定机构+校区账户并加行锁(**跨校区操作专用**)。
  711. //
  712. // 与 GetCampusForUpdate 的唯一区别:**不叠 `Sc.CampusID` 等值过滤**(只叠机构)。
  713. //
  714. // 为什么需要它:课费转结必然同时访问两个校区账户(源/目标)。若用 GetCampusForUpdate,
  715. // 操作者作用域里的 `Sc.CampusID` 会把另一个校区的行过滤掉 → 转结永远失败;
  716. // 而"转结必须在两个校区之间发生"是业务前提,不能用等值过滤把它排除。
  717. //
  718. // 安全边界:机构过滤强制 + 入参显式 campus_id 精确命中(跨机构转结需超管作用域)。
  719. //
  720. // 出参:*model.UserExpenseSchool + ErrNotFound / ErrBadParam / ErrDBError。
  721. func (r *ExpenseRepo) GetCampusForUpdateExact(
  722. ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
  723. ) (*model.UserExpenseSchool, error) {
  724. if err := requireTx(tx, "GetCampusForUpdateExact"); err != nil {
  725. return nil, err
  726. }
  727. if strings.TrimSpace(userID) == "" || institutionID <= 0 || campusID < 0 {
  728. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  729. errString("repository: GetCampusForUpdateExact 需要 user_id 且 institution_id>0 且 campus_id>=0"))
  730. }
  731. db := tx.WithContext(ctxOrBackground(ctx))
  732. var out model.UserExpenseSchool
  733. q := r.institutionScoped(db.Model(&model.UserExpenseSchool{}))
  734. err := q.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
  735. Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  736. Take(&out).Error
  737. if err != nil {
  738. return nil, wrapQueryErr(err, "GetCampusForUpdateExact(t_user_expense_school)")
  739. }
  740. return &out, nil
  741. }
  742. // queryDB 选择查询句柄(tx 优先 → ctx 内事务 → 连接池)。
  743. //
  744. // 入参:ctx 请求上下文(可 nil);tx 事务句柄(可 nil)。
  745. // 出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  746. func (r *ExpenseRepo) queryDB(ctx context.Context, tx *gorm.DB) (*gorm.DB, error) {
  747. if tx != nil {
  748. return tx.WithContext(ctxOrBackground(ctx)), nil
  749. }
  750. return r.db(ctx)
  751. }
  752. // FindLogByRequest 按幂等键 `(request_id, type)` 查既有流水(幂等预检/命中回放)。
  753. //
  754. // 入参:
  755. // - ctx 请求上下文;
  756. // - tx 事务句柄(**可 nil**:读方法,nil 时走 ctx 内事务或连接池);
  757. // - userID 用户(ULID;空 → ErrBadParam);
  758. // - requestID 幂等请求号(**空串 → 返回 (nil, nil),表示该请求不享幂等**,设计 §1.5「缺省」);
  759. // - logType 流水类型(与 request_id 组成唯一键 uk_request_type)。
  760. //
  761. // 出参:
  762. // - *model.UserExpenseLog:命中行;未命中返回 **(nil, nil)**(不是 ErrNotFound——预检语义是"有没有");
  763. // - ErrBadParam / ErrDBError。
  764. //
  765. // 唯一键口径:`uk_request_type(request_id, type)`;MySQL 唯一键对 NULL 放行,
  766. // 因此历史行(request_id IS NULL)永不命中本方法(符合设计 §1.5:NULL 不享幂等)。
  767. func (r *ExpenseRepo) FindLogByRequest(
  768. ctx context.Context, tx *gorm.DB, userID, requestID string, logType int32,
  769. ) (*model.UserExpenseLog, error) {
  770. if strings.TrimSpace(userID) == "" {
  771. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindLogByRequest 需要 user_id"))
  772. }
  773. if strings.TrimSpace(requestID) == "" {
  774. return nil, nil
  775. }
  776. db, err := r.queryDB(ctx, tx)
  777. if err != nil {
  778. return nil, err
  779. }
  780. var out model.UserExpenseLog
  781. takeErr := db.Model(&model.UserExpenseLog{}).
  782. Where("user_id = ? AND request_id = ? AND type = ?", userID, requestID, logType).
  783. Take(&out).Error
  784. if takeErr != nil {
  785. if errors.Is(takeErr, gorm.ErrRecordNotFound) {
  786. return nil, nil
  787. }
  788. return nil, infraerrors.WithCause(infraerrors.ErrDBError, takeErr)
  789. }
  790. return &out, nil
  791. }
  792. // ListLogs 分页查流水(按创建时间倒序,时间窗可空)。
  793. //
  794. // 入参:
  795. // - ctx 请求上下文;
  796. // - userID 用户(ULID;空 → ErrBadParam;流水表无作用域列 → 越权断言在 Service);
  797. // - campusIDs **必须为空**:t_user_expense_log 无 campus_id 列(DDL 第二部分「3.」),
  798. // 传非空值一律 ErrBadParam —— 明确拒绝优于"看起来筛了其实没筛"的静默越权;
  799. // 校区维度流水视图需 Service 侧 join t_user_expense_school 后叠加(不在本方法职责内);
  800. // - start/end 创建时间窗(零值 = 不限;两端**闭区间**);
  801. // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
  802. //
  803. // 出参:
  804. // - []model.UserExpenseLog:**空结果返回长度 0 的非 nil 切片**(JSON 序列化为 [] 而非 null);
  805. // - ErrBadParam / ErrDBError。
  806. func (r *ExpenseRepo) ListLogs(ctx context.Context, userID string, campusIDs []int64, start, end time.Time, page PageQuery) ([]model.UserExpenseLog, error) {
  807. if strings.TrimSpace(userID) == "" {
  808. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListLogs 需要 user_id"))
  809. }
  810. if len(campusIDs) > 0 {
  811. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  812. errString("repository: ListLogs 不支持 campus_id 过滤(t_user_expense_log 无该列)"))
  813. }
  814. db, err := r.db(ctx)
  815. if err != nil {
  816. return nil, err
  817. }
  818. q := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID)
  819. if !start.IsZero() {
  820. q = q.Where("create_time >= ?", start)
  821. }
  822. if !end.IsZero() {
  823. q = q.Where("create_time <= ?", end)
  824. }
  825. pageSize := page.Limit()
  826. rows := make([]model.UserExpenseLog, 0, pageSize)
  827. err = q.Order("create_time DESC").Order("id DESC").
  828. Limit(pageSize).Offset(page.Offset()).
  829. Find(&rows).Error
  830. if err != nil {
  831. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  832. }
  833. if rows == nil {
  834. rows = make([]model.UserExpenseLog, 0)
  835. }
  836. return rows, nil
  837. }
  838. // SumLogMoney 汇总流水金额(对账用;**Go 侧定点累加**)。
  839. //
  840. // 入参:
  841. // - ctx 请求上下文;
  842. // - userID 用户(ULID;空 → ErrBadParam);
  843. // - campusIDs **必须为空**(理由同 ListLogs:流水表无 campus_id 列);
  844. // - types 类型白名单(空 = 全部类型;非空时 `type IN (...)`)。
  845. //
  846. // 出参:
  847. // - money.Amount:金额合计(分;正负按流水方向自然累加;无数据为 0);
  848. // - ErrBadParam / ErrDBError。
  849. //
  850. // 红线:**禁止** `SELECT SUM(money)` —— MySQL 会把 decimal 求和结果转 REAL(浮点),
  851. // 与 money.Amount 的定点口径冲突;本方法 Sql 只取原始字符串,累加在 Go 侧 `money.Parse` 后完成。
  852. func (r *ExpenseRepo) SumLogMoney(ctx context.Context, userID string, campusIDs []int64, types []int) (money.Amount, error) {
  853. if strings.TrimSpace(userID) == "" {
  854. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumLogMoney 需要 user_id"))
  855. }
  856. if len(campusIDs) > 0 {
  857. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  858. errString("repository: SumLogMoney 不支持 campus_id 过滤(t_user_expense_log 无该列)"))
  859. }
  860. db, err := r.db(ctx)
  861. if err != nil {
  862. return 0, err
  863. }
  864. q := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID)
  865. if len(types) > 0 {
  866. q = q.Where("type IN ?", types)
  867. }
  868. var rows []model.UserExpenseLog
  869. if err := q.Select("money").Find(&rows).Error; err != nil {
  870. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  871. }
  872. total := money.Amount(0)
  873. for i := range rows {
  874. amount, perr := money.Parse(rows[i].Money)
  875. if perr != nil {
  876. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  877. }
  878. total = total.Add(amount)
  879. }
  880. return total, nil
  881. }