kra-new/internal/integration/payment/wechat_v2.go

590 lines
20 KiB
Go

package payment
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/pkg/xhttp"
gopayWechat "github.com/go-pay/gopay/wechat"
)
type wechatV2Adapter struct{}
type wechatV2EndpointTransport struct {
endpoint *url.URL
next http.RoundTripper
}
func (t *wechatV2EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
clone := request.Clone(request.Context())
target := *t.endpoint
clone.URL = &target
clone.Host = target.Host
clone.RequestURI = ""
return t.next.RoundTrip(clone)
}
func wechatV2SignType(value string) string {
if strings.EqualFold(strings.TrimSpace(value), gopayWechat.SignType_HMAC_SHA256) {
return gopayWechat.SignType_HMAC_SHA256
}
return gopayWechat.SignType_MD5
}
func wechatV2Sign(values map[string]string, key, signType string) string {
body := toGoPayBodyMap(values)
body.Remove("sign")
return gopayWechat.GetReleaseSign(key, wechatV2SignType(signType), body)
}
func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("微信支付 v2 下单请求为空")
}
client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url"))
if err != nil {
return nil, err
}
tradeType, err := wechatV2CreateMethod(req.Extra, c)
if err != nil {
return nil, err
}
values := map[string]string{
"appid": client.AppId,
"mch_id": client.MchId,
"nonce_str": nonce(),
"body": req.Subject,
"out_trade_no": req.TradeNo,
"total_fee": fmt.Sprint(req.Amount),
"fee_type": strings.ToUpper(req.Currency),
"spbill_create_ip": req.ClientIP,
"notify_url": req.NotifyURL,
"trade_type": tradeType,
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
if values["spbill_create_ip"] == "" {
values["spbill_create_ip"] = firstAny(c, "client_ip", "spbill_create_ip")
}
if values["spbill_create_ip"] == "" {
values["spbill_create_ip"] = "127.0.0.1"
}
if tradeType == "MICROPAY" {
return a.createMicropay(ctx, client, req, c, values)
}
mergeWechatV2Extras(values, req.Extra)
values["trade_type"] = tradeType
rsp, err := client.UnifiedOrder(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
resultValues, err := wechatV2ResponseValues(rsp)
if err != nil {
return nil, err
}
if err = validateWechatV2Response(resultValues); err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: req.TradeNo,
Payload: wechatV2Payload(resultValues),
}, nil
}
func (a *wechatV2Adapter) createMicropay(ctx context.Context, client *gopayWechat.Client, req *biz.PaymentRequest, c map[string]any, values map[string]string) (*biz.PaymentResult, error) {
authCode := firstAny(req.Extra, "auth_code", "authcode", "barcode", "pay_code", "payment_code")
if authCode == "" {
authCode = firstAny(c, "auth_code", "authcode", "barcode", "pay_code", "payment_code")
}
if strings.TrimSpace(authCode) == "" {
return nil, errors.New("微信支付 v2 付款码支付缺少 auth_code")
}
delete(values, "trade_type")
delete(values, "notify_url")
mergeWechatV2Extras(values, req.Extra)
values["auth_code"] = strings.TrimSpace(authCode)
rsp, err := client.Micropay(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("微信支付 v2 付款码支付响应为空")
}
resultValues, err := wechatV2ResponseValues(rsp)
if err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
if resultValues["return_code"] != "SUCCESS" {
return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "return_msg", "err_code_des", "err_code"))
}
if resultValues["result_code"] != "SUCCESS" && !strings.EqualFold(resultValues["err_code"], "USERPAYING") {
return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "err_code_des", "return_msg", "err_code"))
}
if resultValues["out_trade_no"] == "" {
return nil, errors.New("微信支付 v2 付款码支付响应缺少 out_trade_no")
}
if resultValues["out_trade_no"] != req.TradeNo {
return nil, errors.New("微信支付 v2 付款码支付响应的 out_trade_no 不匹配")
}
if strings.EqualFold(resultValues["err_code"], "USERPAYING") {
return &biz.PaymentResult{Provider: biz.PaymentWechatV2, Status: "pending", TradeNo: req.TradeNo, Payload: wechatV2Payload(resultValues)}, nil
}
providerTradeNo := strings.TrimSpace(resultValues["transaction_id"])
if providerTradeNo == "" {
return nil, errors.New("微信支付 v2 付款码支付响应缺少 transaction_id")
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2, Status: "success", TradeNo: req.TradeNo,
ProviderTradeNo: providerTradeNo, Payload: wechatV2Payload(resultValues),
}
amount := strings.TrimSpace(resultValues["total_fee"])
if amount == "" {
return nil, errors.New("微信支付 v2 付款码支付响应缺少 total_fee")
}
result.Amount, err = parseIntegerAmount(amount)
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 付款码金额: %w", err)
}
if result.Amount != req.Amount {
return nil, errors.New("微信支付 v2 付款码支付响应金额不匹配")
}
result.Currency = strings.ToUpper(resultValues["fee_type"])
requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency))
if result.Currency == "" {
result.Currency = requestCurrency
} else if requestCurrency != "" && result.Currency != requestCurrency {
return nil, errors.New("微信支付 v2 付款码支付响应币种不匹配")
}
if err = populateWechatV2Breakdown(result, resultValues); err != nil {
return nil, err
}
return result, nil
}
func wechatV2CreateMethod(extra, config map[string]any) (string, error) {
value := firstAny(extra, "trade_type", "pay_type", "method", "pay_method", "channel")
if value == "" {
value = firstAny(config, "trade_type", "pay_type", "method", "pay_method", "channel")
}
normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
switch normalized {
case "", "jsapi", "js_api", "mini", "miniapp", "mini_program", "miniprogram", "applet":
return gopayWechat.TradeType_JsApi, nil
case "app", "app_pay":
return gopayWechat.TradeType_App, nil
case "native", "qr", "qrcode":
return gopayWechat.TradeType_Native, nil
case "h5", "h5_pay", "mweb", "wap", "wap_pay":
return gopayWechat.TradeType_H5, nil
case "micropay", "micro_pay", "barcode", "barcode_pay", "pay_code", "payment_code":
return "MICROPAY", nil
default:
return "", fmt.Errorf("微信支付 v2 不支持的下单方式: %s", value)
}
}
func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
tradeNo = strings.TrimSpace(tradeNo)
if tradeNo == "" {
return nil, errors.New("微信支付 v2 查单缺少 out_trade_no")
}
client, err := newWechatV2Client(c, firstAny(c, "query_url", "order_query_url"))
if err != nil {
return nil, err
}
body := gopay.BodyMap{
"nonce_str": nonce(),
"out_trade_no": tradeNo,
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
_, response, err := client.QueryOrder(ctx, body)
if err != nil {
return nil, err
}
values := fromGoPayBodyMap(response)
if err = validateWechatV2Response(values); err != nil {
return nil, err
}
if err = verifyWechatV2Response(values, client.ApiKey, body.GetString("sign_type")); err != nil {
return nil, err
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: normalizePaymentStatus(values["trade_state"], "pending"),
TradeNo: first(values, "out_trade_no"),
Payload: wechatV2Payload(values),
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
} else if result.TradeNo != tradeNo {
return nil, errors.New("微信支付 v2 查询响应的 out_trade_no 不匹配")
}
if result.Status != "success" {
return result, nil
}
result.ProviderTradeNo = values["transaction_id"]
if result.ProviderTradeNo == "" {
return nil, errors.New("微信支付 v2 查询响应缺少 transaction_id")
}
result.Amount, err = parseIntegerAmount(values["total_fee"])
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 订单金额: %w", err)
}
result.Currency = strings.ToUpper(values["fee_type"])
if result.Currency == "" {
result.Currency = "CNY"
}
if err = populateWechatV2Breakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("微信支付 v2 退款请求为空")
}
tradeNo := strings.TrimSpace(req.TradeNo)
refundNo := strings.TrimSpace(req.RefundNo)
if tradeNo == "" || refundNo == "" {
return nil, errors.New("微信支付 v2 退款缺少 out_trade_no 或 out_refund_no")
}
if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount {
return nil, errors.New("微信支付 v2 退款金额无效")
}
client, err := newWechatV2Client(c, firstAny(c, "refund_url", "refund_apply_url"))
if err != nil {
return nil, err
}
values := map[string]string{
"appid": client.AppId,
"mch_id": client.MchId,
"nonce_str": nonce(),
"out_trade_no": tradeNo,
"out_refund_no": refundNo,
"total_fee": fmt.Sprint(req.TotalAmount),
"refund_fee": fmt.Sprint(req.Amount),
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
if currency != "" {
values["refund_fee_type"] = currency
}
rsp, response, err := client.Refund(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
resultValues := fromGoPayBodyMap(response)
if rsp == nil {
return nil, errors.New("微信支付 v2 退款响应为空")
}
if err = validateWechatV2Response(resultValues); err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
if err = validateWechatV2RefundIdentity(tradeNo, refundNo, rsp); err != nil {
return nil, err
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: tradeNo,
ProviderTradeNo: strings.TrimSpace(rsp.RefundId),
Currency: currency,
Payload: wechatV2Payload(resultValues),
}
if rsp.RefundFee != "" {
result.Amount, err = parseIntegerAmount(rsp.RefundFee)
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 退款金额: %w", err)
}
if result.Amount != req.Amount {
return nil, errors.New("微信支付 v2 退款响应金额不匹配")
}
} else {
result.Amount = req.Amount
}
if responseCurrency := strings.ToUpper(strings.TrimSpace(rsp.FeeType)); responseCurrency != "" {
if currency != "" && responseCurrency != currency {
return nil, errors.New("微信支付 v2 退款响应币种不匹配")
}
result.Currency = responseCurrency
}
return result, nil
}
func validateWechatV2RefundIdentity(tradeNo, refundNo string, rsp *gopayWechat.RefundResponse) error {
if rsp == nil {
return errors.New("微信支付 v2 退款响应为空")
}
if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != tradeNo {
return errors.New("微信支付 v2 退款响应的 out_trade_no 不匹配")
}
if returnedRefundNo := strings.TrimSpace(rsp.OutRefundNo); returnedRefundNo == "" {
return errors.New("微信支付 v2 退款响应缺少 out_refund_no")
} else if returnedRefundNo != refundNo {
return errors.New("微信支付 v2 退款响应的 out_refund_no 不匹配")
}
if strings.TrimSpace(rsp.RefundId) == "" {
return errors.New("微信支付 v2 退款响应缺少 refund_id")
}
return nil
}
func (a *wechatV2Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil || len(callback.Body) == 0 {
return nil, errors.New("微信支付 v2 回调为空")
}
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
body, err := gopayWechat.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
values := fromGoPayBodyMap(body)
if err = verifyWechatV2(values, firstAny(c, "mch_key", "api_key", "merchant_key")); err != nil {
return nil, err
}
if values["appid"] != firstAny(c, "app_id", "appid") || values["mch_id"] != firstAny(c, "merchant_id", "mch_id") {
return nil, errors.New("微信支付 v2 回调商户配置不匹配")
}
status := "pending"
if values["return_code"] == "SUCCESS" && values["result_code"] == "SUCCESS" && values["trade_state"] != "REFUND" {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentWechatV2, Status: status, TradeNo: first(values, "out_trade_no"), ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}
func newWechatV2Client(c map[string]any, endpoint string) (*gopayWechat.Client, error) {
appID := firstAny(c, "app_id", "appid")
merchantID := firstAny(c, "merchant_id", "mch_id")
mchKey := firstAny(c, "mch_key", "api_key", "merchant_key")
if appID == "" || merchantID == "" || mchKey == "" {
return nil, errors.New("微信支付 v2 缺少 app_id、merchant_id 或 mch_key")
}
// The previous adapter sent the configured amount unchanged, including to
// sandbox or test endpoints. GoPay's sandbox mode rewrites total_fee, so use
// its production signing flow and redirect the high-level call when a custom
// endpoint is configured.
client := gopayWechat.NewClient(appID, merchantID, mchKey, true)
regular := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(4)
if err := setWechatV2Endpoint(regular, endpoint); err != nil {
return nil, err
}
client.SetHttpClient(regular)
tlsHTTPClient, err := paymentHTTPClient(c)
if err != nil {
return nil, err
}
tlsClient := xhttp.NewClient().SetBodySize(4)
tlsClient.HttpClient = tlsHTTPClient
if err = setWechatV2Endpoint(tlsClient, endpoint); err != nil {
return nil, err
}
client.SetTLSHttpClient(tlsClient)
return client, nil
}
func setWechatV2Endpoint(client *xhttp.Client, endpoint string) error {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return nil
}
target, err := url.Parse(endpoint)
if err != nil || target.Scheme == "" || target.Host == "" {
return fmt.Errorf("微信支付 v2 接口地址无效: %s", endpoint)
}
next := client.HttpClient.Transport
if next == nil {
next = http.DefaultTransport
}
client.SetTransport(&wechatV2EndpointTransport{endpoint: target, next: next})
return nil
}
func wechatV2ResponseValues(value any) (map[string]string, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var body gopay.BodyMap
if err = json.Unmarshal(raw, &body); err != nil {
return nil, err
}
return fromGoPayBodyMap(body), nil
}
func validateWechatV2Response(values map[string]string) error {
if strings.EqualFold(strings.TrimSpace(values["return_code"]), "SUCCESS") &&
strings.EqualFold(strings.TrimSpace(values["result_code"]), "SUCCESS") {
return nil
}
message := first(values, "err_code_des", "return_msg", "err_code")
if message == "" {
message = "未知错误"
}
return fmt.Errorf("微信支付 v2 错误: %s", message)
}
func verifyWechatV2Response(values map[string]string, key, fallbackSignType string) error {
if values["sign"] == "" {
return errors.New("微信支付 v2 响应缺少签名")
}
signType := values["sign_type"]
if signType == "" {
signType = fallbackSignType
}
return verifyWechatV2WithSignType(values, key, signType)
}
func wechatV2Payload(values map[string]string) []byte {
return []byte(gopayWechat.GenerateXml(toGoPayBodyMap(values)))
}
func populateWechatV2Breakdown(result *biz.PaymentResult, values map[string]string) error {
if result == nil || result.Status != "success" {
return nil
}
// WeChat v2 reports total_fee/cash_fee/coupon_fee in the smallest currency
// unit. cash_fee is the payer cash amount; coupon_fee is the total coupon
// discount. v2 does not reliably identify coupon funding parties, so those
// fields remain zero rather than being misclassified.
cash, cashOK, err := parseWechatInteger(values, "cash_fee")
if err != nil {
return fmt.Errorf("解析微信支付 v2 cash_fee: %w", err)
}
if !cashOK {
return nil
}
coupon, couponOK, err := parseWechatInteger(values, "coupon_fee")
if err != nil {
return fmt.Errorf("解析微信支付 v2 coupon_fee: %w", err)
}
if !couponOK {
coupon = result.Amount - cash
}
if cash < 0 || coupon < 0 || cash > result.Amount || cash+coupon != result.Amount {
return errors.New("微信支付 v2 总额、现金实付和优惠金额不守恒")
}
result.PayerPaidAmount = cash
result.CashPaidAmount = cash
result.DiscountAmount = coupon
if settlement, ok, settlementErr := parseWechatInteger(values, "settlement_total_fee"); settlementErr != nil {
return fmt.Errorf("解析微信支付 v2 settlement_total_fee: %w", settlementErr)
} else if ok {
result.SettlementAmount = settlement
}
// coupon_fee_n is a per-coupon detail in v2. Its aggregate still has no
// authoritative merchant/provider funding split, so preserve the raw list
// in Payload instead of assigning it to either party.
_ = couponOK
result.AmountBreakdownKnown = true
return nil
}
func parseWechatInteger(values map[string]string, key string) (int64, bool, error) {
value, exists := values[key]
if !exists || strings.TrimSpace(value) == "" {
return 0, false, nil
}
amount, err := parseIntegerAmount(value)
return amount, true, err
}
func mergeWechatV2Extras(dst map[string]string, extra map[string]any) {
protected := map[string]struct{}{
"appid": {}, "mch_id": {}, "nonce_str": {}, "body": {}, "out_trade_no": {},
"total_fee": {}, "fee_type": {}, "spbill_create_ip": {}, "notify_url": {}, "trade_type": {},
"auth_code": {}, "sign": {},
}
for key, value := range stringMap(extra) {
if _, exists := protected[key]; exists {
continue
}
dst[key] = value
}
}
func verifyWechatV2(values map[string]string, key string) error {
return verifyWechatV2WithSignType(values, key, values["sign_type"])
}
func verifyWechatV2WithSignType(values map[string]string, key, signType string) error {
if key == "" {
return errors.New("微信支付 v2 未配置 mch_key")
}
ok, err := gopayWechat.VerifySign(key, wechatV2SignType(signType), cloneGoPayBodyMap(toGoPayBodyMap(values)))
if err != nil {
return err
}
if !ok {
return errors.New("微信支付 v2 回调签名校验失败")
}
return nil
}
func paymentHTTPClient(c map[string]any) (*http.Client, error) {
certPEM := firstAny(c, "client_cert", "cert_pem", "apiclient_cert")
keyPEM := firstAny(c, "client_key", "key_pem", "apiclient_key")
if certPEM == "" && keyPEM == "" {
return &http.Client{Timeout: 20 * time.Second}, nil
}
if certPEM == "" || keyPEM == "" {
return nil, errors.New("微信支付 v2 client_cert 和 client_key 必须同时配置")
}
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, err
}
var pool *x509.CertPool
if ca := firstAny(c, "ca_cert", "root_cert"); ca != "" {
pool = x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca)) {
return nil, errors.New("微信支付 v2 ca_cert 格式错误")
}
}
return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil
}
func nonce() string { return fmt.Sprintf("%d", time.Now().UnixNano()) }
func stringOr(extra map[string]any, key, fallback string) string {
if value, ok := extra[key].(string); ok {
return value
}
return fallback
}
func stringMap(values map[string]any) map[string]string {
result := map[string]string{}
for key, value := range values {
if text, ok := value.(string); ok {
result[key] = text
}
}
return result
}