kra-new/internal/modules/system/integration/payment/alipay_v3.go

535 lines
17 KiB
Go

package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"kra/internal/modules/system/biz"
"github.com/go-pay/gopay"
gopayAlipay "github.com/go-pay/gopay/alipay"
gopayAlipayV3 "github.com/go-pay/gopay/alipay/v3"
"github.com/go-pay/gopay/pkg/xhttp"
)
type alipayV3Adapter struct{}
type alipayV3Client struct {
client *gopayAlipayV3.ClientV3
gateway *url.URL
}
func (a *alipayV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝 V3 下单参数为空")
}
amount, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
sdk, err := newAlipayV3Client(c)
if err != nil {
return nil, err
}
method, err := alipayCreateMethod(req.Extra, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"out_trade_no": req.TradeNo,
"total_amount": amount,
"subject": req.Subject,
}
protected := []string{
"out_trade_no", "total_amount", "subject", "notify_url", "return_url",
"method", "pay_method", "trade_type", "channel",
}
mergeGoPayConfigExtras(bm, c, "create_extra", protected...)
mergeMap(bm, req.Extra, protected...)
if req.NotifyURL != "" {
bm.Set("notify_url", req.NotifyURL)
}
if req.ReturnURL != "" {
bm.Set("return_url", req.ReturnURL)
}
switch method {
case "alipay.trade.pay":
if bm.GetString("scene") == "" {
bm.Set("scene", "bar_code")
}
rsp, callErr := sdk.client.TradePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("支付宝 V3 付款码支付响应为空")
}
if err = alipayV3CheckResponse("付款码支付", rsp.StatusCode, rsp.ErrResponse); err != nil {
return nil, err
}
return alipayV3TradePayResult(req, rsp)
case "alipay.trade.precreate":
rsp, callErr := sdk.client.TradePrecreate(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("支付宝 V3 预创建响应为空")
}
if err = alipayV3CheckResponse("预创建", rsp.StatusCode, rsp.ErrResponse); err != nil {
return nil, err
}
outTradeNo := strings.TrimSpace(rsp.OutTradeNo)
if outTradeNo == "" || outTradeNo != req.TradeNo {
return nil, errors.New("支付宝 V3 预创建响应的 out_trade_no 缺失或不匹配")
}
if strings.TrimSpace(rsp.QrCode) == "" {
return nil, errors.New("支付宝 V3 预创建响应缺少 qr_code")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo,
QueryID: outTradeNo, Payload: mustJSON(rsp),
}, nil
case "alipay.trade.app.pay":
orderString, callErr := sdk.client.TradeAppPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if strings.TrimSpace(orderString) == "" {
return nil, errors.New("支付宝 V3 APP 支付参数为空")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo,
Payload: mustJSON(map[string]string{"order_string": orderString}),
}, nil
case "alipay.trade.page.pay":
payURL, callErr := sdk.client.TradePagePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝 V3 电脑网站支付 URL 为空")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo,
Payload: mustJSON(map[string]string{"pay_url": payURL}),
}, nil
case "alipay.trade.wap.pay":
payURL, callErr := sdk.client.TradeWapPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝 V3 手机网站支付 URL 为空")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo,
Payload: mustJSON(map[string]string{"pay_url": payURL}),
}, nil
default:
if bm.GetString("product_code") == "" {
bm.Set("product_code", firstAnyOr(c, "JSAPI_PAY", "product_code"))
}
if bm.GetString("op_app_id") == "" {
bm.Set("op_app_id", firstAnyOr(c, text(c, "app_id"), "op_app_id"))
}
rsp, callErr := sdk.client.TradeCreate(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("支付宝 V3 创建响应为空")
}
if err = alipayV3CheckResponse("创建", rsp.StatusCode, rsp.ErrResponse); err != nil {
return nil, err
}
outTradeNo := strings.TrimSpace(rsp.OutTradeNo)
if outTradeNo == "" || outTradeNo != req.TradeNo {
return nil, errors.New("支付宝 V3 创建响应的 out_trade_no 缺失或不匹配")
}
providerTradeNo := strings.TrimSpace(rsp.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝 V3 创建响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo,
ProviderTradeNo: providerTradeNo, QueryID: outTradeNo, Payload: mustJSON(rsp),
}, nil
}
}
func (a *alipayV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
tradeNo = strings.TrimSpace(tradeNo)
if tradeNo == "" {
return nil, errors.New("支付宝 V3 查单缺少 out_trade_no")
}
sdk, err := newAlipayV3Client(c)
if err != nil {
return nil, err
}
rsp, callErr := sdk.client.TradeQuery(ctx, gopay.BodyMap{"out_trade_no": tradeNo})
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("支付宝 V3 查询响应为空")
}
if err = alipayV3CheckResponse("查单", rsp.StatusCode, rsp.ErrResponse); err != nil {
return nil, err
}
outTradeNo := strings.TrimSpace(rsp.OutTradeNo)
if outTradeNo == "" || outTradeNo != tradeNo {
return nil, errors.New("支付宝 V3 查询响应的 out_trade_no 缺失或不匹配")
}
result := &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: normalizePaymentStatus(rsp.TradeStatus, "pending"),
TradeNo: outTradeNo, ProviderTradeNo: strings.TrimSpace(rsp.TradeNo),
Currency: alipayV3Currency(c, ""), Payload: mustJSON(rsp),
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("支付宝 V3 查询响应缺少 trade_no")
}
result.Amount, err = parseDecimalAmount(rsp.TotalAmount, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝 V3 订单金额: %w", err)
}
values, err := alipayResponseValues(rsp)
if err != nil {
return nil, err
}
if err = populateAlipayBreakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *alipayV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝 V3 退款请求为空")
}
tradeNo := strings.TrimSpace(req.TradeNo)
refundNo := strings.TrimSpace(req.RefundNo)
if tradeNo == "" || refundNo == "" {
return nil, errors.New("支付宝 V3 退款缺少 out_trade_no 或 out_request_no")
}
amount, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
sdk, err := newAlipayV3Client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"out_trade_no": tradeNo,
"out_request_no": refundNo,
"refund_amount": amount,
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_request_no", "refund_amount")
rsp, callErr := sdk.client.TradeRefund(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("支付宝 V3 退款响应为空")
}
if err = alipayV3CheckResponse("退款", rsp.StatusCode, rsp.ErrResponse); err != nil {
return nil, err
}
outTradeNo := strings.TrimSpace(rsp.OutTradeNo)
if outTradeNo == "" || outTradeNo != tradeNo {
return nil, errors.New("支付宝 V3 退款响应的 out_trade_no 缺失或不匹配")
}
providerTradeNo := strings.TrimSpace(rsp.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝 V3 退款响应缺少 trade_no")
}
if expected := strings.TrimSpace(req.ProviderTradeNo); expected != "" && providerTradeNo != expected {
return nil, errors.New("支付宝 V3 退款响应的 trade_no 不匹配")
}
refundedAmount, err := parseDecimalAmount(rsp.RefundFee, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝 V3 退款金额: %w", err)
}
if refundedAmount != req.Amount {
return nil, errors.New("支付宝 V3 退款响应金额不匹配")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo, Amount: refundedAmount,
Currency: alipayV3Currency(c, req.Currency), Payload: mustJSON(rsp),
}, nil
}
func (a *alipayV3Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil || len(callback.Body) == 0 {
return nil, errors.New("支付宝 V3 回调为空")
}
fields := callbackFields(callback)
if fields["sign"] == "" {
if values, parseErr := url.ParseQuery(string(callback.Body)); parseErr == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
if err := verifyAlipayV3Callback(fields, c); err != nil {
return nil, err
}
if fields["app_id"] != text(c, "app_id") {
return nil, errors.New("支付宝 V3 回调 app_id 不匹配")
}
tradeNo := strings.TrimSpace(fields["out_trade_no"])
providerTradeNo := strings.TrimSpace(fields["trade_no"])
if tradeNo == "" || providerTradeNo == "" {
return nil, errors.New("支付宝 V3 回调缺少 out_trade_no 或 trade_no")
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: normalizePaymentStatus(fields["trade_status"], "pending"),
TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, Payload: payload,
}, nil
}
func alipayV3TradePayResult(req *biz.PaymentRequest, rsp *gopayAlipayV3.TradePayRsp) (*biz.PaymentResult, error) {
if req == nil || rsp == nil {
return nil, errors.New("支付宝 V3 付款码支付响应为空")
}
outTradeNo := strings.TrimSpace(rsp.OutTradeNo)
if outTradeNo == "" || outTradeNo != req.TradeNo {
return nil, errors.New("支付宝 V3 付款码支付响应的 out_trade_no 缺失或不匹配")
}
providerTradeNo := strings.TrimSpace(rsp.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝 V3 付款码支付响应缺少 trade_no")
}
amount, err := parseDecimalAmount(rsp.TotalAmount, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝 V3 付款码支付金额: %w", err)
}
if amount != req.Amount {
return nil, errors.New("支付宝 V3 付款码支付响应金额不匹配")
}
result := &biz.PaymentResult{
Provider: biz.PaymentAlipayV3, Status: "success", TradeNo: outTradeNo,
ProviderTradeNo: providerTradeNo, Amount: amount,
Currency: strings.ToUpper(strings.TrimSpace(req.Currency)), Payload: mustJSON(rsp),
}
if result.Currency == "" {
result.Currency = "CNY"
}
values, err := alipayResponseValues(rsp)
if err != nil {
return nil, err
}
if err = populateAlipayBreakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func newAlipayV3Client(c map[string]any) (*alipayV3Client, error) {
appID := strings.TrimSpace(text(c, "app_id"))
privateKey, err := alipayV3PrivateKey(c)
if err != nil {
return nil, err
}
if appID == "" {
return nil, errors.New("支付宝 V3 缺少 app_id")
}
production := !strings.EqualFold(strings.TrimSpace(text(c, "environment")), "sandbox")
client, err := gopayAlipayV3.NewClientV3(appID, goPayAlipayKey(privateKey, false), production)
if err != nil {
return nil, err
}
appCert, err := alipayV3AppCertificate(c)
if err != nil {
return nil, err
}
rootCert, err := alipayV3RootCertificate(c)
if err != nil {
return nil, err
}
publicCert, err := alipayV3PublicCertificate(c)
if err != nil {
return nil, err
}
if err = client.SetCert(appCert, rootCert, publicCert); err != nil {
return nil, fmt.Errorf("配置支付宝 V3 证书: %w", err)
}
if token := strings.TrimSpace(text(c, "app_auth_token")); token != "" {
client.SetAppAuthToken(token)
}
client.SetHttpClient(xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(8))
sdk := &alipayV3Client{client: client}
if endpoint := strings.TrimSpace(text(c, "gateway_url")); endpoint != "" {
sdk.gateway, err = url.Parse(endpoint)
if err != nil || sdk.gateway.Scheme == "" || sdk.gateway.Host == "" {
return nil, fmt.Errorf("支付宝 V3 gateway_url 无效: %s", endpoint)
}
}
apiEndpoint := strings.TrimSpace(firstAny(c, "api_base_url", "proxy_host", "base_url"))
if apiEndpoint == "" && sdk.gateway != nil && (sdk.gateway.Path == "" || sdk.gateway.Path == "/") {
apiEndpoint = sdk.gateway.String()
}
if apiEndpoint != "" {
parsed, parseErr := url.Parse(apiEndpoint)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("支付宝 V3 api_base_url 无效: %s", apiEndpoint)
}
client.SetProxyHost(strings.TrimRight(apiEndpoint, "/"))
}
return sdk, nil
}
func (c *alipayV3Client) rewriteGeneratedURL(value string) (string, error) {
if strings.TrimSpace(value) == "" || c.gateway == nil {
return value, nil
}
source, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("解析支付宝 V3 支付 URL: %w", err)
}
return alipayTargetURL(source, c.gateway).String(), nil
}
func verifyAlipayV3Callback(fields map[string]string, c map[string]any) error {
if text(c, "app_id") == "" || fields["sign"] == "" {
return errors.New("支付宝 V3 回调验签配置或 sign 缺失")
}
values := url.Values{}
for key, value := range fields {
values.Set(key, value)
}
body, err := gopayAlipay.ParseNotifyByURLValues(values)
if err != nil {
return err
}
publicCert, err := alipayV3PublicCertificate(c)
if err != nil {
return err
}
ok, err := gopayAlipay.VerifySignWithCert(publicCert, cloneGoPayBodyMap(body))
if err != nil {
return err
}
if !ok {
return errors.New("支付宝 V3 回调签名校验失败")
}
return nil
}
func alipayV3CheckResponse(operation string, status int, response gopayAlipayV3.ErrResponse) error {
if status == http.StatusOK {
return nil
}
message := strings.TrimSpace(response.Message)
if code := strings.TrimSpace(response.Code); code != "" {
if message == "" {
message = code
} else {
message = code + ": " + message
}
}
if message == "" {
return fmt.Errorf("支付宝 V3 %s HTTP %d", operation, status)
}
return fmt.Errorf("支付宝 V3 %s HTTP %d: %s", operation, status, message)
}
func alipayV3Currency(c map[string]any, fallback string) string {
currency := strings.ToUpper(strings.TrimSpace(firstAny(c, "currency", "default_currency")))
if currency == "" {
currency = strings.ToUpper(strings.TrimSpace(fallback))
}
if currency == "" {
currency = "CNY"
}
return currency
}
func alipayV3PrivateKey(c map[string]any) (string, error) {
if value := strings.TrimSpace(firstAny(c, "private_key_content", "private_key")); value != "" {
return value, nil
}
path := strings.TrimSpace(text(c, "private_key_path"))
if path == "" {
return "", errors.New("支付宝 V3 缺少 private_key")
}
content, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("读取支付宝 V3 private_key: %w", err)
}
return string(content), nil
}
func alipayV3AppCertificate(c map[string]any) ([]byte, error) {
return alipayV3Certificate(c, "app_cert",
[]string{"app_cert_content", "app_public_cert_content"},
[]string{"app_cert_path", "app_public_cert_path"},
[]string{"app_cert", "app_public_cert"},
)
}
func alipayV3RootCertificate(c map[string]any) ([]byte, error) {
return alipayV3Certificate(c, "root_cert",
[]string{"alipay_root_cert_content", "root_cert_content"},
[]string{"alipay_root_cert_path", "root_cert_path"},
[]string{"alipay_root_cert", "root_cert"},
)
}
func alipayV3PublicCertificate(c map[string]any) ([]byte, error) {
return alipayV3Certificate(c, "public_cert",
[]string{"alipay_public_cert_content", "public_cert_content"},
[]string{"alipay_public_cert_path", "public_cert_path"},
[]string{"alipay_public_cert", "public_cert"},
)
}
func alipayV3Certificate(c map[string]any, label string, contentKeys, pathKeys, flexibleKeys []string) ([]byte, error) {
if value := strings.TrimSpace(firstAny(c, contentKeys...)); value != "" {
return []byte(value), nil
}
if path := strings.TrimSpace(firstAny(c, pathKeys...)); path != "" {
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取支付宝 V3 %s: %w", label, err)
}
return content, nil
}
if value := strings.TrimSpace(firstAny(c, flexibleKeys...)); value != "" {
if strings.Contains(value, "-----BEGIN CERTIFICATE-----") {
return []byte(value), nil
}
content, err := os.ReadFile(value)
if err != nil {
return nil, fmt.Errorf("读取支付宝 V3 %s: %w", label, err)
}
return content, nil
}
return nil, fmt.Errorf("支付宝 V3 缺少 %s", label)
}