优化结构

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
+6
View File
@@ -53,6 +53,12 @@ func skipStackFile(filename string) bool {
"/pkg/logging/",
"/internal/transport/middleware/",
"/internal/transport/router/",
"/internal/server/handler/",
"/internal/server/middleware/",
"/internal/server/router/",
"/internal/server/httpx/",
"/internal/server/middleware_",
"/internal/server/route_",
} {
if strings.Contains(normalized, marker) {
return true
+24
View File
@@ -95,6 +95,30 @@ func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
}
}
func TestSkipStackFileRecognizesServerTransportPackages(t *testing.T) {
tests := []struct {
name string
path string
want bool
}{
{name: "handler", path: `D:\workspace\app\system\internal\server\handler\user.go`, want: true},
{name: "middleware", path: `D:\workspace\app\system\internal\server\middleware\auth.go`, want: true},
{name: "router", path: `/workspace/app/system/internal/server/router/user.go`, want: true},
{name: "http helper", path: `/workspace/app/system/internal/server/httpx/response.go`, want: true},
{name: "legacy flattened middleware", path: `D:\workspace\app\system\internal\server\middleware_auth.go`, want: true},
{name: "legacy flattened route", path: `/workspace/app/system/internal/server/route_user.go`, want: true},
{name: "handler remains application boundary", path: `/workspace/app/system/internal/server/handler_user.go`, want: false},
{name: "legacy middleware", path: `/workspace/app/system/internal/transport/middleware/auth.go`, want: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := skipStackFile(test.path); got != test.want {
t.Fatalf("skipStackFile(%q) = %v, want %v", test.path, got, test.want)
}
})
}
}
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
root := t.TempDir()
var entries []ErrorEntry
+58
View File
@@ -0,0 +1,58 @@
// Package mq exposes the broker-agnostic messaging seam used by services.
package mq
import (
"context"
"encoding/json"
"errors"
"time"
)
var ErrUnavailable = errors.New("message broker unavailable")
const (
AtMostOnce byte = 0
AtLeastOnce byte = 1
ExactlyOnce byte = 2
)
type Message struct {
Topic string
Payload []byte
QoS byte
Retain bool
}
func (m Message) DecodeJSON(target any) error { return json.Unmarshal(m.Payload, target) }
type Handler func(context.Context, Message)
type Client interface {
Publish(context.Context, string, []byte, byte, bool) error
Subscribe(context.Context, string, byte, Handler) error
Unsubscribe(context.Context, ...string) error
Connected() bool
Close() error
}
type Config struct {
Enabled bool
Broker string
ClientID string
Username string
Password string
KeepAlive time.Duration
CleanSession bool
ConnectTimeout time.Duration
}
func PublishJSON(ctx context.Context, client Client, topic string, value any, qos byte, retain bool) error {
if client == nil {
return ErrUnavailable
}
payload, err := json.Marshal(value)
if err != nil {
return err
}
return client.Publish(ctx, topic, payload, qos, retain)
}
+126
View File
@@ -0,0 +1,126 @@
package mq
import (
"context"
"fmt"
"sync"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
type MQTT struct {
client paho.Client
mu sync.RWMutex
closed bool
}
func NewMQTT(cfg Config) (*MQTT, error) {
if !cfg.Enabled {
return &MQTT{}, nil
}
if cfg.Broker == "" {
return nil, fmt.Errorf("mqtt broker is empty")
}
if cfg.KeepAlive <= 0 {
cfg.KeepAlive = 30 * time.Second
}
if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = 10 * time.Second
}
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.SetResumeSubs(true).SetOrderMatters(false)
c := &MQTT{}
token := paho.NewClient(opts)
connect := token.Connect()
if !connect.WaitTimeout(cfg.ConnectTimeout) {
return nil, fmt.Errorf("connect mqtt: %w", ErrUnavailable)
}
if err := connect.Error(); err != nil {
return nil, err
}
c.client = token
return c, nil
}
func (c *MQTT) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
if topic == "" {
return fmt.Errorf("mqtt topic is empty")
}
if qos > ExactlyOnce {
return fmt.Errorf("invalid mqtt qos %d", qos)
}
if c == nil || c.client == nil || !c.client.IsConnected() {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return waitToken(ctx, c.client.Publish(topic, qos, retain, payload))
}
func (c *MQTT) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error {
if topic == "" {
return fmt.Errorf("mqtt topic is empty")
}
if qos > ExactlyOnce {
return fmt.Errorf("invalid mqtt qos %d", qos)
}
if c == nil || c.client == nil || !c.client.IsConnected() {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if handler == nil {
return fmt.Errorf("mqtt handler is nil")
}
return waitToken(ctx, c.client.Subscribe(topic, qos, func(_ paho.Client, msg paho.Message) {
handler(context.Background(), Message{Topic: msg.Topic(), Payload: append([]byte(nil), msg.Payload()...), QoS: msg.Qos(), Retain: msg.Retained()})
}))
}
func (c *MQTT) Unsubscribe(ctx context.Context, topics ...string) error {
if len(topics) == 0 {
return fmt.Errorf("mqtt topics are empty")
}
if c == nil || c.client == nil || !c.client.IsConnected() {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return waitToken(ctx, c.client.Unsubscribe(topics...))
}
func waitToken(ctx context.Context, token paho.Token) error {
if token == nil {
return ErrUnavailable
}
select {
case <-ctx.Done():
return ctx.Err()
case <-token.Done():
return token.Error()
}
}
func (c *MQTT) Connected() bool { return c != nil && c.client != nil && c.client.IsConnected() }
func (c *MQTT) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
if c.client != nil && c.client.IsConnected() {
c.client.Disconnect(250)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package mq
import (
"context"
"errors"
"testing"
)
type captureClient struct{ payload []byte }
func (c *captureClient) Publish(_ context.Context, _ string, payload []byte, _ byte, _ bool) error {
c.payload = append([]byte(nil), payload...)
return nil
}
func (*captureClient) Subscribe(context.Context, string, byte, Handler) error { return nil }
func (*captureClient) Unsubscribe(context.Context, ...string) error { return nil }
func (*captureClient) Connected() bool { return true }
func (*captureClient) Close() error { return nil }
func TestDisabledMQTTIsSafeAndUnavailable(t *testing.T) {
client, err := NewMQTT(Config{})
if err != nil {
t.Fatal(err)
}
if client.Connected() {
t.Fatal("disabled client reported connected")
}
if err = client.Publish(context.Background(), "events/test", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) {
t.Fatalf("publish error = %v", err)
}
if err = client.Close(); err != nil {
t.Fatal(err)
}
if err = client.Close(); err != nil {
t.Fatalf("second close: %v", err)
}
}
func TestEnabledMQTTRequiresBroker(t *testing.T) {
if _, err := NewMQTT(Config{Enabled: true}); err == nil {
t.Fatal("enabled MQTT without a broker should fail")
}
}
func TestPublishAndDecodeJSON(t *testing.T) {
client := &captureClient{}
if err := PublishJSON(context.Background(), client, "orders/paid", map[string]int{"id": 7}, AtLeastOnce, false); err != nil {
t.Fatal(err)
}
var value map[string]int
if err := (Message{Payload: client.payload}).DecodeJSON(&value); err != nil {
t.Fatal(err)
}
if value["id"] != 7 {
t.Fatalf("decoded value = %#v", value)
}
}
+236
View File
@@ -0,0 +1,236 @@
// Package websocket provides a small, framework-neutral wrapper around Melody.
package websocket
import (
"net/http"
"net/url"
"strings"
"sync"
"time"
melody "github.com/olahol/melody"
)
type Config struct {
WriteWait time.Duration
PongWait time.Duration
PingPeriod time.Duration
MaxMessageSize int64
MessageBufferSize int
ConcurrentMessageHandling bool
AllowOrigins []string
}
type Session = melody.Session
// Hub is the shared WebSocket boundary consumed by business modules. The
// application composition root supplies the runtime-configured implementation.
type Hub interface {
Enabled() bool
Broadcast([]byte) error
BroadcastBinary([]byte) error
Sessions() ([]*Session, error)
Len() int
Send(*Session, []byte) error
SendBinary(*Session, []byte) error
OnMessage(func(*Session, []byte))
OnBinaryMessage(func(*Session, []byte))
OnConnect(func(*Session))
OnDisconnect(func(*Session))
}
func (c Config) defaults() Config {
if c.WriteWait <= 0 {
c.WriteWait = 10 * time.Second
}
if c.PongWait <= 0 {
c.PongWait = 60 * time.Second
}
if c.PingPeriod <= 0 {
c.PingPeriod = (c.PongWait * 9) / 10
}
if c.MaxMessageSize <= 0 {
c.MaxMessageSize = 1 << 20
}
if c.MessageBufferSize <= 0 {
c.MessageBufferSize = 256
}
return c
}
type Server struct {
m *melody.Melody
mu sync.RWMutex
closed bool
messageHandlers []func(*melody.Session, []byte)
binaryHandlers []func(*melody.Session, []byte)
connectHandlers []func(*melody.Session)
disconnectHandlers []func(*melody.Session)
}
func New(config Config) *Server {
c := config.defaults()
m := melody.New()
m.Config.WriteWait = c.WriteWait
m.Config.PongWait = c.PongWait
m.Config.PingPeriod = c.PingPeriod
m.Config.MaxMessageSize = c.MaxMessageSize
m.Config.MessageBufferSize = c.MessageBufferSize
m.Config.ConcurrentMessageHandling = c.ConcurrentMessageHandling
allowed := append([]string(nil), c.AllowOrigins...)
m.Upgrader.CheckOrigin = func(r *http.Request) bool {
if len(allowed) == 0 {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
parsed, err := url.Parse(origin)
return err == nil && strings.EqualFold(parsed.Host, r.Host)
}
origin := r.Header.Get("Origin")
for _, value := range allowed {
if value == "*" || strings.EqualFold(strings.TrimSpace(value), origin) {
return true
}
}
return false
}
s := &Server{m: m}
m.HandleMessage(func(session *melody.Session, message []byte) {
for _, handler := range s.messageHandlerSnapshot() {
handler(session, message)
}
})
m.HandleMessageBinary(func(session *melody.Session, message []byte) {
for _, handler := range s.binaryHandlerSnapshot() {
handler(session, message)
}
})
m.HandleConnect(func(session *melody.Session) {
for _, handler := range s.connectHandlerSnapshot() {
handler(session)
}
})
m.HandleDisconnect(func(session *melody.Session) {
for _, handler := range s.disconnectHandlerSnapshot() {
handler(session)
}
})
return s
}
func (s *Server) Melody() *melody.Melody {
if s == nil {
return nil
}
return s.m
}
func (s *Server) Enabled() bool {
if s == nil || s.m == nil {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
return !s.closed
}
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
return s.m.HandleRequest(w, r)
}
func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, keys map[string]any) error {
return s.m.HandleRequestWithKeys(w, r, keys)
}
func (s *Server) Broadcast(message []byte) error { return s.m.Broadcast(message) }
func (s *Server) BroadcastBinary(message []byte) error { return s.m.BroadcastBinary(message) }
func (s *Server) Sessions() ([]*melody.Session, error) { return s.m.Sessions() }
func (s *Server) Len() int { return s.m.Len() }
func (s *Server) OnMessage(handler func(*melody.Session, []byte)) {
if s == nil || handler == nil {
return
}
s.mu.Lock()
s.messageHandlers = append(s.messageHandlers, 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)
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)
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)
s.mu.Unlock()
}
func (s *Server) messageHandlerSnapshot() []func(*melody.Session, []byte) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session, []byte), len(s.messageHandlers))
copy(handlers, s.messageHandlers)
return handlers
}
func (s *Server) binaryHandlerSnapshot() []func(*melody.Session, []byte) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session, []byte), len(s.binaryHandlers))
copy(handlers, s.binaryHandlers)
return handlers
}
func (s *Server) connectHandlerSnapshot() []func(*melody.Session) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session), len(s.connectHandlers))
copy(handlers, s.connectHandlers)
return handlers
}
func (s *Server) disconnectHandlerSnapshot() []func(*melody.Session) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session), len(s.disconnectHandlers))
copy(handlers, s.disconnectHandlers)
return handlers
}
func (s *Server) Send(session *melody.Session, message []byte) error {
if session == nil {
return melody.ErrClosed
}
return session.Write(message)
}
func (s *Server) SendBinary(session *melody.Session, message []byte) error {
if session == nil {
return melody.ErrClosed
}
return session.WriteBinary(message)
}
func (s *Server) Close() error {
if s == nil || s.m == nil {
return nil
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
s.mu.Unlock()
return s.m.Close()
}
+50
View File
@@ -0,0 +1,50 @@
package websocket
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
melody "github.com/olahol/melody"
)
func TestServerReceivesAndSendsTextMessages(t *testing.T) {
server := New(Config{})
var firstHandlerCalled atomic.Bool
server.OnMessage(func(_ *melody.Session, _ []byte) {
firstHandlerCalled.Store(true)
})
server.OnMessage(func(session *melody.Session, message []byte) {
_ = server.Send(session, append([]byte("echo:"), message...))
})
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = server.HandleRequest(w, r)
}))
defer httpServer.Close()
defer server.Close()
url := "ws" + strings.TrimPrefix(httpServer.URL, "http")
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err = conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
t.Fatal(err)
}
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatal(err)
}
if string(message) != "echo:hello" {
t.Fatalf("message = %q", message)
}
if !firstHandlerCalled.Load() {
t.Fatal("first message handler was overwritten")
}
}