优化结构

This commit is contained in:
Yvan
2026-08-21 08:54:29 +08:00
parent c968f9f24e
commit 77e192b6c0
167 changed files with 3657 additions and 1528 deletions
+31
View File
@@ -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`.
+154
View File
@@ -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()
}