// 支付订单仓储(t_order_pay)——AC-03 三通道读取 + CAS 状态推进。 // // 依据:unified/03-migration/academic-domain-design.md §2「5. t_order_pay」/ §2.5(NULL 行可见性裁定) // / §10.3(事务边界只在 Service)。 // // **本仓储不继承 BaseRepo(架构裁定 L3,§2.5)**,原因是这张表的作用域列**可空**: // // t_order_pay.institution_id / campus_id 都是 `BIGINT DEFAULT NULL`(旧库历史行+家长代付行可为 NULL)。 // BaseRepo.Scoped 注入的是 `institution_id = ?`,在 SQL 三值逻辑下 `NULL = 1` 结果是 **UNKNOWN** // (不成立)→ 会把 NULL 行**静默丢掉**;写成 `institution_id = ? OR institution_id IS NULL` 又等于 // 给"任何机构"开了读全量 NULL 行的口子(**严禁**,§2.5 明文禁止)。 // // 因此本仓储用**三条显式通道**,可见性边界写在方法签名里,不靠隐藏的作用域注入: // // ① ListByUser(ctx, userID, page) 付款人主口径:`user_id = 本人`(**含** NULL 机构行—— // 钱是本人付的,本人当然可读); // ② ListByScope(ctx, institutionID, campusID, page) 显式机构/校区:`institution_id = ?`(NULL 行天然不返回); // 非超管传别的机构 → ErrUnscoped; // ③ GetByOrderNo(ctx, orderNo) 系统通道(支付回调/网关对账):按 biz_no 精确命中, // **不受作用域限制**,仅 Service 内部回调路径可调。 // // 事务纪律:写方法一律接收 `tx *gorm.DB`(本文件不开事务)。 package repository import ( "context" stderrors "errors" "strings" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // OrderPayRepo 支付订单仓储(**不继承 BaseRepo**,见文件头 §2.5 裁定)。 type OrderPayRepo struct { // DB 数据库句柄(连接池由 infra/db 统一建立;nil 时任何查询返回 ErrInternal)。 DB *gorm.DB // Sc 请求作用域(仅用于"非超管不得借参数跨机构"的断言;**不参与 SQL 过滤**)。 Sc *appscope.Scope } // NewOrderPayRepo 构造支付订单仓储。 // // 入参: // - db *gorm.DB:连接池句柄(必填); // - sc *appscope.Scope:请求作用域(可 nil:系统回调/对账任务无作用域; // 为 nil 时 ListByScope 返回 ErrUnscoped —— 系统任务请走 GetByOrderNo / ListByUser)。 // // 出参:*OrderPayRepo。 func NewOrderPayRepo(db *gorm.DB, sc *appscope.Scope) *OrderPayRepo { return &OrderPayRepo{DB: db, Sc: sc} } // db 取查询句柄(ctx 内含 tx 时复用同一事务)。 func (r *OrderPayRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: OrderPayRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // ListByUser 通道①:按付款人列出订单(**含机构/校区为 NULL 的行**)。 // // 入参: // - ctx 请求上下文; // - userID 付款用户(ULID;空 → ErrBadParam); // - page 分页(PageQuery;ClampPage:pageSize ∈ [1,200])。 // // 出参:[]model.OrderPay(**空结果返回非 nil 切片**)+ ErrBadParam / ErrDBError。 // // SQL:`SELECT * FROM t_order_pay WHERE user_id = ? ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?` // // 可见性(§2.5):本人付款的行(含 NULL 机构)可见;**不**做机构过滤(付款人主口径)。 // 红线:不得改写成 `user_id = ? AND institution_id = ?`(会把 NULL 行与跨机构付款行一起丢掉)。 func (r *OrderPayRepo) ListByUser(ctx context.Context, userID string, page PageQuery) ([]model.OrderPay, error) { if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByUser 需要 user_id")) } db, err := r.db(ctx) if err != nil { return nil, err } pageSize := page.Limit() rows := make([]model.OrderPay, 0, pageSize) if err := db.Model(&model.OrderPay{}). Where("user_id = ?", userID). Order("create_time DESC").Order("id DESC"). Limit(pageSize).Offset(page.Offset()). Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } if rows == nil { rows = make([]model.OrderPay, 0) } return rows, nil } // GetByUserID 通道①的兼容别名(团队任务书命名;语义完全等同 ListByUser)。 // // 保留双名只为跨文档对齐(设计 §2.5 叫 ListByUser,AC-03 任务书叫 GetByUserID), // 实现只有一份:本方法**直接委托** ListByUser,不复制 SQL。 func (r *OrderPayRepo) GetByUserID(ctx context.Context, userID string, page PageQuery) ([]model.OrderPay, error) { return r.ListByUser(ctx, userID, page) } // ListByScope 通道②:按**显式机构/校区参数**列出订单(NULL 机构行**不返回**)。 // // 入参: // - ctx 请求上下文; // - institutionID 机构 ID(<=0 → ErrBadParam;NULL 行不在本通道范围内); // - campusID 校区 ID(nil = 不限定校区;非 nil → `campus_id = *campusID`,NULL 行天然不返回); // - page 分页(PageQuery)。 // // 出参:[]model.OrderPay(非 nil)+ ErrBadParam / ErrUnscoped(**非超管传了别的机构**)/ ErrDBError。 // // SQL: // // SELECT * FROM t_order_pay WHERE institution_id = ? [AND campus_id = ?] // ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ? // // 红线(§2.5):**严禁** `OR institution_id IS NULL` 兜底;NULL 行只能经通道①(本人)或 // 通道③(系统回调)读取,超管全景视图请显式传 institutionID(逐机构查询)。 func (r *OrderPayRepo) ListByScope(ctx context.Context, institutionID int64, campusID *int64, page PageQuery) ([]model.OrderPay, error) { if institutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByScope 需要 institution_id > 0")) } if r.Sc == nil { return nil, infraerrors.WithCause(infraerrors.ErrUnscoped, errString("repository: ListByScope 需要请求作用域(系统任务请走 GetByOrderNo)")) } if !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID { return nil, infraerrors.WithCause(infraerrors.ErrUnscoped, errString("repository: ListByScope 机构参数越权(作用域机构与入参不一致)")) } db, err := r.db(ctx) if err != nil { return nil, err } q := db.Model(&model.OrderPay{}).Where("institution_id = ?", institutionID) if campusID != nil { if *campusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByScope campus_id 不得为负")) } q = q.Where("campus_id = ?", *campusID) } pageSize := page.Limit() rows := make([]model.OrderPay, 0, pageSize) if err := q.Order("create_time DESC").Order("id DESC"). Limit(pageSize).Offset(page.Offset()). Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } if rows == nil { rows = make([]model.OrderPay, 0) } return rows, nil } // GetByInstitution 通道②的兼容别名(团队任务书命名;语义完全等同 ListByScope,含越权断言)。 func (r *OrderPayRepo) GetByInstitution(ctx context.Context, institutionID int64, campusID *int64, page PageQuery) ([]model.OrderPay, error) { return r.ListByScope(ctx, institutionID, campusID, page) } // GetByOrderNo 通道③:系统通道(支付回调 / 网关对账),按 `biz_no` 精确命中。 // // 入参:ctx 请求上下文;orderNo 对外业务单号(biz_no;空 → ErrBadParam)。 // 出参:*model.OrderPay + ErrNotFound / ErrBadParam / ErrDBError。 // // 命中顺序(回调幂等的双重锚点): // 1. `biz_no = ?`(我们下发给网关的单号,主口径); // 2. 未命中则退到 `pay_order = ?`(第三方流水号,排障/对账场景;`idx_pay_order` 支撑)。 // // 红线:本通道**不受作用域限制**(回调时无用户上下文),只允许 Service 的支付回调/对账路径调用; // 任何面向用户的接口都不得用它取单后直接回显(NULL 机构行会绕过机构边界)。 func (r *OrderPayRepo) GetByOrderNo(ctx context.Context, orderNo string) (*model.OrderPay, error) { if strings.TrimSpace(orderNo) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetByOrderNo 需要单号")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.OrderPay if err := db.Model(&model.OrderPay{}).Where("biz_no = ?", orderNo).Take(&out).Error; err == nil { return &out, nil } else if !isNotFound(err) { return nil, wrapQueryErr(err, "GetByOrderNo(biz_no)") } out = model.OrderPay{} if err := db.Model(&model.OrderPay{}).Where("pay_order = ?", orderNo). Order("id DESC").Take(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetByOrderNo(pay_order)") } return &out, nil } // Create 创建支付订单(下单)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:下单 + 后续业务单号回写需同事务); // - order 订单行(BizNo 非空、PayMoney 合法;Status 零值 = 未支付;机构/校区允许 nil)。 // // 出参:nil 成功;ErrDuplicateBizNo(biz_no 撞键 → 幂等命中)/ ErrBadParam / ErrDBError。 // // 红线:`pay_money` 一旦落库不得再改(回调金额与订单金额不一致必须拒绝入账,判定在 Service)。 func (r *OrderPayRepo) Create(ctx context.Context, tx *gorm.DB, order *model.OrderPay) error { if err := requireTx(tx, "OrderPay.Create"); err != nil { return err } if order == nil || strings.TrimSpace(order.BizNo) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: Create(t_order_pay) 需要 biz_no")) } if err := normalizeExpenseAmounts(&order.PayMoney); err != nil { return err } if err := tx.WithContext(ctxOrBackground(ctx)).Create(order).Error; err != nil { if IsDupKey(err) { return infraerrors.WithCause(ErrDuplicateBizNo, err) } return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // UpdateStatusCAS 状态机 CAS 推进(`WHERE status = from`)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**:状态推进 + 资金入账必须同事务); // - orderNo 对外业务单号(biz_no;空 → ErrBadParam); // - fromStatus 期望当前状态(model.OrderPayStatus*);toStatus 目标状态。 // // 出参: // - int64:受影响行数(**1=本次推进成功**;0=当前状态不等于 fromStatus 或单号不存在 → 幂等返回); // - ErrBadParam(状态组合非法)/ ErrDBError。 // // SQL:`UPDATE t_order_pay SET status = ? WHERE biz_no = ? AND status = ?`。 // // 红线:回调重复投递必须 affected=0(**不得**无条件 UPDATE,否则重复回调会把"已退款/已取消" // 的订单重新推成成功);状态机单向(成功不可回退)由 Service 校验 from/to 组合,本方法只做条件更新。 func (r *OrderPayRepo) UpdateStatusCAS(ctx context.Context, tx *gorm.DB, orderNo string, fromStatus, toStatus int8) (int64, error) { if err := requireTx(tx, "OrderPay.UpdateStatusCAS"); err != nil { return 0, err } if strings.TrimSpace(orderNo) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateStatusCAS 需要单号")) } if !validOrderPayStatus(fromStatus) || !validOrderPayStatus(toStatus) { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateStatusCAS 状态值非法")) } res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.OrderPay{}). Where("biz_no = ? AND status = ?", orderNo, fromStatus). UpdateColumn("status", toStatus) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // validOrderPayStatus 状态值白名单(-2 失败 / -1 取消 / 0 未支付 / 1 成功)。 func validOrderPayStatus(status int8) bool { switch status { case model.OrderPayStatusFailed, model.OrderPayStatusCancelled, model.OrderPayStatusUnpaid, model.OrderPayStatusSucceeded: return true default: return false } } // isNotFound 判断错误是否为"记录不存在"(供 GetByOrderNo 的两段式命中使用)。 func isNotFound(err error) bool { if err == nil { return false } if stderrors.Is(err, gorm.ErrRecordNotFound) { return true } var appErr *infraerrors.AppError if stderrors.As(err, &appErr) { return appErr.Code == infraerrors.CodeNotFound } return false } // MoneyFromString 把 model 层的金额字符串转 money.Amount(调用方高频使用的薄封装)。 // // 入参:raw string(decimal(12,2) 原文,可空串 → 0)。出参:money.Amount + error(格式非法)。 // // 说明:model 层金额列一律 `string`(结构稳定),金额运算集中在 money 包;本函数避免 // 调用方在每个字段上重复写 `if raw == "" {...}` 的样板判断。 func MoneyFromString(raw string) (money.Amount, error) { if strings.TrimSpace(raw) == "" { return 0, nil } return money.Parse(raw) }