优化结构
This commit is contained in:
@@ -8,8 +8,11 @@ state, or translate provider-specific protocols into `biz` interfaces.
|
||||
|
||||
- `cache`: Redis-backed cache with an in-memory fallback.
|
||||
- `email`: SMTP email repository.
|
||||
- `mq`: reloadable EMQX/MQTT client exposed through the shared `pkg/mq` interface.
|
||||
- `payment`: payment-channel SDKs and callback/signature handling.
|
||||
- `storage`: local and object-storage implementations of `biz.FileStorage`.
|
||||
- `websocket`: reloadable Melody endpoint exposed through the shared
|
||||
`pkg/websocket` wrapper.
|
||||
|
||||
These packages are not `utils`: they perform I/O and own external dependency
|
||||
lifecycles. Their constructors are exposed through `ProviderSet`; `cmd` binds
|
||||
@@ -18,3 +21,31 @@ the `cache.RedisProvider` implementation to the shared `data.Data` container.
|
||||
Stateless protocol helpers that do not own clients live in
|
||||
`pkg/paymentkit`. They are intentionally small and dependency
|
||||
light, while provider adapters remain here.
|
||||
|
||||
## Shared WebSocket and MQ APIs
|
||||
|
||||
Business modules depend on `websocket.Hub` and `mq.Client` from the shared
|
||||
`pkg/websocket` and `pkg/mq` packages; they do not construct
|
||||
Melody or Paho clients and do not read system configuration directly. The
|
||||
system integration packages own runtime refresh and shutdown. Registered
|
||||
WebSocket handlers and MQTT subscriptions are retained when database-backed
|
||||
configuration replaces a live client.
|
||||
|
||||
```go
|
||||
ws.OnMessage(func(session *websocket.Session, payload []byte) {
|
||||
_ = ws.Send(session, payload)
|
||||
})
|
||||
_ = ws.Broadcast([]byte(`{"type":"system.ready"}`))
|
||||
|
||||
_ = mq.PublishJSON(ctx, mqClient, "orders/paid", event, mq.AtLeastOnce, false)
|
||||
_ = mqClient.Subscribe(ctx, "orders/+/paid", mq.AtLeastOnce, handler)
|
||||
```
|
||||
|
||||
Integration configuration is stored in `sys_integration_configs`:
|
||||
|
||||
- WebSocket: `kind=websocket`, `provider=melody`
|
||||
- EMQX: `kind=mq`, `provider=emqx`
|
||||
|
||||
The YAML values are only migration/bootstrap inputs. After the integration
|
||||
table exists, the database is authoritative and runtime updates are applied
|
||||
without restarting the process. The WebSocket public path defaults to `/ws`.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kra/app/system/internal/conf"
|
||||
"kra/pkg/mq"
|
||||
)
|
||||
|
||||
// Reloadable follows the runtime snapshot and keeps a single shared EMQX
|
||||
// connection for all modules in this process.
|
||||
type Reloadable struct {
|
||||
mu sync.RWMutex
|
||||
opMu sync.Mutex
|
||||
current mq.Client
|
||||
subscriptions map[string]subscription
|
||||
stop func()
|
||||
logger *slog.Logger
|
||||
closed bool
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
qos byte
|
||||
handler mq.Handler
|
||||
}
|
||||
|
||||
func New(runtime *conf.Runtime, logger *slog.Logger) (*Reloadable, func(), error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
r := &Reloadable{logger: logger, subscriptions: make(map[string]subscription)}
|
||||
if runtime != nil {
|
||||
var config *conf.AdminBackend_MQ
|
||||
if admin := runtime.Admin(); admin != nil {
|
||||
config = admin.GetMq()
|
||||
}
|
||||
r.replace(config)
|
||||
r.stop = runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) {
|
||||
if admin != nil {
|
||||
r.replace(admin.GetMq())
|
||||
}
|
||||
})
|
||||
}
|
||||
cleanup := func() {
|
||||
if r.stop != nil {
|
||||
r.stop()
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
return r, cleanup, nil
|
||||
}
|
||||
|
||||
func (r *Reloadable) replace(config *conf.AdminBackend_MQ) {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_MQ{}
|
||||
}
|
||||
cfg := mq.Config{Enabled: config.Enabled, Broker: config.Broker, ClientID: config.ClientId, Username: config.Username, Password: config.Password, CleanSession: config.CleanSession}
|
||||
if config.KeepAlive > 0 {
|
||||
cfg.KeepAlive = time.Duration(config.KeepAlive) * time.Second
|
||||
}
|
||||
if config.ConnectTimeout > 0 {
|
||||
cfg.ConnectTimeout = time.Duration(config.ConnectTimeout) * time.Second
|
||||
}
|
||||
client, err := mq.NewMQTT(cfg)
|
||||
if err != nil {
|
||||
r.logger.Warn("emqx unavailable", "mod", "mq", "error", err)
|
||||
return
|
||||
}
|
||||
if config.Enabled {
|
||||
if err = r.restoreSubscriptions(context.Background(), client); err != nil {
|
||||
_ = client.Close()
|
||||
r.logger.Warn("restore emqx subscriptions failed", "mod", "mq", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = client
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reloadable) restoreSubscriptions(ctx context.Context, client mq.Client) error {
|
||||
for topic, item := range r.subscriptions {
|
||||
if err := client.Subscribe(ctx, topic, item.qos, item.handler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reloadable) client() mq.Client { r.mu.RLock(); defer r.mu.RUnlock(); return r.current }
|
||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
}
|
||||
return c.Publish(ctx, topic, payload, qos, retain)
|
||||
}
|
||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler mq.Handler) error {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
}
|
||||
if err := c.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
r.subscriptions[topic] = subscription{qos: qos, handler: handler}
|
||||
return nil
|
||||
}
|
||||
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
}
|
||||
if err := c.Unsubscribe(ctx, topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, topic := range topics {
|
||||
delete(r.subscriptions, topic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Reloadable) Connected() bool { c := r.client(); return c != nil && c.Connected() }
|
||||
func (r *Reloadable) Close() error {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
if r.closed {
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = nil
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
return old.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
platformmq "kra/pkg/mq"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
subscribed []string
|
||||
unsubscribed []string
|
||||
}
|
||||
|
||||
func (*fakeClient) Publish(context.Context, string, []byte, byte, bool) error { return nil }
|
||||
func (f *fakeClient) Subscribe(_ context.Context, topic string, _ byte, _ platformmq.Handler) error {
|
||||
f.subscribed = append(f.subscribed, topic)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeClient) Unsubscribe(_ context.Context, topics ...string) error {
|
||||
f.unsubscribed = append(f.unsubscribed, topics...)
|
||||
return nil
|
||||
}
|
||||
func (*fakeClient) Connected() bool { return true }
|
||||
func (*fakeClient) Close() error { return nil }
|
||||
|
||||
func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{current: client, subscriptions: make(map[string]subscription)}
|
||||
handler := func(context.Context, platformmq.Message) {}
|
||||
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.subscriptions["orders/+/paid"]; !ok {
|
||||
t.Fatal("subscription was not retained for configuration reload")
|
||||
}
|
||||
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.subscriptions["orders/+/paid"]; ok {
|
||||
t.Fatal("unsubscribed topic remained in the reload registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{subscriptions: map[string]subscription{
|
||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||
}}
|
||||
if err := r.restoreSubscriptions(context.Background(), client); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
||||
t.Fatalf("restored subscriptions = %v", client.subscribed)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import (
|
||||
"kra/app/system/internal/biz"
|
||||
"kra/app/system/internal/integration/cache"
|
||||
"kra/app/system/internal/integration/email"
|
||||
mqintegration "kra/app/system/internal/integration/mq"
|
||||
"kra/app/system/internal/integration/storage"
|
||||
websocketintegration "kra/app/system/internal/integration/websocket"
|
||||
"kra/pkg/mq"
|
||||
platformws "kra/pkg/websocket"
|
||||
|
||||
"github.com/google/wire"
|
||||
)
|
||||
@@ -16,5 +20,9 @@ var ProviderSet = wire.NewSet(
|
||||
cache.New,
|
||||
email.NewEmailRepo,
|
||||
storage.NewFileStorage,
|
||||
mqintegration.New,
|
||||
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
||||
websocketintegration.New,
|
||||
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
||||
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
melody "github.com/olahol/melody"
|
||||
"kra/app/system/internal/conf"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
// Server is the system-owned WebSocket endpoint. Business modules can attach
|
||||
// handlers and publish messages without depending on Gin or Melody directly.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
current *platformws.Server
|
||||
path string
|
||||
messageHandlers []func(*melody.Session, []byte)
|
||||
binaryHandlers []func(*melody.Session, []byte)
|
||||
connectHandlers []func(*melody.Session)
|
||||
disconnectHandlers []func(*melody.Session)
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(runtime *conf.Runtime) (*Server, func(), error) {
|
||||
s := &Server{}
|
||||
s.Replace(runtime)
|
||||
var unsubscribe func()
|
||||
if runtime != nil {
|
||||
unsubscribe = runtime.Subscribe(func(_ *conf.Data, _ *conf.AdminBackend) { s.Replace(runtime) })
|
||||
}
|
||||
return s, func() {
|
||||
if unsubscribe != nil {
|
||||
unsubscribe()
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
current := s.current
|
||||
s.current = nil
|
||||
s.mu.Unlock()
|
||||
if current != nil {
|
||||
_ = current.Close()
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Replace(runtime *conf.Runtime) {
|
||||
var config *conf.AdminBackend_WebSocket
|
||||
if runtime != nil && runtime.Admin() != nil {
|
||||
config = runtime.Admin().Websocket
|
||||
}
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_WebSocket{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
previous := s.current
|
||||
if !config.Enabled {
|
||||
s.current = nil
|
||||
s.path = ""
|
||||
s.mu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
next := platformws.New(platformws.Config{
|
||||
WriteWait: duration(config.WriteWait, 10*time.Second),
|
||||
PongWait: duration(config.PongWait, 60*time.Second),
|
||||
PingPeriod: duration(config.PingPeriod, 54*time.Second),
|
||||
MaxMessageSize: config.MaxMessageSize,
|
||||
MessageBufferSize: int(config.MessageBufferSize),
|
||||
ConcurrentMessageHandling: config.ConcurrentMessageHandling,
|
||||
AllowOrigins: config.AllowOrigins,
|
||||
})
|
||||
for _, handler := range s.messageHandlers {
|
||||
next.OnMessage(handler)
|
||||
}
|
||||
for _, handler := range s.binaryHandlers {
|
||||
next.OnBinaryMessage(handler)
|
||||
}
|
||||
for _, handler := range s.connectHandlers {
|
||||
next.OnConnect(handler)
|
||||
}
|
||||
for _, handler := range s.disconnectHandlers {
|
||||
next.OnDisconnect(handler)
|
||||
}
|
||||
s.path = strings.TrimSpace(config.Path)
|
||||
if s.path == "" {
|
||||
s.path = "/ws"
|
||||
} else if !strings.HasPrefix(s.path, "/") {
|
||||
s.path = "/" + s.path
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func duration(value interface{ AsDuration() time.Duration }, fallback time.Duration) time.Duration {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
if result := value.AsDuration(); result > 0 {
|
||||
return result
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (s *Server) Enabled() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.current != nil
|
||||
}
|
||||
func (s *Server) Path() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.path
|
||||
}
|
||||
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
return current.HandleRequest(w, r)
|
||||
}
|
||||
func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, keys map[string]any) error {
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.HandleRequestWithKeys(w, r, keys)
|
||||
}
|
||||
func (s *Server) Broadcast(message []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
return current.Broadcast(message)
|
||||
}
|
||||
func (s *Server) BroadcastBinary(message []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
return current.BroadcastBinary(message)
|
||||
}
|
||||
func (s *Server) Sessions() ([]*melody.Session, error) {
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return current.Sessions()
|
||||
}
|
||||
func (s *Server) Len() int {
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return current.Len()
|
||||
}
|
||||
func (s *Server) Send(session *melody.Session, message []byte) error {
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.Send(session, message)
|
||||
}
|
||||
func (s *Server) SendBinary(session *melody.Session, message []byte) error {
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.SendBinary(session, message)
|
||||
}
|
||||
func (s *Server) active() (*platformws.Server, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return nil, errors.New("websocket server is disabled")
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
func (s *Server) OnMessage(handler func(*melody.Session, []byte)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.messageHandlers = append(s.messageHandlers, handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnMessage(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.binaryHandlers = append(s.binaryHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnBinaryMessage(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *Server) OnConnect(handler func(*melody.Session)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.connectHandlers = append(s.connectHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnConnect(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *Server) OnDisconnect(handler func(*melody.Session)) {
|
||||
if s == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnDisconnect(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user