bill_repo.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. // 学生账单对账仓储(sp_user_expense_get_bill 的 4 张 MEMORY 临时表 → 内存结构)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §3.4 #25 `sp_user_expense_get_bill`(dump **764094~764238**)
  5. // - §3.8-**E2**(时间列三分类)/ §3.8-**A18**(`type=108` 归因,**只归因不裁剪**)
  6. // - §5.1 纪律 4「横切优先」(账单恒等式:期末−期初 = Σ(窗口内流水))
  7. //
  8. // ---------------------------------------------------------------------------
  9. // dump 原文与 target 的逐项映射(4 张临时表 → 4 个查询)
  10. // ---------------------------------------------------------------------------
  11. //
  12. // dump 764125-764128 temp_user_balance_request ← LoadRechargeRequestNos
  13. // INSERT ... SELECT id FROM t_user_balance_request
  14. // WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN start AND end
  15. // dump 764132-764139 temp_user_expense_log ← LoadLogs / PageLogs / CountLogs
  16. // INSERT ... SELECT type,logid,money,create_time FROM t_user_expense_log WHERE userid=?
  17. // (**无时间过滤**:区间过滤在后续每次 SUM 的 WHERE 里现做)
  18. // dump 764143-764149 temp_courses_activity_signed ← LoadActivitySigned
  19. // INSERT ... SELECT activityid,activity_money,create_time,requestid FROM .. WHERE userid=?
  20. // dump 764152-764157 temp_courses_activity_used_log ← LoadActivityUsedLogs
  21. // INSERT ... SELECT activityid,activity_money_used,create_time FROM .. WHERE userid=?
  22. //
  23. // 四张临时表在 dump 里都是「该 user 的全量」——本仓储同样按 user 全量取回,
  24. // 时间窗与类型过滤**全部搬进 Go 侧**(与 dump 的 `temp_xxx` + `WHERE create_time BETWEEN` 等价,
  25. // 且避免把 `VARCHAR` 时间比较的歧义带进目标态的 `datetime(3)`)。
  26. //
  27. // ---------------------------------------------------------------------------
  28. // 作用域纪律(**本仓储不继承 BaseRepo**,属 §1.7 例外一)
  29. // ---------------------------------------------------------------------------
  30. //
  31. // 4 张来源表里 `t_user_expense_log` / `t_courses_activity_used_log` **没有** institution_id /
  32. // campus_id 列(DDL 见 model.UserExpenseLog / model.CoursesActivityUsedLog),
  33. // 而"学生账单"本身就是**跨校区账户对账**(期初/期末余额只能按 user 汇总 —— §3.8-f 约束 2
  34. // 「流水表无 campus 维度 ⇒ 恒等式只能按 user 做」)。
  35. //
  36. // 因此越权断言**不能**靠 BaseRepo.Scoped 完成,必须由 Service 层经 port.MembershipChecker
  37. // 完成(BillService.GetBill 已内置该断言)。这里刻意不继承 BaseRepo:
  38. // 若对 signed 表叠 `campus_id = ?`,同一学生跨校区的赠送额会被静默裁掉,
  39. // 反而把"账单恒等式"变成"某校区视图恒等式",与 dump 输出的口径不再一致。
  40. //
  41. // 金额纪律:**禁止 `SELECT SUM(money)`**(MySQL 把 decimal 求和转 REAL,浮点漂移)——
  42. // 本仓储只按行取原始 decimal 字符串,一律由 Service 用 money.Amount 定点累加(同 ExpenseRepo)。
  43. package repository
  44. import (
  45. "context"
  46. "time"
  47. "gorm.io/gorm"
  48. "yuansiwei/unified-backend/internal/academic/model"
  49. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  50. )
  51. // BillRepo 学生账单对账仓储(只读;4 张来源表按 user 全量取回)。
  52. //
  53. // 字段:
  54. // - DB *gorm.DB:连接池句柄(必填)。
  55. type BillRepo struct {
  56. // DB 数据库句柄(nil 时所有方法返回 ErrInternal)。
  57. DB *gorm.DB
  58. }
  59. // NewBillRepo 构造账单仓储。
  60. //
  61. // 入参:db *gorm.DB——连接池句柄(必填)。
  62. // 出参:*BillRepo(不继承 BaseRepo,理由见文件头"作用域纪律")。
  63. func NewBillRepo(db *gorm.DB) *BillRepo { return &BillRepo{DB: db} }
  64. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证读一致)。
  65. //
  66. // 入参:ctx 请求上下文(可 nil)。
  67. // 出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  68. func (r *BillRepo) db(ctx context.Context) (*gorm.DB, error) {
  69. if r == nil || r.DB == nil {
  70. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: BillRepo.DB 为 nil"))
  71. }
  72. if tx := TxFromContext(ctx); tx != nil {
  73. return tx, nil
  74. }
  75. if ctx == nil {
  76. return r.DB, nil
  77. }
  78. return r.DB.WithContext(ctx), nil
  79. }
  80. // LoadLogs 取该 user 的**全量**资金流水(dump `temp_user_expense_log` 的等价物,764139)。
  81. //
  82. // 入参:
  83. // - ctx 请求上下文;
  84. // - userID 用户(ULID;空 → ErrBadParam)。
  85. //
  86. // 出参:
  87. // - []model.UserExpenseLog:按 `create_time ASC, id ASC` 稳定排序(dump 临时表无排序,
  88. // Go 侧显式定序是为了让"同一毫秒多行"的累加结果可复现);**空结果返回长度 0 的非 nil 切片**;
  89. // - ErrBadParam / ErrDBError。
  90. //
  91. // 只 Select 参与计算的 5 列(type / money / create_time / log_id / id):账单不对其余列做展示,
  92. // 少取列可避免把 remark 等自由文本带进日志/响应。
  93. func (r *BillRepo) LoadLogs(ctx context.Context, userID string) ([]model.UserExpenseLog, error) {
  94. if userID == "" {
  95. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadLogs 需要 user_id"))
  96. }
  97. db, err := r.db(ctx)
  98. if err != nil {
  99. return nil, err
  100. }
  101. rows := make([]model.UserExpenseLog, 0, 32)
  102. qErr := db.Model(&model.UserExpenseLog{}).
  103. Select("id", "type", "money", "create_time", "log_id").
  104. Where("user_id = ?", userID).
  105. Order("create_time ASC").Order("id ASC").
  106. Find(&rows).Error
  107. if qErr != nil {
  108. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  109. }
  110. return rows, nil
  111. }
  112. // PageLogs 分页取账单明细流水(**时间倒序**,与 dump 的 `SELECT * FROM t_user_expense_log` 列表视图同序)。
  113. //
  114. // 入参:
  115. // - ctx 请求上下文;
  116. // - userID 用户(ULID;空 → ErrBadParam);
  117. // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
  118. //
  119. // 出参:
  120. // - []model.UserExpenseLog:`create_time DESC, id DESC`(同秒多行按 id 兜底,保证翻页不重不漏);
  121. // - ErrBadParam / ErrDBError。
  122. //
  123. // 红线:**分页只影响明细**——期初/期末/各类当期发生额必须由 LoadLogs 的全量数据算出
  124. // (BillService 的"期初/期末不随分页变化"用例正是拦这条)。
  125. func (r *BillRepo) PageLogs(ctx context.Context, userID string, page PageQuery) ([]model.UserExpenseLog, error) {
  126. if userID == "" {
  127. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 需要 user_id"))
  128. }
  129. db, err := r.db(ctx)
  130. if err != nil {
  131. return nil, err
  132. }
  133. pageSize := page.Limit()
  134. rows := make([]model.UserExpenseLog, 0, pageSize)
  135. qErr := db.Model(&model.UserExpenseLog{}).
  136. Select("id", "type", "money", "create_time", "log_id").
  137. Where("user_id = ?", userID).
  138. Order("create_time DESC").Order("id DESC").
  139. Limit(pageSize).Offset(page.Offset()).
  140. Find(&rows).Error
  141. if qErr != nil {
  142. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  143. }
  144. return rows, nil
  145. }
  146. // CountLogs 统计该 user 的流水条数(明细分页的 total)。
  147. //
  148. // 入参:ctx 请求上下文;userID 用户(空 → ErrBadParam)。
  149. // 出参:int64 条数(无数据为 0);ErrBadParam / ErrDBError。
  150. func (r *BillRepo) CountLogs(ctx context.Context, userID string) (int64, error) {
  151. if userID == "" {
  152. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 需要 user_id"))
  153. }
  154. db, err := r.db(ctx)
  155. if err != nil {
  156. return 0, err
  157. }
  158. var total int64
  159. if cErr := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID).Count(&total).Error; cErr != nil {
  160. return 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr)
  161. }
  162. return total, nil
  163. }
  164. // LoadRechargeRequestNos 取区间内的**已通过普通充值工单**业务单号(dump `temp_user_balance_request`,764125)。
  165. //
  166. // dump 原文(764128 单条 INSERT;表结构 764125-764127):
  167. //
  168. // INSERT INTO temp_user_balance_request
  169. // SELECT id FROM t_user_balance_request
  170. // WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN inStartTime AND inEndTime
  171. //
  172. // 三参口径逐条对齐:
  173. // - `aproved=1` → `approved = model.BalanceRequestApprovedPassed`(旧列名错拼已在 model 修正);
  174. // - `type=100` → `type = model.BalanceRequestTypeRecharge`;
  175. // - `response_time BETWEEN inStartTime AND inEndTime` → **两端闭区间**(`>= from AND <= to`),
  176. // ⚠ 这里**故意不跟随**"期末余额用 `< to`"的口径:dump 的 BETWEEN 就是含右端,
  177. // 两种比较语义并存是既有不对称,不得统一(见 BillService.GetBill 文档)。
  178. //
  179. // 入参:ctx;userID 用户(空 → ErrBadParam);from/to 审批时间窗(`from > to` → ErrBadParam)。
  180. // 出参:
  181. // - []string:命中的 `biz_no` 列表(旧库 `t_user_balance_request.id` 是 UUID,与流水的
  182. // `logid` 同一字符串空间;目标态对应关系是 `expense_log.log_id = balance_request.biz_no`);
  183. // - ErrBadParam / ErrDBError。
  184. func (r *BillRepo) LoadRechargeRequestNos(
  185. ctx context.Context, userID string, from, to time.Time,
  186. ) ([]string, error) {
  187. if userID == "" {
  188. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadRechargeRequestNos 需要 user_id"))
  189. }
  190. if from.After(to) {
  191. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: from 不能晚于 to"))
  192. }
  193. db, err := r.db(ctx)
  194. if err != nil {
  195. return nil, err
  196. }
  197. nos := make([]string, 0, 8)
  198. qErr := db.Model(&model.UserBalanceRequest{}).
  199. Where("user_id = ? AND approved = ? AND type = ? AND response_time >= ? AND response_time <= ?",
  200. userID, model.BalanceRequestApprovedPassed, model.BalanceRequestTypeRecharge, from, to).
  201. Pluck("biz_no", &nos).Error
  202. if qErr != nil {
  203. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  204. }
  205. return nos, nil
  206. }
  207. // LoadActivitySigned 取该 user 的活动报名行(dump `temp_courses_activity_signed`,764143)。
  208. //
  209. // dump 原文(764149 单条 INSERT;表结构 764143-764148):
  210. //
  211. // INSERT INTO temp_courses_activity_signed
  212. // SELECT activityid,activity_money,create_time,requestid FROM t_courses_activity_signed WHERE userid=?
  213. //
  214. // **不过滤 status 与时间**:dump 把 `create_time` 一并搬进临时表却不参与任何过滤
  215. // (只有 `activity_money` 参与赠送额汇总),这里保持同口径。
  216. //
  217. // 入参:ctx;userID(空 → ErrBadParam)。
  218. // 出参:[]model.CoursesActivitySigned(按 `activity_id ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。
  219. func (r *BillRepo) LoadActivitySigned(ctx context.Context, userID string) ([]model.CoursesActivitySigned, error) {
  220. if userID == "" {
  221. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivitySigned 需要 user_id"))
  222. }
  223. db, err := r.db(ctx)
  224. if err != nil {
  225. return nil, err
  226. }
  227. rows := make([]model.CoursesActivitySigned, 0, 8)
  228. qErr := db.Model(&model.CoursesActivitySigned{}).
  229. Select("id", "activity_id", "activity_money", "request_no", "create_time").
  230. Where("user_id = ?", userID).
  231. Order("activity_id ASC").Order("id ASC").
  232. Find(&rows).Error
  233. if qErr != nil {
  234. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  235. }
  236. return rows, nil
  237. }
  238. // LoadActivityUsedLogs 取该 user 的老带新获得/使用记录(dump `temp_courses_activity_used_log`,764152)。
  239. //
  240. // dump 原文(764157 单条 INSERT;表结构 764152-764156):
  241. //
  242. // INSERT INTO temp_courses_activity_used_log
  243. // SELECT activityid,activity_money_used,create_time FROM t_courses_activity_used_log WHERE userid=?
  244. //
  245. // 同样**不带时间过滤**:两个 cutoff(`< inStartTime` / `< inEndTime`)在 Go 侧分别做。
  246. //
  247. // 入参:ctx;userID(空 → ErrBadParam)。
  248. // 出参:[]model.CoursesActivityUsedLog(按 `create_time ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。
  249. func (r *BillRepo) LoadActivityUsedLogs(ctx context.Context, userID string) ([]model.CoursesActivityUsedLog, error) {
  250. if userID == "" {
  251. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivityUsedLogs 需要 user_id"))
  252. }
  253. db, err := r.db(ctx)
  254. if err != nil {
  255. return nil, err
  256. }
  257. rows := make([]model.CoursesActivityUsedLog, 0, 8)
  258. qErr := db.Model(&model.CoursesActivityUsedLog{}).
  259. Select("id", "activity_id", "activity_money_used", "create_time").
  260. Where("user_id = ?", userID).
  261. Order("create_time ASC").Order("id ASC").
  262. Find(&rows).Error
  263. if qErr != nil {
  264. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  265. }
  266. return rows, nil
  267. }