195 lines
6.2 KiB
Go
195 lines
6.2 KiB
Go
package payment
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
|
||
"github.com/smartwalle/alipay/v3"
|
||
)
|
||
|
||
// AlipayChannel 支付宝(电脑网站支付 / 手机网站支付,RSA2)
|
||
//
|
||
// 渠道参数(config_json):
|
||
//
|
||
// appid 开放平台应用 APPID
|
||
// app_private_key 应用私钥(RSA2,PKCS8)
|
||
// alipay_public_key 支付宝公钥(回显公钥,用于验签)
|
||
// is_production "1" 正式环境 / 其他为沙箱
|
||
type AlipayChannel struct{}
|
||
|
||
func (c *AlipayChannel) Code() string { return ChannelAlipay }
|
||
func (c *AlipayChannel) Name() string { return "支付宝" }
|
||
|
||
func (c *AlipayChannel) buildClient(cfg *ChannelConfig) (*alipay.Client, error) {
|
||
appID := cfg.Get("appid")
|
||
priv := cfg.Get("app_private_key")
|
||
pub := cfg.Get("alipay_public_key")
|
||
if appID == "" || priv == "" || pub == "" {
|
||
return nil, fmt.Errorf("支付宝参数不完整:需要 appid / app_private_key / alipay_public_key")
|
||
}
|
||
client, err := alipay.New(appID, priv, cfg.Get("is_production") == "1")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建支付宝客户端失败: %w", err)
|
||
}
|
||
if err := client.LoadAliPayPublicKey(pub); err != nil {
|
||
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
|
||
}
|
||
return client, nil
|
||
}
|
||
|
||
func (c *AlipayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
|
||
client, err := c.buildClient(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
trade := alipay.Trade{
|
||
NotifyURL: cfg.CallbackURL,
|
||
ReturnURL: order.ReturnURL,
|
||
Subject: order.Subject,
|
||
OutTradeNo: order.PayNo,
|
||
TotalAmount: FenToYuan(order.Amount),
|
||
}
|
||
if order.ExpireAt != nil {
|
||
mins := int(time.Until(*order.ExpireAt).Minutes())
|
||
if mins < 1 {
|
||
mins = 1
|
||
}
|
||
trade.TimeoutExpress = fmt.Sprintf("%dm", mins)
|
||
}
|
||
|
||
if strings.EqualFold(opt.PayType, PayTypeH5) {
|
||
// 手机网站支付
|
||
trade.ProductCode = "QUICK_WAP_WAY"
|
||
u, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("支付宝手机网站下单失败: %w", err)
|
||
}
|
||
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeH5, RedirectURL: u.String()}, nil
|
||
}
|
||
|
||
// 默认:电脑网站支付(PC 收银台)
|
||
trade.ProductCode = "FAST_INSTANT_TRADE_PAY"
|
||
u, err := client.TradePagePay(alipay.TradePagePay{Trade: trade})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("支付宝电脑网站下单失败: %w", err)
|
||
}
|
||
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeWeb, RedirectURL: u.String()}, nil
|
||
}
|
||
|
||
// normalizeAlipayStatus 支付宝交易状态 -> 归一化状态
|
||
func normalizeAlipayStatus(status alipay.TradeStatus) string {
|
||
switch status {
|
||
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
|
||
return StateSuccess
|
||
case alipay.TradeStatusClosed:
|
||
return StateClosed
|
||
case alipay.TradeStatusWaitBuyerPay:
|
||
return StatePending
|
||
default:
|
||
return StatePending
|
||
}
|
||
}
|
||
|
||
func (c *AlipayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
|
||
client, err := c.buildClient(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: order.PayNo})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("支付宝查询订单失败: %w", err)
|
||
}
|
||
// 交易不存在(如超时未付被渠道关闭):不视为失败,返回待支付以便走关闭逻辑
|
||
if rsp.Code != "10000" {
|
||
if rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
|
||
return &ChannelState{TradeState: StatePending, Raw: rsp.SubMsg}, nil
|
||
}
|
||
return nil, fmt.Errorf("支付宝查询失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||
}
|
||
|
||
state := &ChannelState{
|
||
ChannelTradeNo: rsp.TradeNo,
|
||
TradeState: normalizeAlipayStatus(rsp.TradeStatus),
|
||
Raw: fmt.Sprintf("trade_no=%s status=%s total=%s", rsp.TradeNo, rsp.TradeStatus, rsp.TotalAmount),
|
||
}
|
||
if fen, err := YuanToFen(rsp.TotalAmount); err == nil {
|
||
state.Amount = fen
|
||
}
|
||
if state.TradeState == StateSuccess {
|
||
now := time.Now()
|
||
state.PaidAt = &now
|
||
}
|
||
return state, nil
|
||
}
|
||
|
||
func (c *AlipayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
|
||
client, err := c.buildClient(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
return nil, fmt.Errorf("解析支付宝通知参数失败: %w", err)
|
||
}
|
||
notification, err := client.DecodeNotification(ctx, r.Form)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("支付宝通知验签失败: %w", err)
|
||
}
|
||
|
||
result := &NotifyResult{
|
||
EventType: notification.NotifyType,
|
||
EventID: notification.NotifyId,
|
||
OutTradeNo: notification.OutTradeNo,
|
||
PayNo: notification.OutTradeNo,
|
||
ChannelTradeNo: notification.TradeNo,
|
||
AckBody: "success",
|
||
}
|
||
if fen, err := YuanToFen(notification.TotalAmount); err == nil {
|
||
result.Amount = fen
|
||
}
|
||
result.TradeState = normalizeAlipayStatus(notification.TradeStatus)
|
||
result.Paid = result.TradeState == StateSuccess
|
||
return result, nil
|
||
}
|
||
|
||
func (c *AlipayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
|
||
client, err := c.buildClient(cfg)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
rsp, err := client.TradeRefund(ctx, alipay.TradeRefund{
|
||
OutTradeNo: order.PayNo,
|
||
RefundAmount: FenToYuan(amount),
|
||
RefundReason: reason,
|
||
OutRequestNo: refundNo,
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("支付宝退回请求失败: %w", err)
|
||
}
|
||
if rsp.Code != "10000" {
|
||
return "", fmt.Errorf("支付宝退回失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||
}
|
||
return rsp.TradeNo, nil
|
||
}
|
||
|
||
func (c *AlipayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
||
client, err := c.buildClient(cfg)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 查询一笔必然不存在的交易:能返回「交易不存在」即说明 APPID 与签名配置正确
|
||
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: "__connect_test__"})
|
||
if err != nil {
|
||
return "", fmt.Errorf("支付宝凭证校验失败: %w", err)
|
||
}
|
||
if rsp.Code == "10000" || rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
|
||
return "连接成功:APPID 与 RSA2 签名校验通过", nil
|
||
}
|
||
return "", fmt.Errorf("支付宝凭证校验未通过: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||
}
|