payment_handler.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. package handler
  2. import (
  3. "fmt"
  4. "strconv"
  5. "strings"
  6. "time"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. "github.com/Wei-Shaw/sub2api/internal/payment"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  11. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. )
  15. // PaymentHandler handles user-facing payment requests.
  16. type PaymentHandler struct {
  17. paymentService *service.PaymentService
  18. configService *service.PaymentConfigService
  19. }
  20. // NewPaymentHandler creates a new PaymentHandler.
  21. func NewPaymentHandler(paymentService *service.PaymentService, configService *service.PaymentConfigService) *PaymentHandler {
  22. return &PaymentHandler{
  23. paymentService: paymentService,
  24. configService: configService,
  25. }
  26. }
  27. // GetPaymentConfig returns the payment system configuration.
  28. // GET /api/v1/payment/config
  29. func (h *PaymentHandler) GetPaymentConfig(c *gin.Context) {
  30. cfg, err := h.configService.GetPaymentConfig(c.Request.Context())
  31. if err != nil {
  32. response.ErrorFrom(c, err)
  33. return
  34. }
  35. response.Success(c, cfg)
  36. }
  37. // GetPlans returns subscription plans available for sale.
  38. // GET /api/v1/payment/plans
  39. func (h *PaymentHandler) GetPlans(c *gin.Context) {
  40. plans, err := h.configService.ListPlansForSale(c.Request.Context())
  41. if err != nil {
  42. response.ErrorFrom(c, err)
  43. return
  44. }
  45. // Enrich plans with group platform for frontend color coding
  46. type planWithPlatform struct {
  47. ID int64 `json:"id"`
  48. GroupID int64 `json:"group_id"`
  49. GroupPlatform string `json:"group_platform"`
  50. GroupName string `json:"group_name"`
  51. RateMultiplier float64 `json:"rate_multiplier"`
  52. PeakRateEnabled bool `json:"peak_rate_enabled"`
  53. PeakStart string `json:"peak_start"`
  54. PeakEnd string `json:"peak_end"`
  55. PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
  56. Name string `json:"name"`
  57. Description string `json:"description"`
  58. Price float64 `json:"price"`
  59. OriginalPrice *float64 `json:"original_price,omitempty"`
  60. Currency string `json:"currency,omitempty"`
  61. ValidityDays int `json:"validity_days"`
  62. ValidityUnit string `json:"validity_unit"`
  63. Features string `json:"features"`
  64. ProductName string `json:"product_name"`
  65. ForSale bool `json:"for_sale"`
  66. SortOrder int `json:"sort_order"`
  67. }
  68. groupInfo := h.configService.GetGroupInfoMap(c.Request.Context(), plans)
  69. result := make([]planWithPlatform, 0, len(plans))
  70. for _, p := range plans {
  71. gi := groupInfo[p.GroupID]
  72. result = append(result, planWithPlatform{
  73. ID: int64(p.ID), GroupID: p.GroupID,
  74. GroupPlatform: gi.Platform, GroupName: gi.Name,
  75. RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled,
  76. PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
  77. Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
  78. Currency: p.Currency,
  79. ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features,
  80. ProductName: p.ProductName, ForSale: p.ForSale, SortOrder: p.SortOrder,
  81. })
  82. }
  83. response.Success(c, result)
  84. }
  85. // GetCheckoutInfo returns all data the payment page needs in a single call:
  86. // payment methods with limits, subscription plans, and configuration.
  87. // GET /api/v1/payment/checkout-info
  88. func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
  89. ctx := c.Request.Context()
  90. // Fetch limits (methods + global range)
  91. limitsResp, err := h.configService.GetAvailableMethodLimits(ctx)
  92. if err != nil {
  93. response.ErrorFrom(c, err)
  94. return
  95. }
  96. // Fetch payment config
  97. cfg, err := h.configService.GetPaymentConfig(ctx)
  98. if err != nil {
  99. response.ErrorFrom(c, err)
  100. return
  101. }
  102. alipayMobilePrecreateDeepLink := false
  103. if cfg.AlipayMobilePrecreateDeepLink {
  104. alipayMobilePrecreateDeepLink, err = h.configService.UsesOfficialAlipayVisibleMethod(ctx)
  105. if err != nil {
  106. response.ErrorFrom(c, err)
  107. return
  108. }
  109. }
  110. // Fetch plans with group info
  111. plans, _ := h.configService.ListPlansForSale(ctx)
  112. groupInfo := h.configService.GetGroupInfoMap(ctx, plans)
  113. planList := make([]checkoutPlan, 0, len(plans))
  114. for _, p := range plans {
  115. gi := groupInfo[p.GroupID]
  116. planList = append(planList, checkoutPlan{
  117. ID: int64(p.ID), GroupID: p.GroupID,
  118. GroupPlatform: gi.Platform, GroupName: gi.Name,
  119. RateMultiplier: gi.RateMultiplier,
  120. PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart,
  121. PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
  122. DailyLimitUSD: gi.DailyLimitUSD,
  123. WeeklyLimitUSD: gi.WeeklyLimitUSD, MonthlyLimitUSD: gi.MonthlyLimitUSD,
  124. ModelScopes: gi.ModelScopes,
  125. Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
  126. Currency: p.Currency,
  127. ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: parseFeatures(p.Features),
  128. ProductName: p.ProductName,
  129. })
  130. }
  131. response.Success(c, checkoutInfoResponse{
  132. Methods: limitsResp.Methods,
  133. GlobalMin: limitsResp.GlobalMin,
  134. GlobalMax: limitsResp.GlobalMax,
  135. Plans: planList,
  136. BalanceDisabled: cfg.BalanceDisabled,
  137. BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
  138. SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
  139. RechargeFeeRate: cfg.RechargeFeeRate,
  140. HelpText: cfg.HelpText,
  141. HelpImageURL: cfg.HelpImageURL,
  142. StripePublishableKey: cfg.StripePublishableKey,
  143. AlipayForceQRCode: cfg.AlipayForceQRCode,
  144. AlipayMobilePrecreateDeepLink: alipayMobilePrecreateDeepLink,
  145. })
  146. }
  147. type checkoutInfoResponse struct {
  148. Methods map[string]service.MethodLimits `json:"methods"`
  149. GlobalMin float64 `json:"global_min"`
  150. GlobalMax float64 `json:"global_max"`
  151. Plans []checkoutPlan `json:"plans"`
  152. BalanceDisabled bool `json:"balance_disabled"`
  153. BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
  154. SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
  155. RechargeFeeRate float64 `json:"recharge_fee_rate"`
  156. HelpText string `json:"help_text"`
  157. HelpImageURL string `json:"help_image_url"`
  158. StripePublishableKey string `json:"stripe_publishable_key"`
  159. AlipayForceQRCode bool `json:"alipay_force_qrcode"`
  160. AlipayMobilePrecreateDeepLink bool `json:"alipay_mobile_precreate_deep_link"`
  161. }
  162. type checkoutPlan struct {
  163. ID int64 `json:"id"`
  164. GroupID int64 `json:"group_id"`
  165. GroupPlatform string `json:"group_platform"`
  166. GroupName string `json:"group_name"`
  167. RateMultiplier float64 `json:"rate_multiplier"`
  168. PeakRateEnabled bool `json:"peak_rate_enabled"`
  169. PeakStart string `json:"peak_start"`
  170. PeakEnd string `json:"peak_end"`
  171. PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
  172. DailyLimitUSD *float64 `json:"daily_limit_usd"`
  173. WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
  174. MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
  175. ModelScopes []string `json:"supported_model_scopes"`
  176. Name string `json:"name"`
  177. Description string `json:"description"`
  178. Price float64 `json:"price"`
  179. OriginalPrice *float64 `json:"original_price,omitempty"`
  180. Currency string `json:"currency,omitempty"`
  181. ValidityDays int `json:"validity_days"`
  182. ValidityUnit string `json:"validity_unit"`
  183. Features []string `json:"features"`
  184. ProductName string `json:"product_name"`
  185. }
  186. // parseFeatures splits a newline-separated features string into a string slice.
  187. func parseFeatures(raw string) []string {
  188. if raw == "" {
  189. return []string{}
  190. }
  191. var out []string
  192. for _, line := range strings.Split(raw, "\n") {
  193. if s := strings.TrimSpace(line); s != "" {
  194. out = append(out, s)
  195. }
  196. }
  197. if out == nil {
  198. return []string{}
  199. }
  200. return out
  201. }
  202. // GetLimits returns per-payment-type limits derived from enabled provider instances.
  203. // GET /api/v1/payment/limits
  204. func (h *PaymentHandler) GetLimits(c *gin.Context) {
  205. resp, err := h.configService.GetAvailableMethodLimits(c.Request.Context())
  206. if err != nil {
  207. response.ErrorFrom(c, err)
  208. return
  209. }
  210. response.Success(c, resp)
  211. }
  212. // CreateOrderRequest is the request body for creating a payment order.
  213. type CreateOrderRequest struct {
  214. Amount float64 `json:"amount"`
  215. PaymentType string `json:"payment_type" binding:"required"`
  216. OpenID string `json:"openid"`
  217. WechatResumeToken string `json:"wechat_resume_token"`
  218. ReturnURL string `json:"return_url"`
  219. PaymentSource string `json:"payment_source"`
  220. OrderType string `json:"order_type"`
  221. PlanID int64 `json:"plan_id"`
  222. // IsMobile lets the frontend declare its mobile status directly. When
  223. // nil we fall back to User-Agent heuristics (which miss iPadOS / some
  224. // embedded browsers that strip the "Mobile" keyword).
  225. IsMobile *bool `json:"is_mobile,omitempty"`
  226. }
  227. // CreateOrder creates a new payment order.
  228. // POST /api/v1/payment/orders
  229. func (h *PaymentHandler) CreateOrder(c *gin.Context) {
  230. subject, ok := requireAuth(c)
  231. if !ok {
  232. return
  233. }
  234. var req CreateOrderRequest
  235. if err := c.ShouldBindJSON(&req); err != nil {
  236. response.BadRequest(c, "Invalid request: "+err.Error())
  237. return
  238. }
  239. if strings.TrimSpace(req.WechatResumeToken) != "" {
  240. claims, err := h.paymentService.ParseWeChatPaymentResumeToken(req.WechatResumeToken)
  241. if err != nil {
  242. response.ErrorFrom(c, err)
  243. return
  244. }
  245. if err := applyWeChatPaymentResumeClaims(&req, claims); err != nil {
  246. response.ErrorFrom(c, err)
  247. return
  248. }
  249. }
  250. mobile := isMobile(c)
  251. if req.IsMobile != nil {
  252. mobile = *req.IsMobile
  253. }
  254. result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
  255. UserID: subject.UserID,
  256. Amount: req.Amount,
  257. PaymentType: req.PaymentType,
  258. OpenID: req.OpenID,
  259. ClientIP: c.ClientIP(),
  260. IsMobile: mobile,
  261. IsWeChatBrowser: isWeChatBrowser(c),
  262. SrcHost: c.Request.Host,
  263. SrcURL: c.Request.Referer(),
  264. ReturnURL: req.ReturnURL,
  265. PaymentSource: req.PaymentSource,
  266. OrderType: req.OrderType,
  267. PlanID: req.PlanID,
  268. Locale: c.GetHeader("Accept-Language"),
  269. })
  270. if err != nil {
  271. response.ErrorFrom(c, err)
  272. return
  273. }
  274. response.Success(c, result)
  275. }
  276. func applyWeChatPaymentResumeClaims(req *CreateOrderRequest, claims *service.WeChatPaymentResumeClaims) error {
  277. if req == nil || claims == nil {
  278. return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume context is missing")
  279. }
  280. openid := strings.TrimSpace(claims.OpenID)
  281. if openid == "" {
  282. return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
  283. }
  284. paymentType := service.NormalizeVisibleMethod(claims.PaymentType)
  285. if paymentType == "" {
  286. paymentType = payment.TypeWxpay
  287. }
  288. if req.PaymentType != "" {
  289. requestPaymentType := service.NormalizeVisibleMethod(req.PaymentType)
  290. if requestPaymentType != "" && requestPaymentType != paymentType {
  291. return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token payment type mismatch")
  292. }
  293. }
  294. req.PaymentType = paymentType
  295. req.OpenID = openid
  296. if strings.TrimSpace(claims.Amount) != "" {
  297. amount, err := strconv.ParseFloat(strings.TrimSpace(claims.Amount), 64)
  298. if err != nil || amount <= 0 {
  299. return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", fmt.Sprintf("invalid resume amount: %s", claims.Amount))
  300. }
  301. req.Amount = amount
  302. }
  303. if claims.OrderType != "" {
  304. req.OrderType = claims.OrderType
  305. }
  306. if claims.PlanID > 0 {
  307. req.PlanID = claims.PlanID
  308. }
  309. return nil
  310. }
  311. // GetMyOrders returns the authenticated user's orders.
  312. // GET /api/v1/payment/orders/my
  313. func (h *PaymentHandler) GetMyOrders(c *gin.Context) {
  314. subject, ok := requireAuth(c)
  315. if !ok {
  316. return
  317. }
  318. page, pageSize := response.ParsePagination(c)
  319. orders, total, err := h.paymentService.GetUserOrders(c.Request.Context(), subject.UserID, service.OrderListParams{
  320. Page: page,
  321. PageSize: pageSize,
  322. Status: c.Query("status"),
  323. OrderType: c.Query("order_type"),
  324. PaymentType: c.Query("payment_type"),
  325. })
  326. if err != nil {
  327. response.ErrorFrom(c, err)
  328. return
  329. }
  330. response.Paginated(c, sanitizePaymentOrdersForResponse(orders), int64(total), page, pageSize)
  331. }
  332. // GetOrder returns a single order for the authenticated user.
  333. // GET /api/v1/payment/orders/:id
  334. func (h *PaymentHandler) GetOrder(c *gin.Context) {
  335. subject, ok := requireAuth(c)
  336. if !ok {
  337. return
  338. }
  339. orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  340. if err != nil {
  341. response.BadRequest(c, "Invalid order ID")
  342. return
  343. }
  344. order, err := h.paymentService.GetOrder(c.Request.Context(), orderID, subject.UserID)
  345. if err != nil {
  346. response.ErrorFrom(c, err)
  347. return
  348. }
  349. response.Success(c, sanitizePaymentOrderForResponse(order))
  350. }
  351. // CancelOrder cancels a pending order for the authenticated user.
  352. // POST /api/v1/payment/orders/:id/cancel
  353. func (h *PaymentHandler) CancelOrder(c *gin.Context) {
  354. subject, ok := requireAuth(c)
  355. if !ok {
  356. return
  357. }
  358. orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  359. if err != nil {
  360. response.BadRequest(c, "Invalid order ID")
  361. return
  362. }
  363. msg, err := h.paymentService.CancelOrder(c.Request.Context(), orderID, subject.UserID)
  364. if err != nil {
  365. response.ErrorFrom(c, err)
  366. return
  367. }
  368. response.Success(c, gin.H{"message": msg})
  369. }
  370. // RefundRequestBody is the request body for requesting a refund.
  371. type RefundRequestBody struct {
  372. Reason string `json:"reason"`
  373. }
  374. // RequestRefund submits a refund request for a completed order.
  375. // POST /api/v1/payment/orders/:id/refund-request
  376. func (h *PaymentHandler) RequestRefund(c *gin.Context) {
  377. subject, ok := requireAuth(c)
  378. if !ok {
  379. return
  380. }
  381. orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  382. if err != nil {
  383. response.BadRequest(c, "Invalid order ID")
  384. return
  385. }
  386. var req RefundRequestBody
  387. if err := c.ShouldBindJSON(&req); err != nil {
  388. response.BadRequest(c, "Invalid request: "+err.Error())
  389. return
  390. }
  391. if err := h.paymentService.RequestRefund(c.Request.Context(), orderID, subject.UserID, req.Reason); err != nil {
  392. response.ErrorFrom(c, err)
  393. return
  394. }
  395. response.Success(c, gin.H{"message": "refund requested"})
  396. }
  397. // GetRefundEligibleProviders returns provider instance IDs that allow user refund.
  398. func (h *PaymentHandler) GetRefundEligibleProviders(c *gin.Context) {
  399. ids, err := h.configService.GetUserRefundEligibleInstanceIDs(c.Request.Context())
  400. if err != nil {
  401. response.ErrorFrom(c, err)
  402. return
  403. }
  404. response.Success(c, gin.H{"provider_instance_ids": ids})
  405. }
  406. // VerifyOrderRequest is the request body for verifying a payment order.
  407. type VerifyOrderRequest struct {
  408. OutTradeNo string `json:"out_trade_no" binding:"required"`
  409. }
  410. type ResolveOrderByResumeTokenRequest struct {
  411. ResumeToken string `json:"resume_token" binding:"required"`
  412. }
  413. // VerifyOrder actively queries the upstream payment provider to check
  414. // if payment was made, and processes it if so.
  415. // POST /api/v1/payment/orders/verify
  416. func (h *PaymentHandler) VerifyOrder(c *gin.Context) {
  417. subject, ok := requireAuth(c)
  418. if !ok {
  419. return
  420. }
  421. var req VerifyOrderRequest
  422. if err := c.ShouldBindJSON(&req); err != nil {
  423. response.BadRequest(c, "Invalid request: "+err.Error())
  424. return
  425. }
  426. order, err := h.paymentService.VerifyOrderByOutTradeNo(c.Request.Context(), req.OutTradeNo, subject.UserID)
  427. if err != nil {
  428. response.ErrorFrom(c, err)
  429. return
  430. }
  431. response.Success(c, sanitizePaymentOrderForResponse(order))
  432. }
  433. // PublicOrderResult is returned after a signed resume-token lookup. The token
  434. // proves possession of the checkout session, so the result keeps the legacy
  435. // frontend contract needed by payment result pages.
  436. type PublicOrderResult struct {
  437. ID int64 `json:"id"`
  438. OutTradeNo string `json:"out_trade_no"`
  439. Amount float64 `json:"amount"`
  440. PayAmount float64 `json:"pay_amount"`
  441. FeeRate float64 `json:"fee_rate"`
  442. Currency string `json:"currency"`
  443. PaymentType string `json:"payment_type"`
  444. OrderType string `json:"order_type"`
  445. Status string `json:"status"`
  446. CreatedAt time.Time `json:"created_at"`
  447. ExpiresAt time.Time `json:"expires_at"`
  448. PaidAt *time.Time `json:"paid_at,omitempty"`
  449. CompletedAt *time.Time `json:"completed_at,omitempty"`
  450. RefundAmount float64 `json:"refund_amount"`
  451. RefundReason *string `json:"refund_reason,omitempty"`
  452. RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
  453. RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
  454. RefundRequestReason *string `json:"refund_request_reason,omitempty"`
  455. PlanID *int64 `json:"plan_id,omitempty"`
  456. }
  457. // PublicOrderVerifyResult is returned by the legacy anonymous out_trade_no
  458. // lookup. Keep this intentionally minimal because out_trade_no is not secret.
  459. type PublicOrderVerifyResult struct {
  460. OutTradeNo string `json:"out_trade_no"`
  461. Status string `json:"status"`
  462. Paid bool `json:"paid"`
  463. CreatedAt time.Time `json:"created_at"`
  464. ExpiresAt time.Time `json:"expires_at"`
  465. PaidAt *time.Time `json:"paid_at,omitempty"`
  466. CompletedAt *time.Time `json:"completed_at,omitempty"`
  467. }
  468. func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
  469. return PublicOrderResult{
  470. ID: order.ID,
  471. OutTradeNo: order.OutTradeNo,
  472. Amount: order.Amount,
  473. PayAmount: order.PayAmount,
  474. FeeRate: order.FeeRate,
  475. Currency: service.PaymentOrderCurrency(order),
  476. PaymentType: order.PaymentType,
  477. OrderType: order.OrderType,
  478. Status: order.Status,
  479. CreatedAt: order.CreatedAt,
  480. ExpiresAt: order.ExpiresAt,
  481. PaidAt: order.PaidAt,
  482. CompletedAt: order.CompletedAt,
  483. RefundAmount: order.RefundAmount,
  484. RefundReason: order.RefundReason,
  485. RefundRequestedAt: order.RefundRequestedAt,
  486. RefundRequestedBy: order.RefundRequestedBy,
  487. RefundRequestReason: order.RefundRequestReason,
  488. PlanID: order.PlanID,
  489. }
  490. }
  491. func buildPublicOrderVerifyResult(order *dbent.PaymentOrder) PublicOrderVerifyResult {
  492. return PublicOrderVerifyResult{
  493. OutTradeNo: order.OutTradeNo,
  494. Status: order.Status,
  495. Paid: publicOrderStatusPaid(order.Status),
  496. CreatedAt: order.CreatedAt,
  497. ExpiresAt: order.ExpiresAt,
  498. PaidAt: order.PaidAt,
  499. CompletedAt: order.CompletedAt,
  500. }
  501. }
  502. func publicOrderStatusPaid(status string) bool {
  503. switch status {
  504. case service.OrderStatusPaid,
  505. service.OrderStatusCompleted,
  506. service.OrderStatusRefundRequested,
  507. service.OrderStatusRefunding,
  508. service.OrderStatusRefundPending,
  509. service.OrderStatusPartiallyRefunded,
  510. service.OrderStatusRefunded,
  511. service.OrderStatusRefundFailed:
  512. return true
  513. default:
  514. return false
  515. }
  516. }
  517. // VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as
  518. // a compatibility path for older result pages and staggered deploys.
  519. // POST /api/v1/payment/public/orders/verify
  520. func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
  521. var req VerifyOrderRequest
  522. if err := c.ShouldBindJSON(&req); err != nil {
  523. response.BadRequest(c, "Invalid request: "+err.Error())
  524. return
  525. }
  526. order, err := h.paymentService.VerifyOrderPublic(c.Request.Context(), req.OutTradeNo)
  527. if err != nil {
  528. response.ErrorFrom(c, err)
  529. return
  530. }
  531. response.Success(c, buildPublicOrderVerifyResult(order))
  532. }
  533. // ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token.
  534. // POST /api/v1/payment/public/orders/resolve
  535. func (h *PaymentHandler) ResolveOrderPublicByResumeToken(c *gin.Context) {
  536. var req ResolveOrderByResumeTokenRequest
  537. if err := c.ShouldBindJSON(&req); err != nil {
  538. response.BadRequest(c, "Invalid request: "+err.Error())
  539. return
  540. }
  541. order, err := h.paymentService.GetPublicOrderByResumeToken(c.Request.Context(), req.ResumeToken)
  542. if err != nil {
  543. response.ErrorFrom(c, err)
  544. return
  545. }
  546. response.Success(c, buildPublicOrderResult(order))
  547. }
  548. // requireAuth extracts the authenticated subject from the context.
  549. // Returns the subject and true on success; on failure it writes an Unauthorized response and returns false.
  550. func requireAuth(c *gin.Context) (middleware2.AuthSubject, bool) {
  551. subject, ok := middleware2.GetAuthSubjectFromContext(c)
  552. if !ok {
  553. response.Unauthorized(c, "User not authenticated")
  554. return middleware2.AuthSubject{}, false
  555. }
  556. return subject, true
  557. }
  558. // isMobile detects mobile user agents.
  559. func isMobile(c *gin.Context) bool {
  560. ua := strings.ToLower(c.GetHeader("User-Agent"))
  561. for _, kw := range []string{"mobile", "android", "iphone", "ipad", "ipod"} {
  562. if strings.Contains(ua, kw) {
  563. return true
  564. }
  565. }
  566. return false
  567. }
  568. type PaymentOrderResult struct {
  569. ID int64 `json:"id"`
  570. UserID int64 `json:"user_id"`
  571. Amount float64 `json:"amount"`
  572. PayAmount float64 `json:"pay_amount"`
  573. FeeRate float64 `json:"fee_rate"`
  574. Currency string `json:"currency"`
  575. PaymentType string `json:"payment_type"`
  576. OutTradeNo string `json:"out_trade_no"`
  577. Status string `json:"status"`
  578. OrderType string `json:"order_type"`
  579. CreatedAt time.Time `json:"created_at"`
  580. ExpiresAt time.Time `json:"expires_at"`
  581. PaidAt *time.Time `json:"paid_at,omitempty"`
  582. CompletedAt *time.Time `json:"completed_at,omitempty"`
  583. RefundAmount float64 `json:"refund_amount"`
  584. RefundReason *string `json:"refund_reason,omitempty"`
  585. RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
  586. RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
  587. RefundRequestReason *string `json:"refund_request_reason,omitempty"`
  588. PlanID *int64 `json:"plan_id,omitempty"`
  589. ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
  590. }
  591. func sanitizePaymentOrdersForResponse(orders []*dbent.PaymentOrder) []PaymentOrderResult {
  592. out := make([]PaymentOrderResult, 0, len(orders))
  593. for _, order := range orders {
  594. if item := sanitizePaymentOrderForResponse(order); item != nil {
  595. out = append(out, *item)
  596. }
  597. }
  598. return out
  599. }
  600. func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *PaymentOrderResult {
  601. if order == nil {
  602. return nil
  603. }
  604. return &PaymentOrderResult{
  605. ID: order.ID,
  606. UserID: order.UserID,
  607. Amount: order.Amount,
  608. PayAmount: order.PayAmount,
  609. FeeRate: order.FeeRate,
  610. Currency: service.PaymentOrderCurrency(order),
  611. PaymentType: order.PaymentType,
  612. OutTradeNo: order.OutTradeNo,
  613. Status: order.Status,
  614. OrderType: order.OrderType,
  615. CreatedAt: order.CreatedAt,
  616. ExpiresAt: order.ExpiresAt,
  617. PaidAt: order.PaidAt,
  618. CompletedAt: order.CompletedAt,
  619. RefundAmount: order.RefundAmount,
  620. RefundReason: order.RefundReason,
  621. RefundRequestedAt: order.RefundRequestedAt,
  622. RefundRequestedBy: order.RefundRequestedBy,
  623. RefundRequestReason: order.RefundRequestReason,
  624. PlanID: order.PlanID,
  625. ProviderInstanceID: order.ProviderInstanceID,
  626. }
  627. }
  628. func isWeChatBrowser(c *gin.Context) bool {
  629. return strings.Contains(strings.ToLower(c.GetHeader("User-Agent")), "micromessenger")
  630. }