| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- // 支付订单仓储(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)
- }
|