alipay.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. package provider
  2. import (
  3. "context"
  4. "fmt"
  5. "net/url"
  6. "strconv"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/payment"
  11. "github.com/smartwalle/alipay/v3"
  12. )
  13. // Alipay product codes.
  14. const (
  15. alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT"
  16. alipayProductCodeWapPay = "QUICK_WAP_WAY"
  17. alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
  18. )
  19. // Alipay response constants.
  20. const (
  21. alipayFundChangeYes = "Y"
  22. alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
  23. alipayRefundSuffix = "-refund"
  24. )
  25. var (
  26. alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
  27. return client.TradeWapPay(param)
  28. }
  29. alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
  30. return client.TradePreCreate(ctx, param)
  31. }
  32. alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
  33. return client.TradePagePay(param)
  34. }
  35. )
  36. // Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
  37. type Alipay struct {
  38. instanceID string
  39. config map[string]string // appId, privateKey, publicKey (or alipayPublicKey), notifyUrl, returnUrl
  40. mu sync.Mutex
  41. client *alipay.Client
  42. }
  43. // NewAlipay creates a new Alipay provider instance.
  44. func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) {
  45. required := []string{"appId", "privateKey"}
  46. for _, k := range required {
  47. if config[k] == "" {
  48. return nil, fmt.Errorf("alipay config missing required key: %s", k)
  49. }
  50. }
  51. return &Alipay{
  52. instanceID: instanceID,
  53. config: config,
  54. }, nil
  55. }
  56. func (a *Alipay) getClient() (*alipay.Client, error) {
  57. a.mu.Lock()
  58. defer a.mu.Unlock()
  59. if a.client != nil {
  60. return a.client, nil
  61. }
  62. client, err := alipay.New(a.config["appId"], a.config["privateKey"], true)
  63. if err != nil {
  64. return nil, fmt.Errorf("alipay init client: %w", err)
  65. }
  66. pubKey := a.config["publicKey"]
  67. if pubKey == "" {
  68. pubKey = a.config["alipayPublicKey"]
  69. }
  70. if pubKey == "" {
  71. return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)")
  72. }
  73. if err := client.LoadAliPayPublicKey(pubKey); err != nil {
  74. return nil, fmt.Errorf("alipay load public key: %w", err)
  75. }
  76. a.client = client
  77. return a.client, nil
  78. }
  79. func (a *Alipay) Name() string { return "Alipay" }
  80. func (a *Alipay) ProviderKey() string { return payment.TypeAlipay }
  81. func (a *Alipay) SupportedTypes() []payment.PaymentType {
  82. return []payment.PaymentType{payment.TypeAlipay}
  83. }
  84. func (a *Alipay) MerchantIdentityMetadata() map[string]string {
  85. if a == nil {
  86. return nil
  87. }
  88. appID := strings.TrimSpace(a.config["appId"])
  89. if appID == "" {
  90. return nil
  91. }
  92. return map[string]string{"app_id": appID}
  93. }
  94. // CreatePayment creates an Alipay payment using the following routing:
  95. // - Mobile (H5), default: alipay.trade.wap.pay — browser redirect into Alipay.
  96. // - Mobile with AlipayMobilePrecreate: alipay.trade.precreate — return the
  97. // dynamic QR payload so the frontend can open it through the Alipay app.
  98. // - Desktop, default: prefer alipay.trade.precreate (FACE_TO_FACE_PAYMENT) to
  99. // get a scannable QR payload. If precreate is unavailable for the merchant,
  100. // fall back to alipay.trade.page.pay and expose pay_url only — the frontend
  101. // opens the Alipay checkout in a new tab.
  102. // - Desktop, paymentMode == "redirect": skip precreate and go straight to
  103. // alipay.trade.page.pay so the frontend always opens the Alipay checkout
  104. // in a new tab. Use this when the merchant has not enabled FACE_TO_FACE_PAYMENT.
  105. //
  106. // Note: alipay.trade.page.pay returns a checkout page URL, not a scannable
  107. // payment QR. Never expose it via the QRCode field.
  108. func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
  109. client, err := a.getClient()
  110. if err != nil {
  111. return nil, err
  112. }
  113. notifyURL := a.config["notifyUrl"]
  114. if req.NotifyURL != "" {
  115. notifyURL = req.NotifyURL
  116. }
  117. returnURL := a.config["returnUrl"]
  118. if req.ReturnURL != "" {
  119. returnURL = req.ReturnURL
  120. }
  121. if req.IsMobile {
  122. if req.AlipayMobilePrecreate {
  123. return a.createPrecreateTrade(ctx, client, req, notifyURL)
  124. }
  125. return a.createWapTrade(client, req, notifyURL, returnURL)
  126. }
  127. return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL)
  128. }
  129. func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
  130. param := alipay.TradeWapPay{}
  131. param.OutTradeNo = req.OrderID
  132. param.TotalAmount = req.Amount
  133. param.Subject = req.Subject
  134. param.ProductCode = alipayProductCodeWapPay
  135. param.NotifyURL = notifyURL
  136. param.ReturnURL = returnURL
  137. payURL, err := alipayTradeWapPay(client, param)
  138. if err != nil {
  139. return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
  140. }
  141. return &payment.CreatePaymentResponse{
  142. TradeNo: req.OrderID,
  143. PayURL: payURL.String(),
  144. }, nil
  145. }
  146. func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
  147. // Explicit redirect mode: merchant opted into "always open the Alipay
  148. // checkout page in a new tab" via the provider instance's payment_mode.
  149. // Skip precreate to avoid a wasted API call.
  150. if strings.EqualFold(strings.TrimSpace(a.config["paymentMode"]), "redirect") {
  151. return a.createPagePayTrade(client, req, notifyURL, returnURL)
  152. }
  153. resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
  154. if precreateErr == nil {
  155. return resp, nil
  156. }
  157. resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL)
  158. if pagePayErr == nil {
  159. return resp, nil
  160. }
  161. return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr)
  162. }
  163. func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) {
  164. param := alipay.TradePreCreate{}
  165. param.OutTradeNo = req.OrderID
  166. param.TotalAmount = req.Amount
  167. param.Subject = req.Subject
  168. param.ProductCode = alipayProductCodePreCreate
  169. param.NotifyURL = notifyURL
  170. rsp, err := alipayTradePreCreate(ctx, client, param)
  171. if err != nil {
  172. return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
  173. }
  174. if rsp == nil {
  175. return nil, fmt.Errorf("alipay TradePreCreate: empty response")
  176. }
  177. if rsp.IsFailure() {
  178. return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error())
  179. }
  180. if strings.TrimSpace(rsp.QRCode) == "" {
  181. return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code")
  182. }
  183. return &payment.CreatePaymentResponse{
  184. TradeNo: req.OrderID,
  185. QRCode: rsp.QRCode,
  186. }, nil
  187. }
  188. func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
  189. param := alipay.TradePagePay{}
  190. param.OutTradeNo = req.OrderID
  191. param.TotalAmount = req.Amount
  192. param.Subject = req.Subject
  193. param.ProductCode = alipayProductCodePagePay
  194. param.NotifyURL = notifyURL
  195. param.ReturnURL = returnURL
  196. payURL, err := alipayTradePagePay(client, param)
  197. if err != nil {
  198. return nil, fmt.Errorf("alipay TradePagePay: %w", err)
  199. }
  200. // Only PayURL is exposed: alipay.trade.page.pay returns a checkout page URL
  201. // that must be opened in a browser, not a scannable payment QR. Setting it
  202. // as QRCode would let the frontend render an unscannable image.
  203. return &payment.CreatePaymentResponse{
  204. TradeNo: req.OrderID,
  205. PayURL: payURL.String(),
  206. }, nil
  207. }
  208. // QueryOrder queries the trade status via Alipay.
  209. func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
  210. client, err := a.getClient()
  211. if err != nil {
  212. return nil, err
  213. }
  214. result, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: tradeNo})
  215. if err != nil {
  216. if isTradeNotExist(err) {
  217. return &payment.QueryOrderResponse{
  218. TradeNo: tradeNo,
  219. Status: payment.ProviderStatusPending,
  220. }, nil
  221. }
  222. return nil, fmt.Errorf("alipay TradeQuery: %w", err)
  223. }
  224. status := payment.ProviderStatusPending
  225. switch result.TradeStatus {
  226. case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
  227. status = payment.ProviderStatusPaid
  228. case alipay.TradeStatusClosed:
  229. status = payment.ProviderStatusFailed
  230. }
  231. amount, err := strconv.ParseFloat(result.TotalAmount, 64)
  232. if err != nil {
  233. amount, err = parseAlipayAmount(
  234. result.TotalAmount,
  235. result.ReceiptAmount,
  236. result.BuyerPayAmount,
  237. result.InvoiceAmount,
  238. )
  239. if err != nil {
  240. return nil, fmt.Errorf("alipay parse amount: %w", err)
  241. }
  242. }
  243. return &payment.QueryOrderResponse{
  244. TradeNo: result.TradeNo,
  245. Status: status,
  246. Amount: amount,
  247. PaidAt: result.SendPayDate,
  248. Metadata: a.MerchantIdentityMetadata(),
  249. }, nil
  250. }
  251. // VerifyNotification decodes and verifies an Alipay async notification.
  252. func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
  253. client, err := a.getClient()
  254. if err != nil {
  255. return nil, err
  256. }
  257. values, err := url.ParseQuery(rawBody)
  258. if err != nil {
  259. return nil, fmt.Errorf("alipay parse notification: %w", err)
  260. }
  261. notification, err := client.DecodeNotification(ctx, values)
  262. if err != nil {
  263. return nil, fmt.Errorf("alipay verify notification: %w", err)
  264. }
  265. status := payment.ProviderStatusFailed
  266. if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished {
  267. status = payment.ProviderStatusSuccess
  268. }
  269. amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
  270. if err != nil {
  271. amount, err = parseAlipayAmount(
  272. notification.TotalAmount,
  273. notification.ReceiptAmount,
  274. notification.BuyerPayAmount,
  275. )
  276. if err != nil {
  277. return nil, fmt.Errorf("alipay parse notification amount: %w", err)
  278. }
  279. }
  280. metadata := a.MerchantIdentityMetadata()
  281. if appID := strings.TrimSpace(notification.AppId); appID != "" {
  282. if metadata == nil {
  283. metadata = map[string]string{}
  284. }
  285. metadata["app_id"] = appID
  286. }
  287. return &payment.PaymentNotification{
  288. TradeNo: notification.TradeNo,
  289. OrderID: notification.OutTradeNo,
  290. Amount: amount,
  291. Status: status,
  292. RawData: rawBody,
  293. Metadata: metadata,
  294. }, nil
  295. }
  296. // Refund requests a refund through Alipay.
  297. func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
  298. client, err := a.getClient()
  299. if err != nil {
  300. return nil, err
  301. }
  302. result, err := client.TradeRefund(ctx, alipay.TradeRefund{
  303. OutTradeNo: req.OrderID,
  304. RefundAmount: req.Amount,
  305. RefundReason: req.Reason,
  306. OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
  307. })
  308. if err != nil {
  309. return nil, fmt.Errorf("alipay TradeRefund: %w", err)
  310. }
  311. refundStatus := payment.ProviderStatusPending
  312. if result.FundChange == alipayFundChangeYes {
  313. refundStatus = payment.ProviderStatusSuccess
  314. }
  315. refundID := result.TradeNo
  316. if refundID == "" {
  317. refundID = req.OrderID + alipayRefundSuffix
  318. }
  319. return &payment.RefundResponse{
  320. RefundID: refundID,
  321. Status: refundStatus,
  322. }, nil
  323. }
  324. // CancelPayment closes a pending trade on Alipay.
  325. func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
  326. client, err := a.getClient()
  327. if err != nil {
  328. return err
  329. }
  330. _, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: tradeNo})
  331. if err != nil {
  332. if isTradeNotExist(err) {
  333. return nil
  334. }
  335. return fmt.Errorf("alipay TradeClose: %w", err)
  336. }
  337. return nil
  338. }
  339. func isTradeNotExist(err error) bool {
  340. if err == nil {
  341. return false
  342. }
  343. return strings.Contains(err.Error(), alipayErrTradeNotExist)
  344. }
  345. func parseAlipayAmount(values ...string) (float64, error) {
  346. for _, raw := range values {
  347. raw = strings.TrimSpace(raw)
  348. if raw == "" {
  349. continue
  350. }
  351. amount, err := strconv.ParseFloat(raw, 64)
  352. if err == nil {
  353. return amount, nil
  354. }
  355. }
  356. return 0, fmt.Errorf("no valid amount field")
  357. }
  358. // Ensure interface compliance.
  359. var (
  360. _ payment.Provider = (*Alipay)(nil)
  361. _ payment.CancelableProvider = (*Alipay)(nil)
  362. _ payment.MerchantIdentityProvider = (*Alipay)(nil)
  363. )