| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- // 学生账单对账仓储(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
- }
|