From 6405a21e60ad52dd867e828c18f90c5d252ae8a4 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 15:05:38 +0800 Subject: [PATCH] feat: add alipay direct payment flow Add Alipay settings, RSA2 provider implementation, create/return/notify handlers, and provider-specific top-up settlement. --- controller/payment_alipay.go | 189 +++++++++++++ model/topup.go | 69 +++++ payment/provider/alipay.go | 471 ++++++++++++++++++++++++++++++++ payment/provider/alipay_test.go | 189 +++++++++++++ setting/payment_alipay.go | 15 + 5 files changed, 933 insertions(+) create mode 100644 controller/payment_alipay.go create mode 100644 payment/provider/alipay.go create mode 100644 payment/provider/alipay_test.go create mode 100644 setting/payment_alipay.go diff --git a/controller/payment_alipay.go b/controller/payment_alipay.go new file mode 100644 index 00000000..5e1d9ad7 --- /dev/null +++ b/controller/payment_alipay.go @@ -0,0 +1,189 @@ +package controller + +import ( + "fmt" + "io" + "math" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/payment" + "github.com/QuantumNous/new-api/payment/provider" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" +) + +type AlipayPaymentRequest struct { + Amount int64 `json:"amount"` + PromoCodeId int `json:"promo_code_id"` + TradeType string `json:"trade_type"` +} + +func CreateAlipayPayment(c *gin.Context) { + if !operation_setting.IsPaymentComplianceConfirmed() { + common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired) + return + } + if !setting.Alipay.Enabled { + common.ApiErrorMsg(c, "支付宝支付未启用") + return + } + + var req AlipayPaymentRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if req.Amount < getMinTopup() { + common.ApiErrorMsg(c, fmt.Sprintf("充值数量不能小于 %d", getMinTopup())) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + common.ApiErrorMsg(c, "获取用户分组失败") + return + } + payMoney := getPayMoney(req.Amount, group) + if payMoney < 0.01 { + common.ApiErrorMsg(c, "充值金额过低") + return + } + + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dAmount := decimal.NewFromInt(req.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + amount = dAmount.Div(dQuotaPerUnit).IntPart() + if amount < 1 { + amount = 1 + } + } + if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, amount) { + return + } + + tradeNo := fmt.Sprintf("ALIPAY-%d-%d-%s", id, time.Now().UnixMilli(), common.GetRandomString(6)) + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: model.PaymentMethodAlipay, + PaymentProvider: model.PaymentProviderAlipay, + PromoCodeId: req.PromoCodeId, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error())) + common.ApiErrorMsg(c, "创建订单失败") + return + } + + alipayProvider, err := provider.NewAlipay(setting.Alipay) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 provider 初始化失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error())) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + common.ApiErrorMsg(c, "支付配置错误") + return + } + + callbackAddr := service.GetCallbackAddress() + notifyURL := callbackAddr + "/api/payment/webhook/alipay" + if setting.Alipay.NotifyURL != "" { + notifyURL = setting.Alipay.NotifyURL + } + returnURL := callbackAddr + "/api/payment/alipay/return" + if setting.Alipay.ReturnURL != "" { + returnURL = setting.Alipay.ReturnURL + } + + resp, err := alipayProvider.CreatePayment(c.Request.Context(), &payment.PaymentRequest{ + OrderID: tradeNo, + Amount: int(math.Round(payMoney * 100)), + Currency: "CNY", + Description: fmt.Sprintf("Top up %d credits", req.Amount), + ReturnURL: returnURL, + NotifyURL: notifyURL, + TradeType: req.TradeType, + }) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 拉起支付失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error())) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + common.ApiErrorMsg(c, "拉起支付失败") + return + } + + logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f trade_type=%s", id, tradeNo, req.Amount, payMoney, req.TradeType)) + common.ApiSuccess(c, gin.H{ + "payment_url": resp.PaymentURL, + "qrcode": resp.QRCode, + "order_id": tradeNo, + }) +} + +func AlipayReturn(c *gin.Context) { + alipayProvider, err := provider.NewAlipay(setting.Alipay) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "error", "data": "支付配置错误"}) + return + } + if _, err := alipayProvider.VerifyNotification(c.Request.Context(), []byte(c.Request.URL.Query().Encode()), nil); err != nil { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 同步回调验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) + c.JSON(http.StatusBadRequest, gin.H{"message": "error", "data": "验签失败"}) + return + } + redirectURL := setting.Alipay.ReturnURL + if redirectURL == "" { + redirectURL = paymentReturnPath("/console/topup?show_history=true") + } + c.Redirect(http.StatusFound, redirectURL) +} + +func AlipayNotify(c *gin.Context) { + alipayProvider, err := provider.NewAlipay(setting.Alipay) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 provider 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) + c.String(http.StatusOK, "fail") + return + } + + body, err := io.ReadAll(c.Request.Body) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 异步通知读取失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) + c.String(http.StatusOK, "fail") + return + } + status, err := alipayProvider.VerifyNotification(c.Request.Context(), body, nil) + if err != nil { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 异步通知验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) + c.String(http.StatusOK, "fail") + return + } + if status.Status != payment.OrderStatusSuccess { + logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 异步通知忽略非成功状态 trade_no=%s status=%s client_ip=%s", status.ProviderOrderID, status.Status, c.ClientIP())) + c.String(http.StatusOK, "success") + return + } + + LockOrder(status.ProviderOrderID) + defer UnlockOrder(status.ProviderOrderID) + if err := model.RechargeAlipay(status.ProviderOrderID, c.ClientIP()); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 充值处理失败 trade_no=%s client_ip=%s error=%q", status.ProviderOrderID, c.ClientIP(), err.Error())) + c.String(http.StatusOK, "fail") + return + } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 充值成功 trade_no=%s client_ip=%s", status.ProviderOrderID, c.ClientIP())) + c.String(http.StatusOK, "success") +} diff --git a/model/topup.go b/model/topup.go index ef8e2308..ca8e2062 100644 --- a/model/topup.go +++ b/model/topup.go @@ -33,6 +33,7 @@ const ( PaymentMethodCreem = "creem" PaymentMethodWaffo = "waffo" PaymentMethodWaffoPancake = "waffo_pancake" + PaymentMethodAlipay = "alipay" ) const ( @@ -41,6 +42,7 @@ const ( PaymentProviderCreem = "creem" PaymentProviderWaffo = "waffo" PaymentProviderWaffoPancake = "waffo_pancake" + PaymentProviderAlipay = "alipay" ) var ( @@ -749,6 +751,73 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { return nil } +func RechargeAlipay(tradeNo string, callerIp string) (err error) { + if tradeNo == "" { + return errors.New("未提供支付单号") + } + + var quotaToAdd int + topUp := &TopUp{} + var promoBonus *promoCodeBonusInfo + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.PaymentProvider != PaymentProviderAlipay { + return ErrPaymentMethodMismatch + } + + if topUp.Status == common.TopUpStatusSuccess { + return nil + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp) + promoBonus = bonus + return bonusErr + }) + + if err != nil { + common.SysError("alipay topup failed: " + err.Error()) + return errors.New("充值失败,请稍后重试") + } + + if quotaToAdd > 0 { + RecordTopupLog(topUp.UserId, fmt.Sprintf("支付宝充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodAlipay) + recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentMethodAlipay) + } + + return nil +} + func RechargeWaffoPancake(tradeNo string) (err error) { if tradeNo == "" { return errors.New("未提供支付单号") diff --git a/payment/provider/alipay.go b/payment/provider/alipay.go new file mode 100644 index 00000000..b8d27b6f --- /dev/null +++ b/payment/provider/alipay.go @@ -0,0 +1,471 @@ +package provider + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/payment" + "github.com/QuantumNous/new-api/setting" +) + +const ( + alipayDefaultGateway = "https://openapi.alipay.com/gateway.do" + alipaySignType = "RSA2" + alipayTradeClosed = "TRADE_CLOSED" + alipayTradeSuccess = "TRADE_SUCCESS" + alipayTradeFinished = "TRADE_FINISHED" + alipayTradePending = "WAIT_BUYER_PAY" +) + +type Alipay struct { + setting setting.AlipaySetting + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + httpClient *http.Client +} + +func NewAlipay(cfg setting.AlipaySetting) (*Alipay, error) { + if !cfg.Enabled { + return nil, errors.New("alipay is not enabled") + } + cfg.AppID = strings.TrimSpace(cfg.AppID) + cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey) + cfg.PublicKey = strings.TrimSpace(cfg.PublicKey) + cfg.GatewayUrl = strings.TrimSpace(cfg.GatewayUrl) + if cfg.GatewayUrl == "" { + cfg.GatewayUrl = alipayDefaultGateway + } + if cfg.AppID == "" { + return nil, errors.New("alipay app_id is required") + } + if cfg.PrivateKey == "" { + return nil, errors.New("alipay private_key is required") + } + if cfg.PublicKey == "" { + return nil, errors.New("alipay public_key is required") + } + privateKey, err := parseRSAPrivateKey(cfg.PrivateKey) + if err != nil { + return nil, fmt.Errorf("parse alipay private_key: %w", err) + } + publicKey, err := parseRSAPublicKey(cfg.PublicKey) + if err != nil { + return nil, fmt.Errorf("parse alipay public_key: %w", err) + } + return &Alipay{ + setting: cfg, + privateKey: privateKey, + publicKey: publicKey, + httpClient: http.DefaultClient, + }, nil +} + +func (a *Alipay) Name() string { + return "alipay" +} + +func (a *Alipay) CreatePayment(ctx context.Context, req *payment.PaymentRequest) (*payment.PaymentResponse, error) { + if req == nil { + return nil, errors.New("missing payment request") + } + if strings.TrimSpace(req.OrderID) == "" { + return nil, errors.New("missing order id") + } + if req.Amount <= 0 { + return nil, errors.New("invalid payment amount") + } + + method := "alipay.trade.page.pay" + productCode := "FAST_INSTANT_TRADE_PAY" + if strings.EqualFold(req.TradeType, "H5") { + method = "alipay.trade.wap.pay" + productCode = "QUICK_WAP_WAY" + } + + bizContent, err := common.Marshal(map[string]string{ + "out_trade_no": req.OrderID, + "total_amount": amountCentsToDecimal(req.Amount), + "subject": nonEmpty(req.Description, "Top up"), + "product_code": productCode, + }) + if err != nil { + return nil, fmt.Errorf("marshal alipay biz_content: %w", err) + } + + params := a.baseParams(method) + params.Set("biz_content", string(bizContent)) + if req.NotifyURL != "" { + params.Set("notify_url", req.NotifyURL) + } else if a.setting.NotifyURL != "" { + params.Set("notify_url", a.setting.NotifyURL) + } + if req.ReturnURL != "" { + params.Set("return_url", req.ReturnURL) + } else if a.setting.ReturnURL != "" { + params.Set("return_url", a.setting.ReturnURL) + } + if err := a.signParams(params); err != nil { + return nil, err + } + + paymentURL := a.setting.GatewayUrl + "?" + params.Encode() + return &payment.PaymentResponse{ + ProviderOrderID: req.OrderID, + PaymentURL: paymentURL, + QRCode: paymentURL, + RawResponse: paymentURL, + }, nil +} + +func (a *Alipay) QueryOrder(ctx context.Context, providerOrderID string) (*payment.OrderStatus, error) { + if strings.TrimSpace(providerOrderID) == "" { + return nil, errors.New("missing provider order id") + } + body, err := common.Marshal(map[string]string{"out_trade_no": providerOrderID}) + if err != nil { + return nil, fmt.Errorf("marshal alipay query biz_content: %w", err) + } + respBody, err := a.requestGateway(ctx, "alipay.trade.query", string(body)) + if err != nil { + return nil, err + } + + var envelope alipayQueryEnvelope + if err := common.Unmarshal(respBody, &envelope); err != nil { + return nil, fmt.Errorf("decode alipay query response: %w", err) + } + resp := envelope.Response + if resp.Code != "10000" { + return nil, fmt.Errorf("alipay query failed code=%s sub_code=%s msg=%s sub_msg=%s", resp.Code, resp.SubCode, resp.Msg, resp.SubMsg) + } + return &payment.OrderStatus{ + ProviderOrderID: nonEmpty(resp.OutTradeNo, resp.TradeNo), + Status: mapAlipayTradeStatus(resp.TradeStatus), + Amount: decimalAmountToCents(resp.TotalAmount), + Currency: "CNY", + }, nil +} + +func (a *Alipay) VerifyNotification(ctx context.Context, body []byte, headers map[string]string) (*payment.OrderStatus, error) { + values, err := url.ParseQuery(string(body)) + if err != nil { + return nil, fmt.Errorf("parse alipay notification form: %w", err) + } + params := make(map[string]string, len(values)) + for key := range values { + params[key] = values.Get(key) + } + signature := params["sign"] + if signature == "" { + return nil, errors.New("missing alipay notification sign") + } + if err := a.verifyParams(params, signature); err != nil { + return nil, err + } + if params["app_id"] != a.setting.AppID { + return nil, errors.New("alipay notification app_id mismatch") + } + + status := mapAlipayTradeStatus(params["trade_status"]) + orderStatus := &payment.OrderStatus{ + ProviderOrderID: nonEmpty(params["out_trade_no"], params["trade_no"]), + Status: status, + Amount: decimalAmountToCents(params["total_amount"]), + Currency: "CNY", + } + if paidAt := parseAlipayTime(params["gmt_payment"]); paidAt != nil { + orderStatus.PaidAt = paidAt + } + return orderStatus, nil +} + +func (a *Alipay) Refund(ctx context.Context, providerOrderID string, amount int) error { + if strings.TrimSpace(providerOrderID) == "" { + return errors.New("missing provider order id") + } + if amount <= 0 { + return errors.New("invalid refund amount") + } + body, err := common.Marshal(map[string]string{ + "out_trade_no": providerOrderID, + "refund_amount": amountCentsToDecimal(amount), + "out_request_no": fmt.Sprintf("%s-refund-%d", providerOrderID, time.Now().UnixNano()), + }) + if err != nil { + return fmt.Errorf("marshal alipay refund biz_content: %w", err) + } + respBody, err := a.requestGateway(ctx, "alipay.trade.refund", string(body)) + if err != nil { + return err + } + var envelope alipayRefundEnvelope + if err := common.Unmarshal(respBody, &envelope); err != nil { + return fmt.Errorf("decode alipay refund response: %w", err) + } + resp := envelope.Response + if resp.Code != "10000" { + return fmt.Errorf("alipay refund failed code=%s sub_code=%s msg=%s sub_msg=%s", resp.Code, resp.SubCode, resp.Msg, resp.SubMsg) + } + return nil +} + +func (a *Alipay) baseParams(method string) url.Values { + params := url.Values{} + params.Set("app_id", a.setting.AppID) + params.Set("method", method) + params.Set("format", "JSON") + params.Set("charset", "utf-8") + params.Set("sign_type", alipaySignType) + params.Set("timestamp", time.Now().Format("2006-01-02 15:04:05")) + params.Set("version", "1.0") + return params +} + +func (a *Alipay) requestGateway(ctx context.Context, method string, bizContent string) ([]byte, error) { + params := a.baseParams(method) + params.Set("biz_content", bizContent) + if err := a.signParams(params); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.setting.GatewayUrl, strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("build alipay request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := a.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request alipay gateway: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read alipay response: %w", err) + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("alipay gateway http status %d: %s", resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +func (a *Alipay) signParams(params url.Values) error { + canonical := canonicalAlipayParams(params) + if canonical == "" { + return errors.New("empty alipay signing payload") + } + digest := sha256.Sum256([]byte(canonical)) + signature, err := rsa.SignPKCS1v15(rand.Reader, a.privateKey, crypto.SHA256, digest[:]) + if err != nil { + return fmt.Errorf("sign alipay params: %w", err) + } + params.Set("sign", base64.StdEncoding.EncodeToString(signature)) + return nil +} + +func (a *Alipay) verifyParams(params map[string]string, signature string) error { + canonical := canonicalAlipayMap(params) + if canonical == "" { + return errors.New("empty alipay verification payload") + } + rawSignature, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("decode alipay sign: %w", err) + } + digest := sha256.Sum256([]byte(canonical)) + if err := rsa.VerifyPKCS1v15(a.publicKey, crypto.SHA256, digest[:], rawSignature); err != nil { + return fmt.Errorf("verify alipay sign: %w", err) + } + return nil +} + +func canonicalAlipayParams(params url.Values) string { + items := make(map[string]string, len(params)) + for key := range params { + items[key] = params.Get(key) + } + return canonicalAlipayMap(items) +} + +func canonicalAlipayMap(params map[string]string) string { + keys := make([]string, 0, len(params)) + for key, value := range params { + if key == "sign" || key == "sign_type" || value == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+params[key]) + } + return strings.Join(parts, "&") +} + +func parseRSAPrivateKey(raw string) (*rsa.PrivateKey, error) { + block, err := normalizePEMBlock(raw, "PRIVATE KEY", "RSA PRIVATE KEY") + if err != nil { + return nil, err + } + switch block.Type { + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + return privateKey, nil + case "RSA PRIVATE KEY": + return x509.ParsePKCS1PrivateKey(block.Bytes) + default: + return nil, fmt.Errorf("unsupported private key type: %s", block.Type) + } +} + +func parseRSAPublicKey(raw string) (*rsa.PublicKey, error) { + block, err := normalizePEMBlock(raw, "PUBLIC KEY", "RSA PUBLIC KEY") + if err != nil { + return nil, err + } + switch block.Type { + case "PUBLIC KEY": + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + publicKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, errors.New("public key is not RSA") + } + return publicKey, nil + case "RSA PUBLIC KEY": + return x509.ParsePKCS1PublicKey(block.Bytes) + default: + return nil, fmt.Errorf("unsupported public key type: %s", block.Type) + } +} + +func normalizePEMBlock(raw string, pkcs8Type string, pkcs1Type string) (*pem.Block, error) { + normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n")) + if normalized == "" { + return nil, errors.New("empty RSA key") + } + if strings.Contains(normalized, "BEGIN ") { + block, _ := pem.Decode([]byte(normalized)) + if block == nil { + return nil, errors.New("invalid PEM encoded RSA key") + } + return block, nil + } + + der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", "")) + if err != nil { + return nil, fmt.Errorf("invalid base64 encoded RSA key: %w", err) + } + pemType := pkcs8Type + if pkcs8Type == "PRIVATE KEY" { + if _, err := x509.ParsePKCS8PrivateKey(der); err != nil { + if _, err := x509.ParsePKCS1PrivateKey(der); err == nil { + pemType = pkcs1Type + } else { + return nil, errors.New("invalid RSA private key") + } + } + } else if _, err := x509.ParsePKIXPublicKey(der); err != nil { + if _, err := x509.ParsePKCS1PublicKey(der); err == nil { + pemType = pkcs1Type + } else { + return nil, errors.New("invalid RSA public key") + } + } + return &pem.Block{Type: pemType, Bytes: der}, nil +} + +func amountCentsToDecimal(amount int) string { + return fmt.Sprintf("%d.%02d", amount/100, amount%100) +} + +func decimalAmountToCents(amount string) int { + value, err := strconv.ParseFloat(strings.TrimSpace(amount), 64) + if err != nil { + return 0 + } + return int(value*100 + 0.5) +} + +func mapAlipayTradeStatus(status string) string { + switch strings.ToUpper(strings.TrimSpace(status)) { + case alipayTradeSuccess, alipayTradeFinished: + return payment.OrderStatusSuccess + case alipayTradeClosed: + return payment.OrderStatusClosed + case alipayTradePending: + return payment.OrderStatusPending + default: + return payment.OrderStatusFailed + } +} + +func parseAlipayTime(value string) *time.Time { + if strings.TrimSpace(value) == "" { + return nil + } + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) + if err != nil { + return nil + } + return &parsed +} + +func nonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +type alipayGatewayResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + SubCode string `json:"sub_code"` + SubMsg string `json:"sub_msg"` +} + +type alipayQueryEnvelope struct { + Response struct { + alipayGatewayResponse + TradeNo string `json:"trade_no"` + OutTradeNo string `json:"out_trade_no"` + TradeStatus string `json:"trade_status"` + TotalAmount string `json:"total_amount"` + } `json:"alipay_trade_query_response"` +} + +type alipayRefundEnvelope struct { + Response alipayGatewayResponse `json:"alipay_trade_refund_response"` +} diff --git a/payment/provider/alipay_test.go b/payment/provider/alipay_test.go new file mode 100644 index 00000000..63bca387 --- /dev/null +++ b/payment/provider/alipay_test.go @@ -0,0 +1,189 @@ +package provider + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/payment" + "github.com/QuantumNous/new-api/setting" +) + +func testAlipayProvider(t *testing.T) *Alipay { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate private key: %v", err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatalf("marshal private key: %v", err) + } + publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + t.Fatalf("marshal public key: %v", err) + } + + provider, err := NewAlipay(setting.AlipaySetting{ + Enabled: true, + AppID: "app-123", + PrivateKey: base64.StdEncoding.EncodeToString(privateDER), + PublicKey: base64.StdEncoding.EncodeToString(publicDER), + GatewayUrl: "https://example.test/gateway.do", + }) + if err != nil { + t.Fatalf("new alipay: %v", err) + } + return provider +} + +func TestAlipayCreatePaymentBuildsSignedWapURL(t *testing.T) { + provider := testAlipayProvider(t) + + resp, err := provider.CreatePayment(context.Background(), &payment.PaymentRequest{ + OrderID: "ORDER-1", + Amount: 1234, + Description: "Recharge", + ReturnURL: "https://merchant.test/return", + NotifyURL: "https://merchant.test/notify", + TradeType: "H5", + }) + if err != nil { + t.Fatalf("create payment: %v", err) + } + + parsed, err := url.Parse(resp.PaymentURL) + if err != nil { + t.Fatalf("parse payment url: %v", err) + } + params := parsed.Query() + if got := params.Get("method"); got != "alipay.trade.wap.pay" { + t.Fatalf("method = %q", got) + } + if got := params.Get("return_url"); got != "https://merchant.test/return" { + t.Fatalf("return_url = %q", got) + } + + var biz map[string]string + if err := common.Unmarshal([]byte(params.Get("biz_content")), &biz); err != nil { + t.Fatalf("decode biz_content: %v", err) + } + if got := biz["total_amount"]; got != "12.34" { + t.Fatalf("total_amount = %q", got) + } + if got := biz["product_code"]; got != "QUICK_WAP_WAY" { + t.Fatalf("product_code = %q", got) + } + + items := map[string]string{} + for key := range params { + items[key] = params.Get(key) + } + if err := provider.verifyParams(items, params.Get("sign")); err != nil { + t.Fatalf("verify generated sign: %v", err) + } +} + +func TestAlipayVerifyNotificationMapsSuccess(t *testing.T) { + provider := testAlipayProvider(t) + params := url.Values{} + params.Set("app_id", "app-123") + params.Set("out_trade_no", "ORDER-2") + params.Set("trade_no", "202605202200") + params.Set("trade_status", alipayTradeSuccess) + params.Set("total_amount", "10.50") + params.Set("gmt_payment", "2026-05-20 12:34:56") + params.Set("sign_type", alipaySignType) + if err := provider.signParams(params); err != nil { + t.Fatalf("sign params: %v", err) + } + + status, err := provider.VerifyNotification(context.Background(), []byte(params.Encode()), nil) + if err != nil { + t.Fatalf("verify notification: %v", err) + } + if status.ProviderOrderID != "ORDER-2" { + t.Fatalf("provider order id = %q", status.ProviderOrderID) + } + if status.Status != payment.OrderStatusSuccess { + t.Fatalf("status = %q", status.Status) + } + if status.Amount != 1050 { + t.Fatalf("amount = %d", status.Amount) + } + if status.PaidAt == nil { + t.Fatal("paid_at is nil") + } +} + +func TestAlipayVerifyNotificationRejectsMismatchedAppID(t *testing.T) { + provider := testAlipayProvider(t) + params := url.Values{} + params.Set("app_id", "other-app") + params.Set("out_trade_no", "ORDER-2") + params.Set("trade_no", "202605202200") + params.Set("trade_status", alipayTradeSuccess) + params.Set("total_amount", "10.50") + params.Set("sign_type", alipaySignType) + if err := provider.signParams(params); err != nil { + t.Fatalf("sign params: %v", err) + } + + if _, err := provider.VerifyNotification(context.Background(), []byte(params.Encode()), nil); err == nil { + t.Fatal("expected app_id mismatch error") + } +} + +func TestAlipayQueryOrderMapsClosedStatus(t *testing.T) { + provider := testAlipayProvider(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if got := r.Form.Get("method"); got != "alipay.trade.query" { + t.Fatalf("method = %q", got) + } + if !strings.Contains(r.Form.Get("biz_content"), "ORDER-3") { + t.Fatalf("biz_content missing order id: %q", r.Form.Get("biz_content")) + } + body, err := common.Marshal(map[string]any{ + "alipay_trade_query_response": map[string]string{ + "code": "10000", + "msg": "Success", + "out_trade_no": "ORDER-3", + "trade_no": "202605202201", + "trade_status": alipayTradeClosed, + "total_amount": "9.99", + }, + }) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + _, _ = w.Write(body) + })) + defer server.Close() + provider.setting.GatewayUrl = server.URL + + status, err := provider.QueryOrder(context.Background(), "ORDER-3") + if err != nil { + t.Fatalf("query order: %v", err) + } + if status.ProviderOrderID != "ORDER-3" { + t.Fatalf("provider order id = %q", status.ProviderOrderID) + } + if status.Status != payment.OrderStatusClosed { + t.Fatalf("status = %q", status.Status) + } + if status.Amount != 999 { + t.Fatalf("amount = %d", status.Amount) + } +} diff --git a/setting/payment_alipay.go b/setting/payment_alipay.go new file mode 100644 index 00000000..2e45ffb8 --- /dev/null +++ b/setting/payment_alipay.go @@ -0,0 +1,15 @@ +package setting + +type AlipaySetting struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` + GatewayUrl string `json:"gateway_url"` + NotifyURL string `json:"notify_url"` + ReturnURL string `json:"return_url"` +} + +var Alipay = AlipaySetting{ + GatewayUrl: "https://openapi.alipay.com/gateway.do", +}