chore: 初始开源发布
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 已说明。
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user