payment_order_lifecycle.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "strconv"
  7. "strings"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
  11. "github.com/Wei-Shaw/sub2api/ent/paymentorder"
  12. "github.com/Wei-Shaw/sub2api/internal/payment"
  13. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  15. )
  16. // --- Cancel & Expire ---
  17. // Cancel rate limit configuration constants.
  18. const (
  19. rateLimitUnitDay = "day"
  20. rateLimitUnitMinute = "minute"
  21. rateLimitUnitHour = "hour"
  22. rateLimitModeFixed = "fixed"
  23. checkPaidResultAlreadyPaid = "already_paid"
  24. checkPaidResultCancelled = "cancelled"
  25. pendingPaymentReconcileLimit = 20
  26. )
  27. type checkPaidOptions struct {
  28. cancelIfUnpaid bool
  29. }
  30. func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error {
  31. if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 {
  32. return nil
  33. }
  34. windowStart := cancelRateLimitWindowStart(cfg)
  35. operator := fmt.Sprintf("user:%d", userID)
  36. count, err := s.entClient.PaymentAuditLog.Query().
  37. Where(
  38. paymentauditlog.ActionEQ("ORDER_CANCELLED"),
  39. paymentauditlog.OperatorEQ(operator),
  40. paymentauditlog.CreatedAtGTE(windowStart),
  41. ).Count(ctx)
  42. if err != nil {
  43. slog.Error("check cancel rate limit failed", "userID", userID, "error", err)
  44. return nil // fail open
  45. }
  46. if count >= cfg.CancelRateLimitMax {
  47. return infraerrors.TooManyRequests("CANCEL_RATE_LIMITED", "cancel rate limited").
  48. WithMetadata(map[string]string{
  49. "max": strconv.Itoa(cfg.CancelRateLimitMax),
  50. "window": strconv.Itoa(cfg.CancelRateLimitWindow),
  51. "unit": cfg.CancelRateLimitUnit,
  52. })
  53. }
  54. return nil
  55. }
  56. func cancelRateLimitWindowStart(cfg *PaymentConfig) time.Time {
  57. now := time.Now()
  58. w := cfg.CancelRateLimitWindow
  59. if w <= 0 {
  60. w = 1
  61. }
  62. unit := cfg.CancelRateLimitUnit
  63. if unit == "" {
  64. unit = rateLimitUnitDay
  65. }
  66. if cfg.CancelRateLimitMode == rateLimitModeFixed {
  67. switch unit {
  68. case rateLimitUnitMinute:
  69. t := now.Truncate(time.Minute)
  70. return t.Add(-time.Duration(w-1) * time.Minute)
  71. case rateLimitUnitDay:
  72. y, m, d := now.Date()
  73. t := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
  74. return t.AddDate(0, 0, -(w - 1))
  75. default: // hour
  76. t := now.Truncate(time.Hour)
  77. return t.Add(-time.Duration(w-1) * time.Hour)
  78. }
  79. }
  80. // rolling window
  81. switch unit {
  82. case rateLimitUnitMinute:
  83. return now.Add(-time.Duration(w) * time.Minute)
  84. case rateLimitUnitDay:
  85. return now.AddDate(0, 0, -w)
  86. default: // hour
  87. return now.Add(-time.Duration(w) * time.Hour)
  88. }
  89. }
  90. func (s *PaymentService) CancelOrder(ctx context.Context, orderID, userID int64) (string, error) {
  91. o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
  92. if err != nil {
  93. return "", infraerrors.NotFound("NOT_FOUND", "order not found")
  94. }
  95. if o.UserID != userID {
  96. return "", infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
  97. }
  98. if o.Status != OrderStatusPending {
  99. return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status")
  100. }
  101. return s.cancelCore(ctx, o, OrderStatusCancelled, fmt.Sprintf("user:%d", userID), "user cancelled order")
  102. }
  103. func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (string, error) {
  104. o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
  105. if err != nil {
  106. return "", infraerrors.NotFound("NOT_FOUND", "order not found")
  107. }
  108. if o.Status != OrderStatusPending {
  109. return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status")
  110. }
  111. return s.cancelCore(ctx, o, OrderStatusCancelled, "admin", "admin cancelled order")
  112. }
  113. func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, fs, op, ad string) (string, error) {
  114. if o.PaymentTradeNo != "" || o.PaymentType != "" {
  115. if s.checkPaid(ctx, o) == checkPaidResultAlreadyPaid {
  116. return checkPaidResultAlreadyPaid, nil
  117. }
  118. }
  119. c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusPending)).SetStatus(fs).Save(ctx)
  120. if err != nil {
  121. return "", fmt.Errorf("update order status: %w", err)
  122. }
  123. if c > 0 {
  124. auditAction := "ORDER_CANCELLED"
  125. if fs == OrderStatusExpired {
  126. auditAction = "ORDER_EXPIRED"
  127. }
  128. s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad})
  129. }
  130. return checkPaidResultCancelled, nil
  131. }
  132. func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string {
  133. return s.checkPaidWithOptions(ctx, o, checkPaidOptions{cancelIfUnpaid: true})
  134. }
  135. func (s *PaymentService) reconcilePaid(ctx context.Context, o *dbent.PaymentOrder) string {
  136. return s.checkPaidWithOptions(ctx, o, checkPaidOptions{})
  137. }
  138. func (s *PaymentService) checkPaidWithOptions(ctx context.Context, o *dbent.PaymentOrder, opts checkPaidOptions) string {
  139. prov, err := s.getOrderProvider(ctx, o)
  140. if err != nil {
  141. return ""
  142. }
  143. queryRef := paymentOrderQueryReference(o, prov)
  144. if queryRef == "" {
  145. return ""
  146. }
  147. finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
  148. resp, err := prov.QueryOrder(ctx, queryRef)
  149. finishProviderCall()
  150. if err != nil {
  151. slog.Warn("query upstream failed", "orderID", o.ID, "error", err)
  152. return ""
  153. }
  154. if resp.Status == payment.ProviderStatusPaid {
  155. if !isValidProviderAmount(resp.Amount) {
  156. s.writeAuditLog(ctx, o.ID, "PAYMENT_INVALID_AMOUNT", prov.ProviderKey(), map[string]any{
  157. "expected": o.PayAmount,
  158. "paid": resp.Amount,
  159. "tradeNo": resp.TradeNo,
  160. "queryRef": queryRef,
  161. })
  162. slog.Warn("query upstream returned invalid paid amount", "orderID", o.ID, "queryRef", queryRef, "paid", resp.Amount)
  163. retriedResp, retryOK := requeryPaidOrderOnce(ctx, prov, queryRef)
  164. if !retryOK {
  165. return ""
  166. }
  167. resp = retriedResp
  168. }
  169. notificationTradeNo := o.PaymentTradeNo
  170. if upstreamTradeNo := strings.TrimSpace(resp.TradeNo); paymentOrderShouldPersistUpstreamTradeNo(queryRef, upstreamTradeNo, notificationTradeNo) {
  171. if _, updateErr := s.entClient.PaymentOrder.Update().
  172. Where(paymentorder.IDEQ(o.ID)).
  173. SetPaymentTradeNo(upstreamTradeNo).
  174. Save(ctx); updateErr != nil {
  175. slog.Error("persist upstream trade no during checkPaid failed", "orderID", o.ID, "tradeNo", upstreamTradeNo, "error", updateErr)
  176. } else {
  177. o.PaymentTradeNo = upstreamTradeNo
  178. }
  179. notificationTradeNo = upstreamTradeNo
  180. }
  181. if err := s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: notificationTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess, Metadata: resp.Metadata}, prov.ProviderKey()); err != nil {
  182. slog.Error("fulfillment failed during checkPaid", "orderID", o.ID, "error", err)
  183. // Still return already_paid — order was paid, fulfillment can be retried
  184. }
  185. return checkPaidResultAlreadyPaid
  186. }
  187. if !opts.cancelIfUnpaid {
  188. return ""
  189. }
  190. if cp, ok := prov.(payment.CancelableProvider); ok {
  191. finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
  192. _ = cp.CancelPayment(ctx, queryRef)
  193. finishProviderCall()
  194. }
  195. return ""
  196. }
  197. func requeryPaidOrderOnce(ctx context.Context, prov payment.Provider, queryRef string) (*payment.QueryOrderResponse, bool) {
  198. if prov == nil || strings.TrimSpace(queryRef) == "" {
  199. return nil, false
  200. }
  201. finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
  202. resp, err := prov.QueryOrder(ctx, queryRef)
  203. finishProviderCall()
  204. if err != nil {
  205. slog.Warn("query upstream retry failed", "queryRef", queryRef, "error", err)
  206. return nil, false
  207. }
  208. if resp == nil || resp.Status != payment.ProviderStatusPaid || !isValidProviderAmount(resp.Amount) {
  209. return nil, false
  210. }
  211. return resp, true
  212. }
  213. func paymentOrderQueryReference(order *dbent.PaymentOrder, prov payment.Provider) string {
  214. if order == nil {
  215. return ""
  216. }
  217. providerKey := ""
  218. if prov != nil {
  219. providerKey = strings.TrimSpace(prov.ProviderKey())
  220. }
  221. if providerKey == "" {
  222. if snapshot := psOrderProviderSnapshot(order); snapshot != nil {
  223. providerKey = strings.TrimSpace(snapshot.ProviderKey)
  224. }
  225. }
  226. if providerKey == "" {
  227. providerKey = strings.TrimSpace(psStringValue(order.ProviderKey))
  228. }
  229. if providerKey == "" {
  230. providerKey = strings.TrimSpace(order.PaymentType)
  231. }
  232. switch payment.GetBasePaymentType(providerKey) {
  233. case payment.TypeAlipay, payment.TypeEasyPay, payment.TypeWxpay:
  234. return strings.TrimSpace(order.OutTradeNo)
  235. default:
  236. if tradeNo := strings.TrimSpace(order.PaymentTradeNo); tradeNo != "" {
  237. return tradeNo
  238. }
  239. return strings.TrimSpace(order.OutTradeNo)
  240. }
  241. }
  242. func paymentOrderShouldPersistUpstreamTradeNo(queryRef, upstreamTradeNo, currentTradeNo string) bool {
  243. upstreamTradeNo = strings.TrimSpace(upstreamTradeNo)
  244. if upstreamTradeNo == "" {
  245. return false
  246. }
  247. if strings.EqualFold(upstreamTradeNo, strings.TrimSpace(currentTradeNo)) {
  248. return false
  249. }
  250. if strings.EqualFold(upstreamTradeNo, strings.TrimSpace(queryRef)) {
  251. return false
  252. }
  253. return true
  254. }
  255. // VerifyOrderByOutTradeNo actively queries the upstream provider to check
  256. // if a payment was made, and processes it if so. This handles the case where
  257. // the provider's notify callback was missed (e.g. EasyPay popup mode).
  258. func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo string, userID int64) (*dbent.PaymentOrder, error) {
  259. outTradeNo, err := normalizeOrderLookupOutTradeNo(outTradeNo)
  260. if err != nil {
  261. return nil, err
  262. }
  263. o, err := s.entClient.PaymentOrder.Query().
  264. Where(paymentorder.OutTradeNo(outTradeNo)).
  265. Only(ctx)
  266. if err != nil {
  267. return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  268. }
  269. if o.UserID != userID {
  270. return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
  271. }
  272. // Only verify orders that are still pending or recently expired
  273. if o.Status == OrderStatusPending || o.Status == OrderStatusExpired {
  274. result := s.reconcilePaid(ctx, o)
  275. if result == checkPaidResultAlreadyPaid {
  276. // Reload order to get updated status
  277. o, err = s.entClient.PaymentOrder.Get(ctx, o.ID)
  278. if err != nil {
  279. return nil, fmt.Errorf("reload order: %w", err)
  280. }
  281. }
  282. }
  283. return o, nil
  284. }
  285. // ReconcilePendingPaymentOrders actively checks recent pending Alipay and WeChat
  286. // orders so missed provider notifications do not wait until order expiry to fulfill.
  287. func (s *PaymentService) ReconcilePendingPaymentOrders(ctx context.Context) (int, error) {
  288. now := time.Now()
  289. orders, err := s.entClient.PaymentOrder.Query().
  290. Where(
  291. paymentorder.StatusEQ(OrderStatusPending),
  292. paymentorder.ExpiresAtGT(now),
  293. paymentorder.Or(
  294. paymentorder.PaymentTypeEQ(payment.TypeWxpay),
  295. paymentorder.PaymentTypeHasPrefix(payment.TypeWxpay+"_"),
  296. paymentorder.ProviderKeyEQ(payment.TypeWxpay),
  297. paymentorder.ProviderKeyHasPrefix(payment.TypeWxpay+"_"),
  298. paymentorder.PaymentTypeEQ(payment.TypeAlipay),
  299. paymentorder.PaymentTypeHasPrefix(payment.TypeAlipay+"_"),
  300. paymentorder.ProviderKeyEQ(payment.TypeAlipay),
  301. paymentorder.ProviderKeyHasPrefix(payment.TypeAlipay+"_"),
  302. ),
  303. ).
  304. Order(dbent.Asc(paymentorder.FieldCreatedAt)).
  305. Limit(pendingPaymentReconcileLimit).
  306. All(ctx)
  307. if err != nil {
  308. return 0, fmt.Errorf("query pending payment orders: %w", err)
  309. }
  310. recovered := 0
  311. for _, order := range orders {
  312. if s.reconcilePaid(ctx, order) == checkPaidResultAlreadyPaid {
  313. recovered++
  314. }
  315. }
  316. return recovered, nil
  317. }
  318. // VerifyOrderPublic returns the currently persisted public order state without
  319. // triggering any upstream reconciliation. Signed resume-token recovery is the
  320. // only public recovery path allowed to query upstream state.
  321. func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*dbent.PaymentOrder, error) {
  322. outTradeNo, err := normalizeOrderLookupOutTradeNo(outTradeNo)
  323. if err != nil {
  324. return nil, err
  325. }
  326. o, err := s.entClient.PaymentOrder.Query().
  327. Where(paymentorder.OutTradeNo(outTradeNo)).
  328. Only(ctx)
  329. if err != nil {
  330. return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
  331. }
  332. return o, nil
  333. }
  334. func normalizeOrderLookupOutTradeNo(raw string) (string, error) {
  335. outTradeNo := strings.TrimSpace(raw)
  336. if outTradeNo == "" {
  337. return "", infraerrors.BadRequest("INVALID_OUT_TRADE_NO", "out_trade_no is required")
  338. }
  339. if len(outTradeNo) > 64 {
  340. return "", infraerrors.BadRequest("INVALID_OUT_TRADE_NO", "out_trade_no is invalid")
  341. }
  342. for _, ch := range outTradeNo {
  343. switch {
  344. case ch >= 'a' && ch <= 'z':
  345. case ch >= 'A' && ch <= 'Z':
  346. case ch >= '0' && ch <= '9':
  347. case ch == '_' || ch == '-':
  348. default:
  349. return "", infraerrors.BadRequest("INVALID_OUT_TRADE_NO", "out_trade_no is invalid")
  350. }
  351. }
  352. return outTradeNo, nil
  353. }
  354. func (s *PaymentService) ExpireTimedOutOrders(ctx context.Context) (int, error) {
  355. now := time.Now()
  356. orders, err := s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusPending), paymentorder.ExpiresAtLTE(now)).All(ctx)
  357. if err != nil {
  358. return 0, fmt.Errorf("query expired: %w", err)
  359. }
  360. n := 0
  361. for _, o := range orders {
  362. // Check upstream payment status before expiring — the user may have
  363. // paid just before timeout and the webhook hasn't arrived yet.
  364. outcome, _ := s.cancelCore(ctx, o, OrderStatusExpired, "system", "order expired")
  365. if outcome == checkPaidResultAlreadyPaid {
  366. slog.Info("order was paid during expiry", "orderID", o.ID)
  367. continue
  368. }
  369. if outcome != "" {
  370. n++
  371. }
  372. }
  373. return n, nil
  374. }
  375. // getOrderProvider creates a provider using the order's original instance config.
  376. // Falls back to registry lookup if instance ID is missing (legacy orders).
  377. func (s *PaymentService) getOrderProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) {
  378. inst, err := s.getOrderProviderInstance(ctx, o)
  379. if err != nil {
  380. return nil, fmt.Errorf("load order provider instance: %w", err)
  381. }
  382. if inst != nil {
  383. return s.createProviderFromInstance(ctx, inst)
  384. }
  385. if !paymentOrderAllowsRegistryFallback(o) {
  386. return nil, fmt.Errorf("order %d provider instance is unresolved", o.ID)
  387. }
  388. providerKey := paymentOrderFallbackProviderKey(s.registry, o)
  389. if providerKey == "" {
  390. return nil, fmt.Errorf("order %d provider fallback key is missing", o.ID)
  391. }
  392. if !s.webhookRegistryFallbackAllowed(ctx, providerKey) {
  393. return nil, fmt.Errorf("order %d provider fallback is ambiguous for %s", o.ID, providerKey)
  394. }
  395. s.EnsureProviders(ctx)
  396. return s.registry.GetProvider(o.PaymentType)
  397. }
  398. func paymentOrderAllowsRegistryFallback(order *dbent.PaymentOrder) bool {
  399. if order == nil {
  400. return false
  401. }
  402. if psOrderProviderSnapshot(order) != nil {
  403. return false
  404. }
  405. if strings.TrimSpace(psStringValue(order.ProviderInstanceID)) != "" {
  406. return false
  407. }
  408. if strings.TrimSpace(psStringValue(order.ProviderKey)) != "" {
  409. return false
  410. }
  411. return true
  412. }
  413. func paymentOrderFallbackProviderKey(registry *payment.Registry, order *dbent.PaymentOrder) string {
  414. if order == nil {
  415. return ""
  416. }
  417. if registry != nil {
  418. if key := strings.TrimSpace(registry.GetProviderKey(payment.PaymentType(order.PaymentType))); key != "" {
  419. return key
  420. }
  421. }
  422. return strings.TrimSpace(payment.GetBasePaymentType(strings.TrimSpace(order.PaymentType)))
  423. }
  424. func (s *PaymentService) createProviderFromInstance(ctx context.Context, inst *dbent.PaymentProviderInstance) (payment.Provider, error) {
  425. if inst == nil {
  426. return nil, fmt.Errorf("payment provider instance is missing")
  427. }
  428. cfg, err := s.loadBalancer.GetInstanceConfig(ctx, int64(inst.ID))
  429. if err != nil {
  430. return nil, fmt.Errorf("load provider instance config: %w", err)
  431. }
  432. if inst.PaymentMode != "" {
  433. cfg["paymentMode"] = inst.PaymentMode
  434. }
  435. instID := strconv.FormatInt(int64(inst.ID), 10)
  436. prov, err := createPaymentProviderFromInstance(inst.ProviderKey, instID, cfg)
  437. if err != nil {
  438. return nil, fmt.Errorf("create provider from instance: %w", err)
  439. }
  440. return prov, nil
  441. }
  442. func psStringValue(value *string) string {
  443. if value == nil {
  444. return ""
  445. }
  446. return *value
  447. }