优化结构
This commit is contained in:
parent
e4ba0dced9
commit
430feaaff6
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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: "删除集成配置"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(¤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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "查询支付订单"},
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue