优化结构

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

6
cmd/wire_gen.go generated
View File

@ -15,6 +15,7 @@ import (
"kra/internal/data/payment" "kra/internal/data/payment"
"kra/internal/data/repository" "kra/internal/data/repository"
"kra/internal/initialize" "kra/internal/initialize"
"kra/internal/integration"
"kra/internal/integration/cache" "kra/internal/integration/cache"
"kra/internal/integration/email" "kra/internal/integration/email"
"kra/internal/integration/mq" "kra/internal/integration/mq"
@ -147,14 +148,15 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
navigation := handler.NewNavigation(userService) navigation := handler.NewNavigation(userService)
session := handler.NewSession(tokenService) session := handler.NewSession(tokenService)
integrationConfigRepo := system.NewIntegrationConfigRepo(dataData) 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) integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService) 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) 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) routes := router.NewRoutes(v)
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime) taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
moduleRuntime := app.Runtime(routes, taskMethods, registry) moduleRuntime := app.Runtime(routes, taskMethods, registry)
store := data.NewIntegrationRuntime(dataData)
websocketServer, cleanup2, err := websocket.New(store) websocketServer, cleanup2, err := websocket.New(store)
if err != nil { if err != nil {
cleanup() cleanup()

View File

@ -16,6 +16,7 @@ func Definition() module.Definition {
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"}, {Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
{Path: "/integration/configs/:kind/:provider", 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", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
{Path: "/integration/configs/:kind/:provider/test", Method: "POST", Group: "集成配置", Description: "测试通信集成连接"},
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"}, {Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
}, },
} }

View File

@ -56,10 +56,17 @@ type IntegrationConfigRepo interface {
DeleteIntegrationConfig(context.Context, string, string) error DeleteIntegrationConfig(context.Context, string, string) error
} }
type IntegrationConfigUsecase struct{ repo IntegrationConfigRepo } type IntegrationConnectionTester interface {
TestIntegration(context.Context, *IntegrationConfig) error
}
func NewIntegrationConfigUsecase(repo IntegrationConfigRepo) *IntegrationConfigUsecase { type IntegrationConfigUsecase struct {
return &IntegrationConfigUsecase{repo: repo} 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) { 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) 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 { func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error {
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider) kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
if kind == "" || provider == "" { if kind == "" || provider == "" {

View File

@ -1,6 +1,39 @@
package biz 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) { func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) {
for _, target := range []struct{ kind, provider string }{ for _, target := range []struct{ kind, provider string }{
@ -30,3 +63,36 @@ func TestCommunicationIntegrationValidationRejectsInvalidValues(t *testing.T) {
t.Fatal("invalid websocket path was accepted") 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)
}
}

View File

@ -26,8 +26,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if versions != 6 { if versions != 7 {
t.Fatalf("migration versions = %d, want 6", versions) t.Fatalf("migration versions = %d, want 7", versions)
} }
var communicationRows []integrationConfigPO var communicationRows []integrationConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {

View File

@ -1,6 +1,8 @@
package system package system
import ( import (
"errors"
"kra/pkg/database/migration" "kra/pkg/database/migration"
"gorm.io/gorm" "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
})
}

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
}

View File

@ -72,6 +72,51 @@ func storeConfig(store *runtimeconfig.Store, provider string) runtimeconfig.Conf
return config 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) { func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
r.opMu.Lock() r.opMu.Lock()
defer r.opMu.Unlock() defer r.opMu.Unlock()

View File

@ -20,6 +20,8 @@ var ProviderSet = wire.NewSet(
cache.New, cache.New,
email.NewEmailRepo, email.NewEmailRepo,
storage.NewFileStorage, storage.NewFileStorage,
NewConnectivityTester,
wire.Bind(new(biz.IntegrationConnectionTester), new(*ConnectivityTester)),
mqintegration.New, mqintegration.New,
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)), wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)), wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)),

View File

@ -1,15 +1,18 @@
package websocket package websocket
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
gorillawebsocket "github.com/gorilla/websocket"
melody "github.com/olahol/melody" melody "github.com/olahol/melody"
"kra/internal/integration/runtimeconfig" "kra/internal/integration/runtimeconfig"
platformws "kra/pkg/websocket" platformws "kra/pkg/websocket"
@ -57,6 +60,71 @@ func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config {
return 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) { func (s *Server) apply(config runtimeconfig.Config) {
if s == nil { if s == nil {
return return

View File

@ -49,6 +49,7 @@ func TestGinRouteContract(t *testing.T) {
"GET /integration/configs/:kind", "GET /integration/configs/:kind",
"GET /integration/configs/:kind/:provider", "GET /integration/configs/:kind/:provider",
"PUT /integration/configs/:kind/:provider", "PUT /integration/configs/:kind/:provider",
"POST /integration/configs/:kind/:provider/test",
"DELETE /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider",
"POST /payment/providers/:provider/test", "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 { 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) 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) t.Fatalf("startup route summary is missing: %s", text)
} }
} }
@ -353,6 +354,7 @@ POST /fileUploadAndDownload/upload
POST /info/createInfo POST /info/createInfo
POST /init/checkdb POST /init/checkdb
POST /init/initdb POST /init/initdb
POST /integration/configs/:kind/:provider/test
POST /jwt/jsonInBlacklist POST /jwt/jsonInBlacklist
POST /mediaUpload/chunk POST /mediaUpload/chunk
POST /mediaUpload/complete POST /mediaUpload/complete

View File

@ -46,6 +46,19 @@ func (h *IntegrationConfig) Save(c *gin.Context) {
OK(c) 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) { func (h *IntegrationConfig) Delete(c *gin.Context) {
if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil { if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil {
Fail(c, err.Error()) Fail(c, err.Error())

View File

@ -225,7 +225,7 @@ var operationRoutes = func() map[string]struct{} {
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds", "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 /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", "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", "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)) out := make(map[string]struct{}, len(values))

View File

@ -7,5 +7,6 @@ func RegisterIntegrationConfig(group *gin.RouterGroup, handler *IntegrationConfi
configs.GET("/:kind", handler.List) configs.GET("/:kind", handler.List)
configs.GET("/:kind/:provider", handler.Find) configs.GET("/:kind/:provider", handler.Find)
configs.PUT("/:kind/:provider", handler.Save) configs.PUT("/:kind/:provider", handler.Save)
configs.POST("/:kind/:provider/test", handler.Test)
configs.DELETE("/:kind/:provider", handler.Delete) configs.DELETE("/:kind/:provider", handler.Delete)
} }

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}) 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 { func (s *IntegrationConfigService) Delete(ctx context.Context, kind, provider string) error {
return s.uc.Delete(ctx, kind, provider) return s.uc.Delete(ctx, kind, provider)
} }

View File

@ -136,6 +136,7 @@ var apiMetadata = map[string]apiMetadataValue{
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"}, "POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"}, "POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"}, "POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
"POST /integration/configs/:kind/:provider/test": {group: "集成配置", description: "测试通信集成连接"},
"PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"}, "PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"},
"DELETE /integration/configs/:kind/:provider": {group: "集成配置", description: "删除集成配置"}, "DELETE /integration/configs/:kind/:provider": {group: "集成配置", description: "删除集成配置"},
"POST /payment/order": {group: "支付", description: "查询支付订单"}, "POST /payment/order": {group: "支付", description: "查询支付订单"},

View File

@ -11,6 +11,12 @@ export const saveIntegrationConfig = (kind, provider, data) => service({
data 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({ export const deleteIntegrationConfig = (kind, provider) => service({
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`, url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`,
method: 'delete' method: 'delete'

View File

@ -166,6 +166,14 @@
<span class="save-state"> <span class="save-state">
{{ selected.configured ? '配置已创建' : '尚未保存配置' }} {{ selected.configured ? '配置已创建' : '尚未保存配置' }}
</span> </span>
<el-button
:icon="Connection"
:loading="isTesting(selected)"
:disabled="isBusy(selected)"
@click="testSelected"
>
测试连接
</el-button>
<el-button <el-button
type="primary" type="primary"
:icon="Check" :icon="Check"
@ -195,7 +203,8 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { import {
getIntegrationConfigs, getIntegrationConfigs,
saveIntegrationConfig saveIntegrationConfig,
testIntegrationConfig
} from '@/api/integration' } from '@/api/integration'
defineOptions({ name: 'IntegrationConfig' }) defineOptions({ name: 'IntegrationConfig' })
@ -311,6 +320,7 @@ const load = async () => {
const isBusy = (item) => Boolean(pending[operationKey(item)]) const isBusy = (item) => Boolean(pending[operationKey(item)])
const isSaving = (item) => pending[operationKey(item)] === 'save' const isSaving = (item) => pending[operationKey(item)] === 'save'
const isToggling = (item) => pending[operationKey(item)] === 'toggle' const isToggling = (item) => pending[operationKey(item)] === 'toggle'
const isTesting = (item) => pending[operationKey(item)] === 'test'
const numberConstraint = (fieldKey) => const numberConstraint = (fieldKey) =>
NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined } 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) => { const toggleIntegration = async (item, enabled) => {
if (isBusy(item)) return if (isBusy(item)) return
const previous = item.enabled const previous = item.enabled

View File

@ -104,12 +104,46 @@
controls-position="right" controls-position="right"
@update:model-value="clearFieldError(field.key)" @update:model-value="clearFieldError(field.key)"
/> />
<div v-else-if="field.type === 'textarea' && field.secret" class="secret-textarea-control">
<el-input
v-if="isSecretVisible(selected, field.key)"
v-model="selected.config[field.key]"
class="field-control"
type="textarea"
:rows="5"
:placeholder="field.placeholder || '请输入凭证内容'"
autocomplete="new-password"
spellcheck="false"
@update:model-value="clearFieldError(field.key)"
/>
<el-input
v-else
:model-value="maskedTextareaValue(selected.config[field.key])"
class="field-control secret-textarea-display"
type="textarea"
:rows="5"
readonly
resize="none"
:placeholder="field.placeholder || '凭证已隐藏,点击“编辑凭证”后输入'"
:aria-label="`${field.label}(已隐藏)`"
/>
<el-button
class="secret-textarea-action"
text
type="primary"
:icon="isSecretVisible(selected, field.key) ? Hide : View"
:aria-label="isSecretVisible(selected, field.key) ? `隐藏${field.label}` : `编辑${field.label}`"
@click="toggleSecretVisibility(selected, field.key)"
>
{{ isSecretVisible(selected, field.key) ? '隐藏凭证' : '编辑凭证' }}
</el-button>
</div>
<el-input <el-input
v-else-if="field.type === 'textarea'" v-else-if="field.type === 'textarea'"
v-model="selected.config[field.key]" v-model="selected.config[field.key]"
class="field-control" class="field-control"
type="textarea" type="textarea"
:rows="field.secret ? 5 : 4" :rows="4"
:placeholder="field.placeholder" :placeholder="field.placeholder"
spellcheck="false" spellcheck="false"
@update:model-value="clearFieldError(field.key)" @update:model-value="clearFieldError(field.key)"
@ -124,7 +158,7 @@
spellcheck="false" spellcheck="false"
@update:model-value="clearFieldError(field.key)" @update:model-value="clearFieldError(field.key)"
/> />
<p v-if="fieldHint(field)" class="field-hint">{{ fieldHint(field) }}</p> <p v-if="fieldHint(field, selected)" class="field-hint">{{ fieldHint(field, selected) }}</p>
</el-form-item> </el-form-item>
</div> </div>
</el-form> </el-form>
@ -134,7 +168,7 @@
<el-button <el-button
:icon="Connection" :icon="Connection"
:loading="operation === 'test'" :loading="operation === 'test'"
:disabled="busy || !selected.configured || isDirty(selected)" :disabled="busy || !selected.enabled || !selected.configured || isDirty(selected)"
@click="testProvider" @click="testProvider"
> >
测试渠道 测试渠道
@ -188,7 +222,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, CircleCheck, CircleClose, Connection, Delete, MoreFilled, Refresh } from '@element-plus/icons-vue' import { Check, CircleCheck, CircleClose, Connection, Delete, Hide, MoreFilled, Refresh, View } from '@element-plus/icons-vue'
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration' import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
import { testPaymentProvider } from '@/api/payment' import { testPaymentProvider } from '@/api/payment'
@ -208,6 +242,7 @@ const selectedProvider = ref('')
const loading = ref(false) const loading = ref(false)
const operation = ref('') const operation = ref('')
const errors = reactive({}) const errors = reactive({})
const secretVisibility = reactive({})
const testVisible = ref(false) const testVisible = ref(false)
const testResult = ref(null) const testResult = ref(null)
let loadRequestID = 0 let loadRequestID = 0
@ -240,11 +275,33 @@ const clearFieldError = (key) => { delete errors[key] }
const isEmpty = (value) => value === null || typeof value === 'undefined' || (typeof value === 'string' && value.trim() === '') const isEmpty = (value) => value === null || typeof value === 'undefined' || (typeof value === 'string' && value.trim() === '')
const isDirty = (item) => JSON.stringify({ enabled: item.enabled, config: item.config }) !== JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig }) const isDirty = (item) => JSON.stringify({ enabled: item.enabled, config: item.config }) !== JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
const hasMaskedSecret = (item) => (item.fields || []).some((field) => field.secret && item.config[field.key] === '******') const hasMaskedSecret = (item) => (item.fields || []).some((field) => field.secret && item.config[field.key] === '******')
const fieldHint = (field) => { const CALLBACK_URL_KEYS = new Set(['notify_url', 'return_url', 'cancel_url', 'callback_url', 'webhook_url', 'redirect_url', 'success_url', 'failure_url'])
const callbackURLPattern = /(?:notify|callback|webhook|return|cancel|redirect|success|failure)_?url$/i
const isCallbackURLField = (field) => {
const key = String(field?.key || '').trim().toLowerCase()
return CALLBACK_URL_KEYS.has(key) || callbackURLPattern.test(key)
}
const environmentText = (item) => String(item?.config?.environment || '').trim().toLowerCase()
const isSandboxEnvironment = (item) => ['sandbox', 'test', 'testing', 'dev', 'development'].includes(environmentText(item))
const isProductionEnvironment = (item) => !isSandboxEnvironment(item)
const secretFieldKey = (item, fieldKey) => `${item?.provider || ''}:${fieldKey}`
const isSecretVisible = (item, fieldKey) => Boolean(secretVisibility[secretFieldKey(item, fieldKey)])
const toggleSecretVisibility = (item, fieldKey) => {
const key = secretFieldKey(item, fieldKey)
secretVisibility[key] = !secretVisibility[key]
}
const maskedTextareaValue = (value) => {
if (value === '******') return value
return isEmpty(value) ? '' : '********'
}
const hideSecrets = () => { Object.keys(secretVisibility).forEach((key) => delete secretVisibility[key]) }
const fieldHint = (field, item = selected.value) => {
if (field.key === 'test_mode') return '仅在需要执行真实渠道测试时开启。' if (field.key === 'test_mode') return '仅在需要执行真实渠道测试时开启。'
if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。' if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。'
if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。' if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。'
if (field.key === 'notify_url') return '异步支付方式必须填写可被支付平台访问的 HTTPS 地址。' if (isCallbackURLField(field)) {
return isProductionEnvironment(item) ? '生产环境回调地址必须使用 HTTPS沙箱环境可使用 HTTP。' : '沙箱环境可使用 HTTP切换生产环境前请改为 HTTPS。'
}
return field.description || '' return field.description || ''
} }
const stageName = (name) => STAGE_NAMES[name] || name const stageName = (name) => STAGE_NAMES[name] || name
@ -265,10 +322,11 @@ function validate(item, enabled = item.enabled) {
const parsed = JSON.parse(String(value)) const parsed = JSON.parse(String(value))
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') message = '测试扩展参数必须是 JSON 对象' if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') message = '测试扩展参数必须是 JSON 对象'
} catch { message = '测试扩展参数必须是合法 JSON' } } catch { message = '测试扩展参数必须是合法 JSON' }
} else if ((field.type === 'url' || field.key.endsWith('_url')) && value) { } else if ((field.type === 'url' || field.key.endsWith('_url') || isCallbackURLField(field)) && value) {
try { try {
const url = new URL(String(value)) const url = new URL(String(value))
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS` if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
else if (isCallbackURLField(field) && isProductionEnvironment(item) && url.protocol !== 'https:') message = `${field.label}在生产环境必须使用 HTTPS`
} catch { message = `${field.label}格式无效` } } catch { message = `${field.label}格式无效` }
} }
if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label
@ -285,6 +343,7 @@ async function load() {
try { try {
const res = await getIntegrationConfigs('payment') const res = await getIntegrationConfigs('payment')
if (requestID !== loadRequestID || res.code !== 0) return if (requestID !== loadRequestID || res.code !== 0) return
hideSecrets()
configs.value = (res.data || []).map(normalizeConfig) configs.value = (res.data || []).map(normalizeConfig)
if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || '' if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || ''
} catch { } catch {
@ -310,6 +369,7 @@ async function selectProvider(provider) {
} catch { return } } catch { return }
} }
Object.keys(errors).forEach((key) => delete errors[key]) Object.keys(errors).forEach((key) => delete errors[key])
hideSecrets()
selectedProvider.value = provider selectedProvider.value = provider
} }
@ -349,9 +409,10 @@ async function toggleProvider(enabled) {
async function testProvider() { async function testProvider() {
const item = selected.value const item = selected.value
if (!item || busy.value) return if (!item || busy.value) return
if (!item.enabled) { ElMessage.warning('当前支付渠道已停用,请先启用并保存渠道后再执行测试。'); return }
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return } if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return } if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
const environment = String(item.config?.environment || '').toLowerCase() const environment = environmentText(item)
if (environment === 'production' || environment === 'prod') { if (environment === 'production' || environment === 'prod') {
operation.value = 'test-confirm' operation.value = 'test-confirm'
try { try {
@ -417,6 +478,9 @@ onMounted(load)
.field-label { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; } .field-label { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
.field-label small { overflow: hidden; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; text-overflow: ellipsis; } .field-label small { overflow: hidden; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; text-overflow: ellipsis; }
.field-control { width: 100%; } .field-control { width: 100%; }
.secret-textarea-control { width: 100%; }
.secret-textarea-display :deep(.el-textarea__inner) { color: var(--el-text-color-placeholder); font-family: monospace; letter-spacing: 0; }
.secret-textarea-action { margin: 4px 0 0; padding: 4px 0; }
.field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; } .field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
.editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); } .editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
.save-state { margin-right: auto; color: var(--el-text-color-secondary); font-size: 12px; } .save-state { margin-right: auto; color: var(--el-text-color-secondary); font-size: 12px; }

View File

@ -419,9 +419,10 @@ async function syncOrder(order) {
} }
async function retryFulfillment(order) { async function retryFulfillment(order) {
if (isRowBusy(order)) return if (isRowBusy(order)) return
setRowAction(order, 'fulfill-confirm')
try { try {
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' }) await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
} catch { return } } catch { setRowAction(order, ''); return }
setRowAction(order, 'fulfill') setRowAction(order, 'fulfill')
try { try {
const res = await fulfillPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo }) const res = await fulfillPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })