From 430feaaff63caa19ec27bfad3d07e66e21e77df5 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Fri, 21 Aug 2026 22:54:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/wire_gen.go | 6 +- internal/app/definition.go | 1 + internal/biz/integration_config.go | 49 +++++- .../integration_config_communication_test.go | 68 ++++++++- internal/data/migrations_test.go | 4 +- internal/data/repository/migrations.go | 144 ++++++++++++++++++ internal/integration/connectivity.go | 86 +++++++++++ internal/integration/mq/emqx.go | 45 ++++++ internal/integration/provider.go | 2 + internal/integration/websocket/server.go | 68 +++++++++ internal/server/gin_test.go | 4 +- internal/server/handler/integration_config.go | 13 ++ internal/server/middleware/audit.go | 2 +- internal/server/router/integration_config.go | 1 + internal/service/integration_config.go | 7 + internal/service/route_metadata.go | 1 + web/src/api/integration.js | 6 + .../view/systemTools/integration/config.vue | 34 ++++- web/src/view/systemTools/payment/config.vue | 80 +++++++++- web/src/view/systemTools/payment/orders.vue | 3 +- 20 files changed, 604 insertions(+), 20 deletions(-) create mode 100644 internal/integration/connectivity.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 12b6ae4..40b26f6 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -15,6 +15,7 @@ import ( "kra/internal/data/payment" "kra/internal/data/repository" "kra/internal/initialize" + "kra/internal/integration" "kra/internal/integration/cache" "kra/internal/integration/email" "kra/internal/integration/mq" @@ -147,14 +148,15 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger navigation := handler.NewNavigation(userService) session := handler.NewSession(tokenService) integrationConfigRepo := system.NewIntegrationConfigRepo(dataData) - integrationConfigUsecase := biz.NewIntegrationConfigUsecase(integrationConfigRepo) + store := data.NewIntegrationRuntime(dataData) + connectivityTester := integration.NewConnectivityTester(store) + integrationConfigUsecase := biz.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester) integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, integrationConfig) routes := router.NewRoutes(v) taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime) moduleRuntime := app.Runtime(routes, taskMethods, registry) - store := data.NewIntegrationRuntime(dataData) websocketServer, cleanup2, err := websocket.New(store) if err != nil { cleanup() diff --git a/internal/app/definition.go b/internal/app/definition.go index 216dd98..59ce90b 100644 --- a/internal/app/definition.go +++ b/internal/app/definition.go @@ -16,6 +16,7 @@ func Definition() module.Definition { {Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"}, {Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"}, {Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"}, + {Path: "/integration/configs/:kind/:provider/test", Method: "POST", Group: "集成配置", Description: "测试通信集成连接"}, {Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"}, }, } diff --git a/internal/biz/integration_config.go b/internal/biz/integration_config.go index f7c2373..2bb1cfe 100644 --- a/internal/biz/integration_config.go +++ b/internal/biz/integration_config.go @@ -56,10 +56,17 @@ type IntegrationConfigRepo interface { DeleteIntegrationConfig(context.Context, string, string) error } -type IntegrationConfigUsecase struct{ repo IntegrationConfigRepo } +type IntegrationConnectionTester interface { + TestIntegration(context.Context, *IntegrationConfig) error +} -func NewIntegrationConfigUsecase(repo IntegrationConfigRepo) *IntegrationConfigUsecase { - return &IntegrationConfigUsecase{repo: repo} +type IntegrationConfigUsecase struct { + repo IntegrationConfigRepo + tester IntegrationConnectionTester +} + +func NewIntegrationConfigUsecase(repo IntegrationConfigRepo, tester IntegrationConnectionTester) *IntegrationConfigUsecase { + return &IntegrationConfigUsecase{repo: repo, tester: tester} } func (uc *IntegrationConfigUsecase) List(ctx context.Context, kind string) ([]*IntegrationConfig, error) { @@ -106,6 +113,42 @@ func (uc *IntegrationConfigUsecase) Save(ctx context.Context, config *Integratio return uc.repo.SaveIntegrationConfig(ctx, config) } +// Test validates and probes a candidate configuration without persisting it. +// The adapter may resolve masked secret values from the active runtime store. +func (uc *IntegrationConfigUsecase) Test(ctx context.Context, config *IntegrationConfig) error { + if config == nil { + return errors.New("集成配置请求为空") + } + config.Kind = normalizeIntegrationPart(config.Kind) + config.Provider = normalizeIntegrationPart(config.Provider) + if config.Kind != IntegrationKindMQ && config.Kind != IntegrationKindWebSocket { + return errors.New("仅支持测试消息队列和 WebSocket 集成") + } + if config.Kind == "" || config.Provider == "" || len(config.Kind) > 32 || len(config.Provider) > 64 { + return errors.New("集成配置 kind 或 provider 无效") + } + if !json.Valid(config.Values) { + return errors.New("集成配置必须是合法 JSON") + } + values := map[string]any{} + if err := json.Unmarshal(config.Values, &values); err != nil { + return errors.New("集成配置必须是 JSON 对象") + } + if definition, ok := IntegrationDefinition(config.Kind, config.Provider); ok { + values = mergeIntegrationDefaults(definition.Defaults, values) + } + if err := ValidateIntegrationConfig(config.Kind, config.Provider, values); err != nil { + return err + } + encoded, _ := json.Marshal(values) + config.Enabled = true + config.Values = encoded + if uc.tester == nil { + return errors.New("集成连接测试器未初始化") + } + return uc.tester.TestIntegration(ctx, config) +} + func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error { kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider) if kind == "" || provider == "" { diff --git a/internal/biz/integration_config_communication_test.go b/internal/biz/integration_config_communication_test.go index 4ea7a4b..3ef3643 100644 --- a/internal/biz/integration_config_communication_test.go +++ b/internal/biz/integration_config_communication_test.go @@ -1,6 +1,39 @@ package biz -import "testing" +import ( + "context" + "encoding/json" + "testing" +) + +type integrationConfigRepoTestDouble struct { + saves int +} + +func (*integrationConfigRepoTestDouble) ListIntegrationConfigs(context.Context, string) ([]*IntegrationConfig, error) { + return nil, nil +} +func (*integrationConfigRepoTestDouble) FindIntegrationConfig(context.Context, string, string) (*IntegrationConfig, error) { + return nil, nil +} +func (r *integrationConfigRepoTestDouble) SaveIntegrationConfig(context.Context, *IntegrationConfig) error { + r.saves++ + return nil +} +func (*integrationConfigRepoTestDouble) DeleteIntegrationConfig(context.Context, string, string) error { + return nil +} + +type integrationConnectionTesterDouble struct { + calls int + config *IntegrationConfig +} + +func (t *integrationConnectionTesterDouble) TestIntegration(_ context.Context, config *IntegrationConfig) error { + t.calls++ + t.config = config + return nil +} func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) { for _, target := range []struct{ kind, provider string }{ @@ -30,3 +63,36 @@ func TestCommunicationIntegrationValidationRejectsInvalidValues(t *testing.T) { t.Fatal("invalid websocket path was accepted") } } + +func TestIntegrationConfigTestDoesNotPersistCandidate(t *testing.T) { + repo := &integrationConfigRepoTestDouble{} + tester := &integrationConnectionTesterDouble{} + usecase := NewIntegrationConfigUsecase(repo, tester) + raw, _ := json.Marshal(map[string]any{"path": "/candidate"}) + + err := usecase.Test(context.Background(), &IntegrationConfig{ + Kind: " WebSocket ", + Provider: " Melody ", + Enabled: false, + Values: raw, + }) + if err != nil { + t.Fatal(err) + } + if repo.saves != 0 { + t.Fatalf("candidate config was persisted %d times", repo.saves) + } + if tester.calls != 1 || tester.config == nil { + t.Fatalf("connection tester calls = %d, config = %#v", tester.calls, tester.config) + } + if tester.config.Kind != IntegrationKindWebSocket || tester.config.Provider != "melody" || !tester.config.Enabled { + t.Fatalf("tested config = %#v", tester.config) + } + values := map[string]any{} + if err = json.Unmarshal(tester.config.Values, &values); err != nil { + t.Fatal(err) + } + if values["path"] != "/candidate" || values["write_wait"] != "10s" { + t.Fatalf("tested values = %#v", values) + } +} diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 29ca0ca..34a89d9 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -26,8 +26,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { t.Fatal(err) } - if versions != 6 { - t.Fatalf("migration versions = %d, want 6", versions) + if versions != 7 { + t.Fatalf("migration versions = %d, want 7", versions) } var communicationRows []integrationConfigPO if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { diff --git a/internal/data/repository/migrations.go b/internal/data/repository/migrations.go index c5269e0..34986bc 100644 --- a/internal/data/repository/migrations.go +++ b/internal/data/repository/migrations.go @@ -1,6 +1,8 @@ package system import ( + "errors" + "kra/pkg/database/migration" "gorm.io/gorm" @@ -26,5 +28,147 @@ func Migrations() []migration.Step { ) }, }, + {ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface}, + {ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface}, } } + +func ensureCommunicationSurface(db *gorm.DB) error { + if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) { + return nil + } + + return db.Transaction(func(tx *gorm.DB) error { + var parent menuPO + if err := tx.Where("name = ?", "extensions").First(&parent).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + menu := menuPO{ + MenuLevel: 1, + ParentID: parent.ID, + Path: "integrationConfig", + Name: "integrationConfig", + Component: "view/systemTools/integration/config.vue", + Title: "通信集成", + Icon: "connection", + Sort: 8, + } + var current menuPO + err := tx.Where("name = ?", menu.Name).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err = tx.Create(&menu).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err = tx.Model(¤t).Updates(map[string]any{ + "menu_level": menu.MenuLevel, + "parent_id": menu.ParentID, + "path": menu.Path, + "component": menu.Component, + "title": menu.Title, + "icon": menu.Icon, + "sort": menu.Sort, + }).Error; err != nil { + return err + } + menu.ID = current.ID + } + + apis := []apiPO{ + {Path: "/integration/configs/:kind", Method: "GET", APIGroup: "集成配置", Description: "按类型获取集成配置"}, + {Path: "/integration/configs/:kind/:provider", Method: "GET", APIGroup: "集成配置", Description: "获取指定集成配置"}, + {Path: "/integration/configs/:kind/:provider", Method: "PUT", APIGroup: "集成配置", Description: "保存集成配置"}, + {Path: "/integration/configs/:kind/:provider/test", Method: "POST", APIGroup: "集成配置", Description: "测试通信集成连接"}, + {Path: "/integration/configs/:kind/:provider", Method: "DELETE", APIGroup: "集成配置", Description: "删除集成配置"}, + } + for _, api := range apis { + if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil { + return err + } + } + + if !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&authorityMenuPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + var authority authorityPO + if err := tx.Where("authority_id = ?", 888).First(&authority).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + var linkCount int64 + if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", authority.AuthorityID, menu.ID).Count(&linkCount).Error; err != nil { + return err + } + if linkCount == 0 { + if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: authority.AuthorityID, SysBaseMenuID: menu.ID}).Error; err != nil { + return err + } + } + for _, api := range apis { + exists, err := policyExists(tx, authority.AuthorityID, api.Path, api.Method) + if err != nil { + return err + } + if !exists { + rule := newPolicyRule(authority.AuthorityID, api.Path, api.Method) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func ensureCommunicationTestSurface(db *gorm.DB) error { + if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) { + return nil + } + + return db.Transaction(func(tx *gorm.DB) error { + var existingMenus int64 + if err := tx.Model(&menuPO{}).Where("name IN ?", []string{"extensions", "integrationConfig"}).Count(&existingMenus).Error; err != nil { + return err + } + if existingMenus == 0 { + return nil + } + api := apiPO{ + Path: "/integration/configs/:kind/:provider/test", + Method: "POST", + APIGroup: "集成配置", + Description: "测试通信集成连接", + } + if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil { + return err + } + if !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + var authority authorityPO + if err := tx.Where("authority_id = ?", 888).First(&authority).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + exists, err := policyExists(tx, authority.AuthorityID, api.Path, api.Method) + if err != nil { + return err + } + if exists { + return nil + } + rule := newPolicyRule(authority.AuthorityID, api.Path, api.Method) + return tx.Create(&rule).Error + }) +} diff --git a/internal/integration/connectivity.go b/internal/integration/connectivity.go new file mode 100644 index 0000000..0c4500e --- /dev/null +++ b/internal/integration/connectivity.go @@ -0,0 +1,86 @@ +package integration + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "kra/internal/biz" + "kra/internal/integration/mq" + "kra/internal/integration/runtimeconfig" + websocketintegration "kra/internal/integration/websocket" +) + +// ConnectivityTester probes candidate communication settings without changing +// the active clients or writing anything to sys_integration_configs. +type ConnectivityTester struct { + store *runtimeconfig.Store +} + +func NewConnectivityTester(store *runtimeconfig.Store) *ConnectivityTester { + return &ConnectivityTester{store: store} +} + +func (t *ConnectivityTester) TestIntegration(ctx context.Context, config *biz.IntegrationConfig) error { + if config == nil { + return errors.New("集成配置请求为空") + } + values := map[string]any{} + if err := json.Unmarshal(config.Values, &values); err != nil { + return fmt.Errorf("解析集成配置失败: %w", err) + } + if err := t.restoreMaskedSecrets(config.Kind, config.Provider, values); err != nil { + return err + } + raw, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("编码集成配置失败: %w", err) + } + switch strings.ToLower(strings.TrimSpace(config.Kind)) { + case biz.IntegrationKindMQ: + return mq.TestConfig(ctx, config.Provider, raw) + case biz.IntegrationKindWebSocket: + if strings.ToLower(strings.TrimSpace(config.Provider)) != websocketintegration.ProviderMelody { + return fmt.Errorf("不支持的 WebSocket provider %q", config.Provider) + } + return websocketintegration.TestConfig(ctx, raw) + default: + return fmt.Errorf("不支持测试集成类型 %q", config.Kind) + } +} + +func (t *ConnectivityTester) restoreMaskedSecrets(kind, provider string, values map[string]any) error { + definition, ok := biz.IntegrationDefinition(kind, provider) + if !ok { + return fmt.Errorf("不支持的集成 %s/%s", kind, provider) + } + masked := make(map[string]struct{}) + for _, field := range definition.Fields { + if field.Secret { + masked[field.Key] = struct{}{} + } + } + if len(masked) == 0 { + return nil + } + currentValues := map[string]any{} + if t != nil && t.store != nil { + if current, exists := t.store.Get(kind, provider); exists { + _ = json.Unmarshal(current.Values, ¤tValues) + } + } + for key := range masked { + value, _ := values[key].(string) + if strings.TrimSpace(value) != "******" { + continue + } + prior, _ := currentValues[key].(string) + if strings.TrimSpace(prior) == "" || strings.TrimSpace(prior) == "******" { + return fmt.Errorf("配置字段 %s 已脱敏,请重新填写后再测试", key) + } + values[key] = prior + } + return nil +} diff --git a/internal/integration/mq/emqx.go b/internal/integration/mq/emqx.go index fe5ab6f..ae0dbec 100644 --- a/internal/integration/mq/emqx.go +++ b/internal/integration/mq/emqx.go @@ -72,6 +72,51 @@ func storeConfig(store *runtimeconfig.Store, provider string) runtimeconfig.Conf return config } +// TestConfig creates a short-lived provider client and closes it immediately. +// For RabbitMQ this also checks the configured exchange and queue topology. +func TestConfig(ctx context.Context, provider string, raw json.RawMessage) error { + if ctx != nil { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == ProviderEMQX { + values := map[string]any{} + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("decode %s configuration: %w", provider, err) + } + baseID := configText(values, "client_id") + values["client_id"] = fmt.Sprintf("%s-test-%d", baseID, time.Now().UnixNano()) + encoded, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("encode %s test configuration: %w", provider, err) + } + raw = encoded + } + client, err := newProviderClient(provider, raw) + if err != nil { + return err + } + if client == nil || !client.Connected() { + if client != nil { + _ = client.Close() + } + return platformmq.ErrUnavailable + } + closeErr := client.Close() + if ctx != nil { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + return closeErr +} + func (r *Reloadable) apply(provider string, config runtimeconfig.Config) { r.opMu.Lock() defer r.opMu.Unlock() diff --git a/internal/integration/provider.go b/internal/integration/provider.go index 4b38b60..fb372a3 100644 --- a/internal/integration/provider.go +++ b/internal/integration/provider.go @@ -20,6 +20,8 @@ var ProviderSet = wire.NewSet( cache.New, email.NewEmailRepo, storage.NewFileStorage, + NewConnectivityTester, + wire.Bind(new(biz.IntegrationConnectionTester), new(*ConnectivityTester)), mqintegration.New, wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)), wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)), diff --git a/internal/integration/websocket/server.go b/internal/integration/websocket/server.go index 189a570..bf72238 100644 --- a/internal/integration/websocket/server.go +++ b/internal/integration/websocket/server.go @@ -1,15 +1,18 @@ package websocket import ( + "context" "encoding/json" "errors" "fmt" "net/http" + "net/http/httptest" "strconv" "strings" "sync" "time" + gorillawebsocket "github.com/gorilla/websocket" melody "github.com/olahol/melody" "kra/internal/integration/runtimeconfig" platformws "kra/pkg/websocket" @@ -57,6 +60,71 @@ func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config { return config } +// TestConfig performs a local WebSocket handshake using a temporary server +// built from the candidate settings. It does not touch the live endpoint. +func TestConfig(ctx context.Context, raw json.RawMessage) error { + values := map[string]any{} + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("decode websocket configuration: %w", err) + } + path := text(values, "path") + if path == "" { + path = "/ws" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + temporary := platformws.New(platformws.Config{ + WriteWait: durationValue(values, "write_wait", 10*time.Second), + PongWait: durationValue(values, "pong_wait", 60*time.Second), + PingPeriod: durationValue(values, "ping_period", 54*time.Second), + MaxMessageSize: int64Value(values, "max_message_size"), + MessageBufferSize: int(intValue(values, "message_buffer_size")), + ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"), + AllowOrigins: stringList(values, "allow_origins"), + }) + defer temporary.Close() + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path { + http.NotFound(w, r) + return + } + if err := temporary.HandleRequest(w, r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + })) + defer httpServer.Close() + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + headers := http.Header{} + origins := stringList(values, "allow_origins") + if len(origins) > 0 { + origin := origins[0] + if origin == "*" { + origin = "http://localhost" + } + headers.Set("Origin", origin) + } + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + path + connection, response, err := gorillawebsocket.DefaultDialer.DialContext(ctx, wsURL, headers) + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + if err != nil { + return fmt.Errorf("websocket handshake failed: %w", err) + } + if connection == nil { + return errors.New("websocket handshake returned an empty connection") + } + return connection.Close() +} + func (s *Server) apply(config runtimeconfig.Config) { if s == nil { return diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index ce0d53f..f0b5c50 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -49,6 +49,7 @@ func TestGinRouteContract(t *testing.T) { "GET /integration/configs/:kind", "GET /integration/configs/:kind/:provider", "PUT /integration/configs/:kind/:provider", + "POST /integration/configs/:kind/:provider/test", "DELETE /integration/configs/:kind/:provider", "POST /payment/providers/:provider/test", } { @@ -71,7 +72,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":193`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":194`) { t.Fatalf("startup route summary is missing: %s", text) } } @@ -353,6 +354,7 @@ POST /fileUploadAndDownload/upload POST /info/createInfo POST /init/checkdb POST /init/initdb +POST /integration/configs/:kind/:provider/test POST /jwt/jsonInBlacklist POST /mediaUpload/chunk POST /mediaUpload/complete diff --git a/internal/server/handler/integration_config.go b/internal/server/handler/integration_config.go index b1184db..d78dfcd 100644 --- a/internal/server/handler/integration_config.go +++ b/internal/server/handler/integration_config.go @@ -46,6 +46,19 @@ func (h *IntegrationConfig) Save(c *gin.Context) { OK(c) } +func (h *IntegrationConfig) Test(c *gin.Context) { + var req dto.IntegrationConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, err.Error()) + return + } + if err := h.service.Test(c.Request.Context(), c.Param("kind"), c.Param("provider"), &req); err != nil { + Fail(c, err.Error()) + return + } + OK(c) +} + func (h *IntegrationConfig) Delete(c *gin.Context) { if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil { Fail(c, err.Error()) diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index 0164090..4809b56 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -225,7 +225,7 @@ var operationRoutes = func() map[string]struct{} { "DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds", "POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask", "POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail", - "PUT /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider", + "PUT /integration/configs/:kind/:provider", "POST /integration/configs/:kind/:provider/test", "DELETE /integration/configs/:kind/:provider", "POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test", } out := make(map[string]struct{}, len(values)) diff --git a/internal/server/router/integration_config.go b/internal/server/router/integration_config.go index 6df07bc..dd225c0 100644 --- a/internal/server/router/integration_config.go +++ b/internal/server/router/integration_config.go @@ -7,5 +7,6 @@ func RegisterIntegrationConfig(group *gin.RouterGroup, handler *IntegrationConfi configs.GET("/:kind", handler.List) configs.GET("/:kind/:provider", handler.Find) configs.PUT("/:kind/:provider", handler.Save) + configs.POST("/:kind/:provider/test", handler.Test) configs.DELETE("/:kind/:provider", handler.Delete) } diff --git a/internal/service/integration_config.go b/internal/service/integration_config.go index a5553a7..dd61e0f 100644 --- a/internal/service/integration_config.go +++ b/internal/service/integration_config.go @@ -70,6 +70,13 @@ func (s *IntegrationConfigService) Save(ctx context.Context, kind, provider stri return s.uc.Save(ctx, &biz.IntegrationConfig{Kind: kind, Provider: provider, Enabled: req.Enabled, Values: req.Config}) } +func (s *IntegrationConfigService) Test(ctx context.Context, kind, provider string, req *dto.IntegrationConfigRequest) error { + if req == nil { + return s.uc.Test(ctx, nil) + } + return s.uc.Test(ctx, &biz.IntegrationConfig{Kind: kind, Provider: provider, Enabled: true, Values: req.Config}) +} + func (s *IntegrationConfigService) Delete(ctx context.Context, kind, provider string) error { return s.uc.Delete(ctx, kind, provider) } diff --git a/internal/service/route_metadata.go b/internal/service/route_metadata.go index 55b7cb4..9da0e40 100644 --- a/internal/service/route_metadata.go +++ b/internal/service/route_metadata.go @@ -136,6 +136,7 @@ var apiMetadata = map[string]apiMetadataValue{ "POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"}, "POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"}, "POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"}, + "POST /integration/configs/:kind/:provider/test": {group: "集成配置", description: "测试通信集成连接"}, "PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"}, "DELETE /integration/configs/:kind/:provider": {group: "集成配置", description: "删除集成配置"}, "POST /payment/order": {group: "支付", description: "查询支付订单"}, diff --git a/web/src/api/integration.js b/web/src/api/integration.js index 3185154..23be3ef 100644 --- a/web/src/api/integration.js +++ b/web/src/api/integration.js @@ -11,6 +11,12 @@ export const saveIntegrationConfig = (kind, provider, data) => service({ data }) +export const testIntegrationConfig = (kind, provider, data) => service({ + url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}/test`, + method: 'post', + data +}) + export const deleteIntegrationConfig = (kind, provider) => service({ url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`, method: 'delete' diff --git a/web/src/view/systemTools/integration/config.vue b/web/src/view/systemTools/integration/config.vue index 34eb100..e02158d 100644 --- a/web/src/view/systemTools/integration/config.vue +++ b/web/src/view/systemTools/integration/config.vue @@ -166,6 +166,14 @@ {{ selected.configured ? '配置已创建' : '尚未保存配置' }} + + 测试连接 + { const isBusy = (item) => Boolean(pending[operationKey(item)]) const isSaving = (item) => pending[operationKey(item)] === 'save' const isToggling = (item) => pending[operationKey(item)] === 'toggle' +const isTesting = (item) => pending[operationKey(item)] === 'test' const numberConstraint = (fieldKey) => NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined } @@ -429,6 +439,28 @@ const saveSelected = async () => { } } +const testSelected = async () => { + const item = selected.value + if (!item || isBusy(item) || !validate(item, true)) return + const key = operationKey(item) + pending[key] = 'test' + try { + const res = await testIntegrationConfig(item.kind, item.provider, { + enabled: true, + config: item.config + }) + if (res.code !== 0) { + ElMessage.error(res.msg || '连接测试失败') + return + } + ElMessage.success(`${item.name || providerMeta(item).name} 连接测试成功`) + } catch { + ElMessage.error('连接测试失败') + } finally { + delete pending[key] + } +} + const toggleIntegration = async (item, enabled) => { if (isBusy(item)) return const previous = item.enabled diff --git a/web/src/view/systemTools/payment/config.vue b/web/src/view/systemTools/payment/config.vue index e0b569c..374e0dc 100644 --- a/web/src/view/systemTools/payment/config.vue +++ b/web/src/view/systemTools/payment/config.vue @@ -104,12 +104,46 @@ controls-position="right" @update:model-value="clearFieldError(field.key)" /> +
+ + + + {{ isSecretVisible(selected, field.key) ? '隐藏凭证' : '编辑凭证' }} + +
-

{{ fieldHint(field) }}

+

{{ fieldHint(field, selected) }}

@@ -134,7 +168,7 @@ 测试渠道 @@ -188,7 +222,7 @@