优化结构
This commit is contained in:
@@ -26,8 +26,10 @@ func AdminSurface() platformmodule.Surface {
|
||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||
},
|
||||
APIs: []platformmodule.API{
|
||||
{Path: "/payment/configs", Method: "GET", Group: "支付", Description: "获取支付渠道配置"},
|
||||
{Path: "/payment/config", Method: "POST", Group: "支付", Description: "保存支付渠道配置"},
|
||||
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
|
||||
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
|
||||
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
|
||||
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
|
||||
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
||||
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
||||
},
|
||||
|
||||
@@ -23,12 +23,31 @@ func NewPaymentRepo(data Provider) biz.PaymentRepo { return &paymentRepo{data: d
|
||||
|
||||
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 {
|
||||
var row integrationConfigPO
|
||||
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error
|
||||
defaults := biz.DefaultIntegrationConfig(integrationKindPayment, provider)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
encoded, _ := json.Marshal(defaults)
|
||||
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: string(encoded)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: "{}"}).Error; err != nil {
|
||||
values := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(row.Config), &values)
|
||||
changed := false
|
||||
for key, value := range defaults {
|
||||
if _, exists := values[key]; !exists {
|
||||
values[key] = value
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
encoded, _ := json.Marshal(values)
|
||||
if err := db.Model(&row).Update("config", string(encoded)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -54,63 +73,6 @@ func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationCon
|
||||
return &row, values, nil
|
||||
}
|
||||
|
||||
func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
|
||||
var rows []integrationConfigPO
|
||||
if err := r.data.DB().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.DB().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 {
|
||||
@@ -302,50 +264,6 @@ func first(values map[string]string, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var paymentSecretKeys = map[string]bool{
|
||||
"key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true,
|
||||
"app_key": true, "client_key": true, "client_secret": true, "api_key": true,
|
||||
"api_v3_key": true, "api_v3key": true, "apiv3_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, "key_content": true, "key_pem": true, "apiclient_key": true,
|
||||
"app_secret": true, "appsecret": true, "token": true, "access_token": true, "app_auth_token": true,
|
||||
"credential_code": true, "cus_id": true, "op_user_passwd": true, "p12": true, "pkcs12": true,
|
||||
"pkcs12_content": 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 {
|
||||
@@ -356,180 +274,5 @@ func contains(values []string, value string) bool {
|
||||
}
|
||||
|
||||
func validatePaymentConfig(provider string, values map[string]any) error {
|
||||
required := []string{}
|
||||
configDriven := false
|
||||
switch provider {
|
||||
case biz.PaymentAlipay:
|
||||
required = []string{"app_id", "private_key", "public_key"}
|
||||
case biz.PaymentAlipayV3:
|
||||
required = []string{"app_id"}
|
||||
for _, group := range []struct {
|
||||
label string
|
||||
keys []string
|
||||
}{
|
||||
{label: "private_key", keys: []string{"private_key", "private_key_content", "private_key_path"}},
|
||||
{label: "app_cert", keys: []string{"app_cert", "app_cert_content", "app_cert_path", "app_public_cert", "app_public_cert_content", "app_public_cert_path"}},
|
||||
{label: "root_cert", keys: []string{"root_cert", "root_cert_content", "root_cert_path", "alipay_root_cert", "alipay_root_cert_content", "alipay_root_cert_path"}},
|
||||
{label: "public_cert", keys: []string{"public_cert", "public_cert_content", "public_cert_path", "alipay_public_cert", "alipay_public_cert_content", "alipay_public_cert_path"}},
|
||||
} {
|
||||
if firstAny(values, group.keys...) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label)
|
||||
}
|
||||
}
|
||||
case biz.PaymentWechatV2:
|
||||
required = []string{"app_id", "merchant_id", "mch_key"}
|
||||
if firstAny(values, "client_cert", "cert_pem", "apiclient_cert") == "" || firstAny(values, "client_key", "key_pem", "apiclient_key") == "" {
|
||||
return fmt.Errorf("%s 退款要求同时配置 client_cert 和 client_key", provider)
|
||||
}
|
||||
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{"app_id", "merchant_id", "serial_no", "api_key", "private_key", "platform_cert"}
|
||||
case biz.PaymentQQ:
|
||||
if firstAny(values, "mch_id", "merchant_id") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
|
||||
}
|
||||
required = []string{"api_key"}
|
||||
if !hasQQRefundCertificate(values) {
|
||||
return fmt.Errorf("%s 退款要求配置 cert_file + key_file、pkcs12_file 或对应的 *_content", provider)
|
||||
}
|
||||
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", "webhook_id"}
|
||||
case biz.PaymentSaobei:
|
||||
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
|
||||
default:
|
||||
configDriven = true
|
||||
required = []string{
|
||||
"protocol_version", "app_id", "merchant_id", "create_url", "query_url", "refund_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 configDriven && key == "query_amount_scale" {
|
||||
if _, exists := values[key]; !exists {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(text(values, key)) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
|
||||
}
|
||||
}
|
||||
if configDriven && !validPaymentAmountScale(configuredInt64(values, "query_amount_scale", 0)) {
|
||||
return fmt.Errorf("%s query_amount_scale 必须是正的 10 的幂", provider)
|
||||
}
|
||||
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
|
||||
}
|
||||
if provider == biz.PaymentAllinPay {
|
||||
orderType := strings.ToLower(strings.TrimSpace(firstAny(values, "query_order_type", "order_type")))
|
||||
if orderType != "" && orderType != "reqsn" && orderType != "trxid" {
|
||||
return fmt.Errorf("%s query_order_type 必须是 reqsn 或 trxid", 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 hasQQRefundCertificate(values map[string]any) bool {
|
||||
if firstAny(values, "pkcs12_file", "p12_file", "pkcs12_content") != "" {
|
||||
return true
|
||||
}
|
||||
if firstAny(values, "cert_file", "cert_path") != "" && firstAny(values, "key_file", "key_path") != "" {
|
||||
return true
|
||||
}
|
||||
return firstAny(values, "cert_content") != "" && firstAny(values, "key_content") != ""
|
||||
}
|
||||
|
||||
func validPaymentAmountScale(scale int64) bool {
|
||||
if scale <= 0 {
|
||||
return false
|
||||
}
|
||||
for scale%10 == 0 {
|
||||
scale /= 10
|
||||
}
|
||||
return scale == 1
|
||||
}
|
||||
|
||||
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
|
||||
return biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, provider, values)
|
||||
}
|
||||
|
||||
@@ -1,300 +1,64 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/app/system/internal/biz"
|
||||
)
|
||||
|
||||
func TestSavePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
||||
func TestValidatePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
|
||||
values := map[string]any{
|
||||
"merchant_id": "merchant-douyin",
|
||||
"serial_no": "merchant-serial",
|
||||
"api_key": "01234567890123456789012345678901",
|
||||
"private_key": "merchant-private-key",
|
||||
"platform_cert": "platform-public-key",
|
||||
"platform_serial_no": "platform-serial",
|
||||
"merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
|
||||
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_serial_no": "platform-serial",
|
||||
}
|
||||
encode := func() json.RawMessage {
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
err := repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
||||
Provider: biz.PaymentDouyin,
|
||||
Enabled: true,
|
||||
Values: encode(),
|
||||
})
|
||||
err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentDouyin, values)
|
||||
if err == nil || !strings.Contains(err.Error(), "app_id") {
|
||||
t.Fatalf("missing app_id error = %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("invalid enabled configuration was persisted: count=%d", count)
|
||||
}
|
||||
|
||||
values["app_id"] = "douyin-app"
|
||||
if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
||||
Provider: biz.PaymentDouyin,
|
||||
Enabled: true,
|
||||
Values: encode(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save complete Douyin configuration: %v", err)
|
||||
}
|
||||
var stored integrationConfigPO
|
||||
if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).First(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.Enabled || !strings.Contains(stored.Config, `"app_id":"douyin-app"`) {
|
||||
t.Fatalf("stored configuration = %+v", stored)
|
||||
if err = biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentDouyin, values); err != nil {
|
||||
t.Fatalf("valid Douyin configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDouyinPaymentConfigAcceptsPlatformCertificateSerialAlias(t *testing.T) {
|
||||
func TestValidatePaymentConfigAcceptsProviderAliases(t *testing.T) {
|
||||
err := validatePaymentConfig(biz.PaymentDouyin, map[string]any{
|
||||
"app_id": "douyin-app",
|
||||
"merchant_id": "merchant-douyin",
|
||||
"serial_no": "merchant-serial",
|
||||
"api_key": "01234567890123456789012345678901",
|
||||
"private_key": "merchant-private-key",
|
||||
"platform_cert": "platform-public-key",
|
||||
"platform_cert_serial": "platform-serial",
|
||||
"app_id": "douyin-app", "merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
|
||||
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_cert_serial": "platform-serial",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("platform_cert_serial alias rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllinPayPaymentConfigRejectsUnknownQueryOrderType(t *testing.T) {
|
||||
err := validatePaymentConfig(biz.PaymentAllinPay, map[string]any{
|
||||
"cus_id": "customer",
|
||||
"app_id": "app",
|
||||
"private_key": "private-key",
|
||||
"public_key": "public-key",
|
||||
"query_order_type": "payinfo",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "reqsn") || !strings.Contains(err.Error(), "trxid") {
|
||||
t.Fatalf("unknown query_order_type error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAlipayV3PaymentConfigRequiresCertificates(t *testing.T) {
|
||||
values := map[string]any{
|
||||
"app_id": "alipay-v3-app",
|
||||
"private_key": "private-key",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "app_cert") {
|
||||
t.Fatalf("missing app certificate error = %v", err)
|
||||
}
|
||||
|
||||
values["app_cert_content"] = "app-certificate"
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "root_cert") {
|
||||
t.Fatalf("missing root certificate error = %v", err)
|
||||
}
|
||||
|
||||
values["alipay_root_cert_path"] = "alipayRootCert.crt"
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "public_cert") {
|
||||
t.Fatalf("missing public certificate error = %v", err)
|
||||
}
|
||||
|
||||
values["alipay_public_cert_content"] = "alipay-public-certificate"
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err != nil {
|
||||
t.Fatalf("valid Alipay V3 configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePayPalPaymentConfigRequiresWebhookID(t *testing.T) {
|
||||
values := map[string]any{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentPayPal, values); err == nil || !strings.Contains(err.Error(), "webhook_id") {
|
||||
t.Fatalf("missing webhook_id error = %v", err)
|
||||
}
|
||||
|
||||
values["webhook_id"] = "webhook-1"
|
||||
if err := validatePaymentConfig(biz.PaymentPayPal, values); err != nil {
|
||||
t.Fatalf("valid PayPal configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWechatV2PaymentConfigRequiresRefundCertificatePair(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"app_id": "wechat-app",
|
||||
"merchant_id": "wechat-merchant",
|
||||
"mch_key": "merchant-key",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_cert") {
|
||||
t.Fatalf("missing refund certificate error = %v", err)
|
||||
}
|
||||
|
||||
base["client_cert"] = "certificate-pem"
|
||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_key") {
|
||||
t.Fatalf("missing refund private key error = %v", err)
|
||||
}
|
||||
|
||||
base["client_key"] = "private-key-pem"
|
||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err != nil {
|
||||
t.Fatalf("valid WeChat v2 configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQQPaymentConfigRequiresRefundCertificate(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"mch_id": "qq-merchant",
|
||||
"api_key": "merchant-key",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentQQ, base); err == nil || !strings.Contains(err.Error(), "cert_file") {
|
||||
t.Fatalf("missing refund certificate error = %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
func TestValidatePaymentConfigProviderRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, provider, want string
|
||||
values map[string]any
|
||||
}{
|
||||
{name: "certificate files", values: map[string]any{"cert_file": "cert.pem", "key_file": "key.pem"}},
|
||||
{name: "pkcs12 file", values: map[string]any{"pkcs12_file": "merchant.p12"}},
|
||||
{name: "certificate content", values: map[string]any{"cert_content": "certificate-pem", "key_content": "private-key-pem"}},
|
||||
{name: "pkcs12 content", values: map[string]any{"pkcs12_content": "base64-pkcs12"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values := map[string]any{}
|
||||
for key, value := range base {
|
||||
values[key] = value
|
||||
}
|
||||
for key, value := range tc.values {
|
||||
values[key] = value
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentQQ, values); err != nil {
|
||||
t.Fatalf("valid QQ configuration rejected: %v", err)
|
||||
{"allinpay order type", biz.PaymentAllinPay, "reqsn", map[string]any{"cus_id": "customer", "app_id": "app", "private_key": "private-key", "public_key": "public-key", "query_order_type": "payinfo"}},
|
||||
{"paypal webhook", biz.PaymentPayPal, "webhook_id", map[string]any{"client_id": "client-id", "client_secret": "client-secret"}},
|
||||
{"wechat v2 refund cert", biz.PaymentWechatV2, "client_cert", map[string]any{"app_id": "app", "merchant_id": "merchant", "mch_key": "key"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, test.provider, test.values)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentProviderNotifyURLRequirementMatchesCallbackSupport(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
provider string
|
||||
want bool
|
||||
}{
|
||||
{provider: biz.PaymentApple, want: false},
|
||||
{provider: biz.PaymentAllinPay, want: false},
|
||||
{provider: biz.PaymentSaobei, want: false},
|
||||
{provider: biz.PaymentAlipay, want: true},
|
||||
{provider: biz.PaymentAlipayV3, want: true},
|
||||
{provider: biz.PaymentWechatV3, want: true},
|
||||
} {
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
if got := paymentProviderRequiresNotifyURL(tc.provider); got != tc.want {
|
||||
t.Fatalf("requires notify_url = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCreateNotifyURLRequirementMatchesSynchronousMethods(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
provider string
|
||||
extra map[string]any
|
||||
config map[string]any
|
||||
want bool
|
||||
}{
|
||||
{name: "paypal create", provider: biz.PaymentPayPal, want: false},
|
||||
{name: "alipay barcode", provider: biz.PaymentAlipay, extra: map[string]any{"method": "barcode"}, want: false},
|
||||
{name: "alipay v3 barcode", provider: biz.PaymentAlipayV3, extra: map[string]any{"method": "barcode"}, want: false},
|
||||
{name: "alipay ignores unsupported pay type selector", provider: biz.PaymentAlipay, extra: map[string]any{"pay_type": "barcode"}, want: true},
|
||||
{name: "wechat v2 micropay", provider: biz.PaymentWechatV2, config: map[string]any{"trade_type": "MICROPAY"}, want: false},
|
||||
{name: "wechat v3 codepay", provider: biz.PaymentWechatV3, extra: map[string]any{"method": "codepay"}, want: false},
|
||||
{name: "qq micropay", provider: biz.PaymentQQ, config: map[string]any{"trade_type": "MICROPAY"}, want: false},
|
||||
{name: "lakala retail", provider: biz.PaymentLakala, extra: map[string]any{"method": "retail"}, want: false},
|
||||
{name: "alipay native", provider: biz.PaymentAlipay, config: map[string]any{"method": "native"}, want: true},
|
||||
{name: "wechat v3 jsapi", provider: biz.PaymentWechatV3, extra: map[string]any{"trade_type": "jsapi"}, want: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := paymentCreateRequiresNotifyURL(tc.provider, tc.extra, tc.config); got != tc.want {
|
||||
t.Fatalf("paymentCreateRequiresNotifyURL() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackEventIDPreservesVerifiedAdapterIdentity(t *testing.T) {
|
||||
callback := &biz.PaymentCallback{
|
||||
Provider: biz.PaymentDouyin,
|
||||
Body: []byte(`{"id":"untrusted-flat-id","data":{"event_id":"nested-id"}}`),
|
||||
}
|
||||
result := &biz.PaymentResult{EventID: " verified-sdk-event "}
|
||||
if got := paymentCallbackEventID(callback, result); got != "verified-sdk-event" {
|
||||
t.Fatalf("paymentCallbackEventID() = %q, want verified adapter ID", got)
|
||||
}
|
||||
result.EventID = ""
|
||||
if got := paymentCallbackEventID(callback, result); got != "untrusted-flat-id" {
|
||||
t.Fatalf("paymentCallbackEventID() fallback = %q, want flat callback ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGenericPaymentConfigRequiresRuntimeEndpointsAndIdentity(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"protocol_version": "v1",
|
||||
"app_id": "app",
|
||||
"merchant_id": "merchant",
|
||||
"create_url": "https://pay.test/create",
|
||||
"query_url": "https://pay.test/query",
|
||||
"refund_url": "https://pay.test/refund",
|
||||
"app_key": "secret",
|
||||
"query_status_field": "data.status",
|
||||
"query_success_values": "SUCCESS",
|
||||
"query_trade_no_field": "data.trade_no",
|
||||
"query_provider_trade_no_field": "data.provider_trade_no",
|
||||
"query_amount_field": "data.amount",
|
||||
"query_currency_field": "data.currency",
|
||||
"query_amount_scale": "100",
|
||||
"callback_status_field": "data.status",
|
||||
"callback_success_values": "SUCCESS",
|
||||
"callback_trade_no_field": "data.trade_no",
|
||||
"callback_provider_trade_no_field": "data.provider_trade_no",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentChinaums, base); err != nil {
|
||||
t.Fatalf("valid generic config rejected: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key string
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "missing app id", key: "app_id", want: "app_id"},
|
||||
{name: "missing merchant id", key: "merchant_id", want: "merchant_id"},
|
||||
{name: "missing refund endpoint", key: "refund_url", want: "refund_url"},
|
||||
{name: "zero amount scale", key: "query_amount_scale", value: "0", want: "query_amount_scale"},
|
||||
{name: "non power amount scale", key: "query_amount_scale", value: "3", want: "query_amount_scale"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values := map[string]any{}
|
||||
for key, value := range base {
|
||||
values[key] = value
|
||||
}
|
||||
if tc.value == nil {
|
||||
values[tc.key] = ""
|
||||
} else {
|
||||
values[tc.key] = tc.value
|
||||
}
|
||||
err := validatePaymentConfig(biz.PaymentChinaums, values)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
func TestValidatePaymentConfigGenericRequiresRuntimeFields(t *testing.T) {
|
||||
values := biz.DefaultIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums)
|
||||
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums, values); err == nil {
|
||||
t.Fatal("empty generic payment config unexpectedly accepted")
|
||||
}
|
||||
values["app_id"], values["merchant_id"] = "app", "merchant"
|
||||
values["create_url"], values["query_url"], values["refund_url"], values["app_key"] = "https://pay.test/create", "https://pay.test/query", "https://pay.test/refund", "secret"
|
||||
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums, values); err == nil {
|
||||
t.Fatal("generic config with only identity/endpoints unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +1,30 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/app/system/internal/biz"
|
||||
)
|
||||
|
||||
func TestMigrateSeedsPaymentProviders(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestPaymentDefinitionsProvideNonEmptyDefaults(t *testing.T) {
|
||||
definitions := biz.IntegrationDefinitions(biz.IntegrationKindPayment)
|
||||
if len(definitions) != len(biz.SupportedPaymentProviders) {
|
||||
t.Fatalf("payment definitions = %d, want %d", len(definitions), len(biz.SupportedPaymentProviders))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfigListMasksAndSavePreservesSecretAliases(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
||||
secrets := map[string]string{
|
||||
"app_key": "app-key-secret",
|
||||
"token": "signing-token",
|
||||
"key_content": "private-key-content",
|
||||
"pkcs12_content": "pkcs12-content",
|
||||
"op_user_passwd": "operator-password",
|
||||
"api_v3key": "api-v3-key-alias",
|
||||
"apiv3_key": "api-v3-key-compact-alias",
|
||||
"key_pem": "key-pem-content",
|
||||
"apiclient_key": "api-client-key-content",
|
||||
}
|
||||
storedValues := map[string]any{"app_id": "public-app-id"}
|
||||
for key, value := range secrets {
|
||||
storedValues[key] = value
|
||||
}
|
||||
storedValues["nested"] = map[string]any{"token": "nested-token"}
|
||||
raw, err := json.Marshal(storedValues)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&integrationConfigPO{
|
||||
Kind: integrationKindPayment, Provider: biz.PaymentQQ, Config: string(raw),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configs, err := repo.ListConfigs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(configs) != 1 {
|
||||
t.Fatalf("payment configs = %d, want 1", len(configs))
|
||||
}
|
||||
masked := map[string]any{}
|
||||
if err = json.Unmarshal(configs[0].Values, &masked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if masked["app_id"] != "public-app-id" {
|
||||
t.Fatalf("non-secret app_id = %#v, want public-app-id", masked["app_id"])
|
||||
}
|
||||
for key := range secrets {
|
||||
if masked[key] != "******" {
|
||||
t.Errorf("secret %s = %#v, want masked value", key, masked[key])
|
||||
for _, definition := range definitions {
|
||||
if definition.Provider == "" || definition.Name == "" || len(definition.Fields) == 0 {
|
||||
t.Fatalf("incomplete payment definition: %+v", definition)
|
||||
}
|
||||
if len(definition.Defaults) == 0 {
|
||||
t.Fatalf("payment provider %s still has empty defaults", definition.Provider)
|
||||
}
|
||||
}
|
||||
if nested, ok := masked["nested"].(map[string]any); !ok || nested["token"] != "******" {
|
||||
t.Errorf("nested token was not masked: %#v", masked["nested"])
|
||||
}
|
||||
}
|
||||
|
||||
if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
||||
Provider: biz.PaymentQQ, Values: configs[0].Values,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row integrationConfigPO
|
||||
if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentQQ).First(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preserved := map[string]any{}
|
||||
if err = json.Unmarshal([]byte(row.Config), &preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, want := range secrets {
|
||||
if preserved[key] != want {
|
||||
t.Errorf("preserved secret %s = %#v, want %q", key, preserved[key], want)
|
||||
}
|
||||
}
|
||||
if nested, ok := preserved["nested"].(map[string]any); !ok || nested["token"] != "nested-token" {
|
||||
t.Errorf("nested token was not preserved: %#v", preserved["nested"])
|
||||
func TestPaymentValidationRejectsUnknownProvider(t *testing.T) {
|
||||
err := validatePaymentConfig("unknown-provider", map[string]any{})
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user