优化结构
This commit is contained in:
parent
055f6811ca
commit
e81a641953
|
|
@ -88,7 +88,7 @@ admin:
|
|||
router_prefix: ""
|
||||
jwt:
|
||||
# Production deployments must override this value with a private secret.
|
||||
signing_key: 86a6eb31-46b3-4da9-ae79-85aeff3e699d
|
||||
signing_key: 812e9411-08e7-47bb-85a0-02a2274b68be
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: kra
|
||||
|
|
|
|||
|
|
@ -19,6 +19,22 @@ func paymentDefinition(provider, name, description string, defaults map[string]a
|
|||
common := []IntegrationConfigField{
|
||||
integrationField("notify_url", "支付回调地址", false, false, "url"),
|
||||
integrationField("return_url", "同步跳转地址", false, false, "url"),
|
||||
integrationField("test_amount", "测试金额(最小货币单位)", false, false, "number"),
|
||||
integrationField("test_currency", "测试币种", false, false, "text"),
|
||||
integrationField("test_extra", "测试下单扩展参数(JSON)", false, false, "textarea"),
|
||||
integrationField("test_mode", "允许执行渠道测试", false, false, "switch"),
|
||||
}
|
||||
if _, exists := defaults["test_amount"]; !exists {
|
||||
defaults["test_amount"] = 1
|
||||
}
|
||||
if _, exists := defaults["test_currency"]; !exists {
|
||||
defaults["test_currency"] = "CNY"
|
||||
}
|
||||
if _, exists := defaults["test_extra"]; !exists {
|
||||
defaults["test_extra"] = "{}"
|
||||
}
|
||||
if _, exists := defaults["test_mode"]; !exists {
|
||||
defaults["test_mode"] = false
|
||||
}
|
||||
fields = append(fields, common...)
|
||||
for _, field := range fields {
|
||||
|
|
@ -85,8 +101,8 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
|||
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", "商户证书", false, true, "textarea"), integrationField("client_key", "证书私钥", false, true, "textarea")),
|
||||
paymentDefinition(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")),
|
||||
paymentDefinition(PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production"},
|
||||
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")),
|
||||
paymentDefinition(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")),
|
||||
paymentDefinition(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", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), integrationSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(PaymentQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
||||
|
|
@ -95,7 +111,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
|||
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")),
|
||||
paymentDefinition(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")),
|
||||
paymentDefinition(PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true},
|
||||
paymentDefinition(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")),
|
||||
paymentDefinition(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")),
|
||||
|
|
|
|||
|
|
@ -182,6 +182,27 @@ type PaymentResult struct {
|
|||
FailureAck PaymentCallbackAck `json:"-"`
|
||||
}
|
||||
|
||||
// PaymentTestStage is one step of a provider connectivity test. A skipped
|
||||
// query/refund is reported explicitly because a prepay flow cannot be marked
|
||||
// paid without a sandbox payer interaction.
|
||||
type PaymentTestStage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
TradeNo string `json:"tradeNo,omitempty"`
|
||||
Duration int64 `json:"durationMs,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentTestResult struct {
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
Passed bool `json:"passed"`
|
||||
FullFlow bool `json:"fullFlow"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Stages []PaymentTestStage `json:"stages"`
|
||||
Result *PaymentResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentCallbackAck = paymentutil.CallbackAck
|
||||
type PaymentCallbackError = paymentutil.CallbackError
|
||||
|
||||
|
|
@ -207,6 +228,10 @@ type PaymentRepo interface {
|
|||
HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error)
|
||||
}
|
||||
|
||||
type PaymentProviderTester interface {
|
||||
TestProvider(context.Context, string) (*PaymentTestResult, error)
|
||||
}
|
||||
|
||||
type PaymentHooks interface {
|
||||
BeforeCreate(context.Context, *PaymentRequest) error
|
||||
}
|
||||
|
|
@ -477,6 +502,21 @@ func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string,
|
|||
return uc.refundWithOrder(ctx, provider, tradeNo, amount)
|
||||
}
|
||||
|
||||
// TestProvider exercises the configured provider without requiring a business
|
||||
// module or a real customer order. Provider adapters use their configured
|
||||
// sandbox/test endpoint when one is selected in integration settings.
|
||||
func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (*PaymentTestResult, error) {
|
||||
provider = strings.TrimSpace(provider)
|
||||
if !supportedPaymentProvider(provider) {
|
||||
return nil, errors.New("不支持的支付渠道")
|
||||
}
|
||||
tester, ok := uc.repo.(PaymentProviderTester)
|
||||
if !ok {
|
||||
return nil, errors.New("支付渠道仓储未接入")
|
||||
}
|
||||
return tester.TestProvider(ctx, provider)
|
||||
}
|
||||
|
||||
// Fulfill retries delivery for an already-paid order. It is intentionally
|
||||
// idempotent: the repository lease prevents concurrent attempts and a
|
||||
// succeeded order is returned as a duplicate without invoking the handler.
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import (
|
|||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
datapayment "kra/internal/integration/payment"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
|
@ -82,6 +84,299 @@ func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment
|
|||
return adapter, values, err
|
||||
}
|
||||
|
||||
func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*biz.PaymentTestResult, error) {
|
||||
started := time.Now()
|
||||
test := &biz.PaymentTestResult{Provider: provider, TradeNo: "", Passed: false, Stages: []biz.PaymentTestStage{}}
|
||||
add := func(name, status, message, tradeNo string, since time.Time) {
|
||||
test.Stages = append(test.Stages, biz.PaymentTestStage{Name: name, Status: status, Message: message, TradeNo: tradeNo, Duration: time.Since(since).Milliseconds()})
|
||||
}
|
||||
values, err := r.testRow(ctx, provider)
|
||||
if err != nil {
|
||||
add("config", "failed", err.Error(), "", started)
|
||||
return test, err
|
||||
}
|
||||
test.Mode = strings.ToLower(strings.TrimSpace(text(values, "environment")))
|
||||
configStart := time.Now()
|
||||
if err = biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, provider, values); err != nil {
|
||||
add("config", "failed", err.Error(), "", configStart)
|
||||
return test, err
|
||||
}
|
||||
add("config", "passed", "支付配置校验通过", "", configStart)
|
||||
if err = validatePaymentTestSettings(provider, values); err != nil {
|
||||
add("test_settings", "failed", err.Error(), "", time.Now())
|
||||
return test, err
|
||||
}
|
||||
|
||||
adapter, err := datapayment.New(provider)
|
||||
if err != nil {
|
||||
add("adapter", "failed", err.Error(), "", time.Now())
|
||||
return test, err
|
||||
}
|
||||
req := paymentTestRequest(provider, values)
|
||||
test.TradeNo = req.TradeNo
|
||||
orders := &paymentOrderRepo{data: r.data}
|
||||
extra, _ := json.Marshal(req.Extra)
|
||||
localStart := time.Now()
|
||||
order, _, err := orders.CreatePaymentOrder(ctx, &biz.PaymentOrder{
|
||||
TradeNo: req.TradeNo, Provider: provider, BusinessType: req.BusinessType, BusinessID: req.BusinessID,
|
||||
Subject: req.Subject, PaymentMode: biz.PaymentModeExternal, OriginalAmount: req.Amount, Amount: req.Amount,
|
||||
Currency: req.Currency, PaymentStatus: biz.PaymentStatusInitialized, FulfillmentStatus: biz.FulfillmentStatusPending,
|
||||
RefundStatus: biz.RefundStatusNone, ConfirmationID: uuid.NewString(), RequestFingerprint: paymentTestFingerprint(req), Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
add("local_order", "failed", err.Error(), req.TradeNo, localStart)
|
||||
return test, err
|
||||
}
|
||||
add("local_order", "passed", "本地测试订单已创建", req.TradeNo, localStart)
|
||||
createStart := time.Now()
|
||||
created, err := adapter.Create(ctx, req, values)
|
||||
if err != nil {
|
||||
recordPaymentTestError(ctx, r.data, provider, req.TradeNo, err)
|
||||
add("create", "failed", err.Error(), req.TradeNo, createStart)
|
||||
return test, err
|
||||
}
|
||||
if created == nil {
|
||||
err = errors.New("测试下单响应为空")
|
||||
add("create", "failed", err.Error(), req.TradeNo, createStart)
|
||||
return test, err
|
||||
}
|
||||
if err = validatePaymentTestResult(provider, req.TradeNo, created); err != nil {
|
||||
recordPaymentTestError(ctx, r.data, provider, req.TradeNo, err)
|
||||
add("create", "failed", err.Error(), req.TradeNo, createStart)
|
||||
return test, err
|
||||
}
|
||||
if order, err = orders.RecordPaymentCreate(ctx, provider, req.TradeNo, paymentTestProviderUpdate(created)); err != nil {
|
||||
add("local_order", "failed", "记录第三方下单结果失败: "+err.Error(), req.TradeNo, createStart)
|
||||
return test, err
|
||||
}
|
||||
add("create", "passed", "测试订单已提交", req.TradeNo, createStart)
|
||||
test.Result = created
|
||||
|
||||
queryID := strings.TrimSpace(created.QueryID)
|
||||
if queryID == "" {
|
||||
queryID = strings.TrimSpace(created.ProviderTradeNo)
|
||||
}
|
||||
if queryID == "" {
|
||||
queryID = req.TradeNo
|
||||
}
|
||||
if provider == biz.PaymentApple {
|
||||
queryID = strings.TrimSpace(text(values, "test_transaction_id"))
|
||||
if queryID == "" {
|
||||
err = errors.New("Apple 连通性测试需要配置 test_transaction_id(沙箱交易 ID)")
|
||||
add("query", "failed", err.Error(), req.TradeNo, time.Now())
|
||||
return test, err
|
||||
}
|
||||
}
|
||||
queryStart := time.Now()
|
||||
queried, queryErr := queryPaymentTest(ctx, adapter, queryID, values)
|
||||
if queryErr != nil {
|
||||
recordPaymentTestError(ctx, r.data, provider, req.TradeNo, queryErr)
|
||||
add("query", "failed", queryErr.Error(), req.TradeNo, queryStart)
|
||||
return test, queryErr
|
||||
}
|
||||
if err = validatePaymentTestResult(provider, req.TradeNo, queried); err != nil {
|
||||
recordPaymentTestError(ctx, r.data, provider, req.TradeNo, err)
|
||||
add("query", "failed", err.Error(), req.TradeNo, queryStart)
|
||||
return test, err
|
||||
}
|
||||
if provider != biz.PaymentApple {
|
||||
if order, err = orders.ApplyPaymentResult(ctx, provider, req.TradeNo, paymentTestProviderUpdate(queried)); err != nil {
|
||||
add("local_order", "failed", "回写测试查单结果失败: "+err.Error(), req.TradeNo, queryStart)
|
||||
return test, err
|
||||
}
|
||||
}
|
||||
if queried == nil {
|
||||
err = errors.New("测试查单响应为空")
|
||||
add("query", "failed", err.Error(), req.TradeNo, queryStart)
|
||||
return test, err
|
||||
}
|
||||
test.Result = queried
|
||||
add("query", "passed", "测试订单查询成功,状态: "+queried.Status, req.TradeNo, queryStart)
|
||||
|
||||
if queried.Status != "success" || provider == biz.PaymentApple {
|
||||
message := "订单尚未支付成功,已完成配置、下单和查单连通性测试;请在沙箱完成付款后重试"
|
||||
if provider == biz.PaymentApple {
|
||||
message = "Apple 退款由 App Store 管理,已完成配置、下单和交易查询测试"
|
||||
}
|
||||
add("refund", "skipped", message, req.TradeNo, time.Now())
|
||||
test.Passed = true
|
||||
return test, nil
|
||||
}
|
||||
refundStart := time.Now()
|
||||
order, refundToken, beginErr := orders.BeginPaymentRefund(ctx, provider, req.TradeNo, req.Amount, time.Minute)
|
||||
if beginErr != nil {
|
||||
add("refund", "failed", beginErr.Error(), req.TradeNo, refundStart)
|
||||
return test, beginErr
|
||||
}
|
||||
refund, refundErr := adapter.Refund(ctx, &biz.PaymentRefundRequest{Provider: provider, TradeNo: req.TradeNo, ProviderTradeNo: order.ProviderTradeNo, QueryID: order.QueryID, RefundNo: order.RefundNo, Amount: req.Amount, TotalAmount: req.Amount, Currency: req.Currency}, values)
|
||||
if refundErr != nil {
|
||||
recordPaymentTestError(ctx, r.data, provider, req.TradeNo, refundErr)
|
||||
add("refund", "failed", refundErr.Error(), req.TradeNo, refundStart)
|
||||
return test, refundErr
|
||||
}
|
||||
if refund == nil {
|
||||
err = errors.New("测试退款响应为空")
|
||||
add("refund", "failed", err.Error(), req.TradeNo, refundStart)
|
||||
return test, err
|
||||
}
|
||||
if _, err = orders.CompletePaymentRefundRequest(ctx, provider, req.TradeNo, refundToken, true, ""); err != nil {
|
||||
add("local_order", "failed", "回写测试退款结果失败: "+err.Error(), req.TradeNo, refundStart)
|
||||
return test, err
|
||||
}
|
||||
test.Result = refund
|
||||
add("refund", "passed", "测试退款申请已被渠道接受", req.TradeNo, refundStart)
|
||||
test.Passed = true
|
||||
test.FullFlow = true
|
||||
return test, nil
|
||||
}
|
||||
|
||||
func paymentTestFingerprint(req *biz.PaymentRequest) string {
|
||||
raw, _ := json.Marshal(req)
|
||||
hash := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func paymentTestProviderUpdate(result *biz.PaymentResult) *biz.PaymentProviderUpdate {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return &biz.PaymentProviderUpdate{
|
||||
Status: result.Status, ProviderStatus: result.Status, ProviderTradeNo: result.ProviderTradeNo, QueryID: result.QueryID,
|
||||
Amount: result.Amount, PayerPaidAmount: result.PayerPaidAmount, CashPaidAmount: result.CashPaidAmount,
|
||||
PointPaidAmount: result.PointPaidAmount, DiscountAmount: result.DiscountAmount,
|
||||
ProviderDiscountAmount: result.ProviderDiscountAmount, MerchantDiscountAmount: result.MerchantDiscountAmount,
|
||||
SettlementAmount: result.SettlementAmount, Currency: result.Currency, PayerCurrency: result.PayerCurrency,
|
||||
AmountBreakdownKnown: result.AmountBreakdownKnown, CreatePayload: result.Payload,
|
||||
}
|
||||
}
|
||||
|
||||
func validatePaymentTestResult(provider, tradeNo string, result *biz.PaymentResult) error {
|
||||
if result == nil {
|
||||
return errors.New("支付渠道响应为空")
|
||||
}
|
||||
if strings.TrimSpace(result.Provider) != provider {
|
||||
return errors.New("支付渠道响应的 provider 不匹配")
|
||||
}
|
||||
if value := strings.TrimSpace(result.TradeNo); provider != biz.PaymentApple && value != "" && value != tradeNo {
|
||||
return errors.New("支付渠道响应的商户订单号不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryPaymentTest(ctx context.Context, adapter datapayment.Adapter, queryID string, values map[string]any) (*biz.PaymentResult, error) {
|
||||
var result *biz.PaymentResult
|
||||
var err error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
result, err = adapter.Query(ctx, queryID, values)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if attempt == 2 {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(500 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func recordPaymentTestError(ctx context.Context, data Provider, provider, tradeNo string, err error) {
|
||||
if data == nil || data.DB() == nil || err == nil {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
if len(message) > 512 {
|
||||
message = message[:512]
|
||||
}
|
||||
_ = data.DB().WithContext(ctx).Model(&paymentOrderPO{}).Where("provider = ? AND trade_no = ?", provider, tradeNo).Update("last_error", message).Error
|
||||
}
|
||||
|
||||
func (r *paymentRepo) testRow(ctx context.Context, provider string) (map[string]any, error) {
|
||||
var row integrationConfigPO
|
||||
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, biz.ErrPaymentProviderNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(row.Config), &values); err != nil {
|
||||
return nil, fmt.Errorf("支付配置格式错误: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func paymentTestRequest(provider string, values map[string]any) *biz.PaymentRequest {
|
||||
tradeNo := "kra-test-" + time.Now().UTC().Format("20060102150405.000000000")
|
||||
amount := configuredInt64(values, "test_amount", 1)
|
||||
if amount <= 0 {
|
||||
amount = 1
|
||||
}
|
||||
req := &biz.PaymentRequest{Provider: provider, TradeNo: strings.ReplaceAll(tradeNo, ".", ""), Subject: "Kra 支付渠道连通性测试", Amount: amount, Currency: strings.ToUpper(firstAny(values, "test_currency", "currency", "fee_type")), NotifyURL: text(values, "notify_url"), ReturnURL: text(values, "return_url"), BusinessType: "system_payment_test", BusinessID: uuid.NewString(), Extra: map[string]any{}}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "CNY"
|
||||
}
|
||||
if provider == biz.PaymentApple {
|
||||
req.TradeNo = uuid.NewString()
|
||||
req.Extra["product_id"] = firstAny(values, "product_id", "test_product_id")
|
||||
}
|
||||
if raw := strings.TrimSpace(text(values, "test_extra")); raw != "" {
|
||||
var extra map[string]any
|
||||
if json.Unmarshal([]byte(raw), &extra) == nil {
|
||||
for key, value := range extra {
|
||||
req.Extra[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"trade_type", "method", "pay_type", "channel", "openid", "open_id", "auth_code", "authcode", "barcode"} {
|
||||
if value := text(values, key); value != "" {
|
||||
req.Extra[key] = value
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func validatePaymentTestSettings(provider string, values map[string]any) error {
|
||||
if !testModeEnabled(values) {
|
||||
return errors.New("请先打开 test_mode(允许执行渠道测试)")
|
||||
}
|
||||
if raw := strings.TrimSpace(text(values, "test_extra")); raw != "" {
|
||||
var extra map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &extra); err != nil {
|
||||
return fmt.Errorf("test_extra 必须是 JSON 对象: %w", err)
|
||||
}
|
||||
}
|
||||
if provider == biz.PaymentApple && strings.TrimSpace(text(values, "test_transaction_id")) == "" {
|
||||
return errors.New("Apple 测试需要 test_transaction_id(沙箱交易 ID)")
|
||||
}
|
||||
if provider == biz.PaymentApple && strings.TrimSpace(firstAny(values, "test_product_id", "product_id")) == "" {
|
||||
return errors.New("Apple 测试需要 test_product_id(沙箱商品 ID)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testModeEnabled(values map[string]any) bool {
|
||||
value, exists := values["test_mode"]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(typed), "true") || typed == "1"
|
||||
case float64:
|
||||
return typed == 1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("支付下单请求为空")
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ func TestGinRouteContract(t *testing.T) {
|
|||
"GET /integration/configs/:kind/:provider",
|
||||
"PUT /integration/configs/:kind/:provider",
|
||||
"DELETE /integration/configs/:kind/:provider",
|
||||
"POST /payment/providers/:provider/test",
|
||||
} {
|
||||
if !strings.Contains(value, route) {
|
||||
t.Fatalf("route contract missing %s:\n%s", route, value)
|
||||
|
|
@ -70,7 +71,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
|||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":192`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":193`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,19 @@ func (h *Payment) Orders(c *gin.Context) {
|
|||
}
|
||||
Write(c, CodeSuccess, PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功")
|
||||
}
|
||||
|
||||
func (h *Payment) TestProvider(c *gin.Context) {
|
||||
result, err := h.service.TestProvider(c.Request.Context(), c.Param("provider"))
|
||||
if err != nil {
|
||||
if result != nil {
|
||||
Write(c, CodeError, result, err.Error())
|
||||
return
|
||||
}
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
func (h *Payment) Create(c *gin.Context) {
|
||||
var req service.PaymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
func RegisterPayment(group, public *gin.RouterGroup, h *Payment) {
|
||||
payment := group.Group("/payment")
|
||||
payment.GET("/orders", h.Orders)
|
||||
payment.POST("/providers/:provider/test", h.TestProvider)
|
||||
payment.POST("/order", h.Order)
|
||||
payment.GET("/orders/:provider/:tradeNo", h.OrderByPath)
|
||||
payment.POST("/create", h.Create)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ type PaymentResultResponse struct {
|
|||
RefundStatus string `json:"refundStatus,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentTestStage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
TradeNo string `json:"tradeNo,omitempty"`
|
||||
Duration int64 `json:"durationMs,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentTestResult struct {
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
Passed bool `json:"passed"`
|
||||
FullFlow bool `json:"fullFlow"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Stages []*PaymentTestStage `json:"stages"`
|
||||
Result *PaymentResultResponse `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentOrderResponse struct {
|
||||
ID uint64 `json:"ID"`
|
||||
Provider string `json:"provider"`
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ type PaymentQueryRequest = dto.PaymentQueryRequest
|
|||
type PaymentRefundRequest = dto.PaymentRefundRequest
|
||||
type PaymentRequest = dto.PaymentRequest
|
||||
type PaymentResultResponse = dto.PaymentResultResponse
|
||||
type PaymentTestResult = dto.PaymentTestResult
|
||||
type PaymentTestStage = dto.PaymentTestStage
|
||||
type PolicyPathRequest = dto.PolicyPathRequest
|
||||
type PositionListRequest = dto.PositionListRequest
|
||||
type PositionRequest = dto.PositionRequest
|
||||
|
|
|
|||
|
|
@ -107,6 +107,19 @@ func (s *PaymentService) Refund(ctx context.Context, req *PaymentRefundRequest)
|
|||
return paymentResultResponse(result), err
|
||||
}
|
||||
|
||||
func (s *PaymentService) TestProvider(ctx context.Context, provider string) (*PaymentTestResult, error) {
|
||||
result, err := s.uc.TestProvider(ctx, provider)
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &PaymentTestResult{Provider: result.Provider, TradeNo: result.TradeNo, Passed: result.Passed, FullFlow: result.FullFlow, Mode: result.Mode, Stages: make([]*PaymentTestStage, 0, len(result.Stages))}
|
||||
for _, stage := range result.Stages {
|
||||
response.Stages = append(response.Stages, &PaymentTestStage{Name: stage.Name, Status: stage.Status, Message: stage.Message, TradeNo: stage.TradeNo, Duration: stage.Duration})
|
||||
}
|
||||
response.Result = paymentResultResponse(result.Result)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (s *PaymentService) Fulfill(ctx context.Context, req *PaymentFulfillRequest) (*PaymentResultResponse, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("支付发货请求为空")
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ var apiMetadata = map[string]apiMetadataValue{
|
|||
"GET /integration/configs/:kind": {group: "集成配置", description: "按类型获取集成配置"},
|
||||
"GET /integration/configs/:kind/:provider": {group: "集成配置", description: "获取指定集成配置"},
|
||||
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
||||
"POST /payment/providers/:provider/test": {group: "支付", description: "测试支付渠道配置与沙箱交易链路"},
|
||||
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
||||
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
|
||||
"GET /sysDictionary/findSysDictionary": {group: "系统字典", description: "根据ID获取字典(建议选择)"},
|
||||
|
|
|
|||
|
|
@ -23,3 +23,8 @@ export const refundPaymentOrder = (data) => service({
|
|||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const testPaymentProvider = (provider) => service({
|
||||
url: `/payment/providers/${encodeURIComponent(provider)}/test`,
|
||||
method: 'post'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
</el-form>
|
||||
<div class="editor-actions">
|
||||
<el-button type="primary" :loading="saving" :icon="Check" @click="save">保存配置</el-button>
|
||||
<el-button :loading="testing" :icon="Connection" @click="testProvider">测试渠道</el-button>
|
||||
<el-button v-if="selected.configured" type="danger" plain :icon="Delete" @click="remove">删除配置</el-button>
|
||||
<el-button text :icon="DocumentCopy" @click="copyConfig">复制 JSON</el-button>
|
||||
</div>
|
||||
|
|
@ -46,14 +47,33 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Delete, DocumentCopy, Refresh } from '@element-plus/icons-vue'
|
||||
import { Check, Connection, Delete, DocumentCopy, Refresh } from '@element-plus/icons-vue'
|
||||
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
|
||||
import { testPaymentProvider } from '@/api/payment'
|
||||
|
||||
const configs = ref([]); const selectedProvider = ref(''); const loading = ref(false); const saving = ref(false)
|
||||
const configs = ref([]); const selectedProvider = ref(''); const loading = ref(false); const saving = ref(false); const testing = ref(false)
|
||||
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
||||
const select = (item) => { selectedProvider.value = item.provider }
|
||||
const load = async () => { loading.value = true; try { const res = await getIntegrationConfigs('payment'); if (res.code === 0) { configs.value = (res.data || []).map((item) => ({ ...item, config: { ...(item.config || {}) } })); if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || '' } } finally { loading.value = false } }
|
||||
const save = async () => { if (!selected.value) return; saving.value = true; try { const res = await saveIntegrationConfig('payment', selected.value.provider, { enabled: selected.value.enabled, config: selected.value.config }); if (res.code === 0) { ElMessage.success('支付渠道配置已保存'); await load() } } finally { saving.value = false } }
|
||||
const testProvider = async () => {
|
||||
if (!selected.value) return
|
||||
const environment = String(selected.value.config?.environment || '').toLowerCase()
|
||||
if (environment === 'production' || environment === 'prod') {
|
||||
try { await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' }) } catch { return }
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await testPaymentProvider(selected.value.provider)
|
||||
const result = res.data || {}
|
||||
const stages = (result.stages || []).map((stage) => `${stage.status === 'passed' ? '通过' : stage.status === 'skipped' ? '跳过' : '失败'}:${stage.name}${stage.message ? ` - ${stage.message}` : ''}${stage.durationMs != null ? ` (${stage.durationMs}ms)` : ''}`).join('\n')
|
||||
if (res.code === 0 && result.passed) {
|
||||
const title = result.fullFlow ? '渠道完整链路测试通过' : '渠道连通性测试通过(完整支付链路未完成)'
|
||||
ElMessage.success({ message: `${title}\n${stages}`, duration: 9000, showClose: true })
|
||||
}
|
||||
else ElMessage.error({ message: `渠道测试未通过\n${stages || res.msg || '未知错误'}`, duration: 9000, showClose: true })
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
const remove = async () => { if (!selected.value) return; await ElMessageBox.confirm(`确认删除 ${selected.value.name || selected.value.provider} 配置吗?`, '删除配置', { type: 'warning' }); const res = await deleteIntegrationConfig('payment', selected.value.provider); if (res.code === 0) { ElMessage.success('配置已删除'); await load() } }
|
||||
const copyConfig = async () => { if (!selected.value) return; await navigator.clipboard.writeText(JSON.stringify(selected.value.config || {}, null, 2)); ElMessage.success('JSON 已复制') }
|
||||
load()
|
||||
|
|
|
|||
Loading…
Reference in New Issue