优化结构

This commit is contained in:
Yvan 2026-08-22 08:25:25 +08:00
parent 626f505b5d
commit 2b78b195c1
10 changed files with 815 additions and 115 deletions

View File

@ -233,6 +233,9 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 { if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 {
return errors.New("emqx connect_timeout 必须大于 0") return errors.New("emqx connect_timeout 必须大于 0")
} }
if interval := integrationInt64(values, "reconnect_interval", 0); interval <= 0 {
return errors.New("emqx reconnect_interval 必须大于 0")
}
case IntegrationKindMQ + "/rabbitmq": case IntegrationKindMQ + "/rabbitmq":
port := integrationInt64(values, "port", 0) port := integrationInt64(values, "port", 0)
if port < 1 || port > 65535 { if port < 1 || port > 65535 {
@ -251,6 +254,9 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
if integrationInt64(values, "connect_timeout", 0) <= 0 { if integrationInt64(values, "connect_timeout", 0) <= 0 {
return errors.New("rabbitmq connect_timeout 必须大于 0") return errors.New("rabbitmq connect_timeout 必须大于 0")
} }
if integrationInt64(values, "reconnect_interval", 0) <= 0 {
return errors.New("rabbitmq reconnect_interval 必须大于 0")
}
case IntegrationKindWebSocket + "/melody": case IntegrationKindWebSocket + "/melody":
path := integrationText(values, "path") path := integrationText(values, "path")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {

View File

@ -92,7 +92,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
IntegrationKindMQ: { IntegrationKindMQ: {
{ {
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务", Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10}, Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10, "reconnect_interval": 5},
Fields: []IntegrationConfigField{ Fields: []IntegrationConfigField{
{Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"}, {Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"},
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"}, {Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
@ -101,11 +101,12 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
{Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true}, {Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true},
{Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"}, {Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"},
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true}, {Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
}, },
}, },
{ {
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列", Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "tls": false}, Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "reconnect_interval": 5, "tls": false},
Fields: []IntegrationConfigField{ Fields: []IntegrationConfigField{
{Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"}, {Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"},
{Key: "port", Label: "端口", Type: "number", Required: true}, {Key: "port", Label: "端口", Type: "number", Required: true},
@ -121,6 +122,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
{Key: "prefetch_count", Label: "预取数量", Type: "number"}, {Key: "prefetch_count", Label: "预取数量", Type: "number"},
{Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"}, {Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"},
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true}, {Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
{Key: "tls", Label: "启用 TLS", Type: "switch"}, {Key: "tls", Label: "启用 TLS", Type: "switch"},
}, },
}, },

View File

@ -20,8 +20,8 @@ const (
retryTick = time.Second retryTick = time.Second
) )
// Reloadable owns the process-wide message clients. Configuration comes only // Reloadable owns process-wide message clients and the logical subscription
// from sys_integration_configs through runtimeconfig.Store. // declarations used to restore them after reconnects or configuration reloads.
type Reloadable struct { type Reloadable struct {
mu sync.RWMutex mu sync.RWMutex
opMu sync.Mutex opMu sync.Mutex
@ -31,10 +31,10 @@ type Reloadable struct {
bindings map[string]map[string]byte bindings map[string]map[string]byte
pending map[string]bool pending map[string]bool
nextRetry map[string]time.Time nextRetry map[string]time.Time
legacySeq uint64
stop []func() stop []func()
retryStop chan struct{} retryStop chan struct{}
retryDone chan struct{} retryDone chan struct{}
closeOnce sync.Once
logger *slog.Logger logger *slog.Logger
closed bool closed bool
} }
@ -139,19 +139,22 @@ func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
if r.closed { if r.closed {
return return
} }
r.ensureStateLocked()
config.Kind = "mq"
config.Provider = provider config.Provider = provider
config.Values = append(json.RawMessage(nil), config.Values...) config.Values = append(json.RawMessage(nil), config.Values...)
r.configs[provider] = config r.configs[provider] = config
r.replaceClientLocked(provider, nil)
if !config.Enabled { if !config.Enabled {
delete(r.pending, provider) delete(r.pending, provider)
delete(r.nextRetry, provider) delete(r.nextRetry, provider)
r.replaceClientLocked(provider, nil)
return return
} }
if err := r.activateLocked(provider, config); err != nil { if err := r.activateLocked(provider, config); err != nil {
r.pending[provider] = true r.scheduleRetryLocked(provider, config.Values)
r.nextRetry[provider] = time.Now().Add(configRetryInterval(config.Values)) if r.logger != nil {
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err) r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
}
} }
} }
@ -167,6 +170,9 @@ func (r *Reloadable) activateLocked(provider string, config runtimeconfig.Config
} }
r.replaceClientLocked(provider, client) r.replaceClientLocked(provider, client)
r.mu.Lock() r.mu.Lock()
if r.bindings == nil {
r.bindings = make(map[string]map[string]byte)
}
r.bindings[provider] = bindings r.bindings[provider] = bindings
r.mu.Unlock() r.mu.Unlock()
delete(r.pending, provider) delete(r.pending, provider)
@ -253,8 +259,89 @@ func configBool(values map[string]any, key string) bool {
return value return value
} }
func (r *Reloadable) retryLoop() {
defer close(r.retryDone)
ticker := time.NewTicker(retryTick)
defer ticker.Stop()
for {
select {
case <-r.retryStop:
return
case <-ticker.C:
r.retryOnce()
}
}
}
func (r *Reloadable) retryOnce() {
r.opMu.Lock()
defer r.opMu.Unlock()
if r.closed {
return
}
r.ensureStateLocked()
now := time.Now()
for _, provider := range []string{ProviderEMQX, ProviderRabbitMQ} {
config, exists := r.configs[provider]
if !exists || !config.Enabled {
continue
}
client := r.clientLocked(provider)
if client != nil && client.Connected() {
if r.pending[provider] {
if err := r.reconcileProviderLocked(provider); err != nil {
r.scheduleRetryLocked(provider, config.Values)
}
}
continue
}
if client != nil {
if recovering, ok := client.(interface{ Reconnecting() bool }); ok && recovering.Reconnecting() {
continue
}
}
if retryAt := r.nextRetry[provider]; !retryAt.IsZero() && now.Before(retryAt) {
continue
}
r.replaceClientLocked(provider, nil)
if err := r.activateLocked(provider, config); err != nil {
r.scheduleRetryLocked(provider, config.Values)
if r.logger != nil {
r.logger.Warn("message integration reconnect failed", "mod", "mq", "provider", provider, "error", err)
}
}
}
}
func (r *Reloadable) scheduleRetryLocked(provider string, raw json.RawMessage) {
if r.pending == nil {
r.pending = make(map[string]bool)
}
if r.nextRetry == nil {
r.nextRetry = make(map[string]time.Time)
}
r.pending[provider] = true
r.nextRetry[provider] = time.Now().Add(configRetryInterval(raw))
}
func configRetryInterval(raw json.RawMessage) time.Duration {
values := map[string]any{}
_ = json.Unmarshal(raw, &values)
interval := configSeconds(values, "reconnect_interval")
if interval <= 0 {
return 5 * time.Second
}
return interval
}
func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) { func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) {
r.mu.Lock() r.mu.Lock()
if r.clients == nil {
r.clients = make(map[string]platformmq.Client)
}
if r.bindings == nil {
r.bindings = make(map[string]map[string]byte)
}
old := r.clients[provider] old := r.clients[provider]
if next == nil { if next == nil {
delete(r.clients, provider) delete(r.clients, provider)
@ -263,7 +350,7 @@ func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client
r.clients[provider] = next r.clients[provider] = next
} }
r.mu.Unlock() r.mu.Unlock()
if old != nil { if old != nil && old != next {
_ = old.Close() _ = old.Close()
} }
} }
@ -280,23 +367,241 @@ func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platform
return bindings, nil return bindings, nil
} }
func configRetryInterval(raw json.RawMessage) time.Duration { func (r *Reloadable) desiredSubscriptions(provider string) map[string]byte {
values := map[string]any{} result := make(map[string]byte)
_ = json.Unmarshal(raw, &values) r.mu.RLock()
interval := configSeconds(values, "reconnect_interval") defer r.mu.RUnlock()
if interval <= 0 { for topic, owners := range r.subscriptions[provider] {
return 5 * time.Second for _, item := range owners {
if qos, exists := result[topic]; !exists || item.qos > qos {
result[topic] = item.qos
}
}
} }
return interval return result
} }
func (r *Reloadable) client(provider string) platformmq.Client { func (r *Reloadable) dispatcher(provider, topic string) platformmq.Handler {
provider = strings.ToLower(strings.TrimSpace(provider)) return func(ctx context.Context, message platformmq.Message) {
r.mu.RLock()
owners := r.subscriptions[provider][topic]
handlers := make([]platformmq.Handler, 0, len(owners))
for _, item := range owners {
handlers = append(handlers, item.handler)
}
r.mu.RUnlock()
for _, handler := range handlers {
handler(ctx, message)
}
}
}
func (r *Reloadable) clientLocked(provider string) platformmq.Client {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
return r.clients[provider] return r.clients[provider]
} }
func (r *Reloadable) reconcileProviderLocked(provider string) error {
client := r.clientLocked(provider)
desired := r.desiredSubscriptions(provider)
if client == nil || !client.Connected() {
if len(desired) == 0 {
delete(r.pending, provider)
delete(r.nextRetry, provider)
return nil
}
return platformmq.ErrUnavailable
}
r.mu.RLock()
current := make(map[string]byte, len(r.bindings[provider]))
for topic, qos := range r.bindings[provider] {
current[topic] = qos
}
r.mu.RUnlock()
for topic := range current {
if _, exists := desired[topic]; exists {
continue
}
if err := client.Unsubscribe(context.Background(), topic); err != nil {
return err
}
delete(current, topic)
}
for topic, qos := range desired {
if oldQoS, exists := current[topic]; exists && oldQoS == qos {
continue
}
if _, exists := current[topic]; exists {
if err := client.Unsubscribe(context.Background(), topic); err != nil {
return err
}
}
if err := client.Subscribe(context.Background(), topic, qos, r.dispatcher(provider, topic)); err != nil {
return err
}
current[topic] = qos
}
r.mu.Lock()
r.bindings[provider] = current
r.mu.Unlock()
delete(r.pending, provider)
delete(r.nextRetry, provider)
return nil
}
func (r *Reloadable) Register(set platformmq.SubscriptionSet) error {
normalized, err := platformmq.NormalizeSubscriptionSet(set)
if err != nil {
return err
}
r.opMu.Lock()
defer r.opMu.Unlock()
if r.closed {
return platformmq.ErrUnavailable
}
r.ensureStateLocked()
r.mu.Lock()
if r.subscriptions[normalized.Provider] == nil {
r.subscriptions[normalized.Provider] = make(map[string]map[string]subscription)
}
for topic, owners := range r.subscriptions[normalized.Provider] {
delete(owners, normalized.Owner)
if len(owners) == 0 {
delete(r.subscriptions[normalized.Provider], topic)
}
}
for _, item := range normalized.Topics {
if r.subscriptions[normalized.Provider][item.Topic] == nil {
r.subscriptions[normalized.Provider][item.Topic] = make(map[string]subscription)
}
r.subscriptions[normalized.Provider][item.Topic][normalized.Owner] = subscription{qos: item.QoS, handler: item.Handler}
}
r.mu.Unlock()
if err := r.reconcileProviderLocked(normalized.Provider); err != nil {
config := r.configs[normalized.Provider]
r.scheduleRetryLocked(normalized.Provider, config.Values)
if r.logger != nil {
r.logger.Warn("message subscription bind deferred", "mod", "mq", "provider", normalized.Provider, "owner", normalized.Owner, "error", err)
}
}
return nil
}
func (r *Reloadable) Unregister(owner string) error {
owner = strings.TrimSpace(owner)
if owner == "" {
return fmt.Errorf("mq subscription owner is empty")
}
r.opMu.Lock()
defer r.opMu.Unlock()
if r.closed {
return nil
}
r.ensureStateLocked()
r.mu.Lock()
providers := make([]string, 0, len(r.subscriptions))
for provider, topics := range r.subscriptions {
providers = append(providers, provider)
for topic, owners := range topics {
delete(owners, owner)
if len(owners) == 0 {
delete(topics, topic)
}
}
}
r.mu.Unlock()
for _, provider := range providers {
if err := r.reconcileProviderLocked(provider); err != nil {
if config, ok := r.configs[provider]; ok {
r.scheduleRetryLocked(provider, config.Values)
}
}
}
return nil
}
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
}
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
provider = strings.ToLower(strings.TrimSpace(provider))
client := r.clientLocked(provider)
if client == nil {
return platformmq.ErrUnavailable
}
return client.Publish(ctx, topic, payload, qos, retain)
}
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
}
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
provider = strings.ToLower(strings.TrimSpace(provider))
r.opMu.Lock()
r.legacySeq++
owner := fmt.Sprintf("legacy/%s/%d", provider, r.legacySeq)
r.opMu.Unlock()
if err := r.Register(platformmq.SubscriptionSet{Owner: owner, Provider: provider, Topics: []platformmq.TopicSubscription{{Topic: topic, QoS: qos, Handler: handler}}}); err != nil {
return err
}
if !r.ConnectedTo(provider) {
return platformmq.ErrUnavailable
}
return nil
}
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...)
}
func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error {
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
provider = strings.ToLower(strings.TrimSpace(provider))
if len(topics) == 0 {
return fmt.Errorf("mq topics are empty")
}
r.opMu.Lock()
defer r.opMu.Unlock()
r.ensureStateLocked()
r.mu.Lock()
for _, rawTopic := range topics {
topic := strings.TrimSpace(rawTopic)
owners := r.subscriptions[provider][topic]
for owner := range owners {
if strings.HasPrefix(owner, "legacy/") {
delete(owners, owner)
}
}
if len(owners) == 0 {
delete(r.subscriptions[provider], topic)
}
}
r.mu.Unlock()
if err := r.reconcileProviderLocked(provider); err != nil {
if config, ok := r.configs[provider]; ok {
r.scheduleRetryLocked(provider, config.Values)
}
return err
}
return nil
}
func (r *Reloadable) Client(provider string) platformmq.Client { func (r *Reloadable) Client(provider string) platformmq.Client {
provider = strings.ToLower(strings.TrimSpace(provider)) provider = strings.ToLower(strings.TrimSpace(provider))
if provider != ProviderEMQX && provider != ProviderRabbitMQ { if provider != ProviderEMQX && provider != ProviderRabbitMQ {
@ -317,81 +622,24 @@ func (c *namedClient) Unsubscribe(ctx context.Context, topics ...string) error {
func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) } func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) }
func (*namedClient) Close() error { return nil } func (*namedClient) Close() error { return nil }
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
}
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
provider = strings.ToLower(strings.TrimSpace(provider))
r.mu.RLock()
defer r.mu.RUnlock()
client := r.clients[provider]
if client == nil {
return platformmq.ErrUnavailable
}
return client.Publish(ctx, topic, payload, qos, retain)
}
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
}
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
provider = strings.ToLower(strings.TrimSpace(provider))
r.opMu.Lock()
defer r.opMu.Unlock()
client := r.client(provider)
if client == nil {
return platformmq.ErrUnavailable
}
if err := client.Subscribe(ctx, topic, qos, handler); err != nil {
return err
}
if r.subscriptions[provider] == nil {
r.subscriptions[provider] = make(map[string]subscription)
}
r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler}
return nil
}
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...)
}
func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
r.opMu.Lock()
defer r.opMu.Unlock()
client := r.client(provider)
if client == nil {
return platformmq.ErrUnavailable
}
if err := client.Unsubscribe(ctx, topics...); err != nil {
return err
}
for _, topic := range topics {
delete(r.subscriptions[provider], topic)
}
return nil
}
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) } func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
func (r *Reloadable) ConnectedTo(provider string) bool { func (r *Reloadable) ConnectedTo(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider)) provider = strings.ToLower(strings.TrimSpace(provider))
r.mu.RLock() client := r.clientLocked(provider)
defer r.mu.RUnlock()
client := r.clients[provider]
return client != nil && client.Connected() return client != nil && client.Connected()
} }
func (r *Reloadable) Close() error { func (r *Reloadable) Close() error {
r.opMu.Lock() r.opMu.Lock()
defer r.opMu.Unlock()
if r.closed { if r.closed {
r.opMu.Unlock()
return nil return nil
} }
r.closed = true r.closed = true
if r.retryStop != nil {
close(r.retryStop)
}
r.mu.Lock() r.mu.Lock()
clients := make([]platformmq.Client, 0, len(r.clients)) clients := make([]platformmq.Client, 0, len(r.clients))
for provider, client := range r.clients { for provider, client := range r.clients {
@ -399,6 +647,10 @@ func (r *Reloadable) Close() error {
delete(r.clients, provider) delete(r.clients, provider)
} }
r.mu.Unlock() r.mu.Unlock()
r.opMu.Unlock()
if r.retryDone != nil {
<-r.retryDone
}
for _, client := range clients { for _, client := range clients {
if client != nil { if client != nil {
_ = client.Close() _ = client.Close()
@ -406,3 +658,24 @@ func (r *Reloadable) Close() error {
} }
return nil return nil
} }
func (r *Reloadable) ensureStateLocked() {
if r.clients == nil {
r.clients = make(map[string]platformmq.Client)
}
if r.configs == nil {
r.configs = make(map[string]runtimeconfig.Config)
}
if r.subscriptions == nil {
r.subscriptions = make(map[string]map[string]map[string]subscription)
}
if r.bindings == nil {
r.bindings = make(map[string]map[string]byte)
}
if r.pending == nil {
r.pending = make(map[string]bool)
}
if r.nextRetry == nil {
r.nextRetry = make(map[string]time.Time)
}
}

View File

@ -2,19 +2,27 @@ package mq
import ( import (
"context" "context"
"log/slog"
"testing" "testing"
"time"
"kra/internal/integration/runtimeconfig"
platformmq "kra/pkg/mq" platformmq "kra/pkg/mq"
) )
type fakeClient struct { type fakeClient struct {
subscribed []string subscribed []string
unsubscribed []string unsubscribed []string
handlers map[string]platformmq.Handler
} }
func (*fakeClient) Publish(context.Context, string, []byte, byte, bool) error { return nil } func (*fakeClient) Publish(context.Context, string, []byte, byte, bool) error { return nil }
func (f *fakeClient) Subscribe(_ context.Context, topic string, _ byte, _ platformmq.Handler) error { func (f *fakeClient) Subscribe(_ context.Context, topic string, _ byte, handler platformmq.Handler) error {
f.subscribed = append(f.subscribed, topic) f.subscribed = append(f.subscribed, topic)
if f.handlers == nil {
f.handlers = make(map[string]platformmq.Handler)
}
f.handlers[topic] = handler
return nil return nil
} }
func (f *fakeClient) Unsubscribe(_ context.Context, topics ...string) error { func (f *fakeClient) Unsubscribe(_ context.Context, topics ...string) error {
@ -28,7 +36,11 @@ func TestReloadableTracksSubscriptions(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
r := &Reloadable{ r := &Reloadable{
clients: map[string]platformmq.Client{ProviderEMQX: client}, clients: map[string]platformmq.Client{ProviderEMQX: client},
subscriptions: make(map[string]map[string]subscription), configs: map[string]runtimeconfig.Config{ProviderEMQX: {Kind: "mq", Provider: ProviderEMQX, Enabled: true}},
subscriptions: make(map[string]map[string]map[string]subscription),
bindings: make(map[string]map[string]byte),
pending: make(map[string]bool),
nextRetry: make(map[string]time.Time),
} }
handler := func(context.Context, platformmq.Message) {} handler := func(context.Context, platformmq.Message) {}
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil { if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
@ -47,15 +59,92 @@ func TestReloadableTracksSubscriptions(t *testing.T) {
func TestReloadableRestoresSubscriptions(t *testing.T) { func TestReloadableRestoresSubscriptions(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
r := &Reloadable{subscriptions: map[string]map[string]subscription{ r := &Reloadable{subscriptions: map[string]map[string]map[string]subscription{
ProviderEMQX: { ProviderEMQX: {
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}}, "orders/+/paid": {
"orders": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
},
}, },
}} }}
if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil { bindings, err := r.restoreSubscriptionsLocked(ProviderEMQX, client)
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" { if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
t.Fatalf("restored subscriptions = %v", client.subscribed) t.Fatalf("restored subscriptions = %v", client.subscribed)
} }
if bindings["orders/+/paid"] != platformmq.AtLeastOnce {
t.Fatalf("restored binding qos = %d", bindings["orders/+/paid"])
}
}
func TestReloadableRegistersWhileOfflineAndRestoresLater(t *testing.T) {
client := &fakeClient{}
r := &Reloadable{
clients: make(map[string]platformmq.Client),
configs: map[string]runtimeconfig.Config{ProviderEMQX: {Kind: "mq", Provider: ProviderEMQX, Enabled: true}},
subscriptions: make(map[string]map[string]map[string]subscription),
bindings: make(map[string]map[string]byte),
pending: make(map[string]bool),
nextRetry: make(map[string]time.Time),
logger: slog.Default(),
}
called := make(chan struct{}, 1)
err := r.Register(platformmq.SubscriptionSet{
Owner: "orders",
Provider: ProviderEMQX,
Topics: []platformmq.TopicSubscription{{Topic: "orders.created", QoS: platformmq.AtLeastOnce, Handler: func(context.Context, platformmq.Message) { called <- struct{}{} }}},
})
if err != nil {
t.Fatal(err)
}
if !r.pending[ProviderEMQX] {
t.Fatal("offline subscription was not marked pending")
}
r.clients[ProviderEMQX] = client
if err = r.reconcileProviderLocked(ProviderEMQX); err != nil {
t.Fatal(err)
}
if len(client.subscribed) != 1 || client.subscribed[0] != "orders.created" {
t.Fatalf("restored subscriptions = %v", client.subscribed)
}
}
func TestReloadableDispatchesSameTopicToMultipleOwners(t *testing.T) {
client := &fakeClient{}
r := &Reloadable{
clients: map[string]platformmq.Client{ProviderEMQX: client},
configs: map[string]runtimeconfig.Config{ProviderEMQX: {Kind: "mq", Provider: ProviderEMQX, Enabled: true}},
subscriptions: make(map[string]map[string]map[string]subscription),
bindings: make(map[string]map[string]byte),
pending: make(map[string]bool),
nextRetry: make(map[string]time.Time),
logger: slog.Default(),
}
first, second := make(chan struct{}, 1), make(chan struct{}, 1)
for _, set := range []platformmq.SubscriptionSet{
{Owner: "orders", Provider: ProviderEMQX, Topics: []platformmq.TopicSubscription{{Topic: "events.created", Handler: func(context.Context, platformmq.Message) { first <- struct{}{} }}}},
{Owner: "audit", Provider: ProviderEMQX, Topics: []platformmq.TopicSubscription{{Topic: "events.created", Handler: func(context.Context, platformmq.Message) { second <- struct{}{} }}}},
} {
if err := r.Register(set); err != nil {
t.Fatal(err)
}
}
if err := r.reconcileProviderLocked(ProviderEMQX); err != nil {
t.Fatalf("explicit reconcile failed: %v", err)
}
if len(client.subscribed) != 1 {
t.Fatalf("broker subscriptions = %v, want one shared topic; desired=%#v bindings=%#v clients=%#v", client.subscribed, r.subscriptions, r.bindings, r.clients)
}
client.handlers["events.created"](context.Background(), platformmq.Message{Topic: "events.created"})
select {
case <-first:
default:
t.Fatal("orders handler was not called")
}
select {
case <-second:
default:
t.Fatal("audit handler was not called")
}
} }

View File

@ -25,6 +25,7 @@ var ProviderSet = wire.NewSet(
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)),
wire.Bind(new(mq.SubscriptionRegistrar), new(*mqintegration.Reloadable)),
websocketintegration.New, websocketintegration.New,
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)), wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)), wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),

View File

@ -83,14 +83,15 @@ type Registry interface {
} }
type Config struct { type Config struct {
Enabled bool Enabled bool
Broker string Broker string
ClientID string ClientID string
Username string Username string
Password string Password string
KeepAlive time.Duration KeepAlive time.Duration
CleanSession bool CleanSession bool
ConnectTimeout time.Duration ConnectTimeout time.Duration
ReconnectInterval time.Duration
} }
func PublishJSON(ctx context.Context, client Client, topic string, value any, qos byte, retain bool) error { func PublishJSON(ctx context.Context, client Client, topic string, value any, qos byte, retain bool) error {

View File

@ -28,9 +28,12 @@ func NewMQTT(cfg Config) (*MQTT, error) {
if cfg.ConnectTimeout <= 0 { if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = 10 * time.Second cfg.ConnectTimeout = 10 * time.Second
} }
if cfg.ReconnectInterval <= 0 {
cfg.ReconnectInterval = 5 * time.Second
}
opts := paho.NewClientOptions().AddBroker(cfg.Broker).SetClientID(cfg.ClientID).SetUsername(cfg.Username).SetPassword(cfg.Password) opts := paho.NewClientOptions().AddBroker(cfg.Broker).SetClientID(cfg.ClientID).SetUsername(cfg.Username).SetPassword(cfg.Password)
opts.SetKeepAlive(cfg.KeepAlive).SetCleanSession(cfg.CleanSession).SetConnectTimeout(cfg.ConnectTimeout).SetAutoReconnect(true) opts.SetKeepAlive(cfg.KeepAlive).SetCleanSession(cfg.CleanSession).SetConnectTimeout(cfg.ConnectTimeout).SetAutoReconnect(true)
opts.SetResumeSubs(true).SetOrderMatters(false) opts.SetMaxReconnectInterval(cfg.ReconnectInterval).SetResumeSubs(true).SetOrderMatters(false)
c := &MQTT{} c := &MQTT{}
token := paho.NewClient(opts) token := paho.NewClient(opts)
connect := token.Connect() connect := token.Connect()

View File

@ -6,28 +6,30 @@ import (
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
) )
type RabbitMQConfig struct { type RabbitMQConfig struct {
Enabled bool Enabled bool
Host string Host string
Port int Port int
Username string Username string
Password string Password string
VHost string VHost string
Exchange string Exchange string
ExchangeType string ExchangeType string
Queue string Queue string
RoutingKey string RoutingKey string
Durable bool Durable bool
AutoDelete bool AutoDelete bool
PrefetchCount int PrefetchCount int
Heartbeat time.Duration Heartbeat time.Duration
ConnectTimeout time.Duration ConnectTimeout time.Duration
TLS bool ReconnectInterval time.Duration
TLS bool
} }
type rabbitSubscription struct { type rabbitSubscription struct {
@ -52,6 +54,7 @@ type RabbitMQ struct {
consuming bool consuming bool
stop chan struct{} stop chan struct{}
closed bool closed bool
reconnecting atomic.Bool
} }
func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) { func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
@ -75,7 +78,13 @@ func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
}.String() }.String()
connection, err := amqp.DialConfig(address, amqp.Config{ connection, err := amqp.DialConfig(address, amqp.Config{
Heartbeat: config.Heartbeat, Heartbeat: config.Heartbeat,
Recovery: &amqp.Recovery{}, Recovery: &amqp.Recovery{
ReconnectionConfig: &amqp.ReconnectionConfig{MaxRetryCount: 5, RetryInterval: config.ReconnectInterval},
OnTopologyEntityError: func(_ *amqp.Connection, entity amqp.TopologyRecoveryEntity) bool {
amqp.Logger.Printf("rabbitmq topology recovery failed: %s", entity.Error())
return false
},
},
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("connect rabbitmq: %w", err) return nil, fmt.Errorf("connect rabbitmq: %w", err)
@ -116,6 +125,9 @@ func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
client.consumeChannel = consumeChannel client.consumeChannel = consumeChannel
client.config = config client.config = config
client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano()) client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano())
stateChanges := make(chan *amqp.StateChanged, 16)
connection.NotifyStateChange(stateChanges)
go client.watchState(stateChanges)
return client, nil return client, nil
} }
@ -148,6 +160,9 @@ func defaultRabbitMQConfig(config RabbitMQConfig) RabbitMQConfig {
if config.ConnectTimeout <= 0 { if config.ConnectTimeout <= 0 {
config.ConnectTimeout = 10 * time.Second config.ConnectTimeout = 10 * time.Second
} }
if config.ReconnectInterval <= 0 {
config.ReconnectInterval = 5 * time.Second
}
return config return config
} }
@ -371,7 +386,28 @@ func (c *RabbitMQ) Connected() bool {
} }
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return !c.closed && c.connection != nil && !c.connection.IsClosed() return !c.closed && !c.reconnecting.Load() && c.connection != nil && !c.connection.IsClosed()
}
// Reconnecting reports whether the AMQP driver is actively recovering the
// current connection. The outer runtime waits for this state to settle before
// deciding whether a fresh client must be built.
func (c *RabbitMQ) Reconnecting() bool {
return c != nil && !c.closed && c.reconnecting.Load()
}
func (c *RabbitMQ) watchState(states <-chan *amqp.StateChanged) {
for state := range states {
if state == nil {
continue
}
switch state.To {
case amqp.StateReconnecting:
c.reconnecting.Store(true)
case amqp.StateOpen, amqp.StateClosed:
c.reconnecting.Store(false)
}
}
} }
func (c *RabbitMQ) Close() error { func (c *RabbitMQ) Close() error {

232
pkg/mq/subscription_test.go Normal file
View File

@ -0,0 +1,232 @@
package mq
import (
"context"
"errors"
"reflect"
"strings"
"testing"
)
func TestNormalizeSubscriptionSetValidDeclaration(t *testing.T) {
handler := func(context.Context, Message) {}
set := SubscriptionSet{
Owner: " orders ",
Provider: " RABBITMQ ",
Topics: []TopicSubscription{
{Topic: " orders.created ", QoS: AtMostOnce, Handler: handler},
{Topic: "orders.updated", QoS: AtLeastOnce, Handler: handler},
{Topic: "orders.deleted", QoS: ExactlyOnce, Handler: handler},
},
}
got, err := NormalizeSubscriptionSet(set)
if err != nil {
t.Fatalf("NormalizeSubscriptionSet() error = %v", err)
}
want := SubscriptionSet{
Owner: "orders",
Provider: ProviderRabbitMQ,
Topics: []TopicSubscription{
{Topic: "orders.created", QoS: AtMostOnce, Handler: handler},
{Topic: "orders.updated", QoS: AtLeastOnce, Handler: handler},
{Topic: "orders.deleted", QoS: ExactlyOnce, Handler: handler},
},
}
if got.Owner != want.Owner || got.Provider != want.Provider {
t.Fatalf("normalized identity = %#v, want owner=%q provider=%q", got, want.Owner, want.Provider)
}
if len(got.Topics) != len(want.Topics) {
t.Fatalf("normalized topic count = %d, want %d", len(got.Topics), len(want.Topics))
}
for index := range want.Topics {
if got.Topics[index].Topic != want.Topics[index].Topic {
t.Errorf("topic[%d] = %q, want %q", index, got.Topics[index].Topic, want.Topics[index].Topic)
}
if got.Topics[index].QoS != want.Topics[index].QoS {
t.Errorf("qos[%d] = %d, want %d", index, got.Topics[index].QoS, want.Topics[index].QoS)
}
if reflect.ValueOf(got.Topics[index].Handler).Pointer() != reflect.ValueOf(want.Topics[index].Handler).Pointer() {
t.Errorf("handler[%d] was changed", index)
}
}
}
func TestNormalizeSubscriptionSetRejectsInvalidDeclarations(t *testing.T) {
handler := func(context.Context, Message) {}
base := func() SubscriptionSet {
return SubscriptionSet{
Owner: "orders",
Provider: ProviderEMQX,
Topics: []TopicSubscription{
{Topic: "orders.created", QoS: AtLeastOnce, Handler: handler},
},
}
}
tests := []struct {
name string
set SubscriptionSet
wantErr string
}{
{
name: "empty owner",
set: func() SubscriptionSet { set := base(); set.Owner = " "; return set }(),
wantErr: "owner is empty",
},
{
name: "empty provider",
set: func() SubscriptionSet { set := base(); set.Provider = " "; return set }(),
wantErr: "unsupported mq provider",
},
{
name: "unsupported provider",
set: func() SubscriptionSet { set := base(); set.Provider = "kafka"; return set }(),
wantErr: "unsupported mq provider",
},
{
name: "empty topics",
set: func() SubscriptionSet { set := base(); set.Topics = nil; return set }(),
wantErr: "topics are empty",
},
{
name: "empty topic",
set: func() SubscriptionSet {
set := base()
set.Topics[0].Topic = " "
return set
}(),
wantErr: "topic at index 0 is empty",
},
{
name: "duplicate topic",
set: func() SubscriptionSet {
set := base()
set.Topics = append(set.Topics, TopicSubscription{
Topic: " orders.created ", QoS: AtMostOnce, Handler: handler,
})
return set
}(),
wantErr: "duplicate mq subscription topic",
},
{
name: "nil handler",
set: func() SubscriptionSet {
set := base()
set.Topics[0].Handler = nil
return set
}(),
wantErr: "handler is nil",
},
{
name: "invalid qos",
set: func() SubscriptionSet {
set := base()
set.Topics[0].QoS = ExactlyOnce + 1
return set
}(),
wantErr: "invalid mq qos",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := NormalizeSubscriptionSet(test.set)
if err == nil {
t.Fatal("NormalizeSubscriptionSet() error = nil, want error")
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %q, want substring %q", err, test.wantErr)
}
})
}
}
type subscriptionTestRegistrar struct {
sets []SubscriptionSet
}
func (r *subscriptionTestRegistrar) Register(set SubscriptionSet) error {
r.sets = append(r.sets, set)
return nil
}
func (*subscriptionTestRegistrar) Unregister(string) error { return nil }
type subscriptionTestContributor struct {
name string
set SubscriptionSet
order *[]string
err error
}
func (c *subscriptionTestContributor) RegisterSubscriptions(registrar SubscriptionRegistrar) error {
*c.order = append(*c.order, c.name)
if c.err != nil {
return c.err
}
return registrar.Register(c.set)
}
func TestApplySubscriptionsAppliesMultipleContributorsInOrder(t *testing.T) {
order := make([]string, 0, 2)
registrar := &subscriptionTestRegistrar{}
handler := func(context.Context, Message) {}
first := &subscriptionTestContributor{
name: "orders",
order: &order,
set: SubscriptionSet{
Owner: "orders",
Provider: ProviderRabbitMQ,
Topics: []TopicSubscription{{Topic: "orders.created", Handler: handler}},
},
}
second := &subscriptionTestContributor{
name: "notifications",
order: &order,
set: SubscriptionSet{
Owner: "notifications",
Provider: ProviderEMQX,
Topics: []TopicSubscription{{Topic: "notifications.sent", Handler: handler}},
},
}
if err := ApplySubscriptions(registrar, first, nil, second); err != nil {
t.Fatalf("ApplySubscriptions() error = %v", err)
}
if !reflect.DeepEqual(order, []string{"orders", "notifications"}) {
t.Fatalf("contributor order = %#v, want %#v", order, []string{"orders", "notifications"})
}
if len(registrar.sets) != 2 {
t.Fatalf("registered sets = %d, want 2", len(registrar.sets))
}
if registrar.sets[0].Owner != "orders" || registrar.sets[1].Owner != "notifications" {
t.Fatalf("registered owners = %q, %q", registrar.sets[0].Owner, registrar.sets[1].Owner)
}
}
func TestApplySubscriptionsPropagatesContributorError(t *testing.T) {
order := make([]string, 0, 2)
registrar := &subscriptionTestRegistrar{}
wantErr := errors.New("registration failed")
failing := &subscriptionTestContributor{name: "failing", order: &order, err: wantErr}
following := &subscriptionTestContributor{name: "following", order: &order}
err := ApplySubscriptions(registrar, failing, following)
if !errors.Is(err, wantErr) {
t.Fatalf("ApplySubscriptions() error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(order, []string{"failing"}) {
t.Fatalf("contributors called = %#v, want %#v", order, []string{"failing"})
}
if len(registrar.sets) != 0 {
t.Fatalf("registered sets = %d, want 0", len(registrar.sets))
}
}
func TestApplySubscriptionsRejectsNilRegistrar(t *testing.T) {
if err := ApplySubscriptions(nil); err == nil || !strings.Contains(err.Error(), "registrar is nil") {
t.Fatalf("ApplySubscriptions(nil) error = %v, want nil-registrar error", err)
}
}

View File

@ -116,6 +116,7 @@
class="field-control" class="field-control"
:min="numberConstraint(field.key).min" :min="numberConstraint(field.key).min"
:max="numberConstraint(field.key).max" :max="numberConstraint(field.key).max"
:precision="numberConstraint(field.key).integer ? 0 : undefined"
:step="1" :step="1"
controls-position="right" controls-position="right"
@update:model-value="clearFieldError(selected, field.key)" @update:model-value="clearFieldError(selected, field.key)"
@ -231,10 +232,21 @@ const TARGETS = {
} }
const TARGET_ORDER = Object.keys(TARGETS) const TARGET_ORDER = Object.keys(TARGETS)
const RECONNECT_INTERVAL_KEY = 'reconnect_interval'
const RECONNECT_DEFAULT_SECONDS = 5
const MQ_RECONNECT_TARGETS = new Set(['mq/emqx', 'mq/rabbitmq'])
const RECONNECT_INTERVAL_FIELD = {
key: RECONNECT_INTERVAL_KEY,
label: '重连间隔(秒)',
type: 'number',
required: true,
description: '连接中断后再次尝试连接的等待时间,最小 1 秒。'
}
const NUMBER_CONSTRAINTS = { const NUMBER_CONSTRAINTS = {
port: { min: 1, max: 65535 }, port: { min: 1, max: 65535 },
keep_alive: { min: 1 }, keep_alive: { min: 1 },
connect_timeout: { min: 1 }, connect_timeout: { min: 1 },
reconnect_interval: { min: 1, integer: true },
prefetch_count: { min: 0 }, prefetch_count: { min: 0 },
heartbeat: { min: 0 }, heartbeat: { min: 0 },
max_message_size: { min: 0 }, max_message_size: { min: 0 },
@ -264,13 +276,56 @@ const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}` const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {})) const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
const communicationFields = (item) => {
const fields = Array.isArray(item.fields)
? item.fields.map((field) =>
field.key === RECONNECT_INTERVAL_KEY
? {
...field,
label: field.label || RECONNECT_INTERVAL_FIELD.label,
type: 'number',
required: true,
description: field.description || RECONNECT_INTERVAL_FIELD.description
}
: { ...field }
)
: []
if (
!MQ_RECONNECT_TARGETS.has(integrationKey(item)) ||
fields.some((field) => field.key === RECONNECT_INTERVAL_KEY)
) {
return fields
}
const connectTimeoutIndex = fields.findIndex(
(field) => field.key === 'connect_timeout'
)
fields.splice(
connectTimeoutIndex < 0 ? fields.length : connectTimeoutIndex + 1,
0,
{ ...RECONNECT_INTERVAL_FIELD }
)
return fields
}
const normalizeIntegration = (item) => { const normalizeIntegration = (item) => {
const config = cloneConfig(item.config)
const fields = communicationFields(item)
if (
MQ_RECONNECT_TARGETS.has(integrationKey(item)) &&
(config[RECONNECT_INTERVAL_KEY] === null ||
typeof config[RECONNECT_INTERVAL_KEY] === 'undefined' ||
config[RECONNECT_INTERVAL_KEY] === '')
) {
config[RECONNECT_INTERVAL_KEY] = RECONNECT_DEFAULT_SECONDS
}
const normalized = { const normalized = {
...item, ...item,
enabled: Boolean(item.enabled), enabled: Boolean(item.enabled),
configured: Boolean(item.configured), configured: Boolean(item.configured),
config: cloneConfig(item.config), config,
fields: Array.isArray(item.fields) ? item.fields : [] fields
} }
normalized._savedEnabled = normalized.enabled normalized._savedEnabled = normalized.enabled
normalized._savedConfig = cloneConfig(normalized.config) normalized._savedConfig = cloneConfig(normalized.config)
@ -359,6 +414,8 @@ const validate = (item, enabled = item.enabled) => {
const constraint = numberConstraint(field.key) const constraint = numberConstraint(field.key)
if (!Number.isFinite(number)) { if (!Number.isFinite(number)) {
message = `${field.label}必须是数字` message = `${field.label}必须是数字`
} else if (constraint.integer && !Number.isInteger(number)) {
message = `${field.label}必须是整数`
} else if (constraint.min !== undefined && number < constraint.min) { } else if (constraint.min !== undefined && number < constraint.min) {
message = `${field.label}不能小于 ${constraint.min}` message = `${field.label}不能小于 ${constraint.min}`
} else if (constraint.max !== undefined && number > constraint.max) { } else if (constraint.max !== undefined && number > constraint.max) {