diff --git a/configs/config.yaml b/configs/config.yaml index 6dbca13..53fce49 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -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 diff --git a/internal/biz/integration_config_definition.go b/internal/biz/integration_config_definition.go index 3ce6fb8..6420772 100644 --- a/internal/biz/integration_config_definition.go +++ b/internal/biz/integration_config_definition.go @@ -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")), diff --git a/internal/biz/payment.go b/internal/biz/payment.go index c5bb90e..a8aa7d8 100644 --- a/internal/biz/payment.go +++ b/internal/biz/payment.go @@ -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. diff --git a/internal/data/payment/payment.go b/internal/data/payment/payment.go index 6ee86d5..353bab8 100644 --- a/internal/data/payment/payment.go +++ b/internal/data/payment/payment.go @@ -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("支付下单请求为空") diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index e89f7b5..ce0d53f 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -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) } } diff --git a/internal/server/handler/payment.go b/internal/server/handler/payment.go index 121d172..b780441 100644 --- a/internal/server/handler/payment.go +++ b/internal/server/handler/payment.go @@ -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 { diff --git a/internal/server/router/payment.go b/internal/server/router/payment.go index dc96419..8e40e65 100644 --- a/internal/server/router/payment.go +++ b/internal/server/router/payment.go @@ -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) diff --git a/internal/service/dto/payment.go b/internal/service/dto/payment.go index c74f845..0bfbf45 100644 --- a/internal/service/dto/payment.go +++ b/internal/service/dto/payment.go @@ -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"` diff --git a/internal/service/dto_alias.go b/internal/service/dto_alias.go index bfd6d22..f5ffea4 100644 --- a/internal/service/dto_alias.go +++ b/internal/service/dto_alias.go @@ -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 diff --git a/internal/service/payment.go b/internal/service/payment.go index a34bca0..f21c40c 100644 --- a/internal/service/payment.go +++ b/internal/service/payment.go @@ -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("支付发货请求为空") diff --git a/internal/service/route_metadata.go b/internal/service/route_metadata.go index bc8718f..6f6fe96 100644 --- a/internal/service/route_metadata.go +++ b/internal/service/route_metadata.go @@ -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获取字典(建议选择)"}, diff --git a/web/src/api/payment.js b/web/src/api/payment.js index 2b741de..d8a87b4 100644 --- a/web/src/api/payment.js +++ b/web/src/api/payment.js @@ -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' +}) diff --git a/web/src/view/systemTools/payment/config.vue b/web/src/view/systemTools/payment/config.vue index c9d86bb..b15d8c8 100644 --- a/web/src/view/systemTools/payment/config.vue +++ b/web/src/view/systemTools/payment/config.vue @@ -34,6 +34,7 @@