优化结构

This commit is contained in:
Yvan
2026-08-21 22:54:40 +08:00
parent e4ba0dced9
commit 430feaaff6
20 changed files with 604 additions and 20 deletions
+1
View File
@@ -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: "删除集成配置"},
},
}
+46 -3
View File
@@ -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 == "" {
@@ -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)
}
}
+2 -2
View File
@@ -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 {
+144
View File
@@ -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(&current).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(&current).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
})
}
+86
View File
@@ -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, &currentValues)
}
}
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
}
+45
View File
@@ -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()
+2
View File
@@ -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)),
+68
View File
@@ -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
+3 -1
View File
@@ -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
@@ -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())
+1 -1
View File
@@ -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))
@@ -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)
}
+7
View File
@@ -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)
}
+1
View File
@@ -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: "查询支付订单"},