bill_repo.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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 分页取账单明细流水(**时间倒序**;**按查询窗口过滤**)。
  113. //
  114. // 入参:
  115. // - ctx 请求上下文;
  116. // - userID 用户(ULID;空 → ErrBadParam);
  117. // - from/to 查询窗口:**`from <= create_time < to`**(左闭右开,与 `FlowExclusiveEnd` **同口径**);
  118. // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
  119. //
  120. // 出参:
  121. // - []model.UserExpenseLog:`create_time DESC, id DESC`(同秒多行按 id 兜底,保证翻页不重不漏);
  122. // - ErrBadParam / ErrDBError。
  123. //
  124. // 为什么要按窗口过滤(v1.4 口径更正,原实现不过滤):dump 没有明细列表,这块**无 dump 约束**。
  125. // 不过滤会造成"同一响应里两个作用域"——`summary` 是 3 月的、明细却是全部历史、`total` 是历史总数,
  126. // 前端选"3 月账单"看到的是全量流水,属真 bug 级体验问题。
  127. // 窗口口径**必须**与 `FlowExclusiveEnd`(右开)一致,否则"明细条数 × 金额"与"汇总发生额"
  128. // 会在 `t = to` 处对不上(那正是 dump 三种比较语义并存的坑,不能在明细上再造一个)。
  129. //
  130. // 红线:**分页只影响明细**——期初/期末/各类当期发生额仍由 LoadLogs 的全量数据算出
  131. // (BillService 的"期初/期末不随分页变化"用例正是拦这条)。
  132. func (r *BillRepo) PageLogs(
  133. ctx context.Context, userID string, from, to time.Time, page PageQuery,
  134. ) ([]model.UserExpenseLog, error) {
  135. if userID == "" {
  136. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 需要 user_id"))
  137. }
  138. if from.After(to) {
  139. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 的 from 不能晚于 to"))
  140. }
  141. db, err := r.db(ctx)
  142. if err != nil {
  143. return nil, err
  144. }
  145. pageSize := page.Limit()
  146. rows := make([]model.UserExpenseLog, 0, pageSize)
  147. qErr := db.Model(&model.UserExpenseLog{}).
  148. Select("id", "type", "money", "create_time", "log_id").
  149. Where("user_id = ? AND create_time >= ? AND create_time < ?", userID, from, to).
  150. Order("create_time DESC").Order("id DESC").
  151. Limit(pageSize).Offset(page.Offset()).
  152. Find(&rows).Error
  153. if qErr != nil {
  154. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  155. }
  156. return rows, nil
  157. }
  158. // CountLogs 统计该 user 在查询窗口内的流水条数(明细分页的 total)。
  159. //
  160. // 入参:ctx;userID 用户(空 → ErrBadParam);from/to 查询窗口(**与 PageLogs 完全相同的条件**)。
  161. // 出参:int64 条数(无数据为 0);ErrBadParam / ErrDBError。
  162. //
  163. // 红线(同快照):本方法的 WHERE 必须与 `PageLogs` **逐字一致**(`from <= create_time < to`),
  164. // 否则 `total` 与 `list` 会指向两个不同的集合——前端分页器显示"共 N 条"但翻到最后一页却没有数据,
  165. // 这类不一致在联调期极难定位(两边都"看起来对")。改一处必须同时改另一处。
  166. func (r *BillRepo) CountLogs(ctx context.Context, userID string, from, to time.Time) (int64, error) {
  167. if userID == "" {
  168. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 需要 user_id"))
  169. }
  170. if from.After(to) {
  171. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 的 from 不能晚于 to"))
  172. }
  173. db, err := r.db(ctx)
  174. if err != nil {
  175. return 0, err
  176. }
  177. var total int64
  178. if cErr := db.Model(&model.UserExpenseLog{}).
  179. Where("user_id = ? AND create_time >= ? AND create_time < ?", userID, from, to).
  180. Count(&total).Error; cErr != nil {
  181. return 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr)
  182. }
  183. return total, nil
  184. }
  185. // LoadRechargeRequestNos 取区间内的**已通过普通充值工单**业务单号(dump `temp_user_balance_request`,764125)。
  186. //
  187. // dump 原文(764128 单条 INSERT;表结构 764125-764127):
  188. //
  189. // INSERT INTO temp_user_balance_request
  190. // SELECT id FROM t_user_balance_request
  191. // WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN inStartTime AND inEndTime
  192. //
  193. // 三参口径逐条对齐:
  194. // - `aproved=1` → `approved = model.BalanceRequestApprovedPassed`(旧列名错拼已在 model 修正);
  195. // - `type=100` → `type = model.BalanceRequestTypeRecharge`;
  196. // - `response_time BETWEEN inStartTime AND inEndTime` → **两端闭区间**(`>= from AND <= to`),
  197. // ⚠ 这里**故意不跟随**"期末余额用 `< to`"的口径:dump 的 BETWEEN 就是含右端,
  198. // 两种比较语义并存是既有不对称,不得统一(见 BillService.GetBill 文档)。
  199. //
  200. // 入参:ctx;userID 用户(空 → ErrBadParam);from/to 审批时间窗(`from > to` → ErrBadParam)。
  201. // 出参:
  202. // - []string:命中的 `biz_no` 列表(旧库 `t_user_balance_request.id` 是 UUID,与流水的
  203. // `logid` 同一字符串空间;目标态对应关系是 `expense_log.log_id = balance_request.biz_no`);
  204. // - ErrBadParam / ErrDBError。
  205. func (r *BillRepo) LoadRechargeRequestNos(
  206. ctx context.Context, userID string, from, to time.Time,
  207. ) ([]string, error) {
  208. if userID == "" {
  209. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadRechargeRequestNos 需要 user_id"))
  210. }
  211. if from.After(to) {
  212. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: from 不能晚于 to"))
  213. }
  214. db, err := r.db(ctx)
  215. if err != nil {
  216. return nil, err
  217. }
  218. nos := make([]string, 0, 8)
  219. qErr := db.Model(&model.UserBalanceRequest{}).
  220. Where("user_id = ? AND approved = ? AND type = ? AND response_time >= ? AND response_time <= ?",
  221. userID, model.BalanceRequestApprovedPassed, model.BalanceRequestTypeRecharge, from, to).
  222. Pluck("biz_no", &nos).Error
  223. if qErr != nil {
  224. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  225. }
  226. return nos, nil
  227. }
  228. // LoadActivitySigned 取该 user 的活动报名行(dump `temp_courses_activity_signed`,764143)。
  229. //
  230. // dump 原文(764149 单条 INSERT;表结构 764143-764148):
  231. //
  232. // INSERT INTO temp_courses_activity_signed
  233. // SELECT activityid,activity_money,create_time,requestid FROM t_courses_activity_signed WHERE userid=?
  234. //
  235. // **不过滤 status 与时间**:dump 把 `create_time` 一并搬进临时表却不参与任何过滤
  236. // (只有 `activity_money` 参与赠送额汇总),这里保持同口径。
  237. //
  238. // 入参:ctx;userID(空 → ErrBadParam)。
  239. // 出参:[]model.CoursesActivitySigned(按 `activity_id ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。
  240. func (r *BillRepo) LoadActivitySigned(ctx context.Context, userID string) ([]model.CoursesActivitySigned, error) {
  241. if userID == "" {
  242. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivitySigned 需要 user_id"))
  243. }
  244. db, err := r.db(ctx)
  245. if err != nil {
  246. return nil, err
  247. }
  248. rows := make([]model.CoursesActivitySigned, 0, 8)
  249. qErr := db.Model(&model.CoursesActivitySigned{}).
  250. Select("id", "activity_id", "activity_money", "request_no", "create_time").
  251. Where("user_id = ?", userID).
  252. Order("activity_id ASC").Order("id ASC").
  253. Find(&rows).Error
  254. if qErr != nil {
  255. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  256. }
  257. return rows, nil
  258. }
  259. // LoadActivityUsedLogs 取该 user 的老带新获得/使用记录(dump `temp_courses_activity_used_log`,764152)。
  260. //
  261. // dump 原文(764157 单条 INSERT;表结构 764152-764156):
  262. //
  263. // INSERT INTO temp_courses_activity_used_log
  264. // SELECT activityid,activity_money_used,create_time FROM t_courses_activity_used_log WHERE userid=?
  265. //
  266. // 同样**不带时间过滤**:两个 cutoff(`< inStartTime` / `< inEndTime`)在 Go 侧分别做。
  267. //
  268. // 入参:ctx;userID(空 → ErrBadParam)。
  269. // 出参:[]model.CoursesActivityUsedLog(按 `create_time ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。
  270. func (r *BillRepo) LoadActivityUsedLogs(ctx context.Context, userID string) ([]model.CoursesActivityUsedLog, error) {
  271. if userID == "" {
  272. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivityUsedLogs 需要 user_id"))
  273. }
  274. db, err := r.db(ctx)
  275. if err != nil {
  276. return nil, err
  277. }
  278. rows := make([]model.CoursesActivityUsedLog, 0, 8)
  279. qErr := db.Model(&model.CoursesActivityUsedLog{}).
  280. Select("id", "activity_id", "activity_money_used", "create_time").
  281. Where("user_id = ?", userID).
  282. Order("create_time ASC").Order("id ASC").
  283. Find(&rows).Error
  284. if qErr != nil {
  285. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  286. }
  287. return rows, nil
  288. }