59eb34976a
iotlcm-admin-svr:管理后台服务。 - Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件) - 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、 MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位; 私网 IP → 文档地址段;库名 xiujie_admin → iotlcm - 真实手机号/邮箱 → 合法 11 位编造号 / example.com - 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、 私有依赖 go get 行、明文 registry 登录口令 - 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名 - Dockerfile 改用公共基础镜像 - 拼错的 RADEME.md → README.md,README 重写 - 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用 - 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
438 lines
14 KiB
Go
438 lines
14 KiB
Go
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
|
|
}
|