// 学生账单对账仓储(sp_user_expense_get_bill 的 4 张 MEMORY 临时表 → 内存结构)。 // // 依据:unified/03-migration/academic-domain-design.md // - §3.4 #25 `sp_user_expense_get_bill`(dump **764094~764238**) // - §3.8-**E2**(时间列三分类)/ §3.8-**A18**(`type=108` 归因,**只归因不裁剪**) // - §5.1 纪律 4「横切优先」(账单恒等式:期末−期初 = Σ(窗口内流水)) // // --------------------------------------------------------------------------- // dump 原文与 target 的逐项映射(4 张临时表 → 4 个查询) // --------------------------------------------------------------------------- // // dump 764125-764128 temp_user_balance_request ← LoadRechargeRequestNos // INSERT ... SELECT id FROM t_user_balance_request // WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN start AND end // dump 764132-764139 temp_user_expense_log ← LoadLogs / PageLogs / CountLogs // INSERT ... SELECT type,logid,money,create_time FROM t_user_expense_log WHERE userid=? // (**无时间过滤**:区间过滤在后续每次 SUM 的 WHERE 里现做) // dump 764143-764149 temp_courses_activity_signed ← LoadActivitySigned // INSERT ... SELECT activityid,activity_money,create_time,requestid FROM .. WHERE userid=? // dump 764152-764157 temp_courses_activity_used_log ← LoadActivityUsedLogs // INSERT ... SELECT activityid,activity_money_used,create_time FROM .. WHERE userid=? // // 四张临时表在 dump 里都是「该 user 的全量」——本仓储同样按 user 全量取回, // 时间窗与类型过滤**全部搬进 Go 侧**(与 dump 的 `temp_xxx` + `WHERE create_time BETWEEN` 等价, // 且避免把 `VARCHAR` 时间比较的歧义带进目标态的 `datetime(3)`)。 // // --------------------------------------------------------------------------- // 作用域纪律(**本仓储不继承 BaseRepo**,属 §1.7 例外一) // --------------------------------------------------------------------------- // // 4 张来源表里 `t_user_expense_log` / `t_courses_activity_used_log` **没有** institution_id / // campus_id 列(DDL 见 model.UserExpenseLog / model.CoursesActivityUsedLog), // 而"学生账单"本身就是**跨校区账户对账**(期初/期末余额只能按 user 汇总 —— §3.8-f 约束 2 // 「流水表无 campus 维度 ⇒ 恒等式只能按 user 做」)。 // // 因此越权断言**不能**靠 BaseRepo.Scoped 完成,必须由 Service 层经 port.MembershipChecker // 完成(BillService.GetBill 已内置该断言)。这里刻意不继承 BaseRepo: // 若对 signed 表叠 `campus_id = ?`,同一学生跨校区的赠送额会被静默裁掉, // 反而把"账单恒等式"变成"某校区视图恒等式",与 dump 输出的口径不再一致。 // // 金额纪律:**禁止 `SELECT SUM(money)`**(MySQL 把 decimal 求和转 REAL,浮点漂移)—— // 本仓储只按行取原始 decimal 字符串,一律由 Service 用 money.Amount 定点累加(同 ExpenseRepo)。 package repository import ( "context" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // BillRepo 学生账单对账仓储(只读;4 张来源表按 user 全量取回)。 // // 字段: // - DB *gorm.DB:连接池句柄(必填)。 type BillRepo struct { // DB 数据库句柄(nil 时所有方法返回 ErrInternal)。 DB *gorm.DB } // NewBillRepo 构造账单仓储。 // // 入参:db *gorm.DB——连接池句柄(必填)。 // 出参:*BillRepo(不继承 BaseRepo,理由见文件头"作用域纪律")。 func NewBillRepo(db *gorm.DB) *BillRepo { return &BillRepo{DB: db} } // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证读一致)。 // // 入参:ctx 请求上下文(可 nil)。 // 出参:(*gorm.DB, error);未装配 DB → ErrInternal。 func (r *BillRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: BillRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // LoadLogs 取该 user 的**全量**资金流水(dump `temp_user_expense_log` 的等价物,764139)。 // // 入参: // - ctx 请求上下文; // - userID 用户(ULID;空 → ErrBadParam)。 // // 出参: // - []model.UserExpenseLog:按 `create_time ASC, id ASC` 稳定排序(dump 临时表无排序, // Go 侧显式定序是为了让"同一毫秒多行"的累加结果可复现);**空结果返回长度 0 的非 nil 切片**; // - ErrBadParam / ErrDBError。 // // 只 Select 参与计算的 5 列(type / money / create_time / log_id / id):账单不对其余列做展示, // 少取列可避免把 remark 等自由文本带进日志/响应。 func (r *BillRepo) LoadLogs(ctx context.Context, userID string) ([]model.UserExpenseLog, error) { if userID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadLogs 需要 user_id")) } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.UserExpenseLog, 0, 32) qErr := db.Model(&model.UserExpenseLog{}). Select("id", "type", "money", "create_time", "log_id"). Where("user_id = ?", userID). Order("create_time ASC").Order("id ASC"). Find(&rows).Error if qErr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } return rows, nil } // PageLogs 分页取账单明细流水(**时间倒序**;**按查询窗口过滤**)。 // // 入参: // - ctx 请求上下文; // - userID 用户(ULID;空 → ErrBadParam); // - from/to 查询窗口:**`from <= create_time < to`**(左闭右开,与 `FlowExclusiveEnd` **同口径**); // - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。 // // 出参: // - []model.UserExpenseLog:`create_time DESC, id DESC`(同秒多行按 id 兜底,保证翻页不重不漏); // - ErrBadParam / ErrDBError。 // // 为什么要按窗口过滤(v1.4 口径更正,原实现不过滤):dump 没有明细列表,这块**无 dump 约束**。 // 不过滤会造成"同一响应里两个作用域"——`summary` 是 3 月的、明细却是全部历史、`total` 是历史总数, // 前端选"3 月账单"看到的是全量流水,属真 bug 级体验问题。 // 窗口口径**必须**与 `FlowExclusiveEnd`(右开)一致,否则"明细条数 × 金额"与"汇总发生额" // 会在 `t = to` 处对不上(那正是 dump 三种比较语义并存的坑,不能在明细上再造一个)。 // // 红线:**分页只影响明细**——期初/期末/各类当期发生额仍由 LoadLogs 的全量数据算出 // (BillService 的"期初/期末不随分页变化"用例正是拦这条)。 func (r *BillRepo) PageLogs( ctx context.Context, userID string, from, to time.Time, page PageQuery, ) ([]model.UserExpenseLog, error) { if userID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 需要 user_id")) } if from.After(to) { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 的 from 不能晚于 to")) } db, err := r.db(ctx) if err != nil { return nil, err } pageSize := page.Limit() rows := make([]model.UserExpenseLog, 0, pageSize) qErr := db.Model(&model.UserExpenseLog{}). Select("id", "type", "money", "create_time", "log_id"). Where("user_id = ? AND create_time >= ? AND create_time < ?", userID, from, to). Order("create_time DESC").Order("id DESC"). Limit(pageSize).Offset(page.Offset()). Find(&rows).Error if qErr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } return rows, nil } // CountLogs 统计该 user 在查询窗口内的流水条数(明细分页的 total)。 // // 入参:ctx;userID 用户(空 → ErrBadParam);from/to 查询窗口(**与 PageLogs 完全相同的条件**)。 // 出参:int64 条数(无数据为 0);ErrBadParam / ErrDBError。 // // 红线(同快照):本方法的 WHERE 必须与 `PageLogs` **逐字一致**(`from <= create_time < to`), // 否则 `total` 与 `list` 会指向两个不同的集合——前端分页器显示"共 N 条"但翻到最后一页却没有数据, // 这类不一致在联调期极难定位(两边都"看起来对")。改一处必须同时改另一处。 func (r *BillRepo) CountLogs(ctx context.Context, userID string, from, to time.Time) (int64, error) { if userID == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 需要 user_id")) } if from.After(to) { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 的 from 不能晚于 to")) } db, err := r.db(ctx) if err != nil { return 0, err } var total int64 if cErr := db.Model(&model.UserExpenseLog{}). Where("user_id = ? AND create_time >= ? AND create_time < ?", userID, from, to). Count(&total).Error; cErr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr) } return total, nil } // LoadRechargeRequestNos 取区间内的**已通过普通充值工单**业务单号(dump `temp_user_balance_request`,764125)。 // // dump 原文(764128 单条 INSERT;表结构 764125-764127): // // INSERT INTO temp_user_balance_request // SELECT id FROM t_user_balance_request // WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN inStartTime AND inEndTime // // 三参口径逐条对齐: // - `aproved=1` → `approved = model.BalanceRequestApprovedPassed`(旧列名错拼已在 model 修正); // - `type=100` → `type = model.BalanceRequestTypeRecharge`; // - `response_time BETWEEN inStartTime AND inEndTime` → **两端闭区间**(`>= from AND <= to`), // ⚠ 这里**故意不跟随**"期末余额用 `< to`"的口径:dump 的 BETWEEN 就是含右端, // 两种比较语义并存是既有不对称,不得统一(见 BillService.GetBill 文档)。 // // 入参:ctx;userID 用户(空 → ErrBadParam);from/to 审批时间窗(`from > to` → ErrBadParam)。 // 出参: // - []string:命中的 `biz_no` 列表(旧库 `t_user_balance_request.id` 是 UUID,与流水的 // `logid` 同一字符串空间;目标态对应关系是 `expense_log.log_id = balance_request.biz_no`); // - ErrBadParam / ErrDBError。 func (r *BillRepo) LoadRechargeRequestNos( ctx context.Context, userID string, from, to time.Time, ) ([]string, error) { if userID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadRechargeRequestNos 需要 user_id")) } if from.After(to) { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: from 不能晚于 to")) } db, err := r.db(ctx) if err != nil { return nil, err } nos := make([]string, 0, 8) qErr := db.Model(&model.UserBalanceRequest{}). Where("user_id = ? AND approved = ? AND type = ? AND response_time >= ? AND response_time <= ?", userID, model.BalanceRequestApprovedPassed, model.BalanceRequestTypeRecharge, from, to). Pluck("biz_no", &nos).Error if qErr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } return nos, nil } // LoadActivitySigned 取该 user 的活动报名行(dump `temp_courses_activity_signed`,764143)。 // // dump 原文(764149 单条 INSERT;表结构 764143-764148): // // INSERT INTO temp_courses_activity_signed // SELECT activityid,activity_money,create_time,requestid FROM t_courses_activity_signed WHERE userid=? // // **不过滤 status 与时间**:dump 把 `create_time` 一并搬进临时表却不参与任何过滤 // (只有 `activity_money` 参与赠送额汇总),这里保持同口径。 // // 入参:ctx;userID(空 → ErrBadParam)。 // 出参:[]model.CoursesActivitySigned(按 `activity_id ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。 func (r *BillRepo) LoadActivitySigned(ctx context.Context, userID string) ([]model.CoursesActivitySigned, error) { if userID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivitySigned 需要 user_id")) } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.CoursesActivitySigned, 0, 8) qErr := db.Model(&model.CoursesActivitySigned{}). Select("id", "activity_id", "activity_money", "request_no", "create_time"). Where("user_id = ?", userID). Order("activity_id ASC").Order("id ASC"). Find(&rows).Error if qErr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } return rows, nil } // LoadActivityUsedLogs 取该 user 的老带新获得/使用记录(dump `temp_courses_activity_used_log`,764152)。 // // dump 原文(764157 单条 INSERT;表结构 764152-764156): // // INSERT INTO temp_courses_activity_used_log // SELECT activityid,activity_money_used,create_time FROM t_courses_activity_used_log WHERE userid=? // // 同样**不带时间过滤**:两个 cutoff(`< inStartTime` / `< inEndTime`)在 Go 侧分别做。 // // 入参:ctx;userID(空 → ErrBadParam)。 // 出参:[]model.CoursesActivityUsedLog(按 `create_time ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。 func (r *BillRepo) LoadActivityUsedLogs(ctx context.Context, userID string) ([]model.CoursesActivityUsedLog, error) { if userID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivityUsedLogs 需要 user_id")) } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.CoursesActivityUsedLog, 0, 8) qErr := db.Model(&model.CoursesActivityUsedLog{}). Select("id", "activity_id", "activity_money_used", "create_time"). Where("user_id = ?", userID). Order("create_time ASC").Order("id ASC"). Find(&rows).Error if qErr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } return rows, nil }