修改支付相关
This commit is contained in:
@@ -17,6 +17,31 @@ func integrationSelect(key, label string, required bool, values ...string) Integ
|
|||||||
return IntegrationConfigField{Key: key, Label: label, Required: required, Type: "select", Options: options}
|
return IntegrationConfigField{Key: key, Label: label, Required: required, Type: "select", Options: options}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var paymentOptionLabels = map[string]string{
|
||||||
|
"production": "生产环境", "sandbox": "沙箱环境",
|
||||||
|
"alipay.trade.create": "统一收单交易创建", "alipay.trade.pay": "付款码支付",
|
||||||
|
"alipay.trade.precreate": "当面付扫码", "alipay.trade.app.pay": "App 支付",
|
||||||
|
"alipay.trade.page.pay": "电脑网站支付", "alipay.trade.wap.pay": "手机网站支付",
|
||||||
|
"JSAPI": "公众号 / 小程序支付", "APP": "App 支付", "NATIVE": "扫码支付",
|
||||||
|
"MWEB": "H5 支付", "MICROPAY": "付款码支付",
|
||||||
|
"jsapi": "公众号 / 小程序支付", "app": "App 支付", "native": "扫码支付",
|
||||||
|
"h5": "H5 支付", "codepay": "付款码支付", "mini": "小程序支付",
|
||||||
|
"qrcode": "二维码支付", "native_jsapi": "扫码后小程序支付", "sdk": "SDK 支付",
|
||||||
|
"web": "网页支付", "retail": "门店收银", "retail_qrcode": "门店扫码",
|
||||||
|
"Wechat": "微信支付", "Alipay": "支付宝", "UnionPay": "银联",
|
||||||
|
"reqsn": "商户订单号", "trxid": "平台交易号",
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentSelect(key, label string, required bool, values ...string) IntegrationConfigField {
|
||||||
|
field := integrationSelect(key, label, required, values...)
|
||||||
|
for i := range field.Options {
|
||||||
|
if name := paymentOptionLabels[values[i]]; name != "" {
|
||||||
|
field.Options[i].Label = name + " (" + values[i] + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
func paymentDefinition(provider, name, description string, defaults map[string]any, fields ...IntegrationConfigField) IntegrationConfigDefinition {
|
func paymentDefinition(provider, name, description string, defaults map[string]any, fields ...IntegrationConfigField) IntegrationConfigDefinition {
|
||||||
common := []IntegrationConfigField{
|
common := []IntegrationConfigField{
|
||||||
integrationField("notify_url", "支付回调地址", false, false, "url"),
|
integrationField("notify_url", "支付回调地址", false, false, "url"),
|
||||||
@@ -172,29 +197,29 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
|||||||
IntegrationKindPayment: {
|
IntegrationKindPayment: {
|
||||||
paymentDefinition(bizpayment.PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
paymentDefinition(bizpayment.PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
||||||
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationSelect("sign_type", "签名算法", false, "RSA2", "RSA"), integrationField("gateway_url", "网关地址", false, false, "url"),
|
paymentSelect("environment", "环境", false, "production", "sandbox"), integrationSelect("sign_type", "签名算法", false, "RSA2", "RSA"), integrationField("gateway_url", "网关地址", false, false, "url"),
|
||||||
integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
paymentSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||||
paymentDefinition(bizpayment.PaymentAlipayV3, "支付宝 V3", "支付宝证书模式 V3 接口", map[string]any{"app_id": "", "private_key": "", "app_cert": "", "root_cert": "", "public_cert": "", "environment": "production", "api_base_url": "https://openapi.alipay.com", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
paymentDefinition(bizpayment.PaymentAlipayV3, "支付宝 V3", "支付宝证书模式 V3 接口", map[string]any{"app_id": "", "private_key": "", "app_cert": "", "root_cert": "", "public_cert": "", "environment": "production", "api_base_url": "https://openapi.alipay.com", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("app_cert", "应用公钥证书", true, true, "textarea"), integrationField("root_cert", "支付宝根证书", true, true, "textarea"), integrationField("public_cert", "支付宝公钥证书", true, true, "textarea"),
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("app_cert", "应用公钥证书", true, true, "textarea"), integrationField("root_cert", "支付宝根证书", true, true, "textarea"), integrationField("public_cert", "支付宝公钥证书", true, true, "textarea"),
|
||||||
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationField("api_base_url", "API 地址", false, false, "url"), integrationField("gateway_url", "网关地址", false, false, "url"), integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
paymentSelect("environment", "环境", false, "production", "sandbox"), integrationField("api_base_url", "API 地址", false, false, "url"), integrationField("gateway_url", "网关地址", false, false, "url"), paymentSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||||
paymentDefinition(bizpayment.PaymentWechatV2, "微信支付 V2", "微信支付 V2,含退款双向证书", map[string]any{"app_id": "", "merchant_id": "", "mch_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "client_cert": "", "client_key": ""},
|
paymentDefinition(bizpayment.PaymentWechatV2, "微信支付 V2", "微信支付 V2,含退款双向证书", map[string]any{"app_id": "", "merchant_id": "", "mch_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "client_cert": "", "client_key": ""},
|
||||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("mch_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "APP", "NATIVE", "MWEB", "MICROPAY"), integrationField("client_cert", "商户证书", true, true, "textarea"), integrationField("client_key", "证书私钥", true, true, "textarea")),
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("mch_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), paymentSelect("trade_type", "默认交易类型", false, "JSAPI", "APP", "NATIVE", "MWEB", "MICROPAY"), integrationField("client_cert", "商户证书", true, true, "textarea"), integrationField("client_key", "证书私钥", true, true, "textarea")),
|
||||||
paymentDefinition(bizpayment.PaymentWechatV3, "微信支付 V3", "微信支付 API v3", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "private_key": "", "api_v3_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi"},
|
paymentDefinition(bizpayment.PaymentWechatV3, "微信支付 V3", "微信支付 API v3", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "private_key": "", "api_v3_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi"},
|
||||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("api_v3_key", "API v3 密钥", true, true, "password"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "jsapi", "app", "native", "h5", "codepay")),
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("api_v3_key", "API v3 密钥", true, true, "password"), IntegrationConfigField{Key: "platform_cert", Label: "平台证书 / 微信支付公钥", Type: "textarea", Secret: true, Description: "可留空,使用商户凭证自动获取平台证书;使用微信支付公钥模式时填写公钥和对应 ID。"}, integrationField("platform_serial_no", "证书序列号 / 公钥 ID", false, false, "text"), paymentSelect("trade_type", "默认交易类型", false, "jsapi", "app", "native", "h5", "codepay")),
|
||||||
paymentDefinition(bizpayment.PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production", "test_product_id": "", "test_transaction_id": ""},
|
paymentDefinition(bizpayment.PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production", "test_product_id": "", "test_transaction_id": ""},
|
||||||
integrationField("issuer_id", "Issuer ID", true, false, "text"), integrationField("key_id", "Key ID", true, false, "text"), integrationField("bundle_id", "Bundle ID", true, false, "text"), integrationField("private_key", "P8 私钥", true, true, "textarea"), integrationField("price_divisor", "价格除数", false, false, "number"), integrationSelect("environment", "环境", false, "production", "sandbox"), integrationField("test_product_id", "测试商品 ID", false, false, "text"), integrationField("test_transaction_id", "沙箱交易 ID", false, false, "text")),
|
integrationField("issuer_id", "Issuer ID", true, false, "text"), integrationField("key_id", "Key ID", true, false, "text"), integrationField("bundle_id", "Bundle ID", true, false, "text"), integrationField("private_key", "P8 私钥", true, true, "textarea"), integrationField("price_divisor", "价格除数", false, false, "number"), paymentSelect("environment", "环境", false, "production", "sandbox"), integrationField("test_product_id", "测试商品 ID", false, false, "text"), integrationField("test_transaction_id", "沙箱交易 ID", false, false, "text")),
|
||||||
paymentDefinition(bizpayment.PaymentDouyin, "抖音支付", "抖音开放平台支付", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "api_key": "", "private_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi", "environment": "production"},
|
paymentDefinition(bizpayment.PaymentDouyin, "抖音支付", "抖音开放平台支付", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "api_key": "", "private_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi", "environment": "production"},
|
||||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", true, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), integrationSelect("environment", "环境", false, "production")),
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", true, false, "text"), paymentSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), paymentSelect("environment", "环境", false, "production")),
|
||||||
paymentDefinition(bizpayment.PaymentQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
paymentDefinition(bizpayment.PaymentQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
||||||
integrationField("mch_id", "商户号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "NATIVE", "APP", "MICROPAY"), integrationField("cert_file", "退款证书路径", false, false, "text"), integrationField("key_file", "退款私钥路径", false, true, "text"), integrationSelect("environment", "环境", false, "production")),
|
integrationField("mch_id", "商户号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), paymentSelect("trade_type", "默认交易类型", false, "JSAPI", "NATIVE", "APP", "MICROPAY"), integrationField("cert_file", "退款证书路径", false, false, "text"), integrationField("key_file", "退款私钥路径", false, true, "text"), paymentSelect("environment", "环境", false, "production")),
|
||||||
paymentDefinition(bizpayment.PaymentAllinPay, "通联支付", "通联收银宝支付", map[string]any{"cus_id": "", "app_id": "", "private_key": "", "public_key": "", "org_id": "", "pay_type": "W02", "query_order_type": "reqsn", "currency": "CNY", "environment": "production"},
|
paymentDefinition(bizpayment.PaymentAllinPay, "通联支付", "通联收银宝支付", map[string]any{"cus_id": "", "app_id": "", "private_key": "", "public_key": "", "org_id": "", "pay_type": "W02", "query_order_type": "reqsn", "currency": "CNY", "environment": "production"},
|
||||||
integrationField("cus_id", "商户号", true, false, "text"), integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("public_key", "平台公钥", true, true, "textarea"), integrationField("org_id", "机构号", false, false, "text"), integrationField("pay_type", "支付类型", false, false, "text"), integrationSelect("query_order_type", "查单标识", false, "reqsn", "trxid"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
integrationField("cus_id", "商户号", true, false, "text"), integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("public_key", "平台公钥", true, true, "textarea"), integrationField("org_id", "机构号", false, false, "text"), integrationField("pay_type", "支付类型", false, false, "text"), paymentSelect("query_order_type", "查单标识", false, "reqsn", "trxid"), integrationField("currency", "币种", false, false, "text"), paymentSelect("environment", "环境", false, "production", "sandbox")),
|
||||||
paymentDefinition(bizpayment.PaymentLakala, "拉卡拉", "拉卡拉聚合支付", map[string]any{"partner_code": "", "credential_code": "", "channel": "Wechat", "method": "jsapi", "currency": "CNY", "environment": "production"},
|
paymentDefinition(bizpayment.PaymentLakala, "拉卡拉", "拉卡拉聚合支付", map[string]any{"partner_code": "", "credential_code": "", "channel": "Wechat", "method": "jsapi", "currency": "CNY", "environment": "production"},
|
||||||
integrationField("partner_code", "合作方编号", true, false, "text"), integrationField("credential_code", "凭证码", true, true, "password"), integrationSelect("channel", "支付渠道", false, "Wechat", "Alipay", "UnionPay"), integrationSelect("method", "默认支付方式", false, "jsapi", "h5", "mini", "native", "qrcode", "native_jsapi", "sdk", "web", "retail", "retail_qrcode"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production")),
|
integrationField("partner_code", "合作方编号", true, false, "text"), integrationField("credential_code", "凭证码", true, true, "password"), paymentSelect("channel", "支付渠道", false, "Wechat", "Alipay", "UnionPay"), paymentSelect("method", "默认支付方式", false, "jsapi", "h5", "mini", "native", "qrcode", "native_jsapi", "sdk", "web", "retail", "retail_qrcode"), integrationField("currency", "币种", false, false, "text"), paymentSelect("environment", "环境", false, "production")),
|
||||||
paymentDefinition(bizpayment.PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true, "test_currency": "USD"},
|
paymentDefinition(bizpayment.PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true, "test_currency": "USD"},
|
||||||
integrationField("client_id", "Client ID", true, false, "text"), integrationField("client_secret", "Client Secret", true, true, "password"), integrationField("webhook_id", "Webhook ID", true, false, "text"), integrationSelect("environment", "环境", false, "sandbox", "production"), integrationField("cancel_url", "取消跳转地址", false, false, "url"), integrationField("auto_capture", "自动捕获", false, false, "switch")),
|
integrationField("client_id", "Client ID", true, false, "text"), integrationField("client_secret", "Client Secret", true, true, "password"), integrationField("webhook_id", "Webhook ID", true, false, "text"), paymentSelect("environment", "环境", false, "sandbox", "production"), integrationField("cancel_url", "取消跳转地址", false, false, "url"), integrationField("auto_capture", "自动捕获", false, false, "switch")),
|
||||||
paymentDefinition(bizpayment.PaymentSaobei, "扫呗", "扫呗聚合支付", map[string]any{"inst_no": "", "key": "", "merchant_no": "", "terminal_id": "", "access_token": "", "pay_type": "010", "currency": "CNY", "environment": "production"},
|
paymentDefinition(bizpayment.PaymentSaobei, "扫呗", "扫呗聚合支付", map[string]any{"inst_no": "", "key": "", "merchant_no": "", "terminal_id": "", "access_token": "", "pay_type": "010", "currency": "CNY", "environment": "production"},
|
||||||
integrationField("inst_no", "机构号", true, false, "text"), integrationField("key", "机构密钥", true, true, "password"), integrationField("merchant_no", "商户号", true, false, "text"), integrationField("terminal_id", "终端号", true, false, "text"), integrationField("access_token", "访问令牌", true, true, "password"), integrationField("pay_type", "支付类型", false, false, "text"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
integrationField("inst_no", "机构号", true, false, "text"), integrationField("key", "机构密钥", true, true, "password"), integrationField("merchant_no", "商户号", true, false, "text"), integrationField("terminal_id", "终端号", true, false, "text"), integrationField("access_token", "访问令牌", true, true, "password"), integrationField("pay_type", "支付类型", false, false, "text"), paymentSelect("environment", "环境", false, "production", "sandbox")),
|
||||||
genericPaymentDefinition(bizpayment.PaymentChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
genericPaymentDefinition(bizpayment.PaymentChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
||||||
genericPaymentDefinition(bizpayment.PaymentSFT, "商福通", "按商户协议配置的商福通适配器"),
|
genericPaymentDefinition(bizpayment.PaymentSFT, "商福通", "按商户协议配置的商福通适配器"),
|
||||||
genericPaymentDefinition(bizpayment.PaymentSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
genericPaymentDefinition(bizpayment.PaymentSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
bizpayment "kra/internal/biz/payment"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaymentSelectLabelsKeepProtocolValues(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
provider, key, value, label string
|
||||||
|
}{
|
||||||
|
{bizpayment.PaymentWechatV3, "trade_type", "native", "扫码支付 (native)"},
|
||||||
|
{bizpayment.PaymentWechatV2, "trade_type", "MICROPAY", "付款码支付 (MICROPAY)"},
|
||||||
|
{bizpayment.PaymentAlipay, "method", "alipay.trade.wap.pay", "手机网站支付 (alipay.trade.wap.pay)"},
|
||||||
|
} {
|
||||||
|
definition, ok := IntegrationDefinition(IntegrationKindPayment, test.provider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing definition %s", test.provider)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, field := range definition.Fields {
|
||||||
|
if field.Key != test.key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, option := range field.Options {
|
||||||
|
if option.Value == test.value {
|
||||||
|
found = true
|
||||||
|
if option.Label != test.label {
|
||||||
|
t.Errorf("%s/%s label = %q, want %q", test.provider, test.value, option.Label, test.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing option %s/%s", test.provider, test.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWechatV3PlatformCertificateIsOptional(t *testing.T) {
|
||||||
|
definition, _ := IntegrationDefinition(IntegrationKindPayment, bizpayment.PaymentWechatV3)
|
||||||
|
for _, field := range definition.Fields {
|
||||||
|
if field.Key == "platform_cert" && field.Required {
|
||||||
|
t.Fatal("platform certificate is marked required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := DefaultIntegrationConfig(IntegrationKindPayment, bizpayment.PaymentWechatV3)
|
||||||
|
values["app_id"] = "wx-app"
|
||||||
|
values["merchant_id"] = "merchant"
|
||||||
|
values["serial_no"] = "merchant-serial"
|
||||||
|
values["private_key"] = "merchant-private-key"
|
||||||
|
values["api_v3_key"] = "01234567890123456789012345678901"
|
||||||
|
if err := ValidateIntegrationConfig(IntegrationKindPayment, bizpayment.PaymentWechatV3, values); err != nil {
|
||||||
|
t.Fatalf("valid merchant credentials rejected without platform certificate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,23 +418,10 @@ func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.Cli
|
|||||||
return nil, err
|
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.SetBodySize(8)
|
||||||
client.GetHttpClient().SetTimeout(20 * time.Second)
|
client.GetHttpClient().SetTimeout(20 * time.Second)
|
||||||
|
|
||||||
|
endpointConfigured := false
|
||||||
if endpointKey != "" {
|
if endpointKey != "" {
|
||||||
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
|
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
|
||||||
parsed, parseErr := url.Parse(endpoint)
|
parsed, parseErr := url.Parse(endpoint)
|
||||||
@@ -446,15 +433,37 @@ func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.Cli
|
|||||||
}
|
}
|
||||||
httpClient := client.GetHttpClient()
|
httpClient := client.GetHttpClient()
|
||||||
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
|
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
|
||||||
return client, nil
|
endpointConfigured = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
|
if !endpointConfigured {
|
||||||
parsed, parseErr := url.Parse(baseURL)
|
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
|
||||||
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
parsed, parseErr := url.Parse(baseURL)
|
||||||
return nil, errors.New("微信支付 v3 base_url 配置无效")
|
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return nil, errors.New("微信支付 v3 base_url 配置无效")
|
||||||
|
}
|
||||||
|
client.SetProxyHost(baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
|
||||||
|
if platformCert == "" {
|
||||||
|
// Fetch the current platform certificates using the merchant credentials.
|
||||||
|
// This client is request-scoped, so avoid starting a background refresher.
|
||||||
|
if err = client.AutoVerifySign(false); err != nil {
|
||||||
|
return nil, fmt.Errorf("微信支付 v3 自动获取平台证书失败: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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.SetProxyHost(baseURL)
|
|
||||||
}
|
}
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWechatV3WithoutPlatformCertificateLoadsVerificationKey(t *testing.T) {
|
||||||
|
_, merchantPrivatePEM, _ := testRSAKeyPair(t)
|
||||||
|
_, _, platformPublicPEM := testRSAKeyPair(t)
|
||||||
|
apiV3Key := "01234567890123456789012345678901"
|
||||||
|
block, err := aes.NewCipher([]byte(apiV3Key))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nonce, associatedData := "0123456789ab", "certificate"
|
||||||
|
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), []byte(platformPublicPEM), []byte(associatedData)))
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v3/certificates" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{
|
||||||
|
"serial_no": "platform-serial",
|
||||||
|
"effective_time": time.Now().Add(-time.Hour).Format(time.RFC3339),
|
||||||
|
"expire_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||||
|
"encrypt_certificate": map[string]any{
|
||||||
|
"algorithm": "AEAD_AES_256_GCM", "nonce": nonce,
|
||||||
|
"associated_data": associatedData, "ciphertext": ciphertext,
|
||||||
|
},
|
||||||
|
}}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := newWechatV3Client(map[string]any{
|
||||||
|
"merchant_id": "merchant", "serial_no": "merchant-serial",
|
||||||
|
"api_v3_key": apiV3Key, "private_key": merchantPrivatePEM,
|
||||||
|
"create_url": server.URL,
|
||||||
|
}, "create_url")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if client.WxPublicKeyMap()["platform-serial"] == nil {
|
||||||
|
t.Fatal("fetched platform key was not installed for signature verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWechatV3WithoutPlatformCertificateFetchesFromConfiguredEndpoint(t *testing.T) {
|
||||||
|
_, merchantPrivatePEM, _ := testRSAKeyPair(t)
|
||||||
|
requestPath := ""
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := newWechatV3Client(map[string]any{
|
||||||
|
"merchant_id": "merchant", "serial_no": "merchant-serial",
|
||||||
|
"api_v3_key": "01234567890123456789012345678901",
|
||||||
|
"private_key": merchantPrivatePEM, "create_url": server.URL,
|
||||||
|
}, "create_url")
|
||||||
|
if requestPath != "/v3/certificates" {
|
||||||
|
t.Fatalf("certificate request path = %q", requestPath)
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "自动获取平台证书失败") {
|
||||||
|
t.Fatalf("certificate fetch error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,496 +1,28 @@
|
|||||||
<template>
|
-----BEGIN PRIVATE KEY-----
|
||||||
<div class="kra-table-box payment-config-page">
|
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDH6e6GdHqs6LOr
|
||||||
<header class="page-heading">
|
6dNCHsL/QkdSU5u3e3o4nRD9qx6IEL72OH+/FZtgAbXZbpQOUSTkT96Yh/BhqCBD
|
||||||
<div>
|
l+xGoXOTj/aUe8+Xd5YGFz8OBv/eFjV2sLm2kmb7pgOpIVuBh94HdrPBS8iR5HUz
|
||||||
<h2>支付渠道配置</h2>
|
ad5DWh2BUJFIa9WroY/EGG7wyzZzWWb0jcZgDTut5+nP0cet3U7m0EHrJmsLiN/3
|
||||||
<p>管理渠道凭证、回调地址和测试交易参数。</p>
|
/u9CXCdhpwQcutxCCGAUuO4/QGficVFv4X5U1AN66S+xWjQAOvPyIQSz2k/yDlXS
|
||||||
</div>
|
AXbfipTKX3odjDwPvI2WFAoi7hAbCWntE+dRNV3mrfGmCy9SvUcbdL1cu9SF5F8R
|
||||||
<el-button :icon="Refresh" :loading="loading" :disabled="busy" @click="refreshConfigs">刷新</el-button>
|
6/mLscVnAgMBAAECggEBALj0ZDq/HxREAp7FqS9eOHhjAItZURLTSYOBRUv7ejbW
|
||||||
</header>
|
j2ZLZV0Lh4Guc2kj1hTc2+3jOwURl8MyAg+nSfEaxEw8gBxl4+y1tfYSocK/lRAK
|
||||||
|
qhISdO9FycN+l5ptgzR22GM9XhcynvDjVc9oXjsf73ZJbPB82+V7hwAcvt9Gkyzp
|
||||||
<div v-loading="loading" class="config-layout">
|
Ydc5zkTIhw10Gf3oVQrFpPhWafM2BcBTyApck5twuQXMMJk3vygi+8xRsDxORda+
|
||||||
<aside class="provider-panel" aria-label="支付渠道列表">
|
fzkvcROeRYaLkkZQb2rAKxav3xymyAvWPNvLhk1u5Pc3pIjqh3N27OGk211wtQUA
|
||||||
<div class="panel-heading"><span>渠道</span><span>{{ configs.length }}</span></div>
|
OZt7CRlXuOUj34KFFbGHBqfInr65xwTGzefTjbhXCpECgYEA64me+ljbrpN+6h7y
|
||||||
<button
|
FGPrKaTg2+sdD2AG5cf3poWh7mUOGuNuwdyXV285N7yd7ood0VdqhNRffez7Rc/y
|
||||||
v-for="item in configs"
|
F0Xxj2v6USLQJFAXC5ypQpXQ973qABy459Wn8IlqMnL0LcBYzLL2GFwReV5qp0QY
|
||||||
:key="item.provider"
|
1frizUe2JGVq/4cYpUgvneuOVF8CgYEA2UgIws3zbF+Tif2hcxdNhqqOyEndXjF8
|
||||||
type="button"
|
cdve2EKBBxvLLqNVnkIEb9NQt+Y+vQehrFHF5c0UvO2nJbicCo8hQO2MAxrc6i7U
|
||||||
class="provider-item"
|
6iWAY9eJciBHeS6IsFL20QV6ft+wpTKyaQR50qvkLyAHLoGpJ0MRsBUMBDCNiNjS
|
||||||
:class="{ active: item.provider === selectedProvider }"
|
oVfvWJ/Ca/kCgYEAoXzQr+oebFoaCODArvQ2tm2h8YYIfZMkB4ULqXMzq+RDQHIw
|
||||||
@click="selectProvider(item.provider)"
|
bOAWL09Flo5OZc7yVKYvlPSaqBbDzICfBg/hZ6jrjZdlx4/VzRNTBSD8je76sar/
|
||||||
>
|
p4vYQdWlJwOjoH6hI2+LxPXS0TjHvPi2YndJ6t7A5cFXE+0HG8MbSpXHFtcCgYEA
|
||||||
<span class="provider-copy">
|
h5LGxKY0yN4p9OPopSNZIDmztY1BLtC50WFbfpygXYseEA+iEIkos42qw9RKOTBV
|
||||||
<strong>{{ item.name || providerText(item.provider) }}</strong>
|
1GuBi42W7vtqW8MAuiXsndKaliVgyk5GU4LqxWFrRWaWsioy57IchvdgI2H3mlwz
|
||||||
<small>{{ item.provider }}</small>
|
rgs46BaQ3kahtIb6wKC8nJ3FTaft6AFCC6IOmcG1UiECgYA1zxm5ttIcKR39cQG2
|
||||||
</span>
|
t4WbHcAlFnMER4SXB34Bfj0WKclgLWG+RoNcke0smmGqdV3r3CmNiK0sIsfL+w2z
|
||||||
<span class="provider-state" :class="{ enabled: item.enabled }">
|
1rXls+laro+cg+jZaG0J7O7b36+vJQOwElQ16yb3qZ9SJ0w8iguUBbcklcPhsaXK
|
||||||
{{ item.enabled ? '已启用' : '已停用' }}
|
Snuim3XDr9wHW63afVWjE+RN3w==
|
||||||
</span>
|
-----END PRIVATE KEY-----
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section v-if="selected" class="editor-panel">
|
|
||||||
<header class="editor-heading">
|
|
||||||
<div class="editor-title-group">
|
|
||||||
<div class="editor-title-row">
|
|
||||||
<h3>{{ selected.name || providerText(selected.provider) }}</h3>
|
|
||||||
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">未保存</el-tag>
|
|
||||||
<el-tag v-else-if="selected.enabled" type="success" effect="plain">运行中</el-tag>
|
|
||||||
</div>
|
|
||||||
<p>{{ selected.description || selected.provider }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="enable-control">
|
|
||||||
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
|
|
||||||
<el-switch
|
|
||||||
:model-value="selected.enabled"
|
|
||||||
:loading="operation === 'toggle'"
|
|
||||||
:disabled="busy"
|
|
||||||
aria-label="启用支付渠道"
|
|
||||||
@change="toggleProvider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="hasMaskedSecret(selected)"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
class="editor-alert"
|
|
||||||
title="凭证已脱敏;保留 ****** 将继续使用当前凭证。"
|
|
||||||
/>
|
|
||||||
<el-alert
|
|
||||||
v-else-if="!selected.configured"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
class="editor-alert"
|
|
||||||
title="该渠道尚未保存。填写配置并保存后才能启用或测试。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-form :model="selected.config" label-position="top" class="config-form" @submit.prevent>
|
|
||||||
<div class="field-grid">
|
|
||||||
<el-form-item
|
|
||||||
v-for="field in selected.fields || []"
|
|
||||||
:key="field.key"
|
|
||||||
:required="field.required"
|
|
||||||
:error="fieldError(field.key)"
|
|
||||||
>
|
|
||||||
<template #label>
|
|
||||||
<span class="field-label"><span>{{ field.label }}</span><small>{{ field.key }}</small></span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-select
|
|
||||||
v-if="field.type === 'select'"
|
|
||||||
v-model="selected.config[field.key]"
|
|
||||||
class="field-control"
|
|
||||||
filterable
|
|
||||||
:placeholder="field.placeholder || '请选择'"
|
|
||||||
@update:model-value="clearFieldError(field.key)"
|
|
||||||
>
|
|
||||||
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" />
|
|
||||||
</el-select>
|
|
||||||
<el-switch
|
|
||||||
v-else-if="field.type === 'switch'"
|
|
||||||
v-model="selected.config[field.key]"
|
|
||||||
@update:model-value="clearFieldError(field.key)"
|
|
||||||
/>
|
|
||||||
<el-input-number
|
|
||||||
v-else-if="field.type === 'number'"
|
|
||||||
v-model="selected.config[field.key]"
|
|
||||||
class="field-control"
|
|
||||||
:min="field.key === 'test_amount' ? 1 : 0"
|
|
||||||
:step="1"
|
|
||||||
controls-position="right"
|
|
||||||
@update:model-value="clearFieldError(field.key)"
|
|
||||||
/>
|
|
||||||
<div v-else-if="field.type === 'textarea' && field.secret" class="secret-textarea-control">
|
|
||||||
<el-input
|
|
||||||
v-if="isSecretVisible(selected, field.key)"
|
|
||||||
v-model="selected.config[field.key]"
|
|
||||||
class="field-control"
|
|
||||||
type="textarea"
|
|
||||||
:rows="5"
|
|
||||||
:placeholder="field.placeholder || '请输入凭证内容'"
|
|
||||||
autocomplete="new-password"
|
|
||||||
spellcheck="false"
|
|
||||||
@update:model-value="clearFieldError(field.key)"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-else
|
|
||||||
:model-value="maskedTextareaValue(selected.config[field.key])"
|
|
||||||
class="field-control secret-textarea-display"
|
|
||||||
type="textarea"
|
|
||||||
:rows="5"
|
|
||||||
readonly
|
|
||||||
resize="none"
|
|
||||||
:placeholder="field.placeholder || '凭证已隐藏,点击“编辑凭证”后输入'"
|
|
||||||
:aria-label="`${field.label}(已隐藏)`"
|
|
||||||
/>
|
|
||||||
<el-button
|
|
||||||
class="secret-textarea-action"
|
|
||||||
text
|
|
||||||
type="primary"
|
|
||||||
:icon="isSecretVisible(selected, field.key) ? Hide : View"
|
|
||||||
:aria-label="isSecretVisible(selected, field.key) ? `隐藏${field.label}` : `编辑${field.label}`"
|
|
||||||
@click="toggleSecretVisibility(selected, field.key)"
|
|
||||||
>
|
|
||||||
{{ isSecretVisible(selected, field.key) ? '隐藏凭证' : '编辑凭证' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<el-input
|
|
||||||
v-else-if="field.type === 'textarea'"
|
|
||||||
v-model="selected.config[field.key]"
|
|
||||||
class="field-control"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
:placeholder="field.placeholder"
|
|
||||||
spellcheck="false"
|
|
||||||
@update:model-value="clearFieldError(field.key)"
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
v-else
|
|
||||||
v-model="selected.config[field.key]"
|
|
||||||
class="field-control"
|
|
||||||
:type="field.secret ? 'password' : 'text'"
|
|
||||||
:show-password="field.secret"
|
|
||||||
:placeholder="field.placeholder"
|
|
||||||
spellcheck="false"
|
|
||||||
@update:model-value="clearFieldError(field.key)"
|
|
||||||
/>
|
|
||||||
<p v-if="fieldHint(field, selected)" class="field-hint">{{ fieldHint(field, selected) }}</p>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<footer class="editor-actions">
|
|
||||||
<span class="save-state">{{ saveStateText }}</span>
|
|
||||||
<el-button
|
|
||||||
:icon="Connection"
|
|
||||||
:loading="operation === 'test'"
|
|
||||||
:disabled="busy || !selected.enabled || !selected.configured || isDirty(selected)"
|
|
||||||
@click="testProvider"
|
|
||||||
>
|
|
||||||
测试渠道
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="selected.configured"
|
|
||||||
type="danger"
|
|
||||||
plain
|
|
||||||
:icon="Delete"
|
|
||||||
:loading="operation === 'delete'"
|
|
||||||
:disabled="busy"
|
|
||||||
@click="remove"
|
|
||||||
>
|
|
||||||
删除配置
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:icon="Check"
|
|
||||||
:loading="operation === 'save'"
|
|
||||||
:disabled="busy || !isDirty(selected)"
|
|
||||||
@click="saveSelected"
|
|
||||||
>
|
|
||||||
保存配置
|
|
||||||
</el-button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<el-empty v-else description="暂无支付渠道" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-dialog v-model="testVisible" title="渠道测试结果" width="min(620px, calc(100vw - 32px))" destroy-on-close>
|
|
||||||
<div v-if="testResult" class="test-result">
|
|
||||||
<el-result
|
|
||||||
:icon="testResult.passed ? 'success' : 'error'"
|
|
||||||
:title="testResultTitle"
|
|
||||||
:sub-title="testResult.tradeNo ? `测试订单:${testResult.tradeNo}` : undefined"
|
|
||||||
/>
|
|
||||||
<div class="test-stages">
|
|
||||||
<div v-for="stage in testResult.stages || []" :key="`${stage.name}-${stage.tradeNo || ''}`" class="test-stage">
|
|
||||||
<el-icon :class="`stage-${stage.status}`"><component :is="stageIcon(stage.status)" /></el-icon>
|
|
||||||
<div><strong>{{ stageName(stage.name) }}</strong><p>{{ stage.message || stageStatusText(stage.status) }}</p></div>
|
|
||||||
<span v-if="stage.durationMs != null">{{ stage.durationMs }} ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer><el-button @click="testVisible = false">关闭</el-button></template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Check, CircleCheck, CircleClose, Connection, Delete, Hide, MoreFilled, Refresh, View } from '@element-plus/icons-vue'
|
|
||||||
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
|
|
||||||
import { testPaymentProvider } from '@/api/payment'
|
|
||||||
|
|
||||||
defineOptions({ name: 'PaymentConfig' })
|
|
||||||
|
|
||||||
const PROVIDER_NAMES = {
|
|
||||||
alipay: '支付宝', 'alipay-v3': '支付宝 V3', 'wechat-v2': '微信支付 V2',
|
|
||||||
'wechat-v3': '微信支付 V3', 'apple-iap': 'Apple IAP', douyin: '抖音支付',
|
|
||||||
qq: 'QQ 钱包', allinpay: '通联支付', lakala: '拉卡拉', paypal: 'PayPal',
|
|
||||||
saobei: '扫呗', chinaums: '银联商务', sft: '商福通', 'supper-pay': 'Supper Pay',
|
|
||||||
'wechat-game-pay': '微信小游戏支付', 'douyin-game-pay': '抖音小游戏支付'
|
|
||||||
}
|
|
||||||
const STAGE_NAMES = { config: '配置校验', test_settings: '测试参数', adapter: '渠道适配器', local_order: '本地测试订单', create: '渠道下单', query: '渠道查单', refund: '渠道退款' }
|
|
||||||
|
|
||||||
const configs = ref([])
|
|
||||||
const selectedProvider = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const operation = ref('')
|
|
||||||
const errors = reactive({})
|
|
||||||
const secretVisibility = reactive({})
|
|
||||||
const testVisible = ref(false)
|
|
||||||
const testResult = ref(null)
|
|
||||||
let loadRequestID = 0
|
|
||||||
|
|
||||||
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
|
||||||
const busy = computed(() => Boolean(operation.value))
|
|
||||||
const saveStateText = computed(() => {
|
|
||||||
if (!selected.value) return ''
|
|
||||||
if (!selected.value.configured) return '尚未创建配置'
|
|
||||||
if (isDirty(selected.value)) return '存在未保存的修改'
|
|
||||||
return selected.value.enabled ? '配置已保存,渠道已启用' : '配置已保存,渠道已停用'
|
|
||||||
})
|
|
||||||
const testResultTitle = computed(() => {
|
|
||||||
if (!testResult.value?.passed) return '渠道测试未通过'
|
|
||||||
const refundStage = testResult.value.stages?.find((stage) => stage.name === 'refund')
|
|
||||||
if (refundStage?.status === 'passed' && /受理|接受|等待|pending|processing/i.test(refundStage.message || '')) return '支付链路已受理,等待退款确认'
|
|
||||||
return testResult.value.fullFlow ? '完整支付链路测试通过' : '渠道连通性测试通过'
|
|
||||||
})
|
|
||||||
|
|
||||||
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
|
||||||
const normalizeConfig = (item) => {
|
|
||||||
const normalized = { ...item, enabled: Boolean(item.enabled), configured: Boolean(item.configured), config: cloneConfig(item.config), fields: Array.isArray(item.fields) ? item.fields : [] }
|
|
||||||
normalized._savedEnabled = normalized.enabled
|
|
||||||
normalized._savedConfig = cloneConfig(normalized.config)
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
const providerText = (provider) => PROVIDER_NAMES[provider] || provider
|
|
||||||
const fieldError = (key) => errors[key] || ''
|
|
||||||
const clearFieldError = (key) => { delete errors[key] }
|
|
||||||
const isEmpty = (value) => value === null || typeof value === 'undefined' || (typeof value === 'string' && value.trim() === '')
|
|
||||||
const isDirty = (item) => JSON.stringify({ enabled: item.enabled, config: item.config }) !== JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
|
|
||||||
const hasMaskedSecret = (item) => (item.fields || []).some((field) => field.secret && item.config[field.key] === '******')
|
|
||||||
const CALLBACK_URL_KEYS = new Set(['notify_url', 'return_url', 'cancel_url', 'callback_url', 'webhook_url', 'redirect_url', 'success_url', 'failure_url'])
|
|
||||||
const callbackURLPattern = /(?:notify|callback|webhook|return|cancel|redirect|success|failure)_?url$/i
|
|
||||||
const isCallbackURLField = (field) => {
|
|
||||||
const key = String(field?.key || '').trim().toLowerCase()
|
|
||||||
return CALLBACK_URL_KEYS.has(key) || callbackURLPattern.test(key)
|
|
||||||
}
|
|
||||||
const environmentText = (item) => String(item?.config?.environment || '').trim().toLowerCase()
|
|
||||||
const isSandboxEnvironment = (item) => ['sandbox', 'test', 'testing', 'dev', 'development'].includes(environmentText(item))
|
|
||||||
const isProductionEnvironment = (item) => !isSandboxEnvironment(item)
|
|
||||||
const secretFieldKey = (item, fieldKey) => `${item?.provider || ''}:${fieldKey}`
|
|
||||||
const isSecretVisible = (item, fieldKey) => Boolean(secretVisibility[secretFieldKey(item, fieldKey)])
|
|
||||||
const toggleSecretVisibility = (item, fieldKey) => {
|
|
||||||
const key = secretFieldKey(item, fieldKey)
|
|
||||||
secretVisibility[key] = !secretVisibility[key]
|
|
||||||
}
|
|
||||||
const maskedTextareaValue = (value) => {
|
|
||||||
if (value === '******') return value
|
|
||||||
return isEmpty(value) ? '' : '********'
|
|
||||||
}
|
|
||||||
const hideSecrets = () => { Object.keys(secretVisibility).forEach((key) => delete secretVisibility[key]) }
|
|
||||||
const fieldHint = (field, item = selected.value) => {
|
|
||||||
if (field.key === 'test_mode') return '仅在需要执行真实渠道测试时开启。'
|
|
||||||
if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。'
|
|
||||||
if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。'
|
|
||||||
if (isCallbackURLField(field)) {
|
|
||||||
return isProductionEnvironment(item) ? '生产环境回调地址必须使用 HTTPS;沙箱环境可使用 HTTP。' : '沙箱环境可使用 HTTP;切换生产环境前请改为 HTTPS。'
|
|
||||||
}
|
|
||||||
return field.description || ''
|
|
||||||
}
|
|
||||||
const stageName = (name) => STAGE_NAMES[name] || name
|
|
||||||
const stageStatusText = (status) => status === 'passed' ? '通过' : status === 'skipped' ? '跳过' : '失败'
|
|
||||||
const stageIcon = (status) => status === 'passed' ? CircleCheck : status === 'failed' ? CircleClose : MoreFilled
|
|
||||||
|
|
||||||
function validate(item, enabled = item.enabled) {
|
|
||||||
Object.keys(errors).forEach((key) => delete errors[key])
|
|
||||||
let valid = true
|
|
||||||
for (const field of item.fields || []) {
|
|
||||||
const value = item.config[field.key]
|
|
||||||
let message = ''
|
|
||||||
if (enabled && field.required && isEmpty(value)) message = `请填写${field.label}`
|
|
||||||
else if (field.type === 'number' && !isEmpty(value) && (!Number.isFinite(Number(value)) || Number(value) < 0)) message = `${field.label}必须是非负数`
|
|
||||||
else if (field.key === 'test_amount' && !isEmpty(value) && Number(value) <= 0) message = '测试金额必须大于 0'
|
|
||||||
else if (field.key === 'test_extra' && String(value || '').trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(String(value))
|
|
||||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') message = '测试扩展参数必须是 JSON 对象'
|
|
||||||
} catch { message = '测试扩展参数必须是合法 JSON' }
|
|
||||||
} else if ((field.type === 'url' || field.key.endsWith('_url') || isCallbackURLField(field)) && value) {
|
|
||||||
try {
|
|
||||||
const url = new URL(String(value))
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
|
|
||||||
else if (isCallbackURLField(field) && isProductionEnvironment(item) && url.protocol !== 'https:') message = `${field.label}在生产环境必须使用 HTTPS`
|
|
||||||
} catch { message = `${field.label}格式无效` }
|
|
||||||
}
|
|
||||||
if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label
|
|
||||||
if (message) { errors[field.key] = message; valid = false }
|
|
||||||
}
|
|
||||||
if (!valid) ElMessage.warning('请先修正配置项')
|
|
||||||
return valid
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
if (loading.value) return
|
|
||||||
const requestID = ++loadRequestID
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getIntegrationConfigs('payment')
|
|
||||||
if (requestID !== loadRequestID || res.code !== 0) return
|
|
||||||
hideSecrets()
|
|
||||||
configs.value = (res.data || []).map(normalizeConfig)
|
|
||||||
if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || ''
|
|
||||||
} catch {
|
|
||||||
// The request layer already presents transport errors.
|
|
||||||
} finally { loading.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshConfigs() {
|
|
||||||
if (busy.value) return
|
|
||||||
if (selected.value && isDirty(selected.value)) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('当前渠道有未保存修改,刷新会丢失这些修改。确认刷新吗?', '刷新支付配置', { type: 'warning', confirmButtonText: '确认刷新' })
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectProvider(provider) {
|
|
||||||
if (provider === selectedProvider.value || busy.value) return
|
|
||||||
if (selected.value && isDirty(selected.value)) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('当前渠道有未保存修改,切换后会丢失这些修改。确认切换吗?', '切换支付渠道', { type: 'warning', confirmButtonText: '确认切换' })
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach((key) => delete errors[key])
|
|
||||||
hideSecrets()
|
|
||||||
selectedProvider.value = provider
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persist(item, type) {
|
|
||||||
if (busy.value || !validate(item, item.enabled)) return false
|
|
||||||
operation.value = type
|
|
||||||
try {
|
|
||||||
const res = await saveIntegrationConfig('payment', item.provider, { enabled: item.enabled, config: item.config })
|
|
||||||
if (res.code !== 0) return false
|
|
||||||
const provider = item.provider
|
|
||||||
await load()
|
|
||||||
selectedProvider.value = provider
|
|
||||||
return true
|
|
||||||
} catch { return false }
|
|
||||||
finally { operation.value = '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSelected() {
|
|
||||||
if (!selected.value) return
|
|
||||||
if (await persist(selected.value, 'save')) ElMessage.success('支付渠道配置已保存')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleProvider(enabled) {
|
|
||||||
const item = selected.value
|
|
||||||
if (!item || busy.value) return
|
|
||||||
const previous = item.enabled
|
|
||||||
item.enabled = Boolean(enabled)
|
|
||||||
if (item.enabled && !validate(item, true)) { item.enabled = previous; return }
|
|
||||||
if (await persist(item, 'toggle')) {
|
|
||||||
ElMessage.success(`${item.name || providerText(item.provider)} 已${item.enabled ? '启用' : '停用'}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item.enabled = previous
|
|
||||||
ElMessage.warning('渠道状态未改变')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testProvider() {
|
|
||||||
const item = selected.value
|
|
||||||
if (!item || busy.value) return
|
|
||||||
if (!item.enabled) { ElMessage.warning('当前支付渠道已停用,请先启用并保存渠道后再执行测试。'); return }
|
|
||||||
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
|
|
||||||
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
|
|
||||||
const environment = environmentText(item)
|
|
||||||
if (environment === 'production' || environment === 'prod') {
|
|
||||||
operation.value = 'test-confirm'
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' })
|
|
||||||
} catch { operation.value = ''; return }
|
|
||||||
}
|
|
||||||
operation.value = 'test'
|
|
||||||
try {
|
|
||||||
const res = await testPaymentProvider(item.provider)
|
|
||||||
testResult.value = res.data || { passed: false, stages: [{ name: 'test', status: 'failed', message: res.msg || '未知错误' }] }
|
|
||||||
testVisible.value = true
|
|
||||||
} catch {
|
|
||||||
// The request layer already presents transport errors.
|
|
||||||
} finally { operation.value = '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove() {
|
|
||||||
const item = selected.value
|
|
||||||
if (!item || busy.value) return
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`删除 ${item.name || providerText(item.provider)} 配置后,该渠道将立即不可用。确认继续吗?`, '删除支付配置', { type: 'warning', confirmButtonText: '确认删除' })
|
|
||||||
} catch { return }
|
|
||||||
operation.value = 'delete'
|
|
||||||
try {
|
|
||||||
const res = await deleteIntegrationConfig('payment', item.provider)
|
|
||||||
if (res.code !== 0) return
|
|
||||||
ElMessage.success('支付渠道配置已删除')
|
|
||||||
await load()
|
|
||||||
} catch {
|
|
||||||
// The request layer already presents transport errors.
|
|
||||||
} finally { operation.value = '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.payment-config-page { min-height: 640px; }
|
|
||||||
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
|
||||||
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
|
|
||||||
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
|
||||||
.config-layout { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 560px; overflow: hidden; border: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); }
|
|
||||||
.provider-panel { padding: 12px 9px; border-right: 1px solid var(--el-border-color-lighter); background: var(--el-fill-color-blank); }
|
|
||||||
.panel-heading { display: flex; justify-content: space-between; padding: 4px 10px 11px; color: var(--el-text-color-secondary); font-size: 12px; }
|
|
||||||
.provider-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 58px; padding: 8px 10px; border: 0; border-left: 3px solid transparent; border-radius: 4px; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
|
||||||
.provider-item:hover { background: var(--el-fill-color-light); }
|
|
||||||
.provider-item.active { border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
|
||||||
.provider-copy { display: grid; min-width: 0; gap: 3px; }
|
|
||||||
.provider-copy strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.provider-copy small { color: var(--el-text-color-secondary); font-size: 11px; }
|
|
||||||
.provider-state { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
|
|
||||||
.provider-state.enabled { color: var(--el-color-success); }
|
|
||||||
.editor-panel { display: flex; min-width: 0; flex-direction: column; padding: 22px 28px 20px; }
|
|
||||||
.editor-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
|
||||||
.editor-title-group { min-width: 0; }
|
|
||||||
.editor-title-row { display: flex; align-items: center; gap: 9px; }
|
|
||||||
.editor-title-row h3 { margin: 0; color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; letter-spacing: 0; }
|
|
||||||
.editor-title-group p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
|
||||||
.enable-control { display: flex; align-items: center; gap: 10px; min-height: 32px; color: var(--el-text-color-regular); font-size: 13px; white-space: nowrap; }
|
|
||||||
.editor-alert { margin-top: 18px; }
|
|
||||||
.config-form { flex: 1; padding-top: 20px; }
|
|
||||||
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
|
|
||||||
.field-label { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
|
|
||||||
.field-label small { overflow: hidden; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; text-overflow: ellipsis; }
|
|
||||||
.field-control { width: 100%; }
|
|
||||||
.secret-textarea-control { width: 100%; }
|
|
||||||
.secret-textarea-display :deep(.el-textarea__inner) { color: var(--el-text-color-placeholder); font-family: monospace; letter-spacing: 0; }
|
|
||||||
.secret-textarea-action { margin: 4px 0 0; padding: 4px 0; }
|
|
||||||
.field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
|
||||||
.editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
|
||||||
.save-state { margin-right: auto; color: var(--el-text-color-secondary); font-size: 12px; }
|
|
||||||
.test-result :deep(.el-result) { padding: 8px 24px 20px; }
|
|
||||||
.test-stages { border-top: 1px solid var(--el-border-color-lighter); }
|
|
||||||
.test-stage { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: start; gap: 10px; padding: 13px 4px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
|
||||||
.test-stage strong { color: var(--el-text-color-primary); font-size: 13px; font-weight: 600; }
|
|
||||||
.test-stage p { margin: 3px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
|
||||||
.test-stage > span { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
|
|
||||||
.stage-passed { color: var(--el-color-success); } .stage-failed { color: var(--el-color-danger); } .stage-skipped { color: var(--el-text-color-placeholder); }
|
|
||||||
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; max-height: 270px; overflow-y: auto; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); } .panel-heading { display: none; } .provider-state { display: none; } .editor-panel { padding: 20px; } .field-grid { grid-template-columns: 1fr; } }
|
|
||||||
@media (max-width: 560px) { .payment-config-page { min-height: 0; } .page-heading { align-items: flex-start; } .provider-panel { grid-template-columns: 1fr; } .provider-state { display: inline; } .editor-panel { padding: 18px 14px; } .editor-heading { align-items: stretch; flex-direction: column; gap: 14px; } .enable-control { justify-content: space-between; } .editor-actions { align-items: stretch; flex-direction: column; } .save-state { margin-right: 0; } .editor-actions :deep(.el-button) { margin-left: 0; } }
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user