增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+194
View File
@@ -0,0 +1,194 @@
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)
}