支付相关
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 验签器,请改用主动查单")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), "")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 验签器,请改用主动查单")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¤t).Error
|
||||
if err == nil {
|
||||
return toBizPaymentOrder(¤t), 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(¤t).Error; lookupErr == nil {
|
||||
return toBizPaymentOrder(¤t), 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user