kra-oa/app/system/internal/integration/payment/alipay.go

621 lines
20 KiB
Go

package payment
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"kra/app/system/internal/biz"
"github.com/go-pay/gopay"
gopayAlipay "github.com/go-pay/gopay/alipay"
"github.com/go-pay/gopay/pkg/xhttp"
)
type alipayAdapter struct{}
type alipayClient struct {
client *gopayAlipay.Client
gateway *url.URL
response bytes.Buffer
}
type alipayTransport struct {
base http.RoundTripper
gateway *url.URL
response *bytes.Buffer
}
type alipayResponseBody struct {
io.Reader
io.Closer
}
func (a *alipayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝下单参数为空")
}
amount, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "total_amount": amount, "subject": req.Subject}
mergeMap(bm, req.Extra,
"out_trade_no", "total_amount", "subject", "notify_url", "return_url",
"method", "pay_method", "trade_type", "channel",
)
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
if req.NotifyURL != "" {
sdk.client.SetNotifyUrl(req.NotifyURL)
}
if req.ReturnURL != "" {
sdk.client.SetReturnUrl(req.ReturnURL)
}
method, err := alipayCreateMethod(req.Extra, c)
if err != nil {
return nil, err
}
switch method {
case "alipay.trade.pay":
rsp, callErr := sdk.client.TradePay(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != req.TradeNo {
return nil, errors.New("支付宝付款码支付响应的 out_trade_no 不匹配")
}
status := ""
switch response.Code {
case "10000":
status = "success"
case "10003":
status = "pending"
default:
return nil, fmt.Errorf("支付宝付款码支付失败: %s", firstNonEmptyAlipay(response.SubMsg, response.Msg, response.SubCode, response.Code))
}
if strings.TrimSpace(response.OutTradeNo) == "" {
return nil, errors.New("支付宝付款码支付响应缺少 out_trade_no")
}
if status == "success" && strings.TrimSpace(response.TradeNo) == "" {
return nil, errors.New("支付宝付款码支付成功响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: status, TradeNo: req.TradeNo,
ProviderTradeNo: strings.TrimSpace(response.TradeNo), Payload: sdk.payload(rsp),
}, nil
case "alipay.trade.precreate":
rsp, callErr := sdk.client.TradePrecreate(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
queryID := strings.TrimSpace(response.OutTradeNo)
if queryID == "" {
queryID = req.TradeNo
} else if queryID != req.TradeNo {
return nil, errors.New("支付宝预创建响应的 out_trade_no 不匹配")
}
if strings.TrimSpace(response.QrCode) == "" {
return nil, errors.New("支付宝预创建响应缺少 qr_code")
}
// Precreate has no Alipay trade number yet. Keep the QR code in Payload
// and query later by the merchant out_trade_no.
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
QueryID: queryID, Payload: sdk.payload(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("支付宝 APP 支付参数为空")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(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
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝电脑网站支付 URL 为空")
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(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
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝手机网站支付 URL 为空")
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}),
}, nil
default:
rsp, callErr := sdk.client.TradeCreate(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != req.TradeNo {
return nil, errors.New("支付宝创建响应的 out_trade_no 不匹配")
}
providerTradeNo := strings.TrimSpace(response.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝创建响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp),
}, nil
}
}
func (a *alipayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
tradeNo = strings.TrimSpace(tradeNo)
if tradeNo == "" {
return nil, errors.New("支付宝查单缺少 out_trade_no")
}
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
const method = "alipay.trade.query"
rsp, callErr := sdk.client.TradeQuery(ctx, gopay.BodyMap{"out_trade_no": tradeNo})
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: normalizePaymentStatus(response.TradeStatus, "pending"),
TradeNo: strings.TrimSpace(response.OutTradeNo), ProviderTradeNo: strings.TrimSpace(response.TradeNo),
Payload: sdk.payload(rsp),
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
} else if result.TradeNo != tradeNo {
return nil, errors.New("支付宝查询响应的 out_trade_no 不匹配")
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("支付宝查询响应缺少 trade_no")
}
result.Amount, err = parseDecimalAmount(response.TotalAmount, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝订单金额: %w", err)
}
result.Currency = strings.ToUpper(firstNonEmptyAlipay(response.TransCurrency, response.PayCurrency))
if result.Currency == "" {
result.Currency = "CNY"
}
values, err := alipayResponseValues(response)
if err != nil {
return nil, err
}
if err = populateAlipayBreakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *alipayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝退款请求为空")
}
tradeNo := strings.TrimSpace(req.TradeNo)
refundNo := strings.TrimSpace(req.RefundNo)
if tradeNo == "" || refundNo == "" {
return nil, errors.New("支付宝退款缺少 out_trade_no 或 out_request_no")
}
formatted, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": tradeNo, "out_request_no": refundNo, "refund_amount": formatted}
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_request_no", "refund_amount")
const method = "alipay.trade.refund"
rsp, callErr := sdk.client.TradeRefund(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
return alipayRefundResult(req, response, sdk.payload(rsp))
}
func alipayRefundResult(req *biz.PaymentRefundRequest, response *gopayAlipay.TradeRefund, payload []byte) (*biz.PaymentResult, error) {
if req == nil || response == nil {
return nil, errors.New("支付宝退款响应为空")
}
tradeNo := strings.TrimSpace(req.TradeNo)
returnedTradeNo := strings.TrimSpace(response.OutTradeNo)
if returnedTradeNo == "" {
return nil, errors.New("支付宝退款响应缺少 out_trade_no")
}
if returnedTradeNo != tradeNo {
return nil, errors.New("支付宝退款响应的 out_trade_no 不匹配")
}
providerTradeNo := strings.TrimSpace(response.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝退款响应缺少 trade_no")
}
if expected := strings.TrimSpace(req.ProviderTradeNo); expected != "" && providerTradeNo != expected {
return nil, errors.New("支付宝退款响应的 trade_no 不匹配")
}
refundFee := strings.TrimSpace(response.RefundFee)
if refundFee == "" {
return nil, errors.New("支付宝退款响应缺少 refund_fee")
}
amount, err := parseDecimalAmount(refundFee, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝退款金额: %w", err)
}
if amount != req.Amount {
return nil, errors.New("支付宝退款响应金额不匹配")
}
currency := strings.ToUpper(strings.TrimSpace(response.RefundCurrency))
requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency))
if currency == "" {
currency = requestCurrency
} else if requestCurrency != "" && currency != requestCurrency {
return nil, errors.New("支付宝退款响应币种不匹配")
}
if currency == "" {
currency = "CNY"
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo, Amount: amount, Currency: currency, Payload: payload,
}, nil
}
func (a *alipayAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil || len(callback.Body) == 0 {
return nil, errors.New("支付宝回调为空")
}
fields := callbackFields(callback)
if err := verifyAlipay(fields, c); err != nil {
return nil, err
}
if fields["app_id"] != text(c, "app_id") {
return nil, errors.New("支付宝回调 app_id 不匹配")
}
status := "pending"
if fields["trade_status"] == "TRADE_SUCCESS" || fields["trade_status"] == "TRADE_FINISHED" {
status = "success"
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: status,
TradeNo: first(fields, "out_trade_no"), ProviderTradeNo: first(fields, "trade_no"),
Payload: payload,
}, nil
}
func newAlipayClient(c map[string]any) (*alipayClient, error) {
appID := strings.TrimSpace(text(c, "app_id"))
privateKey := strings.TrimSpace(text(c, "private_key"))
if appID == "" || privateKey == "" {
return nil, errors.New("支付宝缺少 app_id 或 private_key")
}
production := !strings.EqualFold(text(c, "environment"), "sandbox")
client, err := gopayAlipay.NewClient(appID, goPayAlipayKey(privateKey, false), production)
if err != nil {
return nil, err
}
if charset := strings.TrimSpace(text(c, "charset")); charset != "" {
client.SetCharset(charset)
}
if signType := strings.TrimSpace(text(c, "sign_type")); signType != "" {
client.SetSignType(signType)
}
if token := strings.TrimSpace(text(c, "app_auth_token")); token != "" {
client.SetAppAuthToken(token)
}
if location := strings.TrimSpace(text(c, "location")); location != "" {
client.SetLocation(location)
}
sdk := &alipayClient{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("支付宝 gateway_url 无效: %s", endpoint)
}
}
httpClient := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(8)
httpClient.SetTransport(&alipayTransport{
base: httpClient.HttpClient.Transport, gateway: sdk.gateway, response: &sdk.response,
})
client.SetHttpClient(httpClient)
return sdk, nil
}
func (c *alipayClient) finish(method string, hasResponse bool, callErr error, config map[string]any) error {
if !hasResponse {
if callErr != nil {
return callErr
}
return fmt.Errorf("支付宝 %s 响应为空", method)
}
if c.response.Len() == 0 {
return fmt.Errorf("支付宝 %s 响应原文为空", method)
}
if err := verifyAlipayResponse(c.response.Bytes(), method, config); err != nil {
return err
}
return callErr
}
func (c *alipayClient) payload(fallback any) []byte {
if c.response.Len() > 0 {
return append([]byte(nil), c.response.Bytes()...)
}
return mustMarshalAlipayPayload(fallback)
}
func (c *alipayClient) rewriteGeneratedURL(value string) (string, error) {
if c.gateway == nil {
return value, nil
}
source, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("解析支付宝支付 URL: %w", err)
}
return alipayTargetURL(source, c.gateway).String(), nil
}
func (t *alipayTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.base == nil {
t.base = http.DefaultTransport
}
target := req
if t.gateway != nil {
clone := req.Clone(req.Context())
clone.URL = alipayTargetURL(req.URL, t.gateway)
clone.Host = ""
target = clone
}
if t.response != nil {
t.response.Reset()
}
resp, err := t.base.RoundTrip(target)
if err != nil || resp == nil || resp.Body == nil || t.response == nil {
return resp, err
}
resp.Body = &alipayResponseBody{Reader: io.TeeReader(resp.Body, t.response), Closer: resp.Body}
return resp, nil
}
func alipayTargetURL(source, gateway *url.URL) *url.URL {
target := *gateway
switch {
case target.RawQuery == "":
target.RawQuery = source.RawQuery
case source.RawQuery != "":
target.RawQuery += "&" + source.RawQuery
}
return &target
}
func alipayCreateMethod(extra, config map[string]any) (string, error) {
value := firstAny(extra, "method", "pay_method", "trade_type", "channel")
if value == "" {
value = firstAny(config, "method", "pay_method", "trade_type", "channel")
}
normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
switch normalized {
case "", "create", "trade_create", "alipay_trade_create", "jsapi", "miniapp", "mini_program":
return "alipay.trade.create", nil
case "pay", "trade_pay", "alipay_trade_pay", "barcode", "barcode_pay", "micropay", "face_to_face":
return "alipay.trade.pay", nil
case "precreate", "trade_precreate", "alipay_trade_precreate", "native", "qr", "qrcode", "scan":
return "alipay.trade.precreate", nil
case "app", "app_pay", "trade_app_pay", "alipay_trade_app_pay":
return "alipay.trade.app.pay", nil
case "page", "page_pay", "trade_page_pay", "alipay_trade_page_pay", "web", "pc", "desktop":
return "alipay.trade.page.pay", nil
case "wap", "wap_pay", "trade_wap_pay", "alipay_trade_wap_pay", "h5", "mobile":
return "alipay.trade.wap.pay", nil
default:
return "", fmt.Errorf("支付宝不支持的下单方式: %s", value)
}
}
func alipayResponseValues(value any) (map[string]any, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := map[string]any{}
if err = json.Unmarshal(raw, &result); err != nil {
return nil, err
}
return result, nil
}
func mustMarshalAlipayPayload(value any) []byte {
raw, _ := json.Marshal(value)
return raw
}
func firstNonEmptyAlipay(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func populateAlipayBreakdown(result *biz.PaymentResult, response map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
// Alipay returns these values in major currency units. Keep all arithmetic
// integer-only and only mark the breakdown complete when the core fields are
// present and internally consistent.
total, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(response["total_amount"])), 100)
if err != nil {
return fmt.Errorf("解析支付宝 total_amount: %w", err)
}
result.Amount = total
payer, payerOK, err := parseAlipayDecimal(response, "buyer_pay_amount")
if err != nil {
return err
}
if !payerOK {
payer, payerOK, err = parseAlipayDecimal(response, "receipt_amount")
if err != nil {
return err
}
}
point, pointOK, err := parseAlipayDecimal(response, "point_amount")
if err != nil {
return err
}
if !payerOK {
return nil
}
if pointOK && point > payer {
return errors.New("支付宝 point_amount 大于用户实付金额")
}
result.PayerPaidAmount = payer
result.PointPaidAmount = point
result.CashPaidAmount = payer - point
result.DiscountAmount = total - payer
if result.DiscountAmount < 0 {
return errors.New("支付宝用户实付金额超过订单总额")
}
// receipt_amount is the merchant receipt amount. It is not always present;
// when it is, use it as settlement amount. Alipay's detailed voucher list
// is retained in Payload for audit and is intentionally not guessed here.
if receipt, ok, receiptErr := parseAlipayDecimal(response, "receipt_amount"); receiptErr != nil {
return receiptErr
} else if ok {
result.SettlementAmount = receipt
}
result.AmountBreakdownKnown = true
return nil
}
func parseAlipayDecimal(values map[string]any, key string) (int64, bool, error) {
value, exists := values[key]
if !exists || strings.TrimSpace(fmt.Sprint(value)) == "" || fmt.Sprint(value) == "<nil>" {
return 0, false, nil
}
amount, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(value)), 100)
return amount, true, err
}
func verifyAlipayResponse(raw []byte, method string, c map[string]any) error {
publicKeyValue := text(c, "public_key")
if publicKeyValue == "" {
return errors.New("支付宝缺少 public_key")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return err
}
responseKey := strings.ReplaceAll(method, ".", "_") + "_response"
responseRaw := envelope[responseKey]
if len(responseRaw) == 0 {
return errors.New("支付宝响应缺少 " + responseKey)
}
var sign string
if err := json.Unmarshal(envelope["sign"], &sign); err != nil || sign == "" {
return errors.New("支付宝响应缺少 sign")
}
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicKeyValue, true), string(responseRaw), sign)
if err != nil {
return err
}
if !ok {
return errors.New("支付宝响应签名校验失败")
}
return nil
}
func alipaySignContent(v url.Values) string {
keys := make([]string, 0, len(v))
for key := range v {
if key != "sign" && v.Get(key) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+v.Get(key))
}
return strings.Join(parts, "&")
}
func verifyAlipay(fields map[string]string, c map[string]any) error {
if text(c, "app_id") == "" || text(c, "public_key") == "" || fields["sign"] == "" {
return errors.New("支付宝回调验签配置或 sign 缺失")
}
values := url.Values{}
for key, value := range fields {
values.Set(key, value)
}
body, err := gopayAlipay.ParseNotifyByURLValues(values)
if err != nil {
return err
}
ok, err := gopayAlipay.VerifySign(goPayAlipayKey(text(c, "public_key"), true), cloneGoPayBodyMap(body))
if err != nil {
return err
}
if !ok {
return errors.New("支付宝回调签名校验失败")
}
return nil
}
func mergeMap(dst map[string]any, src map[string]any, protected ...string) {
protectedKeys := make(map[string]struct{}, len(protected))
for _, key := range protected {
protectedKeys[key] = struct{}{}
}
for key, value := range src {
if _, exists := protectedKeys[key]; exists {
continue
}
dst[key] = value
}
}