order_pay_repo.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. // 支付订单仓储(t_order_pay)——AC-03 三通道读取 + CAS 状态推进。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §2「5. t_order_pay」/ §2.5(NULL 行可见性裁定)
  4. // / §10.3(事务边界只在 Service)。
  5. //
  6. // **本仓储不继承 BaseRepo(架构裁定 L3,§2.5)**,原因是这张表的作用域列**可空**:
  7. //
  8. // t_order_pay.institution_id / campus_id 都是 `BIGINT DEFAULT NULL`(旧库历史行+家长代付行可为 NULL)。
  9. // BaseRepo.Scoped 注入的是 `institution_id = ?`,在 SQL 三值逻辑下 `NULL = 1` 结果是 **UNKNOWN**
  10. // (不成立)→ 会把 NULL 行**静默丢掉**;写成 `institution_id = ? OR institution_id IS NULL` 又等于
  11. // 给"任何机构"开了读全量 NULL 行的口子(**严禁**,§2.5 明文禁止)。
  12. //
  13. // 因此本仓储用**三条显式通道**,可见性边界写在方法签名里,不靠隐藏的作用域注入:
  14. //
  15. // ① ListByUser(ctx, userID, page) 付款人主口径:`user_id = 本人`(**含** NULL 机构行——
  16. // 钱是本人付的,本人当然可读);
  17. // ② ListByScope(ctx, institutionID, campusID, page) 显式机构/校区:`institution_id = ?`(NULL 行天然不返回);
  18. // 非超管传别的机构 → ErrUnscoped;
  19. // ③ GetByOrderNo(ctx, orderNo) 系统通道(支付回调/网关对账):按 biz_no 精确命中,
  20. // **不受作用域限制**,仅 Service 内部回调路径可调。
  21. //
  22. // 事务纪律:写方法一律接收 `tx *gorm.DB`(本文件不开事务)。
  23. package repository
  24. import (
  25. "context"
  26. stderrors "errors"
  27. "strings"
  28. "gorm.io/gorm"
  29. "yuansiwei/unified-backend/internal/academic/model"
  30. "yuansiwei/unified-backend/internal/academic/money"
  31. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  32. appscope "yuansiwei/unified-backend/internal/infra/scope"
  33. )
  34. // OrderPayRepo 支付订单仓储(**不继承 BaseRepo**,见文件头 §2.5 裁定)。
  35. type OrderPayRepo struct {
  36. // DB 数据库句柄(连接池由 infra/db 统一建立;nil 时任何查询返回 ErrInternal)。
  37. DB *gorm.DB
  38. // Sc 请求作用域(仅用于"非超管不得借参数跨机构"的断言;**不参与 SQL 过滤**)。
  39. Sc *appscope.Scope
  40. }
  41. // NewOrderPayRepo 构造支付订单仓储。
  42. //
  43. // 入参:
  44. // - db *gorm.DB:连接池句柄(必填);
  45. // - sc *appscope.Scope:请求作用域(可 nil:系统回调/对账任务无作用域;
  46. // 为 nil 时 ListByScope 返回 ErrUnscoped —— 系统任务请走 GetByOrderNo / ListByUser)。
  47. //
  48. // 出参:*OrderPayRepo。
  49. func NewOrderPayRepo(db *gorm.DB, sc *appscope.Scope) *OrderPayRepo {
  50. return &OrderPayRepo{DB: db, Sc: sc}
  51. }
  52. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  53. func (r *OrderPayRepo) db(ctx context.Context) (*gorm.DB, error) {
  54. if r == nil || r.DB == nil {
  55. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: OrderPayRepo.DB 为 nil"))
  56. }
  57. if tx := TxFromContext(ctx); tx != nil {
  58. return tx, nil
  59. }
  60. if ctx == nil {
  61. return r.DB, nil
  62. }
  63. return r.DB.WithContext(ctx), nil
  64. }
  65. // ListByUser 通道①:按付款人列出订单(**含机构/校区为 NULL 的行**)。
  66. //
  67. // 入参:
  68. // - ctx 请求上下文;
  69. // - userID 付款用户(ULID;空 → ErrBadParam);
  70. // - page 分页(PageQuery;ClampPage:pageSize ∈ [1,200])。
  71. //
  72. // 出参:[]model.OrderPay(**空结果返回非 nil 切片**)+ ErrBadParam / ErrDBError。
  73. //
  74. // SQL:`SELECT * FROM t_order_pay WHERE user_id = ? ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?`
  75. //
  76. // 可见性(§2.5):本人付款的行(含 NULL 机构)可见;**不**做机构过滤(付款人主口径)。
  77. // 红线:不得改写成 `user_id = ? AND institution_id = ?`(会把 NULL 行与跨机构付款行一起丢掉)。
  78. func (r *OrderPayRepo) ListByUser(ctx context.Context, userID string, page PageQuery) ([]model.OrderPay, error) {
  79. if strings.TrimSpace(userID) == "" {
  80. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListByUser 需要 user_id"))
  81. }
  82. db, err := r.db(ctx)
  83. if err != nil {
  84. return nil, err
  85. }
  86. pageSize := page.Limit()
  87. rows := make([]model.OrderPay, 0, pageSize)
  88. if err := db.Model(&model.OrderPay{}).
  89. Where("user_id = ?", userID).
  90. Order("create_time DESC").Order("id DESC").
  91. Limit(pageSize).Offset(page.Offset()).
  92. Find(&rows).Error; err != nil {
  93. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  94. }
  95. if rows == nil {
  96. rows = make([]model.OrderPay, 0)
  97. }
  98. return rows, nil
  99. }
  100. // GetByUserID 通道①的兼容别名(团队任务书命名;语义完全等同 ListByUser)。
  101. //
  102. // 保留双名只为跨文档对齐(设计 §2.5 叫 ListByUser,AC-03 任务书叫 GetByUserID),
  103. // 实现只有一份:本方法**直接委托** ListByUser,不复制 SQL。
  104. func (r *OrderPayRepo) GetByUserID(ctx context.Context, userID string, page PageQuery) ([]model.OrderPay, error) {
  105. return r.ListByUser(ctx, userID, page)
  106. }
  107. // ListByScope 通道②:按**显式机构/校区参数**列出订单(NULL 机构行**不返回**)。
  108. //
  109. // 入参:
  110. // - ctx 请求上下文;
  111. // - institutionID 机构 ID(<=0 → ErrBadParam;NULL 行不在本通道范围内);
  112. // - campusID 校区 ID(nil = 不限定校区;非 nil → `campus_id = *campusID`,NULL 行天然不返回);
  113. // - page 分页(PageQuery)。
  114. //
  115. // 出参:[]model.OrderPay(非 nil)+ ErrBadParam / ErrUnscoped(**非超管传了别的机构**)/ ErrDBError。
  116. //
  117. // SQL:
  118. //
  119. // SELECT * FROM t_order_pay WHERE institution_id = ? [AND campus_id = ?]
  120. // ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?
  121. //
  122. // 红线(§2.5):**严禁** `OR institution_id IS NULL` 兜底;NULL 行只能经通道①(本人)或
  123. // 通道③(系统回调)读取,超管全景视图请显式传 institutionID(逐机构查询)。
  124. func (r *OrderPayRepo) ListByScope(ctx context.Context, institutionID int64, campusID *int64, page PageQuery) ([]model.OrderPay, error) {
  125. if institutionID <= 0 {
  126. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  127. errString("repository: ListByScope 需要 institution_id > 0"))
  128. }
  129. if r.Sc == nil {
  130. return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
  131. errString("repository: ListByScope 需要请求作用域(系统任务请走 GetByOrderNo)"))
  132. }
  133. if !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
  134. return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
  135. errString("repository: ListByScope 机构参数越权(作用域机构与入参不一致)"))
  136. }
  137. db, err := r.db(ctx)
  138. if err != nil {
  139. return nil, err
  140. }
  141. q := db.Model(&model.OrderPay{}).Where("institution_id = ?", institutionID)
  142. if campusID != nil {
  143. if *campusID < 0 {
  144. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  145. errString("repository: ListByScope campus_id 不得为负"))
  146. }
  147. q = q.Where("campus_id = ?", *campusID)
  148. }
  149. pageSize := page.Limit()
  150. rows := make([]model.OrderPay, 0, pageSize)
  151. if err := q.Order("create_time DESC").Order("id DESC").
  152. Limit(pageSize).Offset(page.Offset()).
  153. Find(&rows).Error; err != nil {
  154. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  155. }
  156. if rows == nil {
  157. rows = make([]model.OrderPay, 0)
  158. }
  159. return rows, nil
  160. }
  161. // GetByInstitution 通道②的兼容别名(团队任务书命名;语义完全等同 ListByScope,含越权断言)。
  162. func (r *OrderPayRepo) GetByInstitution(ctx context.Context, institutionID int64, campusID *int64, page PageQuery) ([]model.OrderPay, error) {
  163. return r.ListByScope(ctx, institutionID, campusID, page)
  164. }
  165. // GetByOrderNo 通道③:系统通道(支付回调 / 网关对账),按 `biz_no` 精确命中。
  166. //
  167. // 入参:ctx 请求上下文;orderNo 对外业务单号(biz_no;空 → ErrBadParam)。
  168. // 出参:*model.OrderPay + ErrNotFound / ErrBadParam / ErrDBError。
  169. //
  170. // 命中顺序(回调幂等的双重锚点):
  171. // 1. `biz_no = ?`(我们下发给网关的单号,主口径);
  172. // 2. 未命中则退到 `pay_order = ?`(第三方流水号,排障/对账场景;`idx_pay_order` 支撑)。
  173. //
  174. // 红线:本通道**不受作用域限制**(回调时无用户上下文),只允许 Service 的支付回调/对账路径调用;
  175. // 任何面向用户的接口都不得用它取单后直接回显(NULL 机构行会绕过机构边界)。
  176. func (r *OrderPayRepo) GetByOrderNo(ctx context.Context, orderNo string) (*model.OrderPay, error) {
  177. if strings.TrimSpace(orderNo) == "" {
  178. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetByOrderNo 需要单号"))
  179. }
  180. db, err := r.db(ctx)
  181. if err != nil {
  182. return nil, err
  183. }
  184. var out model.OrderPay
  185. if err := db.Model(&model.OrderPay{}).Where("biz_no = ?", orderNo).Take(&out).Error; err == nil {
  186. return &out, nil
  187. } else if !isNotFound(err) {
  188. return nil, wrapQueryErr(err, "GetByOrderNo(biz_no)")
  189. }
  190. out = model.OrderPay{}
  191. if err := db.Model(&model.OrderPay{}).Where("pay_order = ?", orderNo).
  192. Order("id DESC").Take(&out).Error; err != nil {
  193. return nil, wrapQueryErr(err, "GetByOrderNo(pay_order)")
  194. }
  195. return &out, nil
  196. }
  197. // Create 创建支付订单(下单)。
  198. //
  199. // 入参:
  200. // - ctx 请求上下文;
  201. // - tx 事务句柄(**必传**:下单 + 后续业务单号回写需同事务);
  202. // - order 订单行(BizNo 非空、PayMoney 合法;Status 零值 = 未支付;机构/校区允许 nil)。
  203. //
  204. // 出参:nil 成功;ErrDuplicateBizNo(biz_no 撞键 → 幂等命中)/ ErrBadParam / ErrDBError。
  205. //
  206. // 红线:`pay_money` 一旦落库不得再改(回调金额与订单金额不一致必须拒绝入账,判定在 Service)。
  207. func (r *OrderPayRepo) Create(ctx context.Context, tx *gorm.DB, order *model.OrderPay) error {
  208. if err := requireTx(tx, "OrderPay.Create"); err != nil {
  209. return err
  210. }
  211. if order == nil || strings.TrimSpace(order.BizNo) == "" {
  212. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: Create(t_order_pay) 需要 biz_no"))
  213. }
  214. if err := normalizeExpenseAmounts(&order.PayMoney); err != nil {
  215. return err
  216. }
  217. if err := tx.WithContext(ctxOrBackground(ctx)).Create(order).Error; err != nil {
  218. if IsDupKey(err) {
  219. return infraerrors.WithCause(ErrDuplicateBizNo, err)
  220. }
  221. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  222. }
  223. return nil
  224. }
  225. // UpdateStatusCAS 状态机 CAS 推进(`WHERE status = from`)。
  226. //
  227. // 入参:
  228. // - ctx 请求上下文;
  229. // - tx 事务句柄(**必传**:状态推进 + 资金入账必须同事务);
  230. // - orderNo 对外业务单号(biz_no;空 → ErrBadParam);
  231. // - fromStatus 期望当前状态(model.OrderPayStatus*);toStatus 目标状态。
  232. //
  233. // 出参:
  234. // - int64:受影响行数(**1=本次推进成功**;0=当前状态不等于 fromStatus 或单号不存在 → 幂等返回);
  235. // - ErrBadParam(状态组合非法)/ ErrDBError。
  236. //
  237. // SQL:`UPDATE t_order_pay SET status = ? WHERE biz_no = ? AND status = ?`。
  238. //
  239. // 红线:回调重复投递必须 affected=0(**不得**无条件 UPDATE,否则重复回调会把"已退款/已取消"
  240. // 的订单重新推成成功);状态机单向(成功不可回退)由 Service 校验 from/to 组合,本方法只做条件更新。
  241. func (r *OrderPayRepo) UpdateStatusCAS(ctx context.Context, tx *gorm.DB, orderNo string, fromStatus, toStatus int8) (int64, error) {
  242. if err := requireTx(tx, "OrderPay.UpdateStatusCAS"); err != nil {
  243. return 0, err
  244. }
  245. if strings.TrimSpace(orderNo) == "" {
  246. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  247. errString("repository: UpdateStatusCAS 需要单号"))
  248. }
  249. if !validOrderPayStatus(fromStatus) || !validOrderPayStatus(toStatus) {
  250. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  251. errString("repository: UpdateStatusCAS 状态值非法"))
  252. }
  253. res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.OrderPay{}).
  254. Where("biz_no = ? AND status = ?", orderNo, fromStatus).
  255. UpdateColumn("status", toStatus)
  256. if res.Error != nil {
  257. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  258. }
  259. return res.RowsAffected, nil
  260. }
  261. // validOrderPayStatus 状态值白名单(-2 失败 / -1 取消 / 0 未支付 / 1 成功)。
  262. func validOrderPayStatus(status int8) bool {
  263. switch status {
  264. case model.OrderPayStatusFailed, model.OrderPayStatusCancelled,
  265. model.OrderPayStatusUnpaid, model.OrderPayStatusSucceeded:
  266. return true
  267. default:
  268. return false
  269. }
  270. }
  271. // isNotFound 判断错误是否为"记录不存在"(供 GetByOrderNo 的两段式命中使用)。
  272. func isNotFound(err error) bool {
  273. if err == nil {
  274. return false
  275. }
  276. if stderrors.Is(err, gorm.ErrRecordNotFound) {
  277. return true
  278. }
  279. var appErr *infraerrors.AppError
  280. if stderrors.As(err, &appErr) {
  281. return appErr.Code == infraerrors.CodeNotFound
  282. }
  283. return false
  284. }
  285. // MoneyFromString 把 model 层的金额字符串转 money.Amount(调用方高频使用的薄封装)。
  286. //
  287. // 入参:raw string(decimal(12,2) 原文,可空串 → 0)。出参:money.Amount + error(格式非法)。
  288. //
  289. // 说明:model 层金额列一律 `string`(结构稳定),金额运算集中在 money 包;本函数避免
  290. // 调用方在每个字段上重复写 `if raw == "" {...}` 的样板判断。
  291. func MoneyFromString(raw string) (money.Amount, error) {
  292. if strings.TrimSpace(raw) == "" {
  293. return 0, nil
  294. }
  295. return money.Parse(raw)
  296. }