package pay import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "time" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/websocket" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" "github.com/smartwalle/alipay/v3" ) var ( alipayClient *alipay.Client alipayClientOnce sync.Once alipayClientErr error ) type sAlipay struct{} func init() { service.RegisterAlipay(NewAlipay()) } func NewAlipay() *sAlipay { return &sAlipay{} } func (s *sAlipay) Pay(ctx context.Context, req *model.AlipayPayInput) (res *model.AlipayPayOutput, err error) { g.Log().Infof(ctx, "执行了支付宝Pay方法") outTradeNo := strings.TrimSpace(req.OutTradeNo) if outTradeNo == "" { outTradeNo = fmt.Sprintf("%s_%d", "ALI", time.Now().UnixNano()) } subject := strings.TrimSpace(req.Subject) totalAmount := strings.TrimSpace(req.TotalAmount) payType := strings.TrimSpace(req.PayType) if payType == "" { payType = "page" } g.Log().Infof(ctx, "执行了支付宝Pay方法, outTradeNo:%s, subject:%s, totalAmount:%s, payType:%s", outTradeNo, subject, totalAmount, payType) cfg, err := s.getConfig(ctx) if err != nil { return nil, err } client, err := s.getClient(ctx) if err != nil { return nil, err } isBalanceRecharge := req.BusinessType == "balanceRecharge" var balanceRechargeTimeoutDelay time.Duration if isBalanceRecharge { balanceRechargeTimeoutDelay, err = getBalanceRechargeTimeoutDelay(ctx) if err != nil { return nil, err } if err = service.Trade().CreateRechargePending(ctx, outTradeNo, totalAmount); err != nil { return nil, err } } expireTime := "" err = g.Cfg().MustGet(ctx, "pay.expireTime").Scan(&expireTime) if err != nil { return nil, gerror.Wrap(err, "获取支付过期时间失败") } expireMinutes := gconv.Int(expireTime) if isBalanceRecharge { expireTime = time.Now().Add(balanceRechargeTimeoutDelay).Format("2006-01-02 15:04:05") } else { expireTime = time.Now().Add(time.Duration(expireMinutes) * time.Minute).Format("2006-01-02 15:04:05") } g.Log().Infof(ctx, "执行了支付宝Pay方法 mexpireTime:%s", expireTime) var payURL *url.URL switch payType { case "page": p := alipay.TradePagePay{ Trade: alipay.Trade{ Subject: subject, OutTradeNo: outTradeNo, TimeExpire: expireTime, TotalAmount: totalAmount, ProductCode: "FAST_INSTANT_TRADE_PAY", }, } p.ReturnURL = cfg.ReturnUrl p.NotifyURL = cfg.NotifyUrl payURL, err = client.TradePagePay(p) case "wap": p := alipay.TradeWapPay{ Trade: alipay.Trade{ Subject: subject, OutTradeNo: outTradeNo, TimeExpire: expireTime, TotalAmount: totalAmount, ProductCode: "QUICK_WAP_WAY", }, } p.ReturnURL = cfg.ReturnUrl p.NotifyURL = cfg.NotifyUrl payURL, err = client.TradeWapPay(p) default: return nil, gerror.New("支付类型只支持 page 或 wap") } if err != nil { if isBalanceRecharge { _ = service.Trade().MarkRechargeFailed(ctx, outTradeNo) } return nil, gerror.Wrap(err, "创建支付宝支付链接失败") } if isBalanceRecharge { if err = rabbitmq.PublishDelayed(ctx, consts.QueueBalanceRechargeDelay, consts.QueueBalanceRechargeTimeout, balanceRechargeTimeoutDelay, model.BalanceRechargeTimeoutMessage{OutTradeNo: outTradeNo}); err != nil { _ = service.Trade().MarkRechargeFailed(ctx, outTradeNo) return nil, gerror.Wrap(err, "创建充值超时检查任务失败") } } g.Log().Infof(ctx, "alipay pay url created, production:%v, payType:%s, outTradeNo:%s, subject:%s, totalAmount:%s, notifyUrl:%s, returnUrl:%s", cfg.IsProduction, payType, outTradeNo, subject, totalAmount, cfg.NotifyUrl, cfg.ReturnUrl) return &model.AlipayPayOutput{ OutTradeNo: outTradeNo, PayUrl: payURL.String(), }, nil } func getBalanceRechargeTimeoutDelay(ctx context.Context) (time.Duration, error) { value := strings.TrimSpace(g.Cfg().MustGet(ctx, "pay.balanceRecharge.timeoutDelay", "3m").String()) delay, err := time.ParseDuration(value) if err != nil || delay <= 0 { return 0, gerror.Newf("余额充值超时时间配置无效: %q", value) } return delay, nil } func (s *sAlipay) Notify(ctx context.Context, req *model.AlipayNotifyInput) (res *model.AlipayNotifyOutput, err error) { g.Log().Infof(ctx, "执行了支付宝Notify方法") r := g.RequestFromCtx(ctx) client, err := s.getClient(ctx) if err != nil { r.Response.Write("failure") return &model.AlipayNotifyOutput{}, nil } notification, err := client.GetTradeNotification(r.Request) if err != nil { g.Log().Errorf(ctx, "alipay notify verify failed: %+v", err) r.Response.Write("failure") return &model.AlipayNotifyOutput{}, nil } body, _ := json.Marshal(notification) g.Log().Infof(ctx, "alipay notify success: %s", string(body)) // 支付宝回调成功,通知前端查询交易并调用充值接口。 if notification.OutTradeNo != "" && notification.TradeStatus == alipay.TradeStatusSuccess { // WebSocket通知 wsTag := "ws:tag:pay:alipay:" + notification.OutTradeNo g.Log().Infof(ctx, "alipay notify websocket broadcast tag:%s count:%d", wsTag, websocket.GetTagCount(wsTag)) websocket.SendToTag(wsTag, &websocket.WResponse{ Event: "ws:event:pay:alipay:notify", Data: g.Map{ "outTradeNo": notification.OutTradeNo, "tradeNo": notification.TradeNo, "tradeStatus": string(notification.TradeStatus), "totalAmount": notification.TotalAmount, }, }) } r.Response.Write("success") return &model.AlipayNotifyOutput{}, nil } func (s *sAlipay) Return(ctx context.Context, req *model.AlipayReturnInput) (res *model.AlipayReturnOutput, err error) { g.Log().Infof(ctx, "执行了支付宝Return方法") r := g.RequestFromCtx(ctx) client, err := s.getClient(ctx) if err != nil { return nil, err } if err = client.VerifySign(ctx, r.Request.URL.Query()); err != nil { return nil, gerror.Wrap(err, "支付宝同步回跳验签失败") } return &model.AlipayReturnOutput{ OutTradeNo: r.Get("out_trade_no").String(), TradeNo: r.Get("trade_no").String(), TotalAmount: r.Get("total_amount").String(), Message: "支付完成", }, nil } func (s *sAlipay) Query(ctx context.Context, req *model.AlipayTradeQueryInput) (res *model.AlipayTradeQueryOutput, err error) { g.Log().Infof(ctx, "执行了支付宝Query方法") if req == nil { return nil, gerror.New("查询参数不能为空") } outTradeNo := strings.TrimSpace(req.OutTradeNo) tradeNo := strings.TrimSpace(req.TradeNo) if outTradeNo == "" && tradeNo == "" { return nil, gerror.New("商户订单号和支付宝交易号不能同时为空") } cfg, err := s.getConfig(ctx) if err != nil { return nil, err } client, err := s.getClient(ctx) if err != nil { return nil, err } query := alipay.TradeQuery{ OutTradeNo: outTradeNo, TradeNo: tradeNo, } g.Log().Infof(ctx, "alipay trade query start, production:%v, outTradeNo:%s, tradeNo:%s", cfg.IsProduction, outTradeNo, tradeNo) rsp, err := s.tradeQueryWithRetry(ctx, client, query) if err != nil { if isAlipayTradeNotFoundError(err) { return pendingAlipayTradeQuery(outTradeNo, tradeNo), nil } g.Log().Errorf(ctx, "alipay trade query failed, outTradeNo:%s, tradeNo:%s, err:%+v", outTradeNo, tradeNo, err) return nil, gerror.Wrap(err, "查询支付宝交易失败") } if rsp == nil { return nil, gerror.New("支付宝交易查询未返回结果") } if rsp.IsFailure() { if isAlipayTradeNotFound(string(rsp.Code), rsp.SubCode, rsp.SubMsg) { g.Log().Infof(ctx, "alipay trade query pending, outTradeNo:%s, tradeNo:%s", outTradeNo, tradeNo) return pendingAlipayTradeQuery(outTradeNo, tradeNo), nil } return nil, gerror.Newf("支付宝交易查询失败: code=%s, subCode=%s, subMsg=%s", rsp.Code, rsp.SubCode, rsp.SubMsg) } if rsp.TradeStatus == alipay.TradeStatusSuccess || rsp.TradeStatus == alipay.TradeStatusFinished { rechargeModel := &model.TBalanceDetailRechargeModel{ OutTradeNo: rsp.OutTradeNo, TradeNo: rsp.TradeNo, TradeStatus: string(rsp.TradeStatus), TotalAmount: rsp.TotalAmount, BuyerLogonId: rsp.BuyerLogonId, BuyerUserId: rsp.BuyerUserId, BuyerOpenId: rsp.BuyerOpenId, SendPayDate: rsp.SendPayDate, } if err = service.Trade().Recharge(ctx, rechargeModel); err != nil { return nil, gerror.Wrap(err, "支付宝充值入账失败") } } else { g.Log().Infof(ctx, "alipay trade query not paid, outTradeNo:%s, tradeNo:%s, tradeStatus:%s", rsp.OutTradeNo, rsp.TradeNo, rsp.TradeStatus) } return &model.AlipayTradeQueryOutput{ OutTradeNo: rsp.OutTradeNo, TradeNo: rsp.TradeNo, TradeStatus: string(rsp.TradeStatus), TotalAmount: rsp.TotalAmount, BuyerLogonId: rsp.BuyerLogonId, BuyerUserId: rsp.BuyerUserId, BuyerOpenId: rsp.BuyerOpenId, SendPayDate: rsp.SendPayDate, }, nil } func pendingAlipayTradeQuery(outTradeNo, tradeNo string) *model.AlipayTradeQueryOutput { return &model.AlipayTradeQueryOutput{ OutTradeNo: outTradeNo, TradeNo: tradeNo, TradeStatus: string(alipay.TradeStatusWaitBuyerPay), } } func (s *sAlipay) tradeQueryWithRetry(ctx context.Context, client *alipay.Client, query alipay.TradeQuery) (rsp *alipay.TradeQueryRsp, err error) { const maxRetry = 3 for i := 1; i <= maxRetry; i++ { rsp, err = client.TradeQuery(ctx, query) if err == nil { if i > 1 { g.Log().Infof(ctx, "alipay trade query retry success, attempt:%d, outTradeNo:%s, tradeNo:%s", i, query.OutTradeNo, query.TradeNo) } return rsp, nil } if !isRetryableAlipayError(err) || i == maxRetry { return nil, err } g.Log().Warningf(ctx, "alipay trade query retryable error, attempt:%d, outTradeNo:%s, tradeNo:%s, err:%+v", i, query.OutTradeNo, query.TradeNo, err) if err = sleepWithContext(ctx, time.Duration(i)*300*time.Millisecond); err != nil { return nil, err } } return nil, err } func isRetryableAlipayError(err error) bool { if err == nil { return false } if errors.Is(err, context.DeadlineExceeded) { return true } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "invalid character '<'") || strings.Contains(msg, "unexpected end of json") || strings.Contains(msg, "bad response") || strings.Contains(msg, "eof") || strings.Contains(msg, "context deadline exceeded") || strings.Contains(msg, "timeout") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "tls handshake timeout") || strings.Contains(msg, "temporary") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "429") || strings.Contains(msg, "500") || strings.Contains(msg, "502") || strings.Contains(msg, "503") || strings.Contains(msg, "504") } func isAlipayTradeNotFoundError(err error) bool { if err == nil { return false } return isAlipayTradeNotFound(err.Error()) } func isAlipayTradeNotFound(parts ...string) bool { msg := strings.ToLower(strings.Join(parts, " ")) return strings.Contains(msg, "acq.trade_not_exist") || strings.Contains(msg, "trade_not_exist") || strings.Contains(msg, "交易不存在") || strings.Contains(msg, "订单不存在") } func sleepWithContext(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func (s *sAlipay) Refund(ctx context.Context, req *model.AlipayRefundInput) (res *model.AlipayRefundOutput, err error) { g.Log().Infof(ctx, "执行了支付宝Refund方法") refundRequest, err := req.Normalize() if err != nil { return nil, err } client, err := s.getClient(ctx) if err != nil { return nil, err } refund := alipay.TradeRefund{ OutTradeNo: refundRequest.OutTradeNo, TradeNo: refundRequest.TradeNo, RefundAmount: refundRequest.RefundAmount, OutRequestNo: refundRequest.OutRequestNo, RefundReason: refundRequest.RefundReason, } g.Log().Infof(ctx, "alipay refund start, outTradeNo:%s, tradeNo:%s, outRequestNo:%s, refundAmount:%s", refundRequest.OutTradeNo, refundRequest.TradeNo, refundRequest.OutRequestNo, refundRequest.RefundAmount) rsp, err := client.TradeRefund(ctx, refund) if err != nil { g.Log().Errorf(ctx, "alipay refund failed, outTradeNo:%s, tradeNo:%s, outRequestNo:%s, err:%+v", refundRequest.OutTradeNo, refundRequest.TradeNo, refundRequest.OutRequestNo, err) return nil, gerror.Wrap(err, "支付宝退款失败") } if rsp == nil { return nil, gerror.New("支付宝退款未返回结果") } if rsp.IsFailure() { return nil, gerror.Newf("支付宝退款失败: code=%s, subCode=%s, subMsg=%s", rsp.Code, rsp.SubCode, rsp.SubMsg) } return &model.AlipayRefundOutput{ TradeNo: rsp.TradeNo, OutTradeNo: rsp.OutTradeNo, BuyerLogonId: rsp.BuyerLogonId, RefundFee: rsp.RefundFee, }, nil } func (s *sAlipay) getClient(ctx context.Context) (*alipay.Client, error) { alipayClientOnce.Do(func() { cfg, err := s.getConfig(ctx) if err != nil { alipayClientErr = err return } client, err := alipay.New( cfg.AppId, cfg.PrivateKey, cfg.IsProduction, alipay.WithHTTPClient(&http.Client{Timeout: 10 * time.Second}), ) if err != nil { alipayClientErr = gerror.Wrap(err, "初始化支付宝客户端失败") return } if err = client.LoadAliPayPublicKey(cfg.AlipayPublicKey); err != nil { alipayClientErr = gerror.Wrap(err, "加载支付宝公钥失败") return } alipayClient = client }) if alipayClientErr != nil { return nil, alipayClientErr } if alipayClient == nil { return nil, gerror.New("支付宝客户端未初始化") } return alipayClient, nil } func (s *sAlipay) getConfig(ctx context.Context) (cfg model.AlipayConfig, err error) { if err = g.Cfg().MustGet(ctx, "alipay").Scan(&cfg); err != nil { return cfg, gerror.Wrap(err, "读取支付宝配置失败") } if strings.TrimSpace(cfg.AppId) == "" { return cfg, gerror.New("支付宝 appId 未配置") } if strings.TrimSpace(cfg.PrivateKey) == "" { return cfg, gerror.New("支付宝应用私钥未配置") } if strings.TrimSpace(cfg.AlipayPublicKey) == "" { return cfg, gerror.New("支付宝公钥未配置") } return cfg, nil }