优化结构

This commit is contained in:
Yvan 2026-08-21 11:16:12 +08:00
parent 055f6811ca
commit e81a641953
13 changed files with 432 additions and 7 deletions

View File

@ -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

View File

@ -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")),

View File

@ -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.

View File

@ -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("支付下单请求为空")

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -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"`

View File

@ -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

View File

@ -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("支付发货请求为空")

View File

@ -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获取字典建议选择"},

View File

@ -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'
})

View File

@ -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()