支付相关

This commit is contained in:
Yvan
2026-08-19 18:06:03 +08:00
parent 1ef8ecb7b2
commit e54cbd1207
56 changed files with 8453 additions and 42 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"kra/internal/conf"
)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewPaymentRepo, NewPaymentOrderRepo, NewCache, NewFileStorage)
type Data struct {
initMu sync.Mutex
+4
View File
@@ -25,9 +25,13 @@ func migrateAll(db *gorm.DB) error {
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
&paymentOrderPO{},
); err != nil {
return err
}
if err := ensurePaymentIntegrationConfigs(db); err != nil {
return err
}
if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
return err
}
+410
View File
@@ -0,0 +1,410 @@
package data
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"kra/internal/biz"
datapayment "kra/internal/data/payment"
"gorm.io/gorm"
)
type paymentRepo struct{ data *Data }
func NewPaymentRepo(data *Data) biz.PaymentRepo { return &paymentRepo{data: data} }
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, provider := range biz.SupportedPaymentProviders {
var count int64
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: "{}"}).Error; err != nil {
return err
}
}
}
return nil
}
func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationConfigPO, map[string]any, error) {
var row integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, biz.ErrPaymentProviderNotFound
}
return nil, nil, err
}
if !row.Enabled {
return nil, nil, fmt.Errorf("支付渠道 %s 未启用", provider)
}
values := map[string]any{}
if err := json.Unmarshal([]byte(row.Config), &values); err != nil {
return nil, nil, fmt.Errorf("支付配置格式错误: %w", err)
}
return &row, values, nil
}
func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
var rows []integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ?", integrationKindPayment).Order("provider ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*biz.PaymentConfig, 0, len(rows))
for _, row := range rows {
values := map[string]any{}
if json.Unmarshal([]byte(row.Config), &values) == nil {
maskPaymentSecrets(values)
masked, _ := json.Marshal(values)
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: masked})
continue
}
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: json.RawMessage("{}")})
}
return out, nil
}
func (r *paymentRepo) SaveConfig(ctx context.Context, config *biz.PaymentConfig) error {
if config == nil || !contains(biz.SupportedPaymentProviders, config.Provider) {
return errors.New("不支持的支付渠道")
}
if !json.Valid(config.Values) {
return errors.New("支付配置必须是 JSON")
}
values := map[string]any{}
if err := json.Unmarshal(config.Values, &values); err != nil {
return errors.New("支付配置必须是 JSON 对象")
}
db := r.data.gormDB.WithContext(ctx)
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, config.Provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
}
if err != nil {
return err
}
old := map[string]any{}
_ = json.Unmarshal([]byte(row.Config), &old)
mergePaymentSecrets(values, old)
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error
}
func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment.Adapter, map[string]any, error) {
_, values, err := r.row(ctx, provider)
if err != nil {
return nil, nil, err
}
adapter, err := datapayment.New(provider)
return adapter, values, err
}
func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
effective := *req
effective.NotifyURL = strings.TrimSpace(text(c, "notify_url"))
effective.ReturnURL = strings.TrimSpace(text(c, "return_url"))
if req.Provider != biz.PaymentApple && effective.NotifyURL == "" {
return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider)
}
return a.Create(ctx, &effective, c)
}
func (r *paymentRepo) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, provider)
if err != nil {
return nil, err
}
return a.Query(ctx, tradeNo, c)
}
func (r *paymentRepo) Refund(ctx context.Context, req *biz.PaymentRefundRequest) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
return a.Refund(ctx, req, c)
}
func (r *paymentRepo) HandleCallback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("支付回调为空")
}
a, c, err := r.adapter(ctx, callback.Provider)
if err != nil {
return nil, err
}
result, err := a.Callback(ctx, callback, c)
if err != nil {
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
if result == nil {
err = errors.New("支付回调解析结果为空")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
result.SuccessAck = paymentCallbackAck(callback.Provider, c, true)
result.FailureAck = paymentCallbackAck(callback.Provider, c, false)
if result.Provider != callback.Provider {
err = errors.New("支付回调渠道不匹配")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: result.FailureAck}
}
fields := callbackFields(callback)
eventID := first(fields, "event_id", "notify_id", "notificationUUID", "id")
if eventID == "" {
hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...))
eventID = hex.EncodeToString(hash[:])
}
result.EventID = eventID
return result, nil
}
func paymentCallbackAck(provider string, values map[string]any, success bool) biz.PaymentCallbackAck {
ack := biz.DefaultPaymentCallbackAck(provider, success)
prefix := "callback_success_"
if !success {
prefix = "callback_failure_"
}
if configured := strings.TrimSpace(text(values, prefix+"status")); configured != "" {
if status, err := strconv.Atoi(configured); err == nil && status >= 200 && status <= 599 {
ack.StatusCode = status
}
}
if contentType := strings.TrimSpace(text(values, prefix+"content_type")); contentType != "" {
ack.ContentType = contentType
}
if body, exists := values[prefix+"body"]; exists {
ack.Body = []byte(fmt.Sprint(body))
}
return ack
}
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if text, ok := value.(string); ok {
fields[key] = text
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
var paymentSecretKeys = map[string]bool{
"key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true,
"client_key": true, "client_secret": true, "api_key": true, "api_v3_key": true,
"mch_key": true, "merchant_key": true, "signing_secret": true, "certificate": true,
"cert": true, "root_cert": true, "platform_cert": true, "public_cert": true,
"public_key": true, "client_cert": true, "app_secret": true, "appsecret": true,
"access_token": true, "app_auth_token": true, "credential_code": true, "cus_id": true, "p12": true,
"pkcs12": true, "merchant_private_key": true, "platform_key": true,
}
func maskPaymentSecrets(values map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text != "" {
values[key] = "******"
}
continue
}
if nested, ok := value.(map[string]any); ok {
maskPaymentSecrets(nested)
}
}
}
func mergePaymentSecrets(values, old map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text == "******" {
if prior, exists := old[key]; exists {
values[key] = prior
}
}
continue
}
if nested, ok := value.(map[string]any); ok {
if prior, ok := old[key].(map[string]any); ok {
mergePaymentSecrets(nested, prior)
}
}
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func validatePaymentConfig(provider string, values map[string]any) error {
required := []string{}
switch provider {
case biz.PaymentAlipay:
required = []string{"app_id", "private_key", "public_key"}
case biz.PaymentWechatV2:
required = []string{"app_id", "merchant_id", "mch_key"}
case biz.PaymentWechatV3:
required = []string{"app_id", "merchant_id", "serial_no", "private_key", "api_v3_key", "platform_cert"}
case biz.PaymentApple:
required = []string{"issuer_id", "key_id", "bundle_id", "private_key"}
if configuredInt64(values, "price_divisor", 0) <= 0 {
if _, ok := values["price_divisors"].(map[string]any); !ok {
return fmt.Errorf("%s 缺少配置字段 price_divisor 或 price_divisors", provider)
}
}
case biz.PaymentDouyin:
required = []string{"merchant_id", "serial_no", "api_key", "private_key", "platform_cert", "platform_serial_no"}
case biz.PaymentQQ:
if firstAny(values, "mch_id", "merchant_id") == "" {
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
}
required = []string{"api_key"}
case biz.PaymentAllinPay:
required = []string{"cus_id", "app_id", "private_key", "public_key"}
case biz.PaymentLakala:
required = []string{"partner_code", "credential_code"}
case biz.PaymentPayPal:
required = []string{"client_id", "client_secret"}
case biz.PaymentSaobei:
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
default:
required = []string{
"protocol_version", "create_url", "query_url", "query_status_field", "query_success_values",
"query_trade_no_field", "query_provider_trade_no_field", "query_amount_field",
"query_currency_field", "query_amount_scale", "callback_status_field",
"callback_success_values", "callback_trade_no_field", "callback_provider_trade_no_field",
}
if firstAny(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
return fmt.Errorf("%s 缺少签名密钥", provider)
}
if err := validateConfiguredBreakdownFields(values); err != nil {
return fmt.Errorf("%s 金额拆分配置无效: %w", provider, err)
}
}
for _, key := range required {
if text(values, key) == "" {
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
}
}
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
}
if environment := strings.ToLower(strings.TrimSpace(text(values, "environment"))); environment != "" {
switch provider {
case biz.PaymentQQ, biz.PaymentDouyin, biz.PaymentLakala:
if environment != "production" && environment != "prod" {
return fmt.Errorf("%s 仅支持 production 环境", provider)
}
case biz.PaymentPayPal:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 必须是 production 或 sandbox", provider)
}
default:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 配置无效", provider)
}
}
}
if provider == biz.PaymentQQ {
signType := strings.ToUpper(strings.TrimSpace(text(values, "sign_type")))
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
}
}
if err := validatePaymentCallbackAckConfig(values); err != nil {
return fmt.Errorf("%s 回调响应配置无效: %w", provider, err)
}
return nil
}
func validateConfiguredBreakdownFields(values map[string]any) error {
for _, key := range []string{
"query_payer_paid_amount_scale", "query_cash_paid_amount_scale", "query_point_paid_amount_scale",
"query_discount_amount_scale", "query_provider_discount_amount_scale", "query_merchant_discount_amount_scale",
"query_settlement_amount_scale",
} {
if _, exists := values[key]; exists && configuredInt64(values, key, 0) <= 0 {
return fmt.Errorf("%s 必须是正整数", key)
}
}
for _, key := range []string{
"query_payer_paid_amount_field", "query_cash_paid_amount_field", "query_point_paid_amount_field",
"query_discount_amount_field", "query_provider_discount_amount_field", "query_merchant_discount_amount_field",
"query_settlement_amount_field", "query_payer_currency_field",
} {
if value, exists := values[key]; exists && strings.TrimSpace(fmt.Sprint(value)) == "" {
return fmt.Errorf("%s 不能为空", key)
}
}
return nil
}
func validatePaymentCallbackAckConfig(values map[string]any) error {
for _, prefix := range []string{"callback_success_", "callback_failure_"} {
if value := strings.TrimSpace(text(values, prefix+"status")); value != "" {
status, err := strconv.Atoi(value)
if err != nil || status < 200 || status > 599 {
return fmt.Errorf("%sstatus 必须是 200-599", prefix)
}
}
if contentType := text(values, prefix+"content_type"); strings.ContainsAny(contentType, "\r\n") || len(contentType) > 200 {
return fmt.Errorf("%scontent_type 非法", prefix)
}
if body, exists := values[prefix+"body"]; exists && len(fmt.Sprint(body)) > 64<<10 {
return fmt.Errorf("%sbody 超过 64KiB", prefix)
}
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package payment
import (
"context"
"fmt"
"kra/internal/biz"
)
// Adapter is the provider boundary used by the payment repository. Provider
// SDK types stay in this package and are normalized to biz.PaymentResult.
type Adapter interface {
Create(context.Context, *biz.PaymentRequest, map[string]any) (*biz.PaymentResult, error)
Query(context.Context, string, map[string]any) (*biz.PaymentResult, error)
Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error)
Callback(context.Context, *biz.PaymentCallback, map[string]any) (*biz.PaymentResult, error)
}
// New constructs the SDK-backed adapter for a configured provider.
func New(provider string) (Adapter, error) {
switch provider {
case biz.PaymentAlipay:
return &alipayAdapter{}, nil
case biz.PaymentWechatV2:
return &wechatV2Adapter{}, nil
case biz.PaymentWechatV3:
return &wechatV3Adapter{}, nil
case biz.PaymentApple:
return &appleAdapter{}, nil
case biz.PaymentDouyin:
return &douyinAdapter{}, nil
case biz.PaymentQQ:
return &qqAdapter{}, nil
case biz.PaymentAllinPay:
return &allinpayAdapter{}, nil
case biz.PaymentLakala:
return &lakalaAdapter{}, nil
case biz.PaymentPayPal:
return &paypalAdapter{}, nil
case biz.PaymentSaobei:
return &saobeiAdapter{}, nil
case biz.PaymentChinaums:
return newVendorAdapter(provider, vendorChinaums), nil
case biz.PaymentSFT:
return newVendorAdapter(provider, vendorSFT), nil
case biz.PaymentSuperPay:
return newVendorAdapter(provider, vendorSupperPay), nil
case biz.PaymentWechatGame:
return newVendorAdapter(provider, vendorWechatGame), nil
case biz.PaymentDouyinGame:
return newVendorAdapter(provider, vendorDouyinGame), nil
default:
return nil, fmt.Errorf("支付渠道 %s 没有适配器", provider)
}
}
+21
View File
@@ -0,0 +1,21 @@
package payment
import (
"testing"
"kra/internal/biz"
)
func TestEverySupportedProviderHasAdapter(t *testing.T) {
for _, provider := range biz.SupportedPaymentProviders {
t.Run(provider, func(t *testing.T) {
adapter, err := New(provider)
if err != nil {
t.Fatalf("New(%q): %v", provider, err)
}
if adapter == nil {
t.Fatalf("New(%q) returned a nil adapter", provider)
}
})
}
}
+551
View File
@@ -0,0 +1,551 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"kra/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.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
if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != 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: tradeNo,
ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp),
}, nil
}
func (a *alipayAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
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 "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
}
}
+139
View File
@@ -0,0 +1,139 @@
package payment
import (
"context"
"errors"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/allinpay"
)
type allinpayAdapter struct{}
func (a *allinpayAdapter) client(c map[string]any) (*allinpay.Client, error) {
client, err := allinpay.NewClient(text(c, "cus_id"), text(c, "app_id"), text(c, "private_key"), text(c, "public_key"), !strings.EqualFold(text(c, "environment"), "sandbox"))
if err != nil {
return nil, err
}
if org := text(c, "org_id"); org != "" {
client.SetOrgId(org)
}
return client, nil
}
func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"reqsn": req.TradeNo, "trxamt": req.Amount, "body": req.Subject}
mergeGoPayExtras(bm, req.Extra, "reqsn", "trxamt", "body", "method", "pay_method", "trade_type")
method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type"))
if method == "scan" || method == "micropay" || method == "barcode" {
bm.Set("authcode", firstAny(req.Extra, "authcode", "auth_code"))
bm.SetBodyMap("terminfo", func(info gopay.BodyMap) {
info.Set("devicetype", firstAnyOr(req.Extra, "10", "device_type", "devicetype"))
info.Set("termno", firstAnyOr(req.Extra, "00000001", "termno", "terminal_no"))
})
rsp, callErr := client.ScanPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("通联支付扫码下单响应为空")
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, Payload: mustJSON(rsp)}, nil
}
if method == "native" || method == "qrcode" {
bm.Set("expiretime", firstAny(req.Extra, "expiretime", "expire_time"))
rsp, callErr := client.NativePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("通联支付下单响应为空")
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.ReqSn, QueryID: rsp.PayInfo, Payload: mustJSON(rsp)}, nil
}
payType := firstAny(req.Extra, "paytype", "pay_type")
if payType == "" {
payType = firstAny(c, "paytype", "pay_type")
}
if payType == "" {
payType = allinpay.PayTypeWXJS
}
bm.Set("paytype", payType)
rsp, err := client.Pay(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付下单响应为空")
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, QueryID: rsp.PayInfo, Payload: mustJSON(rsp)}, nil
}
func (a *allinpayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
orderType := firstAny(c, "query_order_type", "order_type")
if orderType == "" {
orderType = allinpay.OrderTypeReqSN
}
rsp, err := client.Query(ctx, orderType, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付查单响应为空")
}
result := &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: rsp.Reqsn, ProviderTradeNo: rsp.Trxid, Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if rsp.TrxAmt != "" {
result.Amount, err = parseIntegerAmount(rsp.TrxAmt)
if err != nil {
if scale := configuredInt64(c, "amount_scale", 100); scale != 1 {
result.Amount, err = parseDecimalAmount(rsp.TrxAmt, scale)
}
}
}
if result.Currency == "" {
result.Currency = "CNY"
}
return result, err
}
func (a *allinpayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("通联支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"reqsn": req.RefundNo, "trxamt": req.Amount, "oldreqsn": req.TradeNo, "remark": firstAny(c, "refund_remark", "remark")}
mergeGoPayConfigExtras(bm, c, "refund_extra", "reqsn", "trxamt", "oldreqsn", "remark")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付退款响应为空")
}
refundAmount, _ := parseIntegerAmount(rsp.Fee)
if refundAmount == 0 {
refundAmount = req.Amount
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeRefundState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, Amount: refundAmount, Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)}, nil
}
func (a *allinpayAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("通联支付回调没有可复用的 GoPay 验签器,请改用主动查单")
}
+213
View File
@@ -0,0 +1,213 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
gopayApple "github.com/go-pay/gopay/apple"
"github.com/google/uuid"
)
type appleAdapter struct{}
func (a *appleAdapter) Create(_ context.Context, req *biz.PaymentRequest, _ map[string]any) (*biz.PaymentResult, error) {
productID := ""
if req.Extra != nil {
productID, _ = req.Extra["product_id"].(string)
}
if productID == "" {
return nil, errors.New("Apple 内购缺少 extra.product_id")
}
if _, err := uuid.Parse(req.TradeNo); err != nil {
return nil, errors.New("Apple 内购 tradeNo 必须是用于 appAccountToken 的 UUID")
}
payload, _ := json.Marshal(map[string]any{"product_id": productID, "app_account_token": req.TradeNo, "client_only": true, "trade_no": req.TradeNo})
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil
}
func (a *appleAdapter) Query(ctx context.Context, transactionID string, c map[string]any) (*biz.PaymentResult, error) {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
return nil, errors.New("Apple 查询缺少 transactionId")
}
client, err := gopayApple.NewClient(
text(c, "issuer_id"),
text(c, "bundle_id"),
text(c, "key_id"),
text(c, "private_key"),
!strings.EqualFold(text(c, "environment"), "sandbox"),
)
if err != nil {
return nil, err
}
response, err := client.GetTransactionInfo(ctx, transactionID)
if err != nil {
return nil, err
}
if response == nil || response.SignedTransactionInfo == "" {
return nil, errors.New("Apple 查询响应缺少 signedTransactionInfo")
}
transaction, err := response.DecodeSignedTransaction()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 交易 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
result, err := applePaymentResult(transactionID, claims, payload, c)
if err != nil {
return nil, err
}
if result.ProviderTradeNo == "" {
return nil, errors.New("Apple 交易缺少 transactionId")
}
return result, nil
}
func (a *appleAdapter) Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("Apple 内购退款由 App Store 管理,服务端不提供主动退款接口")
}
func (a *appleAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("Apple 回调为空")
}
var body struct {
SignedPayload string `json:"signedPayload"`
}
if err := json.Unmarshal(callback.Body, &body); err != nil {
return nil, err
}
if body.SignedPayload == "" {
return nil, errors.New("Apple 回调缺少 signedPayload")
}
notification, err := gopayApple.DecodeSignedPayload(body.SignedPayload)
if err != nil {
return nil, err
}
if notificationID := strings.TrimSpace(notification.NotificationUUID); notificationID != "" {
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notificationID
}
if notification.Data == nil {
return nil, errors.New("Apple 回调缺少 data")
}
transaction, err := notification.DecodeTransactionInfo()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 回调 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
transactionID := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if tradeNo == "" || tradeNo == "<nil>" || transactionID == "" || transactionID == "<nil>" {
return nil, errors.New("Apple 回调缺少 appAccountToken 或 transactionId")
}
if _, parseErr := uuid.Parse(tradeNo); parseErr != nil {
return nil, errors.New("Apple 回调 appAccountToken 不是有效 UUID")
}
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: transactionID, QueryID: transactionID, Payload: payload}, nil
}
func appleClaims(value any) (map[string]any, error) {
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
claims := map[string]any{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func applePaymentResult(transactionID string, claims map[string]any, payload []byte, c map[string]any) (*biz.PaymentResult, error) {
status := "success"
if fmt.Sprint(claims["revocationDate"]) != "" && fmt.Sprint(claims["revocationDate"]) != "<nil>" {
status = "failed"
}
providerTradeNo := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if providerTradeNo == "" || providerTradeNo == "<nil>" || providerTradeNo != transactionID {
return nil, errors.New("Apple 查询返回的 transactionId 不匹配")
}
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
if tradeNo == "" || tradeNo == "<nil>" {
return nil, errors.New("Apple 交易缺少 appAccountToken")
}
if _, err := uuid.Parse(tradeNo); err != nil {
return nil, errors.New("Apple 交易 appAccountToken 不是有效 UUID")
}
result := &biz.PaymentResult{Provider: biz.PaymentApple, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, QueryID: providerTradeNo, Payload: payload}
if status != "success" {
return result, nil
}
result.Currency = strings.ToUpper(strings.TrimSpace(fmt.Sprint(claims["currency"])))
if result.Currency == "<nil>" {
result.Currency = ""
}
if result.Currency == "" {
return nil, errors.New("Apple 交易缺少 currency")
}
price, err := parseIntegerAmount(strings.TrimSpace(fmt.Sprint(claims["price"])))
if err != nil {
return nil, fmt.Errorf("解析 Apple 交易 price: %w", err)
}
divisor := applePriceDivisor(c, result.Currency)
if divisor <= 0 || price%divisor != 0 {
return nil, errors.New("Apple price_divisor 未配置或交易金额无法精确换算到最小货币单位")
}
result.Amount = price / divisor
result.PayerPaidAmount = result.Amount
result.CashPaidAmount = result.Amount
result.SettlementAmount = result.Amount
result.PayerCurrency = result.Currency
result.AmountBreakdownKnown = true
return result, nil
}
func applePriceDivisor(c map[string]any, currency string) int64 {
if values, ok := c["price_divisors"].(map[string]any); ok {
for key, value := range values {
if strings.EqualFold(strings.TrimSpace(key), currency) {
return configuredInt64(map[string]any{"value": value}, "value", 0)
}
}
}
return configuredInt64(c, "price_divisor", 0)
}
func validateAppleEnvironment(claims map[string]any, c map[string]any) error {
actual := strings.ToLower(strings.TrimSpace(fmt.Sprint(claims["environment"])))
if actual == "" || actual == "<nil>" {
return errors.New("Apple 交易缺少 environment")
}
expected := "production"
if strings.EqualFold(text(c, "environment"), "sandbox") {
expected = "sandbox"
}
if actual != expected {
return errors.New("Apple 交易 environment 不匹配")
}
return nil
}
+230
View File
@@ -0,0 +1,230 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/douyin"
)
type douyinAdapter struct{}
func (a *douyinAdapter) client(c map[string]any) (*douyin.Client, error) {
client, err := douyin.NewClient(text(c, "merchant_id"), text(c, "serial_no"), text(c, "api_key"), text(c, "private_key"))
if err != nil {
return nil, err
}
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial")
if cert := text(c, "platform_cert"); cert != "" {
if platformSerial == "" {
return nil, errors.New("抖音支付缺少 platform_serial_no")
}
if err := client.SetPlatformCert([]byte(cert), platformSerial); err != nil {
return nil, err
}
}
if proxy := text(c, "proxy_url"); proxy != "" {
client.SetProxyHost(proxy)
}
return client, nil
}
func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("appid", text(c, "app_id"))
bm.Set("description", req.Subject)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("notify_url", req.NotifyURL)
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayExtras(bm, req.Extra, "appid", "description", "out_trade_no", "notify_url", "amount", "mchid", "method", "pay_method", "trade_type", "channel")
tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "channel"))
if tradeType == "" {
tradeType = strings.ToLower(text(c, "trade_type"))
}
switch tradeType {
case "app", "app_pay", "apporder":
rsp, callErr := client.AppOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.PrepayId, Payload: payload}, nil
case "h5", "h5_pay":
rsp, callErr := client.H5Order(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.H5Url, Payload: payload}, nil
case "native", "qr", "qrcode":
rsp, callErr := client.NativeOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.CodeUrl, Payload: payload}, nil
default:
rsp, callErr := client.JsapiOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.PrepayId, Payload: payload}, nil
}
}
func (a *douyinAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
rsp, err := client.OrderQueryByOutTradeNo(ctx, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
order := rsp.Response
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(order.TradeState), TradeNo: order.OutTradeNo, ProviderTradeNo: order.TransactionId, Payload: mustJSON(order)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if order.Amount != nil {
result.Amount = int64(order.Amount.Total)
result.Currency = strings.ToUpper(order.Amount.Currency)
result.PayerPaidAmount = int64(order.Amount.PayerTotal)
result.PayerCurrency = strings.ToUpper(order.Amount.PayerCurrency)
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
if result.PayerPaidAmount > 0 && result.Amount >= result.PayerPaidAmount {
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, nil
}
func (a *douyinAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("抖音支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("out_trade_no", req.TradeNo).Set("out_refund_no", req.RefundNo)
bm.SetBodyMap("amount", func(m gopay.BodyMap) {
m.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount", "mchid")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
r := rsp.Response
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeRefundState(r.Status), TradeNo: r.OutTradeNo, ProviderTradeNo: r.RefundId, Payload: mustJSON(r)}
if result.TradeNo == "" {
result.TradeNo = req.TradeNo
}
if r.Amount != nil {
result.Amount = int64(r.Amount.Refund)
result.Currency = strings.ToUpper(r.Amount.Currency)
}
return result, nil
}
func (a *douyinAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("抖音支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
notify, err := douyin.ParseNotify(request)
if err != nil {
return nil, err
}
client, err := a.client(c)
if err != nil {
return nil, err
}
if cert := text(c, "platform_cert"); cert == "" {
return nil, errors.New("抖音支付回调缺少 platform_cert")
}
if err := notify.VerifySignByPKMap(client.PlatformCertMap()); err != nil {
return nil, err
}
if strings.EqualFold(notify.EventType, douyin.EventTransactionSuccess) {
pay, err := notify.DecryptPayCipherText(text(c, "api_key"))
if err != nil {
return nil, err
}
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(pay.TradeState), TradeNo: pay.OutTradeNo, ProviderTradeNo: pay.TransactionId, Payload: mustJSON(pay), EventID: notify.Id}
if pay.Amount != nil {
result.Amount = int64(pay.Amount.Total)
result.Currency = strings.ToUpper(pay.Amount.Currency)
result.PayerPaidAmount = int64(pay.Amount.PayerTotal)
result.PayerCurrency = strings.ToUpper(pay.Amount.PayerCurrency)
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
if result.PayerPaidAmount > 0 && result.Amount >= result.PayerPaidAmount {
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, nil
}
if strings.HasPrefix(strings.ToUpper(notify.EventType), "REFUND.") {
refund, err := notify.DecryptRefundCipherText(text(c, "api_key"))
if err != nil {
return nil, err
}
status := "pending"
if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusSuccess) {
status = "success"
} else if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusClosed) || strings.EqualFold(refund.RefundStatus, douyin.RefundStatusAbnormal) {
status = "failed"
}
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: status, TradeNo: refund.OutTradeNo, ProviderTradeNo: refund.RefundId, Payload: mustJSON(refund), EventID: notify.Id}
if refund.Amount != nil {
result.Amount = int64(refund.Amount.Refund)
result.Currency = strings.ToUpper(firstAny(c, "currency"))
}
return result, nil
}
return nil, fmt.Errorf("抖音支付不支持回调事件 %q", notify.EventType)
}
+55
View File
@@ -0,0 +1,55 @@
package payment
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"strings"
"github.com/go-pay/gopay"
)
// toGoPayBodyMap keeps provider protocol types inside the data layer while
// allowing GoPay's signing and notification helpers to be reused.
func toGoPayBodyMap(values map[string]string) gopay.BodyMap {
bm := make(gopay.BodyMap, len(values))
for key, value := range values {
bm[key] = value
}
return bm
}
func cloneGoPayBodyMap(values gopay.BodyMap) gopay.BodyMap {
clone := make(gopay.BodyMap, len(values))
for key, value := range values {
clone[key] = value
}
return clone
}
func fromGoPayBodyMap(values gopay.BodyMap) map[string]string {
result := make(map[string]string, len(values))
for key := range values {
result[key] = values.GetString(key)
}
return result
}
// goPayAlipayKey converts the PEM-friendly configuration accepted by this
// service into GoPay's raw base64 key format. GoPay's Alipay helpers add their
// own PEM envelope internally.
func goPayAlipayKey(value string, public bool) string {
value = strings.TrimSpace(value)
if block, _ := pem.Decode([]byte(value)); block != nil {
der := block.Bytes
if public && block.Type == "CERTIFICATE" {
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
if pub, err := x509.MarshalPKIXPublicKey(cert.PublicKey); err == nil {
der = pub
}
}
}
return base64.StdEncoding.EncodeToString(der)
}
return strings.Join(strings.Fields(value), "")
}
+157
View File
@@ -0,0 +1,157 @@
package payment
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/douyin"
)
// The adapters in this package intentionally keep GoPay request/response
// models below the data boundary. The business layer only sees PaymentResult.
func callbackRequest(callback *biz.PaymentCallback) (*http.Request, error) {
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
for key, value := range callback.Headers {
request.Header.Set(key, value)
}
return request, nil
}
func mergeGoPayExtras(dst gopay.BodyMap, 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 _, ok := protectedKeys[key]; ok {
continue
}
dst[key] = value
}
}
func mergeGoPayConfigExtras(dst gopay.BodyMap, config map[string]any, key string, protected ...string) {
extra, ok := config[key].(map[string]any)
if !ok || extra == nil {
return
}
mergeGoPayExtras(dst, extra, protected...)
}
func anyConfig(c map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := c[key]; ok && fmt.Sprint(value) != "" {
return value
}
}
return nil
}
func firstAnyOr(values map[string]any, fallback string, keys ...string) string {
if value := firstAny(values, keys...); value != "" {
return value
}
return fallback
}
func mustJSON(value any) []byte {
encoded, _ := json.Marshal(value)
return encoded
}
func douyinError(rsp any) error {
return fmt.Errorf("抖音支付请求失败: %v", rsp)
}
func qqOK(returnCode, resultCode string) bool {
return strings.EqualFold(returnCode, "SUCCESS") && (resultCode == "" || strings.EqualFold(resultCode, "SUCCESS"))
}
func rspMap(value any) map[string]string {
encoded, _ := json.Marshal(value)
var object map[string]any
_ = json.Unmarshal(encoded, &object)
result := make(map[string]string, len(object))
for key, val := range object {
result[key] = fmt.Sprint(val)
}
return result
}
func normalizeDouyinState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case douyin.TradeStateSuccess:
return "success"
case douyin.TradeStatePayErr:
return "failed"
case douyin.TradeStateClosed:
return "failed"
default:
return "pending"
}
}
func normalizeRefundState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case douyin.RefundStatusSuccess, "REFUNDED":
return "success"
case douyin.RefundStatusClosed, douyin.RefundStatusAbnormal, "FAILED":
return "failed"
default:
return "pending"
}
}
func normalizeQQState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "REFUND":
return "success"
case "CLOSED", "PAYERROR", "REVOKED", "NOPAY":
return "failed"
default:
return "pending"
}
}
func normalizeAllinState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "0000", "2000", "3040":
return "success"
case "FAIL", "FAILED", "3000", "3045":
return "failed"
default:
return "pending"
}
}
func normalizeLakalaState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "PAID", "PAY_SUCCESS", "1":
return "success"
case "CLOSED", "FAILED", "FAIL", "CANCELLED":
return "failed"
default:
return "pending"
}
}
func normalizeSaobeiState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS":
return "success"
case "CLOSED", "PAYERROR", "REVOKED", "FAILED", "NOPAY":
return "failed"
default:
return "pending"
}
}
+349
View File
@@ -0,0 +1,349 @@
package payment
import (
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"kra/internal/biz"
"kra/pkg/paymentkit"
gopayAlipay "github.com/go-pay/gopay/alipay"
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
)
func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, string, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatal(err)
}
publicPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER})
return key, string(privatePEM), string(publicPEM)
}
func testRSA2Sign(t *testing.T, key *rsa.PrivateKey, data []byte) string {
t.Helper()
digest := sha256.Sum256(data)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(signature)
}
func TestAlipayGoPayRequestParamHonorsGatewayAndVerifiesResponse(t *testing.T) {
platformKey, privatePEM, publicPEM := testRSAKeyPair(t)
var requestForm url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request: %v", err)
return
}
requestForm, err = url.ParseQuery(string(body))
if err != nil {
t.Errorf("parse request: %v", err)
return
}
if requestForm.Get("method") != "alipay.trade.query" {
t.Errorf("method = %q", requestForm.Get("method"))
}
if !strings.Contains(requestForm.Get("biz_content"), `"out_trade_no":"T-ALIPAY"`) {
t.Errorf("biz_content = %q", requestForm.Get("biz_content"))
}
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicPEM, true), alipaySignContent(requestForm), requestForm.Get("sign"))
if err != nil || !ok {
t.Errorf("request signature valid = %v, err = %v", ok, err)
}
response := []byte(`{"code":"10000","msg":"Success","trade_no":"P-ALIPAY","out_trade_no":"T-ALIPAY","trade_status":"TRADE_SUCCESS","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00","trans_currency":"CNY"}`)
payload := fmt.Sprintf(`{"alipay_trade_query_response":%s,"sign":%q}`, response, testRSA2Sign(t, platformKey, response))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, payload)
}))
defer server.Close()
result, err := (&alipayAdapter{}).Query(context.Background(), "T-ALIPAY", map[string]any{
"app_id": "app-test",
"private_key": privatePEM,
"public_key": publicPEM,
"gateway_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if requestForm == nil || result.TradeNo != "T-ALIPAY" || result.ProviderTradeNo != "P-ALIPAY" || result.Amount != 1000 || result.Currency != "CNY" {
t.Fatalf("unexpected result: %+v, request: %v", result, requestForm)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.CashPaidAmount != 800 || result.PointPaidAmount != 100 || result.DiscountAmount != 100 || result.SettlementAmount != 900 {
t.Fatalf("unexpected amount breakdown: %+v", result)
}
}
func TestAlipayGoPayCallbackVerificationDoesNotMutateFields(t *testing.T) {
key, _, publicPEM := testRSAKeyPair(t)
fields := map[string]string{
"app_id": "app-test",
"trade_no": "P-ALIPAY",
"out_trade_no": "T-ALIPAY",
"trade_status": "TRADE_SUCCESS",
"sign_type": "RSA2",
}
body := toGoPayBodyMap(fields)
body.Remove("sign_type")
sign, err := gopayAlipay.GetRsaSign(body, gopayAlipay.RSA2, key)
if err != nil {
t.Fatal(err)
}
fields["sign"] = sign
config := map[string]any{"app_id": "app-test", "public_key": publicPEM}
if err = verifyAlipay(fields, config); err != nil {
t.Fatal(err)
}
if fields["sign"] == "" || fields["sign_type"] != "RSA2" {
t.Fatalf("verification mutated source fields: %#v", fields)
}
fields["trade_no"] = "tampered"
if err = verifyAlipay(fields, config); err == nil {
t.Fatal("tampered callback unexpectedly verified")
}
}
func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
const key = "wechat-secret"
var requestValues map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request: %v", err)
return
}
requestValues, err = paymentkit.XMLValues(body)
if err != nil {
t.Errorf("parse request: %v", err)
return
}
if requestValues["total_fee"] != "123" {
t.Errorf("total_fee = %q, want 123", requestValues["total_fee"])
}
if err = verifyWechatV2(requestValues, key); err != nil {
t.Errorf("request signature: %v", err)
}
response := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"}
response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response))
}))
defer server.Close()
result, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{
TradeNo: "T-WECHAT", Subject: "subject", Amount: 123, Currency: "CNY", NotifyURL: "http://notify.local",
}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
"environment": "sandbox", "create_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if result.Status != "created" || requestValues["total_fee"] != "123" {
t.Fatalf("unexpected create result: %+v, request: %#v", result, requestValues)
}
callbackValues := map[string]string{
"return_code": "SUCCESS", "result_code": "SUCCESS", "appid": "wx-test", "mch_id": "mch-test",
"out_trade_no": "T-WECHAT", "transaction_id": "P-WECHAT", "trade_state": "SUCCESS",
}
callbackValues["sign"] = wechatV2Sign(callbackValues, key, "MD5")
originalSign := callbackValues["sign"]
if err = verifyWechatV2(callbackValues, key); err != nil {
t.Fatal(err)
}
if callbackValues["sign"] != originalSign {
t.Fatal("verification mutated source values")
}
callbackBody := paymentkit.XMLEncode(callbackValues)
callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
})
if err != nil {
t.Fatal(err)
}
if callbackResult.Status != "success" || callbackResult.ProviderTradeNo != "P-WECHAT" {
t.Fatalf("unexpected callback result: %+v", callbackResult)
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func TestWechatV3EndpointTransportPreservesSignedPathAndQuery(t *testing.T) {
endpoint, err := url.Parse("https://payments.test.local")
if err != nil {
t.Fatal(err)
}
request, err := http.NewRequest(http.MethodGet, "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/T-1?mchid=M-1", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Authorization", `WECHATPAY2-SHA256-RSA2048 mchid="M-1"`)
transport := &wechatV3EndpointTransport{
endpoint: endpoint,
base: roundTripperFunc(func(got *http.Request) (*http.Response, error) {
if got.URL.Scheme != "https" || got.URL.Host != "payments.test.local" {
t.Fatalf("endpoint = %s", got.URL)
}
if got.URL.EscapedPath() != "/v3/pay/transactions/out-trade-no/T-1" || got.URL.RawQuery != "mchid=M-1" {
t.Fatalf("signed target changed to %s", got.URL.RequestURI())
}
if got.Header.Get("Authorization") != request.Header.Get("Authorization") {
t.Fatal("authorization header changed while rewriting endpoint")
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header), Request: got}, nil
}),
}
if _, err = transport.RoundTrip(request); err != nil {
t.Fatal(err)
}
if request.URL.Host != "api.mch.weixin.qq.com" {
t.Fatal("transport mutated the original request")
}
}
func TestWechatV3PrepayResultUsesGoPayClientSigning(t *testing.T) {
_, privatePEM, _ := testRSAKeyPair(t)
client, err := gopayWechatV3.NewClientV3(
"mch-test",
"merchant-serial",
"01234567890123456789012345678901",
privatePEM,
)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
tradeType string
assert func(*testing.T, map[string]any)
}{
{
name: "jsapi", tradeType: "jsapi",
assert: func(t *testing.T, payload map[string]any) {
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
t.Fatalf("JSAPI payload = %#v", payload)
}
},
},
{
name: "applet", tradeType: "mini_program",
assert: func(t *testing.T, payload map[string]any) {
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
t.Fatalf("applet payload = %#v", payload)
}
},
},
{
name: "app", tradeType: "app",
assert: func(t *testing.T, payload map[string]any) {
if payload["appid"] != "wx-test" || payload["partnerid"] != "mch-test" || payload["prepayid"] != "PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["sign"])) == "" {
t.Fatalf("App payload = %#v", payload)
}
},
},
} {
t.Run(tc.name, func(t *testing.T) {
result, err := wechatV3PrepayCreateResult(client, "wx-test", tc.tradeType, "T-WECHAT-V3", &gopayWechatV3.PrepayRsp{
Code: gopayWechatV3.Success,
Response: &gopayWechatV3.Prepay{PrepayId: "PREPAY-1"},
})
if err != nil {
t.Fatal(err)
}
if result.TradeNo != "T-WECHAT-V3" || result.QueryID != "" || result.Status != "created" {
t.Fatalf("result = %+v", result)
}
var payload map[string]any
if err = json.Unmarshal(result.Payload, &payload); err != nil {
t.Fatal(err)
}
tc.assert(t, payload)
})
}
}
func TestWechatV3GoPayCallbackVerificationAndDecryption(t *testing.T) {
platformKey, merchantPrivatePEM, platformPublicPEM := testRSAKeyPair(t)
const (
apiV3Key = "01234567890123456789012345678901"
resourceNonce = "0123456789ab"
headerNonce = "callback-nonce"
timestamp = "1787068800"
serial = "ABCDEF123456"
)
plain := []byte(`{"appid":"wx-test","mchid":"mch-test","out_trade_no":"T-WECHAT-V3","transaction_id":"P-WECHAT-V3","trade_state":"SUCCESS"}`)
block, err := aes.NewCipher([]byte(apiV3Key))
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(resourceNonce), plain, []byte("transaction")))
body, err := json.Marshal(map[string]any{
"id": "EVENT-WECHAT-V3", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "summary": "payment success",
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "original_type": "transaction", "nonce": resourceNonce},
})
if err != nil {
t.Fatal(err)
}
signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+headerNonce+"\n"+string(body)+"\n"))
callback := &biz.PaymentCallback{
Body: body,
Headers: map[string]string{
gopayWechatV3.HeaderTimestamp: timestamp,
gopayWechatV3.HeaderNonce: headerNonce,
gopayWechatV3.HeaderSerial: serial,
gopayWechatV3.HeaderSignature: signature,
},
}
result, err := (&wechatV3Adapter{}).Callback(context.Background(), callback, map[string]any{
"api_v3_key": apiV3Key, "platform_cert": platformPublicPEM, "private_key": merchantPrivatePEM,
"platform_serial_no": strings.ToLower(serial), "serial_no": "merchant-serial", "app_id": "wx-test", "merchant_id": "mch-test",
})
if err != nil {
t.Fatal(err)
}
if result.Status != "success" || result.TradeNo != "T-WECHAT-V3" || result.ProviderTradeNo != "P-WECHAT-V3" {
t.Fatalf("unexpected callback result: %+v", result)
}
if callback.Query["event_id"] != "EVENT-WECHAT-V3" {
t.Fatalf("event id = %q", callback.Query["event_id"])
}
}
+138
View File
@@ -0,0 +1,138 @@
package payment
import (
"context"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/lakala"
)
type lakalaAdapter struct{}
func (a *lakalaAdapter) client(c map[string]any) (*lakala.Client, error) {
return lakala.NewClient(text(c, "partner_code"), text(c, "credential_code"), !strings.EqualFold(text(c, "environment"), "sandbox"))
}
func (a *lakalaAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"description": req.Subject, "price": req.Amount, "channel": firstAny(req.Extra, "channel", "pay_channel")}
if bm.GetString("channel") == gopay.NULL {
bm.Set("channel", firstAnyOr(c, "Wechat", "channel", "pay_channel"))
}
mergeGoPayExtras(bm, req.Extra, "description", "price", "channel", "pay_channel", "method", "pay_method", "trade_type")
method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type"))
if method == "h5" {
rsp, callErr := client.CreateH5PayOrder(ctx, req.TradeNo, bm)
if callErr != nil {
return nil, callErr
}
return lakalaCreateResult(req.TradeNo, rsp)
}
rsp, err := client.CreateJSAPIOrder(ctx, req.TradeNo, bm)
if err != nil {
return nil, err
}
return lakalaCreateResult(req.TradeNo, rsp)
}
func lakalaCreateResult(tradeNo string, rsp *lakala.PaymentRsp) (*biz.PaymentResult, error) {
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
return nil, fmt.Errorf("拉卡拉支付下单失败: %v", rsp)
}
return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "created", TradeNo: tradeNo, ProviderTradeNo: rsp.OrderId, QueryID: first(rspMap(rsp), "code_url", "pay_url", "sdk_params"), Payload: mustJSON(rsp)}, nil
}
func (a *lakalaAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
orderID := firstAny(c, "order_id", "provider_order_id")
if orderID == "" {
orderID = tradeNo
}
rsp, err := client.OrderStatus(ctx, orderID)
if err != nil {
return nil, err
}
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
return nil, fmt.Errorf("拉卡拉支付查单失败: %v", rsp)
}
status := normalizeLakalaState(first(rspMap(rsp), "status", "channel_error_code"))
if rsp.ChannelOrderId != "" && status == "pending" {
status = "success"
}
returnedTradeNo := rsp.PartnerOrderId
if returnedTradeNo == "" {
returnedTradeNo = tradeNo
}
result := &biz.PaymentResult{Provider: biz.PaymentLakala, Status: status, TradeNo: returnedTradeNo, ProviderTradeNo: rsp.ChannelOrderId, Amount: int64(rsp.TotalFee), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)}
if result.Currency == "" {
result.Currency = strings.ToUpper(firstAny(c, "currency", "default_currency"))
}
if result.Currency == "" {
result.Currency = "JPY"
}
if rsp.RealFee > 0 {
result.PayerPaidAmount = int64(rsp.RealFee)
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
if result.DiscountAmount >= 0 {
result.AmountBreakdownKnown = true
}
}
return result, nil
}
func (a *lakalaAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("拉卡拉支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
orderID := firstAny(c, "order_id", "provider_order_id")
if orderID == "" {
orderID = req.TradeNo
}
bm := gopay.BodyMap{"fee": req.Amount}
if req.Currency != "" {
bm.Set("currency", strings.ToUpper(req.Currency))
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "fee", "currency")
rsp, err := client.ApplyRefund(ctx, orderID, req.RefundNo, bm)
if err != nil {
return nil, err
}
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
return nil, fmt.Errorf("拉卡拉支付退款失败: %v", rsp)
}
return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.RefundId, Amount: int64(rsp.Amount), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *lakalaAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("拉卡拉支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
notify, err := lakala.ParseNotify(request)
if err != nil {
return nil, err
}
if err := lakala.VerifySign(notify, text(c, "partner_code"), text(c, "credential_code")); err != nil {
return nil, err
}
return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "success", TradeNo: notify.PartnerOrderId, ProviderTradeNo: notify.ChannelOrderId, Amount: int64(notify.TotalFee), Currency: strings.ToUpper(notify.Currency), Payload: mustJSON(notify)}, nil
}
+129
View File
@@ -0,0 +1,129 @@
package payment
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"net/url"
"strings"
"testing"
"kra/internal/biz"
)
func TestWechatV2SignOfficialStyle(t *testing.T) {
values := map[string]string{
"appid": "wxd930ea5d5a258f4f", "mch_id": "10000100", "device_info": "1000",
"body": "test", "nonce_str": "ibuaiVcKdpRxkhJA",
}
got := wechatV2Sign(values, "192006250b4c09247ec02edce69f6a2d", "MD5")
if got != strings.ToUpper(got) || len(got) != 32 {
t.Fatalf("invalid WeChat v2 MD5 signature %q", got)
}
values["sign"] = got
if err := verifyWechatV2(values, "192006250b4c09247ec02edce69f6a2d"); err != nil {
t.Fatal(err)
}
}
func TestApplePaymentResultSeparatesMerchantAndProviderTradeNumbers(t *testing.T) {
result, err := applePaymentResult("1000000000001", map[string]any{
"transactionId": "1000000000001",
"appAccountToken": "123e4567-e89b-12d3-a456-426614174000",
"currency": "CNY",
"price": "1000",
}, nil, map[string]any{"price_divisor": "10"})
if err != nil {
t.Fatal(err)
}
if result.TradeNo != "123e4567-e89b-12d3-a456-426614174000" || result.ProviderTradeNo != "1000000000001" || result.QueryID != "1000000000001" {
t.Fatalf("unexpected Apple order identifiers: %+v", result)
}
}
func TestWechatV3Decrypt(t *testing.T) {
key := "01234567890123456789012345678901"
nonce := "0123456789ab"
aad := "transaction"
plain := []byte(`{"out_trade_no":"T1","trade_state":"SUCCESS"}`)
block, err := aes.NewCipher([]byte(key))
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plain, []byte(aad)))
got, err := decryptWechatV3(ciphertext, nonce, aad, key)
if err != nil {
t.Fatal(err)
}
if string(got) != string(plain) {
t.Fatalf("plaintext = %q", got)
}
}
func TestAlipaySignContentSortedAndExcludesSign(t *testing.T) {
values := url.Values{"method": {"alipay.trade.query"}, "app_id": {"123"}, "sign": {"ignored"}, "empty": {""}}
if got := alipaySignContent(values); got != "app_id=123&method=alipay.trade.query" {
t.Fatalf("sign content = %q", got)
}
}
func TestAlipayBreakdownWithPointPayment(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateAlipayBreakdown(result, map[string]any{
"total_amount": "10.00", "buyer_pay_amount": "8.00", "point_amount": "2.00", "receipt_amount": "8.00",
})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 600 || result.PointPaidAmount != 200 || result.DiscountAmount != 200 || result.SettlementAmount != 800 {
t.Fatalf("unexpected Alipay breakdown: %+v", result)
}
}
func TestWechatV2BreakdownWithCoupon(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateWechatV2Breakdown(result, map[string]string{"cash_fee": "900", "coupon_fee": "100", "settlement_total_fee": "950"})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.DiscountAmount != 100 || result.SettlementAmount != 950 {
t.Fatalf("unexpected WeChat v2 breakdown: %+v", result)
}
}
func TestWechatV3BreakdownWithPromotion(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateWechatV3Breakdown(result, map[string]any{
"amount": map[string]any{"payer_total": float64(800), "settlement_amount": float64(780)},
"promotion_detail": []any{map[string]any{"amount": float64(200)}},
})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 800 || result.DiscountAmount != 200 || result.SettlementAmount != 780 {
t.Fatalf("unexpected WeChat v3 breakdown: %+v", result)
}
}
func TestVendorBreakdownUsesConfiguredUnits(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000, Currency: "CNY"}
object := map[string]any{"data": map[string]any{
"payer": "8.00", "cash": "6.00", "points": "2.00", "discount": "2.00", "settlement": "7.80",
}}
config := map[string]any{
"query_amount_scale": "100", "query_payer_paid_amount_field": "data.payer", "query_cash_paid_amount_field": "data.cash",
"query_point_paid_amount_field": "data.points", "query_discount_amount_field": "data.discount",
"query_settlement_amount_field": "data.settlement", "query_payer_currency_field": "data.currency",
}
err := populateVendorBreakdown(result, object, config)
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 600 || result.PointPaidAmount != 200 || result.DiscountAmount != 200 || result.SettlementAmount != 780 {
t.Fatalf("unexpected vendor breakdown: %+v", result)
}
}
+596
View File
@@ -0,0 +1,596 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/paypal"
)
type paypalAdapter struct{}
func (a *paypalAdapter) client(c map[string]any) (*paypal.Client, error) {
environment := strings.ToLower(strings.TrimSpace(text(c, "environment")))
isProd := environment != "sandbox"
options := []paypal.Option{paypal.WithoutAutoRefreshToken()}
if baseURL := strings.TrimRight(firstAny(c, "api_base_url", "base_url", "proxy_url"), "/"); baseURL != "" {
options = append(options, paypal.WithProxyUrl(baseURL, baseURL))
}
client, err := paypal.NewClient(text(c, "client_id"), text(c, "client_secret"), isProd, options...)
if err != nil {
return nil, fmt.Errorf("PayPal 客户端初始化失败: %w", err)
}
client.SetRequestHeader("Prefer", "return=representation")
return client, nil
}
func (a *paypalAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("PayPal 下单参数为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
value, err := paypalFormatAmount(req.Amount, currency, c)
if err != nil {
return nil, err
}
purchaseUnit := &paypal.PurchaseUnit{
ReferenceId: req.TradeNo,
Description: req.Subject,
CustomId: req.TradeNo,
InvoiceId: req.TradeNo,
Amount: &paypal.Amount{CurrencyCode: currency, Value: value},
}
bm := gopay.BodyMap{
"intent": "CAPTURE",
"purchase_units": []*paypal.PurchaseUnit{purchaseUnit},
}
if applicationContext := paypalApplicationContext(req, c); len(applicationContext) > 0 {
bm["application_context"] = applicationContext
}
mergeGoPayExtras(bm, req.Extra, "intent", "purchase_units", "application_context")
client.SetRequestHeader("PayPal-Request-Id", req.TradeNo)
rsp, err := client.CreateOrder(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("下单", paypalResponseCode(rsp), paypalCreateError(rsp))
}
order := rsp.Response
if strings.TrimSpace(order.Id) == "" {
return nil, errors.New("PayPal 下单成功但缺少 order ID")
}
result, err := paypalOrderResult(order, order.Id, req.TradeNo, c)
if err != nil {
return nil, err
}
result.TradeNo = req.TradeNo
return result, nil
}
func (a *paypalAdapter) Query(ctx context.Context, orderID string, c map[string]any) (*biz.PaymentResult, error) {
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return nil, errors.New("PayPal 查单缺少 order ID")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
rsp, err := client.OrderDetail(ctx, orderID, nil)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("查单", paypalResponseCode(rsp), paypalOrderError(rsp))
}
if rsp.Response.Id != "" && rsp.Response.Id != orderID {
return nil, errors.New("PayPal 查单响应的 order ID 不匹配")
}
return paypalOrderResult(rsp.Response, orderID, "", c)
}
func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("PayPal 退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
captureID, err := paypalRefundCaptureID(ctx, client, req)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
value, err := paypalFormatAmount(req.Amount, currency, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"invoice_id": req.RefundNo,
"amount": &paypal.Amount{
CurrencyCode: currency,
Value: value,
},
}
if note := strings.TrimSpace(text(c, "refund_note")); note != "" {
bm["note_to_payer"] = note
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "amount", "invoice_id", "capture_id", "order_id", "query_id", "provider_trade_no")
client.SetRequestHeader("PayPal-Request-Id", req.RefundNo)
rsp, err := client.PaymentCaptureRefund(ctx, captureID, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("退款", paypalResponseCode(rsp), paypalRefundError(rsp))
}
refund := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: normalizePayPalRefundState(refund.Status),
TradeNo: req.TradeNo,
ProviderTradeNo: refund.Id,
Amount: req.Amount,
Currency: currency,
Payload: mustJSON(refund),
}
if refund.Amount != nil {
parsed, parseErr := paypalParseAmount(refund.Amount, c)
if parseErr != nil {
return nil, parseErr
}
result.Amount = parsed
result.Currency = strings.ToUpper(refund.Amount.CurrencyCode)
}
return result, nil
}
func (a *paypalAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil || len(callback.Body) == 0 {
return nil, errors.New("PayPal 回调为空")
}
webhookID := strings.TrimSpace(text(c, "webhook_id"))
if webhookID == "" {
return nil, errors.New("PayPal 回调验签缺少 webhook_id")
}
var event paypal.WebhookEvent
if err := json.Unmarshal(callback.Body, &event); err != nil {
return nil, fmt.Errorf("PayPal 回调 JSON 无效: %w", err)
}
if strings.TrimSpace(event.Id) == "" || len(event.Resource) == 0 {
return nil, errors.New("PayPal 回调事件不完整")
}
var webhookEvent any
if err := json.Unmarshal(callback.Body, &webhookEvent); err != nil {
return nil, err
}
verifyBody := gopay.BodyMap{
"auth_algo": paypalCallbackHeader(callback, "Paypal-Auth-Algo"),
"cert_url": paypalCallbackHeader(callback, "Paypal-Cert-Url"),
"transmission_id": paypalCallbackHeader(callback, "Paypal-Transmission-Id"),
"transmission_sig": paypalCallbackHeader(callback, "Paypal-Transmission-Sig"),
"transmission_time": paypalCallbackHeader(callback, "Paypal-Transmission-Time"),
"webhook_id": webhookID,
"webhook_event": webhookEvent,
}
client, err := a.client(c)
if err != nil {
return nil, err
}
verified, err := client.VerifyWebhookSignature(ctx, verifyBody)
if err != nil {
return nil, err
}
if verified == nil || !strings.EqualFold(verified.VerificationStatus, "SUCCESS") {
return nil, errors.New("PayPal 回调签名校验失败")
}
result, err := paypalWebhookResult(&event)
if err != nil {
return nil, err
}
result.Payload = append([]byte(nil), callback.Body...)
result.EventID = event.Id
return result, nil
}
func paypalApplicationContext(req *biz.PaymentRequest, c map[string]any) map[string]any {
applicationContext := map[string]any{}
if configured, ok := req.Extra["application_context"].(map[string]any); ok {
for key, value := range configured {
applicationContext[key] = value
}
}
for _, key := range []string{"brand_name", "locale", "landing_page", "shipping_preference", "user_action"} {
if _, exists := applicationContext[key]; !exists {
if value := strings.TrimSpace(text(c, key)); value != "" {
applicationContext[key] = value
}
}
}
if returnURL := strings.TrimSpace(req.ReturnURL); returnURL != "" {
applicationContext["return_url"] = returnURL
}
cancelURL := firstAny(c, "cancel_url", "return_url")
if cancelURL == "" {
cancelURL = strings.TrimSpace(req.ReturnURL)
}
if cancelURL != "" {
applicationContext["cancel_url"] = cancelURL
}
return applicationContext
}
func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo string, c map[string]any) (*biz.PaymentResult, error) {
if order == nil {
return nil, errors.New("PayPal 订单结果为空")
}
tradeNo := paypalOrderTradeNo(order)
if tradeNo == "" {
tradeNo = fallbackTradeNo
}
capture := paypalOrderCapture(order)
result := &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: normalizePayPalOrderState(order.Status),
TradeNo: tradeNo,
QueryID: queryID,
Payload: mustJSON(order),
}
if capture != nil {
result.ProviderTradeNo = capture.Id
}
amount, currency, found, err := paypalOrderAmount(order, c)
if err != nil {
return nil, err
}
if found {
result.Amount = amount
result.Currency = currency
}
return result, nil
}
func paypalOrderTradeNo(order *paypal.OrderDetail) string {
if order == nil {
return ""
}
for _, unit := range order.PurchaseUnits {
if unit == nil {
continue
}
if value := firstNonEmpty(unit.InvoiceId, unit.CustomId, unit.ReferenceId); value != "" {
return value
}
}
return ""
}
func paypalOrderCapture(order *paypal.OrderDetail) *paypal.Capture {
var firstCapture *paypal.Capture
if order == nil {
return nil
}
for _, unit := range order.PurchaseUnits {
if unit == nil || unit.Payments == nil {
continue
}
for _, capture := range unit.Payments.Captures {
if capture == nil || strings.TrimSpace(capture.Id) == "" {
continue
}
if firstCapture == nil {
firstCapture = capture
}
if strings.EqualFold(capture.Status, "COMPLETED") {
return capture
}
}
}
return firstCapture
}
func paypalOrderAmount(order *paypal.OrderDetail, c map[string]any) (int64, string, bool, error) {
var total int64
var currency string
found := false
for _, unit := range order.PurchaseUnits {
if unit == nil || unit.Amount == nil || strings.TrimSpace(unit.Amount.Value) == "" {
continue
}
unitCurrency := strings.ToUpper(strings.TrimSpace(unit.Amount.CurrencyCode))
amount, err := paypalParseAmount(unit.Amount, c)
if err != nil {
return 0, "", false, err
}
if found && currency != unitCurrency {
return 0, "", false, errors.New("PayPal 订单包含不同币种的 purchase unit")
}
if amount > math.MaxInt64-total {
return 0, "", false, errors.New("PayPal 订单金额超出范围")
}
currency = unitCurrency
total += amount
found = true
}
return total, currency, found, nil
}
func paypalRefundCaptureID(ctx context.Context, client *paypal.Client, req *biz.PaymentRefundRequest) (string, error) {
if req == nil {
return "", errors.New("PayPal 退款请求为空")
}
if captureID := strings.TrimSpace(req.ProviderTradeNo); captureID != "" {
return captureID, nil
}
orderID := strings.TrimSpace(req.QueryID)
if orderID == "" {
return "", errors.New("PayPal 退款缺少已持久化的 capture ID")
}
rsp, err := client.OrderDetail(ctx, orderID, nil)
if err != nil {
return "", err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return "", paypalResponseError("退款前查单", paypalResponseCode(rsp), paypalOrderError(rsp))
}
if rsp.Response.Id != "" && rsp.Response.Id != orderID {
return "", errors.New("PayPal 退款查单响应的 order ID 不匹配")
}
if returnedTradeNo := paypalOrderTradeNo(rsp.Response); returnedTradeNo == "" || returnedTradeNo != req.TradeNo {
return "", errors.New("PayPal 退款订单与本地商户订单号不匹配")
}
capture := paypalOrderCapture(rsp.Response)
if capture == nil || strings.TrimSpace(capture.Id) == "" {
return "", errors.New("PayPal 退款订单缺少 capture ID")
}
return capture.Id, nil
}
func paypalWebhookResult(event *paypal.WebhookEvent) (*biz.PaymentResult, error) {
var resource map[string]any
if err := json.Unmarshal(event.Resource, &resource); err != nil {
return nil, fmt.Errorf("PayPal 回调 resource 无效: %w", err)
}
eventType := strings.ToUpper(strings.TrimSpace(event.EventType))
tradeNo := firstNonEmpty(paypalMapString(resource, "invoice_id"), paypalMapString(resource, "custom_id"))
queryID := paypalNestedMapString(resource, "supplementary_data", "related_ids", "order_id")
providerTradeNo := ""
status := normalizePayPalCaptureState(paypalMapString(resource, "status"))
if strings.HasPrefix(eventType, "CHECKOUT.ORDER.") {
var order paypal.OrderDetail
if err := json.Unmarshal(event.Resource, &order); err != nil {
return nil, err
}
tradeNo = paypalOrderTradeNo(&order)
queryID = order.Id
if capture := paypalOrderCapture(&order); capture != nil {
providerTradeNo = capture.Id
}
status = normalizePayPalOrderState(order.Status)
} else {
providerTradeNo = paypalMapString(resource, "id")
}
if tradeNo == "" {
tradeNo = paypalPurchaseUnitTradeNo(resource)
}
if tradeNo == "" || queryID == "" {
return nil, errors.New("PayPal 回调缺少商户订单号或 order ID")
}
return &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: status,
TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo,
QueryID: queryID,
}, nil
}
func paypalPurchaseUnitTradeNo(resource map[string]any) string {
units, _ := resource["purchase_units"].([]any)
for _, value := range units {
unit, _ := value.(map[string]any)
if tradeNo := firstNonEmpty(paypalMapString(unit, "invoice_id"), paypalMapString(unit, "custom_id"), paypalMapString(unit, "reference_id")); tradeNo != "" {
return tradeNo
}
}
return ""
}
func paypalCallbackHeader(callback *biz.PaymentCallback, name string) string {
for key, value := range callback.Headers {
if strings.EqualFold(key, name) {
return strings.TrimSpace(value)
}
}
return ""
}
func paypalFormatAmount(amount int64, currency string, c map[string]any) (string, error) {
scale, err := paypalAmountScale(currency, c)
if err != nil {
return "", err
}
return formatDecimalAmount(amount, scale)
}
func paypalParseAmount(amount *paypal.Amount, c map[string]any) (int64, error) {
if amount == nil {
return 0, errors.New("PayPal 金额为空")
}
scale, err := paypalAmountScale(amount.CurrencyCode, c)
if err != nil {
return 0, err
}
return parseDecimalAmount(amount.Value, scale)
}
func paypalAmountScale(currency string, c map[string]any) (int64, error) {
currency = strings.ToUpper(strings.TrimSpace(currency))
for _, key := range []string{"amount_scales", "currency_scales"} {
if scales, ok := c[key].(map[string]any); ok {
for configuredCurrency, raw := range scales {
if !strings.EqualFold(configuredCurrency, currency) {
continue
}
scale, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(raw)), 10, 64)
if err != nil || scale <= 0 {
return 0, fmt.Errorf("PayPal %s 金额换算比例无效", currency)
}
if _, err = formatDecimalAmount(scale, scale); err != nil {
return 0, fmt.Errorf("PayPal %s 金额换算比例无效: %w", currency, err)
}
return scale, nil
}
}
}
if scale := configuredInt64(c, "amount_scale", 0); scale > 0 {
if _, err := formatDecimalAmount(scale, scale); err != nil {
return 0, fmt.Errorf("PayPal 金额换算比例无效: %w", err)
}
return scale, nil
}
switch currency {
case "HUF", "JPY", "TWD":
return 1, nil
default:
return 100, nil
}
}
func normalizePayPalOrderState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED":
return "success"
case "VOIDED":
return "failed"
default:
return "pending"
}
}
func normalizePayPalCaptureState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED", "PARTIALLY_REFUNDED", "REFUNDED":
return "success"
case "DECLINED", "FAILED", "DENIED", "VOIDED":
return "failed"
default:
return "pending"
}
}
func normalizePayPalRefundState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED":
return "success"
case "CANCELLED", "FAILED":
return "failed"
default:
return "pending"
}
}
func paypalMapString(values map[string]any, key string) string {
value := values[key]
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func paypalNestedMapString(values map[string]any, keys ...string) string {
var current any = values
for _, key := range keys {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
if current == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(current))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" && value != "<nil>" {
return value
}
}
return ""
}
func paypalResponseError(operation string, code int, detail string) error {
detail = strings.TrimSpace(detail)
if detail == "" {
detail = "未知错误"
}
if code == 0 {
return fmt.Errorf("PayPal %s失败: %s", operation, detail)
}
return fmt.Errorf("PayPal %s失败(%d): %s", operation, code, detail)
}
func paypalResponseCode(value any) int {
switch rsp := value.(type) {
case *paypal.CreateOrderRsp:
if rsp != nil {
return rsp.Code
}
case *paypal.OrderDetailRsp:
if rsp != nil {
return rsp.Code
}
case *paypal.PaymentCaptureRefundRsp:
if rsp != nil {
return rsp.Code
}
}
return 0
}
func paypalCreateError(rsp *paypal.CreateOrderRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalOrderError(rsp *paypal.OrderDetailRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalRefundError(rsp *paypal.PaymentCaptureRefundRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalErrorDetail(raw string, response *paypal.ErrorResponse) string {
if response != nil {
if value := firstNonEmpty(response.Message, response.Name); value != "" {
return value
}
}
return raw
}
+241
View File
@@ -0,0 +1,241 @@
package payment
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"kra/internal/biz"
)
func TestPayPalCreateAndQueryKeepOrderAndCaptureIDsSeparate(t *testing.T) {
var createBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders":
if got := r.Header.Get("PayPal-Request-Id"); got != "LOCAL-1" {
t.Errorf("PayPal-Request-Id = %q, want LOCAL-1", got)
}
if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil {
t.Errorf("decode create body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"CREATED",
"purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"USD","value":"10.99"}}],
"links":[{"rel":"approve","href":"https://example.test/approve","method":"GET"}]
}`))
case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"COMPLETED",
"purchase_units":[{
"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1",
"amount":{"currency_code":"USD","value":"10.99"},
"payments":{"captures":[{"id":"CAPTURE-1","status":"COMPLETED","amount":{"currency_code":"USD","value":"10.99"}}]}
}]
}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := &paypalAdapter{}
config := paypalTestConfig(server.URL)
created, err := adapter.Create(context.Background(), &biz.PaymentRequest{
TradeNo: "LOCAL-1",
Subject: "Order one",
Amount: 1099,
Currency: "USD",
ReturnURL: "https://merchant.test/return",
Extra: map[string]any{"payment_source": map[string]any{"paypal": map[string]any{}}},
}, config)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if created.TradeNo != "LOCAL-1" || created.QueryID != "ORDER-1" || created.ProviderTradeNo != "" || created.Status != "pending" {
t.Fatalf("Create() result = %+v", created)
}
units, _ := createBody["purchase_units"].([]any)
if len(units) != 1 {
t.Fatalf("purchase_units = %#v", createBody["purchase_units"])
}
unit, _ := units[0].(map[string]any)
amount, _ := unit["amount"].(map[string]any)
if unit["invoice_id"] != "LOCAL-1" || amount["value"] != "10.99" {
t.Fatalf("purchase unit = %#v", unit)
}
if _, ok := createBody["payment_source"]; !ok {
t.Fatalf("payment_source was not forwarded: %#v", createBody)
}
queried, err := adapter.Query(context.Background(), created.QueryID, config)
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if queried.Status != "success" || queried.TradeNo != "LOCAL-1" || queried.QueryID != "ORDER-1" || queried.ProviderTradeNo != "CAPTURE-1" || queried.Amount != 1099 || queried.Currency != "USD" {
t.Fatalf("Query() result = %+v", queried)
}
}
func TestPayPalRefundUsesPersistedCaptureID(t *testing.T) {
var refundBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-1/refund":
if got := r.Header.Get("PayPal-Request-Id"); got != "REFUND-1" {
t.Errorf("PayPal-Request-Id = %q, want REFUND-1", got)
}
if err := json.NewDecoder(r.Body).Decode(&refundBody); err != nil {
t.Errorf("decode refund body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-1","status":"PENDING","amount":{"currency_code":"USD","value":"1.23"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := paypalTestConfig(server.URL)
config["provider_trade_no"] = "WRONG-CONFIG-CAPTURE"
result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", ProviderTradeNo: "CAPTURE-1",
QueryID: "ORDER-1", RefundNo: "REFUND-1", Amount: 123, TotalAmount: 1099, Currency: "USD",
}, config)
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result.TradeNo != "LOCAL-1" || result.ProviderTradeNo != "PAYPAL-REFUND-1" || result.Amount != 123 || result.Currency != "USD" || result.Status != "pending" {
t.Fatalf("Refund() result = %+v", result)
}
amount, _ := refundBody["amount"].(map[string]any)
if refundBody["invoice_id"] != "REFUND-1" || amount["value"] != "1.23" {
t.Fatalf("refund body = %#v", refundBody)
}
}
func TestPayPalRefundResolvesCaptureFromPersistedQueryID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"COMPLETED",
"purchase_units":[{
"invoice_id":"LOCAL-1",
"amount":{"currency_code":"USD","value":"10.99"},
"payments":{"captures":[{"id":"CAPTURE-FROM-ORDER","status":"COMPLETED"}]}
}]
}`))
case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-FROM-ORDER/refund":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-2","status":"COMPLETED","amount":{"currency_code":"USD","value":"1.23"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := paypalTestConfig(server.URL)
config["capture_id"] = "WRONG-CONFIG-CAPTURE"
result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", QueryID: "ORDER-1",
RefundNo: "REFUND-2", Amount: 123, TotalAmount: 1099, Currency: "USD",
}, config)
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result.ProviderTradeNo != "PAYPAL-REFUND-2" || result.Status != "success" {
t.Fatalf("Refund() result = %+v", result)
}
}
func TestPayPalRefundRejectsStaticCaptureIDWithoutDurableIdentity(t *testing.T) {
config := paypalTestConfig("https://paypal.invalid")
config["capture_id"] = "CONFIG-CAPTURE"
_, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 123, Currency: "USD",
}, config)
if err == nil {
t.Fatal("Refund() accepted a static config capture ID")
}
}
func TestPayPalCallbackUsesGoPayWebhookVerification(t *testing.T) {
var verifyBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/notifications/verify-webhook-signature":
if err := json.NewDecoder(r.Body).Decode(&verifyBody); err != nil {
t.Errorf("decode verify body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"verification_status":"SUCCESS"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
body := []byte(`{
"id":"WEBHOOK-1","event_type":"PAYMENT.CAPTURE.COMPLETED",
"resource":{
"id":"CAPTURE-1","status":"COMPLETED","invoice_id":"LOCAL-1",
"supplementary_data":{"related_ids":{"order_id":"ORDER-1"}}
}
}`)
config := paypalTestConfig(server.URL)
config["webhook_id"] = "HOOK-1"
result, err := (&paypalAdapter{}).Callback(context.Background(), &biz.PaymentCallback{
Body: body,
Headers: map[string]string{
"Paypal-Auth-Algo": "SHA256withRSA",
"Paypal-Cert-Url": "https://api.paypal.test/cert",
"Paypal-Transmission-Id": "TRANSMISSION-1",
"Paypal-Transmission-Sig": "SIGNATURE",
"Paypal-Transmission-Time": "2026-08-19T00:00:00Z",
},
}, config)
if err != nil {
t.Fatalf("Callback() error = %v", err)
}
if result.TradeNo != "LOCAL-1" || result.QueryID != "ORDER-1" || result.ProviderTradeNo != "CAPTURE-1" || result.EventID != "WEBHOOK-1" || result.Status != "success" {
t.Fatalf("Callback() result = %+v", result)
}
if verifyBody["webhook_id"] != "HOOK-1" || verifyBody["transmission_id"] != "TRANSMISSION-1" {
t.Fatalf("verify body = %#v", verifyBody)
}
if _, ok := verifyBody["webhook_event"].(map[string]any); !ok {
t.Fatalf("webhook_event was not sent as an object: %#v", verifyBody["webhook_event"])
}
}
func paypalTestConfig(baseURL string) map[string]any {
return map[string]any{
"client_id": "client-id",
"client_secret": "client-secret",
"environment": "sandbox",
"api_base_url": baseURL,
"cancel_url": "https://merchant.test/cancel",
"notify_url": "https://merchant.test/paypal/webhook",
}
}
+160
View File
@@ -0,0 +1,160 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq"
)
type qqAdapter struct{}
func (a *qqAdapter) client(c map[string]any) (*gopayQQ.Client, error) {
mch := firstAny(c, "merchant_id", "mch_id")
if mch == "" || text(c, "api_key") == "" {
return nil, errors.New("QQ 支付缺少 merchant_id 或 api_key")
}
client := gopayQQ.NewClient(mch, text(c, "api_key"))
return client, nil
}
func (a *qqAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("nonce_str", nonce()).Set("body", req.Subject).Set("out_trade_no", req.TradeNo).Set("total_fee", req.Amount).Set("spbill_create_ip", req.ClientIP).Set("notify_url", req.NotifyURL)
tradeType := firstAny(req.Extra, "trade_type", "pay_type")
if tradeType == "" {
tradeType = firstAny(c, "trade_type", "pay_type")
}
if tradeType == "" {
tradeType = gopayQQ.TradeType_Native
}
bm.Set("trade_type", tradeType)
mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "mch_id")
rsp, err := client.UnifiedOrder(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付下单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "created", TradeNo: req.TradeNo, QueryID: first(rspMap(rsp), "prepay_id", "code_url"), Payload: mustJSON(rsp)}
return result, nil
}
func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo}
mergeGoPayConfigExtras(bm, c, "query_extra", "nonce_str", "out_trade_no", "mch_id")
rsp, err := client.OrderQuery(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付查单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: normalizeQQState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.TransactionId, Payload: mustJSON(rsp)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
result.Currency = strings.ToUpper(rsp.FeeType)
if result.Currency == "" {
result.Currency = "CNY"
}
if rsp.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
if err != nil {
return nil, err
}
}
if rsp.CashFee != "" {
result.PayerPaidAmount, _ = parseIntegerAmount(rsp.CashFee)
result.CashPaidAmount = result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
return result, nil
}
func (a *qqAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("QQ 支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo, "refund_fee": req.Amount, "op_user_id": firstAny(c, "op_user_id", "merchant_id", "mch_id"), "op_user_passwd": text(c, "op_user_passwd")}
if req.TotalAmount > 0 {
bm.Set("total_fee", req.TotalAmount)
}
if req.Currency != "" {
bm.Set("refund_fee_type", strings.ToUpper(req.Currency))
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "nonce_str", "out_trade_no", "out_refund_no", "refund_fee", "total_fee", "op_user_id", "op_user_passwd", "mch_id")
cert, key, p12 := anyConfig(c, "cert_file", "cert_path"), anyConfig(c, "key_file", "key_path"), anyConfig(c, "pkcs12_file", "p12_file")
if (cert == nil || key == nil) && p12 == nil {
if text(c, "cert_content") != "" || text(c, "key_content") != "" || text(c, "pkcs12_content") != "" {
cert, key, p12 = []byte(text(c, "cert_content")), []byte(text(c, "key_content")), []byte(text(c, "pkcs12_content"))
}
}
if (cert == nil || key == nil) && p12 == nil {
return nil, errors.New("QQ 支付退款缺少 cert_file、key_file、pkcs12_file(或 *_content)")
}
rsp, err := client.Refund(ctx, bm, cert, key, p12)
if err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付退款失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
refundAmount, _ := parseIntegerAmount(rsp.RefundFee)
returnedTradeNo := rsp.OutTradeNo
if returnedTradeNo == "" {
returnedTradeNo = req.TradeNo
}
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "pending", TradeNo: returnedTradeNo, ProviderTradeNo: rsp.RefundId, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *qqAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("QQ 支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
bm, err := gopayQQ.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
signType := firstAny(c, "sign_type", "signature_type", "signType")
if signType == "" {
signType = gopayQQ.SignType_MD5
}
ok, err := gopayQQ.VerifySign(text(c, "api_key"), signType, cloneGoPayBodyMap(bm))
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("QQ 支付回调签名校验失败")
}
values := fromGoPayBodyMap(bm)
status := "pending"
if strings.EqualFold(values["return_code"], "SUCCESS") && strings.EqualFold(values["result_code"], "SUCCESS") && strings.EqualFold(values["trade_state"], "SUCCESS") {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: status, TradeNo: values["out_trade_no"], ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}
+149
View File
@@ -0,0 +1,149 @@
package payment
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"kra/internal/biz"
"kra/pkg/paymentkit"
)
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
if callback == nil {
return fields
}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if stringValue, ok := value.(string); ok {
fields[key] = stringValue
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
}
// Compatibility shims keep channel adapters focused on protocol and
// persistence concerns while stateless parsing rules live in paymentkit.
func normalizePaymentStatus(value, fallback string) string {
return paymentkit.NormalizeStatus(value, fallback)
}
func parseIntegerAmount(value string) (int64, error) {
return paymentkit.ParseIntegerAmount(value)
}
func parseDecimalAmount(value string, scale int64) (int64, error) {
return paymentkit.ParseDecimalAmount(value, scale)
}
func formatDecimalAmount(amount, scale int64) (string, error) {
return paymentkit.FormatDecimalAmount(amount, scale)
}
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
}
func jsonObject(raw []byte) map[string]any {
return paymentkit.JSONObject(raw)
}
func nestedString(value any, keys ...string) string {
return paymentkit.NestedString(value, keys...)
}
func valueAtPath(value any, path string) any {
return paymentkit.ValueAtPath(value, path)
}
func stringAtPath(value any, path string) string {
return paymentkit.StringAtPath(value, path)
}
func configuredValues(values map[string]any, key string) []string {
return paymentkit.ConfiguredValues(values, key)
}
func containsFold(values []string, value string) bool {
return paymentkit.ContainsFold(values, value)
}
// parseConfiguredAmount reads a provider amount field using an explicit unit
// conversion. Missing fields are reported separately so callers can avoid
// manufacturing a complete breakdown from incomplete provider responses.
func parseConfiguredAmount(object any, config map[string]any, fieldKey, scaleKey string, fallbackScale int64) (int64, bool, error) {
path := strings.TrimSpace(text(config, fieldKey))
if path == "" {
return 0, false, nil
}
raw := valueAtPath(object, path)
if raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || strings.EqualFold(strings.TrimSpace(fmt.Sprint(raw)), "<nil>") {
return 0, false, nil
}
value := strings.TrimSpace(fmt.Sprint(raw))
scale := configuredInt64(config, scaleKey, fallbackScale)
if scale <= 0 {
return 0, false, errors.New("支付金额换算比例无效")
}
amount, err := parseIntegerAmount(value)
if scale != 1 {
amount, err = parseDecimalAmount(value, scale)
}
if err != nil {
return 0, true, err
}
if amount < 0 {
return 0, true, errors.New("支付金额不能为负数")
}
return amount, true, nil
}
func amountFromDecimalField(values map[string]any, key string, fallback int64) (int64, bool, error) {
value := strings.TrimSpace(fmt.Sprint(values[key]))
if value == "" || value == "<nil>" {
return 0, false, nil
}
amount, err := parseDecimalAmount(value, fallback)
return amount, true, err
}
+96
View File
@@ -0,0 +1,96 @@
package payment
import (
"context"
"errors"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/saobei"
)
type saobeiAdapter struct{}
func (a *saobeiAdapter) client(c map[string]any) (*saobei.Client, error) {
return saobei.NewClient(text(c, "inst_no"), text(c, "key"), text(c, "merchant_no"), text(c, "terminal_id"), text(c, "access_token"), !strings.EqualFold(text(c, "environment"), "sandbox"))
}
func (a *saobeiAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
now := time.Now().Format("20060102150405")
bm := gopay.BodyMap{"pay_type": firstAnyOr(req.Extra, "010", "pay_type", "payment_type"), "terminal_ip": req.ClientIP, "terminal_trace": req.TradeNo, "terminal_time": now, "total_fee": req.Amount}
mergeGoPayExtras(bm, req.Extra, "pay_type", "payment_type", "terminal_ip", "terminal_trace", "terminal_time", "total_fee", "method", "pay_method", "trade_type")
method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type"))
if method == "barcode" || method == "micropay" {
rsp, callErr := client.BarcodePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, Payload: mustJSON(rsp)}, nil
}
rsp, err := client.MiniPay(ctx, bm)
if err != nil {
return nil, err
}
return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "client_pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, QueryID: rsp.PackageStr, Payload: mustJSON(rsp)}, nil
}
func (a *saobeiAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": tradeNo, "terminal_time": time.Now().Format("20060102150405"), "out_trade_no": tradeNo}
mergeGoPayConfigExtras(bm, c, "query_extra", "pay_type", "terminal_trace", "terminal_time", "out_trade_no")
rsp, err := client.Query(ctx, bm)
if err != nil {
return nil, err
}
result := &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: normalizeSaobeiState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.ChannelTradeNo, Payload: mustJSON(rsp), Currency: strings.ToUpper(firstAnyOr(c, "CNY", "currency", "default_currency"))}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if rsp.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
}
if rsp.BuyerPayFee != "" {
result.PayerPaidAmount, _ = parseIntegerAmount(rsp.BuyerPayFee)
result.CashPaidAmount = result.PayerPaidAmount
if result.Amount >= result.PayerPaidAmount {
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, err
}
func (a *saobeiAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("扫呗支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": req.TradeNo, "terminal_time": time.Now().Format("20060102150405"), "refund_fee": req.Amount, "out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo}
mergeGoPayConfigExtras(bm, c, "refund_extra", "pay_type", "terminal_trace", "terminal_time", "refund_fee", "out_trade_no", "out_refund_no")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("扫呗支付退款响应为空")
}
refundAmount, _ := parseIntegerAmount(rsp.RefundFee)
return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *saobeiAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("扫呗支付回调没有可复用的 GoPay 验签器,请改用主动查单")
}
+289
View File
@@ -0,0 +1,289 @@
package payment
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"kra/internal/biz"
"kra/pkg/paymentkit"
)
type vendorProfile string
const (
vendorChinaums vendorProfile = "chinaums"
vendorSFT vendorProfile = "sft"
vendorSupperPay vendorProfile = "supper-pay"
vendorWechatGame vendorProfile = "wechat-game"
vendorDouyinGame vendorProfile = "douyin-game"
)
type vendorPaymentAdapter struct {
provider string
profile vendorProfile
}
func newVendorAdapter(provider string, profile vendorProfile) Adapter {
return &vendorPaymentAdapter{provider: provider, profile: profile}
}
func (a *vendorPaymentAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
payload := map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": req.TradeNo, "subject": req.Subject, "amount": req.Amount, "currency": req.Currency, "notify_url": req.NotifyURL, "client_ip": req.ClientIP, "timestamp": time.Now().Unix(), "nonce": nonce()}
mergeMap(payload, req.Extra, "merchant_id", "app_id", "trade_no", "subject", "amount", "currency", "notify_url", "client_ip", "timestamp", "nonce")
return a.call(ctx, "create_url", payload, req.TradeNo, c)
}
func (a *vendorPaymentAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
return a.call(ctx, "query_url", map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": tradeNo, "timestamp": time.Now().Unix(), "nonce": nonce()}, tradeNo, c)
}
func (a *vendorPaymentAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
return a.call(ctx, "refund_url", map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": req.TradeNo, "refund_no": req.RefundNo, "amount": req.Amount, "total_amount": req.TotalAmount, "currency": req.Currency, "timestamp": time.Now().Unix(), "nonce": nonce()}, req.TradeNo, c)
}
func (a *vendorPaymentAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
fields := callbackFields(callback)
if err := a.verify(fields, callback.Body, callback.Headers, c); err != nil {
return nil, err
}
object := jsonObject(callback.Body)
statusField := text(c, "callback_status_field")
if statusField == "" {
statusField = text(c, "query_status_field")
}
tradeNoField := text(c, "callback_trade_no_field")
if tradeNoField == "" {
tradeNoField = text(c, "query_trade_no_field")
}
providerTradeNoField := text(c, "callback_provider_trade_no_field")
if providerTradeNoField == "" {
providerTradeNoField = text(c, "query_provider_trade_no_field")
}
state := first(fields, statusField, "status", "trade_status", "order_status", "pay_status")
tradeNo := first(fields, tradeNoField, "trade_no", "out_trade_no", "merchant_order_no", "cp_order_id")
providerTradeNo := first(fields, providerTradeNoField, "transaction_id", "platform_trade_no", "order_no")
if object != nil {
if value := stringAtPath(object, statusField); value != "" {
state = value
}
if value := stringAtPath(object, tradeNoField); value != "" {
tradeNo = value
}
if value := stringAtPath(object, providerTradeNoField); value != "" {
providerTradeNo = value
}
}
status := "pending"
successValues := configuredValues(c, "callback_success_values")
if len(successValues) == 0 {
successValues = configuredValues(c, "query_success_values")
}
if containsFold(successValues, state) {
status = "success"
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{Provider: a.provider, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, Payload: payload}, nil
}
func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, payload map[string]any, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
endpoint := text(c, endpointKey)
if endpoint == "" {
return nil, fmt.Errorf("%s 未配置 %s", a.provider, endpointKey)
}
secret := firstAny(c, "app_key", "merchant_key", "signing_secret", "token")
if secret == "" {
return nil, fmt.Errorf("%s 未配置签名密钥", a.provider)
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
a.signRequest(request, payload, raw, secret, c)
response, err := (&http.Client{Timeout: 20 * time.Second}).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
if err != nil {
return nil, err
}
if response.StatusCode >= 300 {
return nil, fmt.Errorf("%s HTTP %d", a.provider, response.StatusCode)
}
result := &biz.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)}
if endpointKey != "query_url" {
return result, nil
}
object := jsonObject(body)
if object == nil {
return nil, fmt.Errorf("%s 查询响应不是 JSON 对象", a.provider)
}
state := stringAtPath(object, text(c, "query_status_field"))
if !containsFold(configuredValues(c, "query_success_values"), state) {
result.Status = normalizePaymentStatus(state, "pending")
return result, nil
}
result.Status = "success"
result.TradeNo = stringAtPath(object, text(c, "query_trade_no_field"))
result.ProviderTradeNo = stringAtPath(object, text(c, "query_provider_trade_no_field"))
result.Currency = strings.ToUpper(stringAtPath(object, text(c, "query_currency_field")))
amountText := stringAtPath(object, text(c, "query_amount_field"))
amountScale := configuredInt64(c, "query_amount_scale", 0)
if amountScale == 1 {
result.Amount, err = parseIntegerAmount(amountText)
} else {
result.Amount, err = parseDecimalAmount(amountText, amountScale)
}
if err != nil {
return nil, fmt.Errorf("解析 %s 查询金额: %w", a.provider, err)
}
if result.TradeNo == "" || result.ProviderTradeNo == "" || result.Currency == "" {
return nil, fmt.Errorf("%s 查询响应缺少订单号、平台单号或币种", a.provider)
}
if err = populateVendorBreakdown(result, object, c); err != nil {
return nil, fmt.Errorf("解析 %s 查询金额拆分: %w", a.provider, err)
}
return result, nil
}
func populateVendorBreakdown(result *biz.PaymentResult, object map[string]any, c map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
defaultScale := configuredInt64(c, "query_amount_scale", 0)
payer, payerOK, err := parseConfiguredAmount(object, c, "query_payer_paid_amount_field", "query_payer_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("payer paid amount: %w", err)
}
if !payerOK {
return nil
}
cash, cashOK, err := parseConfiguredAmount(object, c, "query_cash_paid_amount_field", "query_cash_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("cash paid amount: %w", err)
}
point, pointOK, err := parseConfiguredAmount(object, c, "query_point_paid_amount_field", "query_point_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("point paid amount: %w", err)
}
discount, discountOK, err := parseConfiguredAmount(object, c, "query_discount_amount_field", "query_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("discount amount: %w", err)
}
providerDiscount, _, err := parseConfiguredAmount(object, c, "query_provider_discount_amount_field", "query_provider_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("provider discount amount: %w", err)
}
merchantDiscount, _, err := parseConfiguredAmount(object, c, "query_merchant_discount_amount_field", "query_merchant_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("merchant discount amount: %w", err)
}
settlement, _, err := parseConfiguredAmount(object, c, "query_settlement_amount_field", "query_settlement_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("settlement amount: %w", err)
}
if !cashOK && !pointOK {
cash = payer
cashOK = true
}
if !cashOK {
cash = payer - point
cashOK = cash >= 0
}
if !pointOK {
point = payer - cash
pointOK = point >= 0
}
if !discountOK {
discount = result.Amount - payer
discountOK = discount >= 0
}
if !cashOK || !pointOK || !discountOK || payer > result.Amount || cash+point != payer || payer+discount != result.Amount {
return errors.New("总额、用户实付、现金/积分和优惠金额不守恒")
}
result.PayerPaidAmount = payer
result.CashPaidAmount = cash
result.PointPaidAmount = point
result.DiscountAmount = discount
result.ProviderDiscountAmount = providerDiscount
result.MerchantDiscountAmount = merchantDiscount
result.SettlementAmount = settlement
result.PayerCurrency = strings.ToUpper(stringAtPath(object, text(c, "query_payer_currency_field")))
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
result.AmountBreakdownKnown = true
return nil
}
func (a *vendorPaymentAdapter) signRequest(request *http.Request, payload map[string]any, raw []byte, secret string, c map[string]any) {
switch a.profile {
case vendorChinaums:
timestamp, nonceValue := fmt.Sprint(payload["timestamp"]), fmt.Sprint(payload["nonce"])
appID := text(c, "app_id")
digest := sha256.Sum256([]byte(appID + timestamp + nonceValue + string(raw) + secret))
request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:]))
case vendorSFT:
request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret))
case vendorWechatGame:
request.Header.Set("X-Wechat-Game-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
if token := text(c, "access_token"); token != "" {
query := request.URL.Query()
query.Set("access_token", token)
request.URL.RawQuery = query.Encode()
}
case vendorDouyinGame:
request.Header.Set("X-TT-Pay-Sign", paymentkit.MD5Canonical(payload, secret))
default:
request.Header.Set("X-Payment-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
}
}
func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, headers map[string]string, c map[string]any) error {
secret := firstAny(c, "app_key", "merchant_key", "signing_secret", "token")
if secret == "" {
return errors.New("支付回调未配置签名密钥")
}
expected := first(fields, "sign", "signature")
if expected == "" {
expected = first(headers, "X-Payment-Sign", "X-SFT-Sign", "X-Wechat-Game-Sign", "X-TT-Pay-Sign")
}
var actual string
switch a.profile {
case vendorSFT, vendorDouyinGame:
values := map[string]any{}
for key, value := range fields {
if key != "sign" && key != "signature" {
values[key] = value
}
}
actual = paymentkit.MD5Canonical(values, secret)
default:
actual = paymentkit.HMACSHA256Hex(raw, secret, false)
}
if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) {
return errors.New("支付回调签名校验失败")
}
return nil
}
func ensureJSON(raw []byte) []byte {
if json.Valid(raw) {
return raw
}
encoded, _ := json.Marshal(string(raw))
return encoded
}
+435
View File
@@ -0,0 +1,435 @@
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) {
client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url"))
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": stringOr(req.Extra, "trade_type", firstAny(c, "trade_type", "pay_type")),
"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 values["trade_type"] == "" {
values["trade_type"] = "JSAPI"
}
mergeWechatV2Extras(values, req.Extra)
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) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
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
}
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 退款请求为空")
}
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": req.TradeNo,
"out_refund_no": req.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
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: req.TradeNo,
ProviderTradeNo: 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)
}
}
return result, nil
}
func (a *wechatV2Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
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 values["return_code"] == "SUCCESS" && (values["result_code"] == "" || 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 nil
}
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": {}, "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
}
+461
View File
@@ -0,0 +1,461 @@
package payment
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
)
type wechatV3Adapter struct{}
func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV3Client(c, "create_url")
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"appid": firstAny(c, "app_id", "appid"),
"mchid": firstAny(c, "merchant_id", "mch_id", "mchid"),
"description": req.Subject,
"out_trade_no": req.TradeNo,
"notify_url": req.NotifyURL,
}
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayExtras(bm, req.Extra,
"appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer",
"trade_type", "pay_type", "method", "openid", "open_id",
)
tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "method"))
if tradeType == "" {
tradeType = strings.ToLower(firstAny(c, "trade_type", "pay_type", "method"))
}
switch tradeType {
case "", "jsapi", "mini", "miniprogram", "mini_program", "applet":
if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" {
bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("openid", openID) })
}
rsp, callErr := client.V3TransactionJsapi(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), tradeType, req.TradeNo, rsp)
case "app":
rsp, callErr := client.V3TransactionApp(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), "app", req.TradeNo, rsp)
case "native", "qr", "qrcode":
rsp, callErr := client.V3TransactionNative(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
case "h5", "mweb":
rsp, callErr := client.V3TransactionH5(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
default:
return nil, fmt.Errorf("微信支付 v3 不支持交易类型 %q", tradeType)
}
}
func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV3Client(c, "query_url")
if err != nil {
return nil, err
}
rsp, err := client.V3TransactionQueryOrder(ctx, gopayWechatV3.OutTradeNo, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("微信支付 v3 查单响应为空")
}
if rsp.Code != gopayWechatV3.Success {
return nil, wechatV3APIError("查单", rsp.Code, rsp.ErrResponse, rsp.Error)
}
if rsp.Response == nil {
return nil, errors.New("微信支付 v3 查单响应缺少订单")
}
order := rsp.Response
payload := wechatV3ResponsePayload(rsp.SignInfo, order)
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: normalizePaymentStatus(order.TradeState, "pending"),
TradeNo: order.OutTradeNo,
ProviderTradeNo: order.TransactionId,
Payload: payload,
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("微信支付 v3 查询响应缺少 transaction_id")
}
if order.Amount == nil || order.Amount.Total <= 0 {
return nil, errors.New("微信支付 v3 查询响应缺少 amount.total")
}
result.Amount = int64(order.Amount.Total)
result.Currency = strings.ToUpper(order.Amount.Currency)
if result.Currency == "" {
return nil, errors.New("微信支付 v3 查询响应缺少 amount.currency")
}
if err := populateWechatV3Breakdown(result, jsonObject(payload)); err != nil {
return nil, err
}
return result, nil
}
func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("微信支付 v3 退款请求为空")
}
client, err := newWechatV3Client(c, "refund_url")
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo}
bm.SetBodyMap("amount", func(values gopay.BodyMap) {
values.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount")
rsp, err := client.V3Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("微信支付 v3 退款响应为空")
}
if rsp.Code != gopayWechatV3.Success {
return nil, wechatV3APIError("退款", rsp.Code, rsp.ErrResponse, rsp.Error)
}
if rsp.Response == nil {
return nil, errors.New("微信支付 v3 退款响应缺少退款单")
}
refund := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: normalizeRefundState(refund.Status),
TradeNo: refund.OutTradeNo,
ProviderTradeNo: refund.RefundId,
Payload: wechatV3ResponsePayload(rsp.SignInfo, refund),
}
if result.TradeNo == "" {
result.TradeNo = req.TradeNo
}
if refund.Amount != nil {
result.Amount = int64(refund.Amount.Refund)
result.Currency = strings.ToUpper(refund.Amount.Currency)
}
if result.Amount == 0 {
result.Amount = req.Amount
}
if result.Currency == "" {
result.Currency = strings.ToUpper(req.Currency)
}
return result, nil
}
func (a *wechatV3Adapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("微信支付 v3 回调为空")
}
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
if apiV3Key == "" {
return nil, errors.New("微信支付 v3 缺少 api_v3_key")
}
client, err := newWechatV3Client(c, "")
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
for key, value := range callback.Headers {
request.Header.Set(key, value)
}
notify, err := gopayWechatV3.V3ParseNotify(request)
if err != nil {
return nil, err
}
if notify.SignInfo == nil {
return nil, errors.New("微信支付 v3 回调缺少签名信息")
}
platformKeys := client.WxPublicKeyMap()
if _, exists := platformKeys[notify.SignInfo.HeaderSerial]; !exists {
for serial, key := range platformKeys {
if strings.EqualFold(serial, notify.SignInfo.HeaderSerial) {
platformKeys[notify.SignInfo.HeaderSerial] = key
break
}
}
}
if err = notify.VerifySignByPKMap(platformKeys); err != nil {
return nil, err
}
pay, err := notify.DecryptPayCipherText(apiV3Key)
if err != nil {
return nil, err
}
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notify.Id
status := "pending"
if pay.TradeState == gopayWechatV3.TradeStateSuccess {
status = "success"
}
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
if pay.Mchid != "" && pay.Mchid != merchantID {
return nil, errors.New("微信支付 v3 回调商户号不匹配")
}
appID := firstAny(c, "app_id", "appid")
if pay.Appid != "" && pay.Appid != appID {
return nil, errors.New("微信支付 v3 回调 appid 不匹配")
}
plain, _ := json.Marshal(pay)
return &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: status,
TradeNo: pay.OutTradeNo,
ProviderTradeNo: pay.TransactionId,
Payload: plain,
}, nil
}
func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.ClientV3, error) {
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
serialNo := firstAny(c, "serial_no", "merchant_serial_no", "certificate_serial_no")
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
privateKey := firstAny(c, "private_key", "merchant_private_key")
if merchantID == "" || serialNo == "" || apiV3Key == "" || privateKey == "" {
return nil, errors.New("微信支付 v3 缺少 merchant_id、serial_no、api_v3_key 或 private_key")
}
client, err := gopayWechatV3.NewClientV3(merchantID, serialNo, apiV3Key, privateKey)
if err != nil {
return nil, err
}
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
if platformCert == "" {
return nil, errors.New("微信支付 v3 缺少 platform_cert")
}
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no")
if platformSerial == "" {
platformSerial, err = wechatV3PlatformSerial(platformCert)
if err != nil {
return nil, err
}
}
if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil {
return nil, err
}
client.SetBodySize(8)
client.GetHttpClient().SetTimeout(20 * time.Second)
if endpointKey != "" {
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("微信支付 v3 %s 配置无效", endpointKey)
}
if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("微信支付 v3 %s 只能配置 scheme 和 host", endpointKey)
}
httpClient := client.GetHttpClient()
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
return client, nil
}
}
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
parsed, parseErr := url.Parse(baseURL)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("微信支付 v3 base_url 配置无效")
}
client.SetProxyHost(baseURL)
}
return client, nil
}
type wechatV3EndpointTransport struct {
base http.RoundTripper
endpoint *url.URL
}
func (t *wechatV3EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
clone := request.Clone(request.Context())
target := *request.URL
target.Scheme = t.endpoint.Scheme
target.Host = t.endpoint.Host
target.User = t.endpoint.User
clone.URL = &target
clone.Host = ""
return base.RoundTrip(clone)
}
func wechatV3PlatformSerial(certificate string) (string, error) {
block, _ := pem.Decode([]byte(certificate))
if block == nil || block.Type != "CERTIFICATE" {
return "", errors.New("微信支付 v3 platform_cert 格式错误")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析微信支付 v3 platform_cert: %w", err)
}
return strings.ToUpper(cert.SerialNumber.Text(16)), nil
}
func wechatV3CreateResult(tradeNo string, code int, signInfo *gopayWechatV3.SignInfo, response any, errResponse gopayWechatV3.ErrResponse, rawError string) (*biz.PaymentResult, error) {
if code != gopayWechatV3.Success {
return nil, wechatV3APIError("下单", code, errResponse, rawError)
}
if response == nil {
return nil, errors.New("微信支付 v3 下单响应缺少预支付信息")
}
return &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: "created",
TradeNo: tradeNo,
Payload: wechatV3ResponsePayload(signInfo, response),
}, nil
}
func wechatV3PrepayCreateResult(client *gopayWechatV3.ClientV3, appID, tradeType, tradeNo string, rsp *gopayWechatV3.PrepayRsp) (*biz.PaymentResult, error) {
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
result, err := wechatV3CreateResult(tradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
if err != nil {
return nil, err
}
if rsp.Response == nil || strings.TrimSpace(rsp.Response.PrepayId) == "" {
return nil, errors.New("微信支付 v3 下单响应缺少 prepay_id")
}
var payParams any
switch strings.ToLower(strings.TrimSpace(tradeType)) {
case "app":
payParams, err = client.PaySignOfApp(appID, rsp.Response.PrepayId)
case "mini", "miniprogram", "mini_program", "applet":
payParams, err = client.PaySignOfApplet(appID, rsp.Response.PrepayId)
default:
payParams, err = client.PaySignOfJSAPI(appID, rsp.Response.PrepayId)
}
if err != nil {
return nil, fmt.Errorf("生成微信支付 v3 客户端调起参数: %w", err)
}
result.Payload = mustJSON(payParams)
return result, nil
}
func wechatV3APIError(operation string, code int, response gopayWechatV3.ErrResponse, raw string) error {
message := strings.TrimSpace(response.Message)
if message == "" {
message = strings.TrimSpace(raw)
}
if message == "" {
return fmt.Errorf("微信支付 v3 %s HTTP %d", operation, code)
}
return fmt.Errorf("微信支付 v3 %s HTTP %d: %s", operation, code, message)
}
func wechatV3ResponsePayload(signInfo *gopayWechatV3.SignInfo, response any) []byte {
if signInfo != nil && strings.TrimSpace(signInfo.SignBody) != "" {
return []byte(signInfo.SignBody)
}
payload, _ := json.Marshal(response)
return payload
}
func populateWechatV3Breakdown(result *biz.PaymentResult, object map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
amount, ok := valueAtPath(object, "amount").(map[string]any)
if !ok {
return nil
}
payerTotal, payerOK, err := parseJSONInteger(amount, "payer_total")
if err != nil {
return fmt.Errorf("解析微信支付 v3 amount.payer_total: %w", err)
}
if !payerOK {
return nil
}
if payerTotal < 0 || payerTotal > result.Amount {
return errors.New("微信支付 v3 用户实付金额无效")
}
result.PayerPaidAmount = payerTotal
result.CashPaidAmount = payerTotal
result.DiscountAmount = result.Amount - payerTotal
if settlement, ok, settlementErr := parseJSONInteger(amount, "settlement_amount"); settlementErr != nil {
return fmt.Errorf("解析微信支付 v3 amount.settlement_amount: %w", settlementErr)
} else if ok {
result.SettlementAmount = settlement
}
// promotion_detail contains the authoritative promotion breakdown, but its
// funding fields vary by promotion type. We only expose the aggregate
// discount here; the original JSON remains in Payload for audit.
if result.DiscountAmount < 0 {
return errors.New("微信支付 v3 优惠金额无效")
}
result.AmountBreakdownKnown = true
return nil
}
func parseJSONInteger(object map[string]any, key string) (int64, bool, error) {
raw, exists := object[key]
if !exists || raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || fmt.Sprint(raw) == "<nil>" {
return 0, false, nil
}
amount, err := parseIntegerAmount(fmt.Sprint(raw))
return amount, true, err
}
func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) {
if len(key) != 32 {
return nil, errors.New("微信支付 v3 api_v3_key 必须为 32 字节")
}
return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key)
}
+23
View File
@@ -0,0 +1,23 @@
package data
import "kra/pkg/paymentkit"
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
}
// configuredInt64 keeps repository-side validation independent from the
// provider adapter package while sharing the canonical paymentkit parser.
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
}
+42
View File
@@ -0,0 +1,42 @@
package data
import (
"testing"
"kra/internal/biz"
)
func TestMigrateSeedsPaymentProviders(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
var count int64
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != int64(len(biz.SupportedPaymentProviders)) {
t.Fatalf("payment providers = %d", count)
}
if !db.Migrator().HasTable("pay_orders") {
t.Fatal("payment migration must create pay_orders")
}
if db.Migrator().HasTable("pay_callback_events") {
t.Fatal("payment migration must not create pay_callback_events")
}
}
func TestPaymentSecretMaskAndMerge(t *testing.T) {
values := map[string]any{"app_id": "a", "private_key": "private", "nested": map[string]any{"api_v3_key": "key"}}
maskPaymentSecrets(values)
if values["private_key"] != "******" || values["nested"].(map[string]any)["api_v3_key"] != "******" {
t.Fatalf("not masked: %#v", values)
}
mergePaymentSecrets(values, map[string]any{"private_key": "private", "nested": map[string]any{"api_v3_key": "key"}})
if values["private_key"] != "private" || values["nested"].(map[string]any)["api_v3_key"] != "key" {
t.Fatalf("not merged: %#v", values)
}
}
+488
View File
@@ -0,0 +1,488 @@
package data
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz"
)
type paymentOrderPO struct {
ID uint64 `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
TradeNo string `gorm:"size:128;not null;uniqueIndex:idx_pay_orders_provider_trade"`
Provider string `gorm:"size:64;not null;uniqueIndex:idx_pay_orders_provider_trade;uniqueIndex:idx_pay_orders_provider_provider_trade;index"`
ProviderTradeNo *string `gorm:"size:128;uniqueIndex:idx_pay_orders_provider_provider_trade"`
QueryID string `gorm:"size:128"`
BusinessType string `gorm:"size:64;not null;index:idx_pay_orders_business"`
BusinessID string `gorm:"size:128;not null;index:idx_pay_orders_business"`
Subject string `gorm:"size:256;not null"`
PaymentMode string `gorm:"size:32;not null;default:external;index"`
OriginalAmount int64 `gorm:"not null;default:0"`
Amount int64 `gorm:"not null"`
PaidAmount int64 `gorm:"not null;default:0"`
PayerPaidAmount int64 `gorm:"not null;default:0"`
CashPaidAmount int64 `gorm:"not null;default:0"`
PointPaidAmount int64 `gorm:"not null;default:0"`
DiscountAmount int64 `gorm:"not null;default:0"`
ProviderDiscountAmount int64 `gorm:"not null;default:0"`
MerchantDiscountAmount int64 `gorm:"not null;default:0"`
SettlementAmount int64 `gorm:"not null;default:0"`
Currency string `gorm:"size:16;not null"`
PayerCurrency string `gorm:"size:16"`
AmountBreakdownKnown bool `gorm:"not null;default:false"`
PaymentStatus string `gorm:"size:32;not null;index"`
ProviderStatus string `gorm:"size:64"`
FulfillmentStatus string `gorm:"size:32;not null;index"`
RefundStatus string `gorm:"size:32;not null;index"`
RefundedAmount int64 `gorm:"not null;default:0"`
RefundRequestedAmount int64 `gorm:"not null;default:0"`
RefundNo string `gorm:"size:128;index"`
ConfirmationID string `gorm:"size:36;not null;uniqueIndex"`
RequestFingerprint string `gorm:"size:64;not null;index"`
CreatePayload string `gorm:"type:text"`
Extra string `gorm:"type:text"`
LastEventID string `gorm:"size:128"`
LastPayloadHash string `gorm:"size:64"`
LastError string `gorm:"size:512"`
FulfillmentToken string `gorm:"size:36"`
RefundToken string `gorm:"size:36"`
Version uint64 `gorm:"not null;default:1"`
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
func (paymentOrderPO) TableName() string { return "pay_orders" }
type paymentOrderRepo struct{ data *Data }
func NewPaymentOrderRepo(data *Data) biz.PaymentOrderRepo {
return &paymentOrderRepo{data: data}
}
func newPaymentOrderPO(order *biz.PaymentOrder) (*paymentOrderPO, error) {
if order == nil {
return nil, errors.New("支付订单为空")
}
extra := string(order.Extra)
if extra == "" {
extra = "{}"
}
createPayload := string(order.CreatePayload)
return &paymentOrderPO{
ID: order.ID, TradeNo: order.TradeNo, Provider: order.Provider,
ProviderTradeNo: optionalString(order.ProviderTradeNo), QueryID: order.QueryID,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: defaultString(order.PaymentMode, biz.PaymentModeExternal), OriginalAmount: order.OriginalAmount,
Amount: order.Amount, PaidAmount: order.PaidAmount, PayerPaidAmount: order.PayerPaidAmount,
CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount, DiscountAmount: order.DiscountAmount,
ProviderDiscountAmount: order.ProviderDiscountAmount, MerchantDiscountAmount: order.MerchantDiscountAmount,
SettlementAmount: order.SettlementAmount, Currency: order.Currency, PayerCurrency: order.PayerCurrency,
AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: defaultString(order.PaymentStatus, biz.PaymentStatusInitialized),
ProviderStatus: order.ProviderStatus,
FulfillmentStatus: defaultString(order.FulfillmentStatus, biz.FulfillmentStatusPending),
RefundStatus: defaultString(order.RefundStatus, biz.RefundStatusNone),
RefundedAmount: order.RefundedAmount, RefundRequestedAmount: order.RefundRequestedAmount, RefundNo: order.RefundNo,
ConfirmationID: order.ConfirmationID, RequestFingerprint: order.RequestFingerprint,
CreatePayload: createPayload, Extra: extra, LastEventID: order.LastEventID,
LastPayloadHash: order.LastPayloadHash, LastError: order.LastError,
FulfillmentToken: order.FulfillmentToken, RefundToken: order.RefundToken, Version: defaultVersion(order.Version),
PaidAt: order.PaidAt, FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt,
FulfillmentLeaseUntil: order.FulfillmentLeaseUntil, RefundLeaseUntil: order.RefundLeaseUntil,
}, nil
}
func toBizPaymentOrder(po *paymentOrderPO) *biz.PaymentOrder {
if po == nil {
return nil
}
return &biz.PaymentOrder{
ID: po.ID, TradeNo: po.TradeNo, Provider: po.Provider,
ProviderTradeNo: dereferenceString(po.ProviderTradeNo), QueryID: po.QueryID,
BusinessType: po.BusinessType, BusinessID: po.BusinessID, Subject: po.Subject,
PaymentMode: defaultString(po.PaymentMode, biz.PaymentModeExternal), OriginalAmount: po.OriginalAmount,
Amount: po.Amount, PaidAmount: po.PaidAmount, PayerPaidAmount: po.PayerPaidAmount,
CashPaidAmount: po.CashPaidAmount, PointPaidAmount: po.PointPaidAmount, DiscountAmount: po.DiscountAmount,
ProviderDiscountAmount: po.ProviderDiscountAmount, MerchantDiscountAmount: po.MerchantDiscountAmount,
SettlementAmount: po.SettlementAmount, Currency: po.Currency, PayerCurrency: po.PayerCurrency,
AmountBreakdownKnown: po.AmountBreakdownKnown,
PaymentStatus: po.PaymentStatus, ProviderStatus: po.ProviderStatus,
FulfillmentStatus: po.FulfillmentStatus, RefundStatus: po.RefundStatus,
RefundedAmount: po.RefundedAmount, RefundRequestedAmount: po.RefundRequestedAmount, RefundNo: po.RefundNo,
ConfirmationID: po.ConfirmationID, RequestFingerprint: po.RequestFingerprint,
CreatePayload: json.RawMessage(po.CreatePayload), Extra: json.RawMessage(po.Extra),
LastEventID: po.LastEventID, LastPayloadHash: po.LastPayloadHash, LastError: po.LastError,
FulfillmentToken: po.FulfillmentToken, RefundToken: po.RefundToken, Version: po.Version, CreatedAt: po.CreatedAt,
UpdatedAt: po.UpdatedAt, PaidAt: po.PaidAt, FulfilledAt: po.FulfilledAt,
RefundedAt: po.RefundedAt, FulfillmentLeaseUntil: po.FulfillmentLeaseUntil, RefundLeaseUntil: po.RefundLeaseUntil,
}
}
func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *biz.PaymentOrder) (*biz.PaymentOrder, bool, error) {
po, err := newPaymentOrderPO(order)
if err != nil {
return nil, false, err
}
db := r.data.gormDB.WithContext(ctx)
var current paymentOrderPO
err = db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error
if err == nil {
return toBizPaymentOrder(&current), false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
if err = db.Create(po).Error; err == nil {
return toBizPaymentOrder(po), true, nil
}
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error; lookupErr == nil {
return toBizPaymentOrder(&current), false, nil
}
return nil, false, err
}
func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, tradeNo string) (*biz.PaymentOrder, error) {
var po paymentOrderPO
if err := r.data.gormDB.WithContext(ctx).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrPaymentOrderNotFound
}
return nil, err
}
return toBizPaymentOrder(&po), nil
}
func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
return errors.New("支付下单结果为空")
}
if err := applyProviderIdentity(tx, po, update); err != nil {
return err
}
if len(update.CreatePayload) > 0 {
po.CreatePayload = string(update.CreatePayload)
}
po.ProviderStatus = trimTo(update.ProviderStatus, 64)
po.LastEventID = trimTo(update.EventID, 128)
po.LastPayloadHash = trimTo(update.PayloadHash, 64)
status := normalizeOrderPaymentStatus(update.Status)
if status == biz.PaymentStatusPaid {
// Provider create responses are never sufficient proof of payment.
status = biz.PaymentStatusPending
}
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded && status != "" {
po.PaymentStatus = status
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
return errors.New("支付查单结果为空")
}
if err := applyProviderIdentity(tx, po, update); err != nil {
return err
}
if update.Amount > 0 && update.Amount != po.Amount {
return biz.ErrPaymentOrderConflict
}
if update.PayerPaidAmount < 0 || update.CashPaidAmount < 0 || update.PointPaidAmount < 0 || update.DiscountAmount < 0 || update.ProviderDiscountAmount < 0 || update.MerchantDiscountAmount < 0 || update.SettlementAmount < 0 {
return biz.ErrPaymentOrderConflict
}
if update.Currency != "" && !strings.EqualFold(update.Currency, po.Currency) {
return biz.ErrPaymentOrderConflict
}
po.ProviderStatus = trimTo(update.ProviderStatus, 64)
po.LastEventID = trimTo(update.EventID, 128)
po.LastPayloadHash = trimTo(update.PayloadHash, 64)
if update.AmountBreakdownKnown {
po.PayerPaidAmount = update.PayerPaidAmount
po.CashPaidAmount = update.CashPaidAmount
po.PointPaidAmount = update.PointPaidAmount
po.DiscountAmount = update.DiscountAmount
po.ProviderDiscountAmount = update.ProviderDiscountAmount
po.MerchantDiscountAmount = update.MerchantDiscountAmount
po.SettlementAmount = update.SettlementAmount
po.PayerCurrency = trimTo(update.PayerCurrency, 16)
po.AmountBreakdownKnown = true
}
switch normalizeOrderPaymentStatus(update.Status) {
case biz.PaymentStatusPaid:
if po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusPaid
}
po.PaidAmount = po.Amount
if po.PaidAt == nil {
now := time.Now().UTC()
po.PaidAt = &now
}
case biz.PaymentStatusPending:
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusPending
}
case biz.PaymentStatusFailed:
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusFailed
}
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider, tradeNo string, lease time.Duration) (*biz.PaymentOrder, string, bool, error) {
var token string
var duplicate bool
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus == biz.FulfillmentStatusSucceeded {
duplicate = true
return nil
}
if po.PaymentStatus != biz.PaymentStatusPaid {
return biz.ErrPaymentOrderState
}
now := time.Now().UTC()
if po.FulfillmentStatus == biz.FulfillmentStatusProcessing && po.FulfillmentLeaseUntil != nil && po.FulfillmentLeaseUntil.After(now) {
return biz.ErrPaymentOrderBusy
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
until := now.Add(lease)
po.FulfillmentStatus = biz.FulfillmentStatusProcessing
po.FulfillmentToken = token
po.FulfillmentLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
})
return order, token, duplicate, err
}
func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provider, tradeNo, token string, success bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus != biz.FulfillmentStatusProcessing || po.FulfillmentToken != token {
return biz.ErrPaymentOrderBusy
}
po.FulfillmentToken = ""
po.FulfillmentLeaseUntil = nil
po.LastError = trimTo(message, 512)
if success {
po.FulfillmentStatus = biz.FulfillmentStatusSucceeded
now := time.Now().UTC()
po.FulfilledAt = &now
} else {
po.FulfillmentStatus = biz.FulfillmentStatusFailed
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tradeNo string, amount int64, lease time.Duration) (*biz.PaymentOrder, string, error) {
var token string
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded {
return biz.ErrPaymentOrderState
}
if amount <= 0 {
return biz.ErrPaymentOrderConflict
}
now := time.Now().UTC()
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && po.RefundLeaseUntil.After(now) {
return biz.ErrPaymentOrderBusy
}
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundRequestedAmount != amount {
return biz.ErrPaymentOrderConflict
}
if po.RefundStatus == biz.RefundStatusPending {
return biz.ErrPaymentOrderBusy
}
// An expired processing lease means the provider outcome is unknown.
// Retry the same refund amount with the same durable refund number. A
// different amount must never reuse that operation identity.
reserved := int64(0)
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && !po.RefundLeaseUntil.After(now) {
reserved = 0
} else {
reserved = po.RefundRequestedAmount
}
if amount > po.Amount-po.RefundedAmount-reserved {
return biz.ErrPaymentOrderConflict
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
if po.RefundNo == "" {
po.RefundNo = uuid.NewString()
}
until := now.Add(lease)
po.RefundStatus = biz.RefundStatusProcessing
po.RefundRequestedAmount = amount
po.RefundToken = token
po.RefundLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
})
return order, token, err
}
func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, provider, tradeNo, token string, accepted bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != biz.RefundStatusProcessing || po.RefundToken != token {
return biz.ErrPaymentOrderBusy
}
po.RefundToken = ""
po.RefundLeaseUntil = nil
po.LastError = trimTo(message, 512)
if accepted {
po.RefundStatus = biz.RefundStatusPending
} else {
po.RefundStatus = biz.RefundStatusFailed
po.RefundRequestedAmount = 0
po.RefundNo = ""
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != biz.RefundStatusPending || po.RefundNo == "" || po.RefundNo != refundNo || po.RefundRequestedAmount != amount {
return biz.ErrPaymentOrderState
}
po.LastError = trimTo(message, 512)
po.RefundRequestedAmount = 0
if !success {
po.RefundStatus = biz.RefundStatusFailed
po.RefundNo = ""
po.Version++
return tx.Save(po).Error
}
po.RefundedAmount += amount
if po.RefundedAmount >= po.Amount {
po.PaymentStatus = biz.PaymentStatusRefunded
po.RefundStatus = biz.RefundStatusSucceeded
} else {
po.PaymentStatus = biz.PaymentStatusPartiallyRefunded
po.RefundStatus = biz.RefundStatusPartial
}
po.RefundNo = ""
now := time.Now().UTC()
po.RefundedAt = &now
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) withLockedOrder(ctx context.Context, provider, tradeNo string, fn func(*gorm.DB, *paymentOrderPO) error) (*biz.PaymentOrder, error) {
var result *biz.PaymentOrder
err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var po paymentOrderPO
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return biz.ErrPaymentOrderNotFound
}
return err
}
if err := fn(tx, &po); err != nil {
return err
}
result = toBizPaymentOrder(&po)
return nil
})
return result, err
}
func applyProviderIdentity(tx *gorm.DB, po *paymentOrderPO, update *biz.PaymentProviderUpdate) error {
if update.ProviderTradeNo != "" {
if po.ProviderTradeNo != nil && *po.ProviderTradeNo != update.ProviderTradeNo {
return biz.ErrPaymentProviderConflict
}
var other paymentOrderPO
// This check is repeated under the order transaction so a platform
// transaction cannot be attached to a different merchant order.
if err := tx.Where("provider = ? AND provider_trade_no = ? AND id <> ?", po.Provider, update.ProviderTradeNo, po.ID).First(&other).Error; err == nil {
return biz.ErrPaymentProviderConflict
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
value := update.ProviderTradeNo
po.ProviderTradeNo = &value
}
if update.QueryID != "" {
if po.QueryID != "" && po.QueryID != update.QueryID {
return biz.ErrPaymentProviderConflict
}
po.QueryID = update.QueryID
}
return nil
}
func normalizeOrderPaymentStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "success", "paid", "fulfilled":
return biz.PaymentStatusPaid
case "pending", "created", "client_pending", "processing":
return biz.PaymentStatusPending
case "failed", "closed", "cancelled", "canceled":
return biz.PaymentStatusFailed
default:
return ""
}
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func dereferenceString(value *string) string {
if value == nil {
return ""
}
return *value
}
func defaultString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func defaultVersion(value uint64) uint64 {
if value == 0 {
return 1
}
return value
}
func trimTo(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) > max {
return value[:max]
}
return value
}
+93
View File
@@ -0,0 +1,93 @@
package data
import (
"context"
"testing"
"time"
"kra/internal/biz"
)
func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo {
t.Helper()
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&paymentOrderPO{}); err != nil {
t.Fatal(err)
}
return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
}
func testPaymentOrder() *biz.PaymentOrder {
return &biz.PaymentOrder{
TradeNo: "order-1", Provider: biz.PaymentAlipay, BusinessType: "game_item", BusinessID: "item-1",
Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: biz.PaymentStatusInitialized,
FulfillmentStatus: biz.FulfillmentStatusPending, RefundStatus: biz.RefundStatusNone,
ConfirmationID: "11111111-1111-1111-1111-111111111111", RequestFingerprint: "fingerprint",
}
}
func TestPaymentOrderRepositoryPersistsPaymentFulfillmentAndRefundState(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := context.Background()
order, created, err := repo.CreatePaymentOrder(ctx, testPaymentOrder())
if err != nil || !created {
t.Fatalf("create order = %#v created=%v err=%v", order, created, err)
}
update := &biz.PaymentProviderUpdate{Status: "success", ProviderStatus: "TRADE_SUCCESS", ProviderTradeNo: "provider-1", Amount: 100, Currency: "CNY", EventID: "event-1"}
order, err = repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-1", update)
if err != nil || order.PaymentStatus != biz.PaymentStatusPaid || order.PaidAmount != 100 {
t.Fatalf("apply payment = %#v err=%v", order, err)
}
order, token, duplicate, err := repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute)
if err != nil || duplicate || token == "" || order.FulfillmentStatus != biz.FulfillmentStatusProcessing {
t.Fatalf("begin fulfillment = %#v token=%q duplicate=%v err=%v", order, token, duplicate, err)
}
if _, _, _, err = repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute); err == nil {
t.Fatal("concurrent fulfillment was accepted")
}
order, err = repo.CompletePaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", token, true, "")
if err != nil || order.FulfillmentStatus != biz.FulfillmentStatusSucceeded {
t.Fatalf("complete fulfillment = %#v err=%v", order, err)
}
_, _, duplicate, err = repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute)
if err != nil || !duplicate {
t.Fatalf("duplicate fulfillment = duplicate=%v err=%v", duplicate, err)
}
order, token, err = repo.BeginPaymentRefund(ctx, biz.PaymentAlipay, "order-1", 40, time.Minute)
if err != nil || token == "" || order.RefundStatus != biz.RefundStatusProcessing {
t.Fatalf("begin refund = %#v token=%q err=%v", order, token, err)
}
order, err = repo.CompletePaymentRefundRequest(ctx, biz.PaymentAlipay, "order-1", token, true, "")
if err != nil || order.RefundStatus != biz.RefundStatusPending || order.RefundRequestedAmount != 40 {
t.Fatalf("accept refund = %#v err=%v", order, err)
}
order, err = repo.ConfirmPaymentRefund(ctx, biz.PaymentAlipay, "order-1", order.RefundNo, 40, true, "")
if err != nil || order.RefundedAmount != 40 || order.PaymentStatus != biz.PaymentStatusPartiallyRefunded {
t.Fatalf("confirm refund = %#v err=%v", order, err)
}
}
func TestPaymentOrderRepositoryRejectsProviderTradeReuse(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := context.Background()
first := testPaymentOrder()
if _, _, err := repo.CreatePaymentOrder(ctx, first); err != nil {
t.Fatal(err)
}
second := testPaymentOrder()
second.TradeNo = "order-2"
second.ConfirmationID = "22222222-2222-2222-2222-222222222222"
if _, _, err := repo.CreatePaymentOrder(ctx, second); err != nil {
t.Fatal(err)
}
update := &biz.PaymentProviderUpdate{Status: "success", ProviderTradeNo: "provider-1", Amount: 100, Currency: "CNY"}
if _, err := repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-1", update); err != nil {
t.Fatal(err)
}
if _, err := repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-2", update); err != biz.ErrPaymentProviderConflict {
t.Fatalf("provider trade reuse err = %v", err)
}
}
+8 -33
View File
@@ -2,8 +2,6 @@ package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"mime"
@@ -14,6 +12,7 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/osskit"
)
type fileStorage struct {
@@ -66,40 +65,16 @@ func New(config *conf.AdminBackend) (biz.FileStorage, error) {
}
func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if ctx.Err() != nil {
errCh <- ctx.Err()
return
}
file, err := storage.Open(ctx, name)
if err != nil {
errCh <- err
return
}
_, err = io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if err != nil {
errCh <- err
return
}
}
errCh <- nil
}()
stored, err := storage.Put(ctx, destination, reader)
composeErr := <-errCh
var stored *biz.StoredFile
hash, err := osskit.ComposeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error {
var putErr error
stored, putErr = storage.Put(ctx, destination, reader)
return putErr
}, storage.Delete, destination)
if err != nil {
return nil, "", err
}
if composeErr != nil {
_ = storage.Delete(ctx, destination)
return nil, "", composeErr
}
return stored, hex.EncodeToString(hash.Sum(nil)), nil
return stored, hash, nil
}
func (s *fileStorage) resolve(name string) (string, error) {