Files
2026-09-15 12:46:29 +08:00

195 lines
6.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}