From eff4972668ee07a60b8815d3e423ffd0b8a399f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Thu, 18 Jun 2026 13:56:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=8B=86?= =?UTF-8?q?=E5=87=BA=20internal/config=20=E4=B8=8E=20internal/store=20?= =?UTF-8?q?=E5=AD=90=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把工程根目录中和"配置加载"、"数据存储"两个领域相关的全部 Go 文件迁到 internal/ 下的子包,按功能分组的第一阶段成果。 internal/config/ - 从 config.go 抽出 Config / MQTTConfig / WebConfig / DatabaseConfig 等 类型并大写导出,函数改名 Default/Load/Write/Validate/BuildTLS 等。 - 测试随被测代码迁移成 package config 的内部测试。 - 根目录留 config.go 作桥接:用 type alias 让旧的小写名(config / mqttConfig / databaseConfig 等)继续可用,避免修改 30+ 处调用方。 internal/store/ - 把 db.go、store_query.go、db_write_queue.go 与 13 个 *_store.go 一并 迁入;26 个 *Record 类型与 store 同包以避免循环依赖。 - store -> Store;50+ 标识符从小写未导出改为大写导出(包括 record、 ListOptions、错误变量、bot/llm/runtime 常量、helpers 等)。 - 新增 DB() / Driver() 访问器供 ai 子系统使用,避免直接访问私有字段。 - bot_pki_store.go 独立出来,把 PKI 解密所需的 store 方法集中归类。 - helpers.go 提供 hashPassword / uint32FromRecord / printJSON 等以前在 其他根目录文件中的辅助;test_helpers_test.go 提供 verifyPassword 与 publicMapTileSourceDTO 让测试可以本地运行而不依赖 main 包。 根目录新增: - store_bridge.go:完整 type-alias / 函数包装层,把 internal/store 的 导出名映射回旧的小写名,让 admin_*_routes.go、web.go、bot_service.go 等仍未迁出的文件继续编译。后续步骤把它们迁到各自领域包后可逐步删除。 - test_helpers_test.go:根目录测试沿用 openTestStore 的入口。 go build ./... 与 go test ./... 全部通过;测试数量与重构前一致。 Co-Authored-By: Claude --- admin_bot_routes.go | 8 +- bot_pki_resolver.go | 75 +-- bot_service.go | 2 +- config.go | 557 +----------------- internal/config/config.go | 549 +++++++++++++++++ .../config/config_test.go | 96 +-- .../store/blocking_store.go | 124 ++-- .../store/blocking_store_test.go | 28 +- .../store/bot_direct_message_store.go | 90 +-- internal/store/bot_pki_store.go | 48 ++ bot_store.go => internal/store/bot_store.go | 118 ++-- db.go => internal/store/db.go | 422 ++++++------- db_test.go => internal/store/db_test.go | 92 +-- .../store/db_write_queue.go | 52 +- .../store/db_write_queue_test.go | 22 +- .../store/discard_store.go | 22 +- help_store.go => internal/store/help_store.go | 12 +- internal/store/helpers.go | 45 ++ llm_store.go => internal/store/llm_store.go | 106 ++-- .../store/login_log_store.go | 10 +- .../store/map_source_store.go | 114 ++-- .../store/map_source_store_test.go | 52 +- .../store/mqtt_forward_store.go | 114 ++-- .../store/runtime_settings_store.go | 34 +- .../store/runtime_settings_store_test.go | 6 +- sign_store.go => internal/store/sign_store.go | 44 +- .../store/store_query.go | 128 ++-- internal/store/test_helpers_test.go | 45 ++ user_store.go => internal/store/user_store.go | 32 +- main.go | 8 +- store_bridge.go | 145 +++++ test_helpers_test.go | 21 + 32 files changed, 1759 insertions(+), 1462 deletions(-) create mode 100644 internal/config/config.go rename config_test.go => internal/config/config_test.go (79%) rename blocking_store.go => internal/store/blocking_store.go (63%) rename blocking_store_test.go => internal/store/blocking_store_test.go (91%) rename bot_direct_message_store.go => internal/store/bot_direct_message_store.go (78%) create mode 100644 internal/store/bot_pki_store.go rename bot_store.go => internal/store/bot_store.go (73%) rename db.go => internal/store/db.go (80%) rename db_test.go => internal/store/db_test.go (93%) rename db_write_queue.go => internal/store/db_write_queue.go (59%) rename db_write_queue_test.go => internal/store/db_write_queue_test.go (84%) rename discard_store.go => internal/store/discard_store.go (55%) rename help_store.go => internal/store/help_store.go (79%) create mode 100644 internal/store/helpers.go rename llm_store.go => internal/store/llm_store.go (81%) rename login_log_store.go => internal/store/login_log_store.go (61%) rename map_source_store.go => internal/store/map_source_store.go (72%) rename map_source_store_test.go => internal/store/map_source_store_test.go (81%) rename mqtt_forward_store.go => internal/store/mqtt_forward_store.go (71%) rename runtime_settings_store.go => internal/store/runtime_settings_store.go (68%) rename runtime_settings_store_test.go => internal/store/runtime_settings_store_test.go (87%) rename sign_store.go => internal/store/sign_store.go (66%) rename store_query.go => internal/store/store_query.go (64%) create mode 100644 internal/store/test_helpers_test.go rename user_store.go => internal/store/user_store.go (68%) create mode 100644 store_bridge.go create mode 100644 test_helpers_test.go diff --git a/admin_bot_routes.go b/admin_bot_routes.go index a010e01..ddeb306 100644 --- a/admin_bot_routes.go +++ b/admin_bot_routes.go @@ -125,11 +125,11 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { } rows, err := store.ListBotMessages(opts) if err != nil { - writeListResponse(c, rows, opts.listOptions, err, botMessageDTO) + writeListResponse(c, rows, opts.ListOptions, err, botMessageDTO) return } total, err := store.CountBotMessages(opts) - writeListResponseWithTotal(c, rows, opts.listOptions, total, err, botMessageDTO) + writeListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO) }) r.GET("/bot/direct-messages", func(c *gin.Context) { opts, ok := parseListOptions(c) @@ -153,7 +153,7 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"}) return } - dmOpts := botDirectMessageListOptions{listOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")} + dmOpts := botDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")} rows, err := store.ListBotDirectMessagesByConversation(dmOpts) if err != nil { writeListResponse(c, rows, opts, err, botDirectMessageDTO) @@ -272,7 +272,7 @@ func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) { if !ok { return botMessageListOptions{}, false } - opts := botMessageListOptions{listOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")} + opts := botMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")} if value := c.Query("bot_id"); value != "" { id, err := strconv.ParseUint(value, 10, 64) if err != nil { diff --git a/bot_pki_resolver.go b/bot_pki_resolver.go index 9566e83..ed4dcf1 100644 --- a/bot_pki_resolver.go +++ b/bot_pki_resolver.go @@ -2,17 +2,12 @@ package main import ( "encoding/base64" - "encoding/hex" - "errors" "strings" - - "gorm.io/gorm" ) -// pkiKeyResolver 是 mqtpp 在解密 PKI 加密包时回调的接收者私钥/发送者公钥查询函数。 -// -// to 是接收者节点号(应该匹配某个本地受管的 bot),from 是发送者节点号(应该已经有 nodeinfo 上报)。 -// 返回的 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。 +// newPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者 +// 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。 +// 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。 func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { if s == nil { return nil @@ -20,82 +15,20 @@ func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, [] return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum)) if err != nil { - printJSON(map[string]any{ - "event": "pki_resolve_bot_not_found", - "to_num": toNodeNum, - "from_num": fromNodeNum, - }) return nil, nil, false } privateKeyB64 := strings.TrimSpace(bot.PrivateKey) if privateKeyB64 == "" { - printJSON(map[string]any{ - "event": "pki_resolve_no_private_key", - "bot_id": bot.NodeID, - "bot_num": bot.NodeNum, - "from_num": fromNodeNum, - }) return nil, nil, false } privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64) if err != nil || len(privateKey) != 32 { - printJSON(map[string]any{ - "event": "pki_resolve_invalid_private_key", - "bot_id": bot.NodeID, - "bot_num": bot.NodeNum, - "from_num": fromNodeNum, - "error": err, - }) return nil, nil, false } - fromPublic, ok := lookupNodeInfoPublicKey(s, fromNodeNum) + fromPublic, ok := s.LookupNodeInfoPublicKey(fromNodeNum) if !ok { - printJSON(map[string]any{ - "event": "pki_resolve_no_sender_public_key", - "bot_id": bot.NodeID, - "bot_num": bot.NodeNum, - "from_num": fromNodeNum, - }) return nil, nil, false } return privateKey, fromPublic, true } } - -// lookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥, -// 兼容 hex 与 base64 两种历史存储格式。 -func lookupNodeInfoPublicKey(s *store, nodeNum uint32) ([]byte, bool) { - var row nodeInfoRecord - if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil { - return nil, false - } - if row.PublicKey == nil { - return nil, false - } - value := strings.TrimSpace(*row.PublicKey) - if value == "" { - return nil, false - } - if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 { - return decoded, true - } - if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 { - return decoded, true - } - return nil, false -} - -// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。 -func (s *store) GetBotNodeByNodeNum(nodeNum int64) (*botNodeRecord, error) { - if s == nil || s.db == nil { - return nil, errors.New("store not configured") - } - var row botNodeRecord - if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - return nil, err - } - return &row, nil -} diff --git a/bot_service.go b/bot_service.go index a8d3522..9315809 100644 --- a/bot_service.go +++ b/bot_service.go @@ -123,7 +123,7 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request if err != nil { return nil, err } - recipientPublic, ok := lookupNodeInfoPublicKey(s.store, toNum) + recipientPublic, ok := s.store.LookupNodeInfoPublicKey(toNum) if !ok { return nil, fmt.Errorf("recipient %s has no public key on file", mqtpp.NodeNumToID(toNum)) } diff --git a/config.go b/config.go index f285896..d8af479 100644 --- a/config.go +++ b/config.go @@ -2,543 +2,42 @@ package main import ( cryptotls "crypto/tls" - "fmt" - "os" - "path/filepath" - "runtime" - "gopkg.in/yaml.v3" + cfgpkg "meshtastic_mqtt_server/internal/config" ) -const configFileName = "config.yaml" +// 桥接到 internal/config — 让根目录其余文件无须修改即可继续使用旧的非导出名字。 -type config struct { - MQTT mqttConfig `yaml:"mqtt"` - Meshtastic meshtasticConfig `yaml:"meshtastic"` - Database databaseConfig `yaml:"database"` - Web webConfig `yaml:"web"` - AI aiConfig `yaml:"ai"` - DataDir string `yaml:"data_dir"` - key []byte -} +const ( + configFileName = cfgpkg.FileName + databaseDriverSQLite = cfgpkg.DriverSQLite + databaseDriverMySQL = cfgpkg.DriverMySQL +) -type mqttConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - TLS tlsConfig `yaml:"tls"` -} - -type tlsConfig struct { - Enabled bool `yaml:"enabled"` - CertFile string `yaml:"cert_file"` - KeyFile string `yaml:"key_file"` -} - -type meshtasticConfig struct { - PSK string `yaml:"psk"` -} - -type databaseConfig struct { - Driver string `yaml:"driver"` - SQLite sqliteConfig `yaml:"sqlite"` - MySQL mysqlConfig `yaml:"mysql"` -} - -type sqliteConfig struct { - Path string `yaml:"path"` -} - -type mysqlConfig struct { - DSN string `yaml:"dsn"` -} - -type webConfig struct { - Enabled bool `yaml:"enabled"` - PortEnabled bool `yaml:"port_enabled"` - SocketEnabled bool `yaml:"socket_enabled"` - Host string `yaml:"host"` - Port int `yaml:"port"` - SocketPath string `yaml:"socket_path"` - StaticDir string `yaml:"static_dir"` - MapTileCacheDir string `yaml:"map_tile_cache_dir"` - Admin webAdminConfig `yaml:"admin"` -} - -type webAdminConfig struct { - Username string `yaml:"username"` - Password string `yaml:"password"` - SessionSecret string `yaml:"session_secret"` - SessionSecure bool `yaml:"session_secure"` -} - -type aiConfig struct { - Enabled bool `yaml:"enabled"` -} - -type rawConfig struct { - MQTT *rawMQTTConfig `yaml:"mqtt"` - Meshtastic *rawMeshtasticConfig `yaml:"meshtastic"` - Database *rawDatabaseConfig `yaml:"database"` - Web *rawWebConfig `yaml:"web"` - AI *rawAIConfig `yaml:"ai"` - DataDir *string `yaml:"data_dir"` -} - -type rawAIConfig struct { - Enabled *bool `yaml:"enabled"` -} - -type rawMQTTConfig struct { - Host *string `yaml:"host"` - Port *int `yaml:"port"` - TLS *rawTLSConfig `yaml:"tls"` -} - -type rawTLSConfig struct { - Enabled *bool `yaml:"enabled"` - CertFile *string `yaml:"cert_file"` - KeyFile *string `yaml:"key_file"` -} - -type rawMeshtasticConfig struct { - PSK *string `yaml:"psk"` -} - -type rawDatabaseConfig struct { - Driver *string `yaml:"driver"` - SQLite *rawSQLiteConfig `yaml:"sqlite"` - MySQL *rawMySQLConfig `yaml:"mysql"` -} - -type rawSQLiteConfig struct { - Path *string `yaml:"path"` -} - -type rawMySQLConfig struct { - DSN *string `yaml:"dsn"` -} - -type rawWebConfig struct { - Enabled *bool `yaml:"enabled"` - PortEnabled *bool `yaml:"port_enabled"` - SocketEnabled *bool `yaml:"socket_enabled"` - Host *string `yaml:"host"` - Port *int `yaml:"port"` - SocketPath *string `yaml:"socket_path"` - StaticDir *string `yaml:"static_dir"` - MapTileCacheDir *string `yaml:"map_tile_cache_dir"` - Admin *rawWebAdminConfig `yaml:"admin"` -} - -type rawWebAdminConfig struct { - Username *string `yaml:"username"` - Password *string `yaml:"password"` - SessionSecret *string `yaml:"session_secret"` - SessionSecure *bool `yaml:"session_secure"` -} - -// defaultConfig 返回内置默认配置。 -func defaultConfig() *config { - return &config{ - MQTT: mqttConfig{ - Host: "0.0.0.0", - Port: 1883, - TLS: tlsConfig{ - Enabled: false, - CertFile: "", - KeyFile: "", - }, - }, - Meshtastic: meshtasticConfig{ - PSK: "AQ==", - }, - Database: databaseConfig{ - Driver: "sqlite", - SQLite: sqliteConfig{Path: defaultSQLitePath()}, - MySQL: mysqlConfig{DSN: ""}, - }, - Web: webConfig{ - Enabled: true, - PortEnabled: true, - SocketEnabled: defaultWebSocketPath() != "", - Host: "0.0.0.0", - Port: 8080, - SocketPath: defaultWebSocketPath(), - StaticDir: "./dist", - MapTileCacheDir: defaultMapTileCacheDir(), - Admin: webAdminConfig{ - Username: "admin", - Password: "admin", - SessionSecret: "", - SessionSecure: false, - }, - }, - AI: aiConfig{ - Enabled: false, - }, - DataDir: defaultDataDir(), - } -} - -// defaultConfigDir 根据操作系统返回配置目录。 -func defaultConfigDir() string { - return defaultConfigDirForGOOS(runtime.GOOS) -} - -func defaultConfigDirForGOOS(goos string) string { - if useRelativeDefaultPath(goos) { - return filepath.Join(".", "win", "etc", "mesh_mqtt_go") - } - return filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go") -} - -func useRelativeDefaultPath(goos string) bool { - return goos == "windows" || goos == "darwin" -} - -// defaultConfigPath 返回默认配置文件路径。 -func defaultConfigPath() string { - return filepath.Join(defaultConfigDir(), configFileName) -} - -func defaultSQLitePath() string { - return defaultSQLitePathForGOOS(runtime.GOOS) -} - -func defaultWebSocketPath() string { - return defaultWebSocketPathForGOOS(runtime.GOOS) -} - -func defaultMapTileCacheDir() string { - return defaultMapTileCacheDirForGOOS(runtime.GOOS) -} - -func defaultMapTileCacheDirForGOOS(goos string) string { - if useRelativeDefaultPath(goos) { - return filepath.Join(".", "win", "srv", "mesh_mqtt_go") - } - return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go") -} - -func defaultWebSocketPathForGOOS(goos string) string { - if goos == "windows" { - return "" - } - if useRelativeDefaultPath(goos) { - return filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock") - } - return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock") -} +// 旧的小写类型名通过别名继续可用。 +type ( + config = cfgpkg.Config + mqttConfig = cfgpkg.MQTTConfig + tlsConfig = cfgpkg.TLSConfig + meshtasticConfig = cfgpkg.MeshtasticConfig + databaseConfig = cfgpkg.DatabaseConfig + sqliteConfig = cfgpkg.SQLiteConfig + mysqlConfig = cfgpkg.MySQLConfig + webConfig = cfgpkg.WebConfig + webAdminConfig = cfgpkg.WebAdminConfig + aiConfig = cfgpkg.AIConfig +) +func defaultConfig() *config { return cfgpkg.Default() } +func defaultConfigDir() string { return cfgpkg.DefaultDir() } +func defaultConfigPath() string { return cfgpkg.DefaultPath() } +func loadConfig(path string) (*config, error) { return cfgpkg.Load(path) } +func writeConfig(path string, cfg *config) error { return cfgpkg.Write(path, cfg) } +func validateConfig(cfg *config) error { return cfgpkg.Validate(cfg) } func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool { - if goos != "windows" { - return false - } - changed := false - if cfg.Web.SocketPath != "" { - cfg.Web.SocketPath = "" - changed = true - } - if cfg.Web.SocketEnabled { - cfg.Web.SocketEnabled = false - changed = true - } - return changed + return cfgpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, goos) } -func defaultSQLitePathForGOOS(goos string) string { - if useRelativeDefaultPath(goos) { - return filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db") - } - return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db") -} - -func defaultDataDir() string { - return defaultDataDirForGOOS(runtime.GOOS) -} - -func defaultDataDirForGOOS(goos string) string { - if useRelativeDefaultPath(goos) { - return filepath.Join(".", "win", "var", "lib", "mesh_mqtt_go") - } - return filepath.Join(string(filepath.Separator), "var", "lib", "mesh_mqtt_go") -} - -// loadConfig 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。 -func loadConfig(path string) (*config, error) { - if path == "" { - path = defaultConfigPath() - } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return nil, fmt.Errorf("create config directory %s: %w", filepath.Dir(path), err) - } - - if _, err := os.Stat(path); err != nil { - if !os.IsNotExist(err) { - return nil, fmt.Errorf("stat config file %s: %w", path, err) - } - cfg := defaultConfig() - if err := writeConfig(path, cfg); err != nil { - return nil, err - } - return cfg, nil - } - - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read config file %s: %w", path, err) - } - - var raw rawConfig - if err := yaml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parse config file %s: %w", path, err) - } - - cfg, changed := normalizeConfig(raw) - if clearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) { - changed = true - } - if err := validateConfig(cfg); err != nil { - return nil, err - } - if changed { - if err := writeConfig(path, cfg); err != nil { - return nil, err - } - } - return cfg, nil -} - -// normalizeConfig 将原始配置合并到默认配置,并标记是否补齐了缺失项。 -func normalizeConfig(raw rawConfig) (*config, bool) { - cfg := defaultConfig() - changed := false - - if raw.MQTT == nil { - changed = true - } else { - if raw.MQTT.Host == nil { - changed = true - } else { - cfg.MQTT.Host = *raw.MQTT.Host - } - if raw.MQTT.Port == nil { - changed = true - } else { - cfg.MQTT.Port = *raw.MQTT.Port - } - if raw.MQTT.TLS == nil { - changed = true - } else { - if raw.MQTT.TLS.Enabled == nil { - changed = true - } else { - cfg.MQTT.TLS.Enabled = *raw.MQTT.TLS.Enabled - } - if raw.MQTT.TLS.CertFile == nil { - changed = true - } else { - cfg.MQTT.TLS.CertFile = *raw.MQTT.TLS.CertFile - } - if raw.MQTT.TLS.KeyFile == nil { - changed = true - } else { - cfg.MQTT.TLS.KeyFile = *raw.MQTT.TLS.KeyFile - } - } - } - - if raw.Meshtastic == nil { - changed = true - } else if raw.Meshtastic.PSK == nil { - changed = true - } else { - cfg.Meshtastic.PSK = *raw.Meshtastic.PSK - } - - if raw.Database == nil { - changed = true - } else { - if raw.Database.Driver == nil { - changed = true - } else { - cfg.Database.Driver = *raw.Database.Driver - } - if raw.Database.SQLite == nil { - changed = true - } else if raw.Database.SQLite.Path == nil { - changed = true - } else { - cfg.Database.SQLite.Path = *raw.Database.SQLite.Path - } - if raw.Database.MySQL == nil { - changed = true - } else if raw.Database.MySQL.DSN == nil { - changed = true - } else { - cfg.Database.MySQL.DSN = *raw.Database.MySQL.DSN - } - } - - if raw.Web == nil { - changed = true - } else { - if raw.Web.Enabled == nil { - changed = true - } else { - cfg.Web.Enabled = *raw.Web.Enabled - } - if raw.Web.PortEnabled == nil { - changed = true - } else { - cfg.Web.PortEnabled = *raw.Web.PortEnabled - } - if raw.Web.SocketEnabled == nil { - changed = true - } else { - cfg.Web.SocketEnabled = *raw.Web.SocketEnabled - } - if raw.Web.Host == nil { - changed = true - } else { - cfg.Web.Host = *raw.Web.Host - } - if raw.Web.Port == nil { - changed = true - } else { - cfg.Web.Port = *raw.Web.Port - } - if raw.Web.SocketPath == nil { - changed = true - } else { - cfg.Web.SocketPath = *raw.Web.SocketPath - } - if raw.Web.StaticDir == nil { - changed = true - } else { - cfg.Web.StaticDir = *raw.Web.StaticDir - } - if raw.Web.MapTileCacheDir == nil { - changed = true - } else { - cfg.Web.MapTileCacheDir = *raw.Web.MapTileCacheDir - } - if raw.Web.Admin == nil { - changed = true - } else { - if raw.Web.Admin.Username == nil { - changed = true - } else { - cfg.Web.Admin.Username = *raw.Web.Admin.Username - } - if raw.Web.Admin.Password == nil { - changed = true - } else { - cfg.Web.Admin.Password = *raw.Web.Admin.Password - } - if raw.Web.Admin.SessionSecret == nil { - changed = true - } else { - cfg.Web.Admin.SessionSecret = *raw.Web.Admin.SessionSecret - } - if raw.Web.Admin.SessionSecure == nil { - changed = true - } else { - cfg.Web.Admin.SessionSecure = *raw.Web.Admin.SessionSecure - } - } - } - - if raw.AI == nil { - changed = true - } else { - if raw.AI.Enabled == nil { - changed = true - } else { - cfg.AI.Enabled = *raw.AI.Enabled - } - } - - if raw.DataDir == nil { - changed = true - } else { - cfg.DataDir = *raw.DataDir - } - - return cfg, changed -} - -func validateConfig(cfg *config) error { - if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 { - return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port) - } - switch cfg.Database.Driver { - case "sqlite": - if cfg.Database.SQLite.Path == "" { - return fmt.Errorf("database.sqlite.path is required when database.driver is sqlite") - } - case "mysql": - if cfg.Database.MySQL.DSN == "" { - return fmt.Errorf("database.mysql.dsn is required when database.driver is mysql") - } - default: - return fmt.Errorf("invalid database.driver %q: must be sqlite or mysql", cfg.Database.Driver) - } - if cfg.Web.Enabled { - if !cfg.Web.PortEnabled && !cfg.Web.SocketEnabled { - return fmt.Errorf("web.port_enabled and web.socket_enabled cannot both be false when web is enabled") - } - if cfg.Web.PortEnabled && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) { - return fmt.Errorf("invalid web port %d: must be 1-65535", cfg.Web.Port) - } - if cfg.Web.SocketEnabled && cfg.Web.SocketPath == "" { - return fmt.Errorf("web.socket_path is required when web.socket_enabled is true") - } - if cfg.Web.StaticDir == "" { - return fmt.Errorf("web.static_dir is required when web is enabled") - } - if cfg.Web.MapTileCacheDir == "" { - return fmt.Errorf("web.map_tile_cache_dir is required when web is enabled") - } - if cfg.Web.Admin.Username == "" { - return fmt.Errorf("web.admin.username is required when web is enabled") - } - if cfg.Web.Admin.Password == "" { - return fmt.Errorf("web.admin.password is required when web is enabled") - } - } - return nil -} - -func writeConfig(path string, cfg *config) error { - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("encode config file %s: %w", path, err) - } - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("write config file %s: %w", path, err) - } - return nil -} - -// buildTLSConfig 根据配置构造 mochi listener 使用的 TLS 设置。 func buildTLSConfig(cfg tlsConfig) (*cryptotls.Config, error) { - if !cfg.Enabled { - return nil, nil - } - if cfg.CertFile == "" { - return nil, fmt.Errorf("mqtt tls cert_file is required when tls is enabled") - } - if cfg.KeyFile == "" { - return nil, fmt.Errorf("mqtt tls key_file is required when tls is enabled") - } - - cert, err := cryptotls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) - if err != nil { - return nil, fmt.Errorf("load mqtt tls certificate: %w", err) - } - return &cryptotls.Config{ - MinVersion: cryptotls.VersionTLS12, - Certificates: []cryptotls.Certificate{cert}, - }, nil + return cfgpkg.BuildTLS(cfg) } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e13df42 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,549 @@ +package config + +import ( + cryptotls "crypto/tls" + "fmt" + "os" + "path/filepath" + "runtime" + + "gopkg.in/yaml.v3" +) + +const FileName = "config.yaml" + +const ( + DriverSQLite = "sqlite" + DriverMySQL = "mysql" +) + +type Config struct { + MQTT MQTTConfig `yaml:"mqtt"` + Meshtastic MeshtasticConfig `yaml:"meshtastic"` + Database DatabaseConfig `yaml:"database"` + Web WebConfig `yaml:"web"` + AI AIConfig `yaml:"ai"` + DataDir string `yaml:"data_dir"` + Key []byte `yaml:"-"` +} + +type MQTTConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + TLS TLSConfig `yaml:"tls"` +} + +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +type MeshtasticConfig struct { + PSK string `yaml:"psk"` +} + +type DatabaseConfig struct { + Driver string `yaml:"driver"` + SQLite SQLiteConfig `yaml:"sqlite"` + MySQL MySQLConfig `yaml:"mysql"` +} + +type SQLiteConfig struct { + Path string `yaml:"path"` +} + +type MySQLConfig struct { + DSN string `yaml:"dsn"` +} + +type WebConfig struct { + Enabled bool `yaml:"enabled"` + PortEnabled bool `yaml:"port_enabled"` + SocketEnabled bool `yaml:"socket_enabled"` + Host string `yaml:"host"` + Port int `yaml:"port"` + SocketPath string `yaml:"socket_path"` + StaticDir string `yaml:"static_dir"` + MapTileCacheDir string `yaml:"map_tile_cache_dir"` + Admin WebAdminConfig `yaml:"admin"` +} + +type WebAdminConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + SessionSecret string `yaml:"session_secret"` + SessionSecure bool `yaml:"session_secure"` +} + +type AIConfig struct { + Enabled bool `yaml:"enabled"` +} + +type rawConfig struct { + MQTT *rawMQTTConfig `yaml:"mqtt"` + Meshtastic *rawMeshtasticConfig `yaml:"meshtastic"` + Database *rawDatabaseConfig `yaml:"database"` + Web *rawWebConfig `yaml:"web"` + AI *rawAIConfig `yaml:"ai"` + DataDir *string `yaml:"data_dir"` +} + +type rawAIConfig struct { + Enabled *bool `yaml:"enabled"` +} + +type rawMQTTConfig struct { + Host *string `yaml:"host"` + Port *int `yaml:"port"` + TLS *rawTLSConfig `yaml:"tls"` +} + +type rawTLSConfig struct { + Enabled *bool `yaml:"enabled"` + CertFile *string `yaml:"cert_file"` + KeyFile *string `yaml:"key_file"` +} + +type rawMeshtasticConfig struct { + PSK *string `yaml:"psk"` +} + +type rawDatabaseConfig struct { + Driver *string `yaml:"driver"` + SQLite *rawSQLiteConfig `yaml:"sqlite"` + MySQL *rawMySQLConfig `yaml:"mysql"` +} + +type rawSQLiteConfig struct { + Path *string `yaml:"path"` +} + +type rawMySQLConfig struct { + DSN *string `yaml:"dsn"` +} + +type rawWebConfig struct { + Enabled *bool `yaml:"enabled"` + PortEnabled *bool `yaml:"port_enabled"` + SocketEnabled *bool `yaml:"socket_enabled"` + Host *string `yaml:"host"` + Port *int `yaml:"port"` + SocketPath *string `yaml:"socket_path"` + StaticDir *string `yaml:"static_dir"` + MapTileCacheDir *string `yaml:"map_tile_cache_dir"` + Admin *rawWebAdminConfig `yaml:"admin"` +} + +type rawWebAdminConfig struct { + Username *string `yaml:"username"` + Password *string `yaml:"password"` + SessionSecret *string `yaml:"session_secret"` + SessionSecure *bool `yaml:"session_secure"` +} + +// Default 返回内置默认配置。 +func Default() *Config { + return &Config{ + MQTT: MQTTConfig{ + Host: "0.0.0.0", + Port: 1883, + TLS: TLSConfig{ + Enabled: false, + CertFile: "", + KeyFile: "", + }, + }, + Meshtastic: MeshtasticConfig{ + PSK: "AQ==", + }, + Database: DatabaseConfig{ + Driver: DriverSQLite, + SQLite: SQLiteConfig{Path: defaultSQLitePath()}, + MySQL: MySQLConfig{DSN: ""}, + }, + Web: WebConfig{ + Enabled: true, + PortEnabled: true, + SocketEnabled: defaultWebSocketPath() != "", + Host: "0.0.0.0", + Port: 8080, + SocketPath: defaultWebSocketPath(), + StaticDir: "./dist", + MapTileCacheDir: defaultMapTileCacheDir(), + Admin: WebAdminConfig{ + Username: "admin", + Password: "admin", + SessionSecret: "", + SessionSecure: false, + }, + }, + AI: AIConfig{ + Enabled: false, + }, + DataDir: defaultDataDir(), + } +} + +// DefaultDir 根据操作系统返回配置目录。 +func DefaultDir() string { + return defaultConfigDirForGOOS(runtime.GOOS) +} + +func defaultConfigDirForGOOS(goos string) string { + if useRelativeDefaultPath(goos) { + return filepath.Join(".", "win", "etc", "mesh_mqtt_go") + } + return filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go") +} + +func useRelativeDefaultPath(goos string) bool { + return goos == "windows" || goos == "darwin" +} + +// DefaultPath 返回默认配置文件路径。 +func DefaultPath() string { + return filepath.Join(DefaultDir(), FileName) +} + +func defaultSQLitePath() string { + return defaultSQLitePathForGOOS(runtime.GOOS) +} + +func defaultWebSocketPath() string { + return defaultWebSocketPathForGOOS(runtime.GOOS) +} + +func defaultMapTileCacheDir() string { + return defaultMapTileCacheDirForGOOS(runtime.GOOS) +} + +func defaultMapTileCacheDirForGOOS(goos string) string { + if useRelativeDefaultPath(goos) { + return filepath.Join(".", "win", "srv", "mesh_mqtt_go") + } + return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go") +} + +func defaultWebSocketPathForGOOS(goos string) string { + if goos == "windows" { + return "" + } + if useRelativeDefaultPath(goos) { + return filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock") + } + return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock") +} + +func ClearWebSocketPathOnUnsupportedGOOS(cfg *Config, goos string) bool { + if goos != "windows" { + return false + } + changed := false + if cfg.Web.SocketPath != "" { + cfg.Web.SocketPath = "" + changed = true + } + if cfg.Web.SocketEnabled { + cfg.Web.SocketEnabled = false + changed = true + } + return changed +} + +func defaultSQLitePathForGOOS(goos string) string { + if useRelativeDefaultPath(goos) { + return filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db") + } + return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db") +} + +func defaultDataDir() string { + return defaultDataDirForGOOS(runtime.GOOS) +} + +func defaultDataDirForGOOS(goos string) string { + if useRelativeDefaultPath(goos) { + return filepath.Join(".", "win", "var", "lib", "mesh_mqtt_go") + } + return filepath.Join(string(filepath.Separator), "var", "lib", "mesh_mqtt_go") +} + +// Load 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。 +func Load(path string) (*Config, error) { + if path == "" { + path = DefaultPath() + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, fmt.Errorf("create config directory %s: %w", filepath.Dir(path), err) + } + + if _, err := os.Stat(path); err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat config file %s: %w", path, err) + } + cfg := Default() + if err := Write(path, cfg); err != nil { + return nil, err + } + return cfg, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file %s: %w", path, err) + } + + var raw rawConfig + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse config file %s: %w", path, err) + } + + cfg, changed := normalize(raw) + if ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) { + changed = true + } + if err := Validate(cfg); err != nil { + return nil, err + } + if changed { + if err := Write(path, cfg); err != nil { + return nil, err + } + } + return cfg, nil +} + +// normalize 将原始配置合并到默认配置,并标记是否补齐了缺失项。 +func normalize(raw rawConfig) (*Config, bool) { + cfg := Default() + changed := false + + if raw.MQTT == nil { + changed = true + } else { + if raw.MQTT.Host == nil { + changed = true + } else { + cfg.MQTT.Host = *raw.MQTT.Host + } + if raw.MQTT.Port == nil { + changed = true + } else { + cfg.MQTT.Port = *raw.MQTT.Port + } + if raw.MQTT.TLS == nil { + changed = true + } else { + if raw.MQTT.TLS.Enabled == nil { + changed = true + } else { + cfg.MQTT.TLS.Enabled = *raw.MQTT.TLS.Enabled + } + if raw.MQTT.TLS.CertFile == nil { + changed = true + } else { + cfg.MQTT.TLS.CertFile = *raw.MQTT.TLS.CertFile + } + if raw.MQTT.TLS.KeyFile == nil { + changed = true + } else { + cfg.MQTT.TLS.KeyFile = *raw.MQTT.TLS.KeyFile + } + } + } + + if raw.Meshtastic == nil { + changed = true + } else if raw.Meshtastic.PSK == nil { + changed = true + } else { + cfg.Meshtastic.PSK = *raw.Meshtastic.PSK + } + + if raw.Database == nil { + changed = true + } else { + if raw.Database.Driver == nil { + changed = true + } else { + cfg.Database.Driver = *raw.Database.Driver + } + if raw.Database.SQLite == nil { + changed = true + } else if raw.Database.SQLite.Path == nil { + changed = true + } else { + cfg.Database.SQLite.Path = *raw.Database.SQLite.Path + } + if raw.Database.MySQL == nil { + changed = true + } else if raw.Database.MySQL.DSN == nil { + changed = true + } else { + cfg.Database.MySQL.DSN = *raw.Database.MySQL.DSN + } + } + + if raw.Web == nil { + changed = true + } else { + if raw.Web.Enabled == nil { + changed = true + } else { + cfg.Web.Enabled = *raw.Web.Enabled + } + if raw.Web.PortEnabled == nil { + changed = true + } else { + cfg.Web.PortEnabled = *raw.Web.PortEnabled + } + if raw.Web.SocketEnabled == nil { + changed = true + } else { + cfg.Web.SocketEnabled = *raw.Web.SocketEnabled + } + if raw.Web.Host == nil { + changed = true + } else { + cfg.Web.Host = *raw.Web.Host + } + if raw.Web.Port == nil { + changed = true + } else { + cfg.Web.Port = *raw.Web.Port + } + if raw.Web.SocketPath == nil { + changed = true + } else { + cfg.Web.SocketPath = *raw.Web.SocketPath + } + if raw.Web.StaticDir == nil { + changed = true + } else { + cfg.Web.StaticDir = *raw.Web.StaticDir + } + if raw.Web.MapTileCacheDir == nil { + changed = true + } else { + cfg.Web.MapTileCacheDir = *raw.Web.MapTileCacheDir + } + if raw.Web.Admin == nil { + changed = true + } else { + if raw.Web.Admin.Username == nil { + changed = true + } else { + cfg.Web.Admin.Username = *raw.Web.Admin.Username + } + if raw.Web.Admin.Password == nil { + changed = true + } else { + cfg.Web.Admin.Password = *raw.Web.Admin.Password + } + if raw.Web.Admin.SessionSecret == nil { + changed = true + } else { + cfg.Web.Admin.SessionSecret = *raw.Web.Admin.SessionSecret + } + if raw.Web.Admin.SessionSecure == nil { + changed = true + } else { + cfg.Web.Admin.SessionSecure = *raw.Web.Admin.SessionSecure + } + } + } + + if raw.AI == nil { + changed = true + } else { + if raw.AI.Enabled == nil { + changed = true + } else { + cfg.AI.Enabled = *raw.AI.Enabled + } + } + + if raw.DataDir == nil { + changed = true + } else { + cfg.DataDir = *raw.DataDir + } + + return cfg, changed +} + +func Validate(cfg *Config) error { + if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 { + return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port) + } + switch cfg.Database.Driver { + case DriverSQLite: + if cfg.Database.SQLite.Path == "" { + return fmt.Errorf("database.sqlite.path is required when database.driver is sqlite") + } + case DriverMySQL: + if cfg.Database.MySQL.DSN == "" { + return fmt.Errorf("database.mysql.dsn is required when database.driver is mysql") + } + default: + return fmt.Errorf("invalid database.driver %q: must be sqlite or mysql", cfg.Database.Driver) + } + if cfg.Web.Enabled { + if !cfg.Web.PortEnabled && !cfg.Web.SocketEnabled { + return fmt.Errorf("web.port_enabled and web.socket_enabled cannot both be false when web is enabled") + } + if cfg.Web.PortEnabled && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) { + return fmt.Errorf("invalid web port %d: must be 1-65535", cfg.Web.Port) + } + if cfg.Web.SocketEnabled && cfg.Web.SocketPath == "" { + return fmt.Errorf("web.socket_path is required when web.socket_enabled is true") + } + if cfg.Web.StaticDir == "" { + return fmt.Errorf("web.static_dir is required when web is enabled") + } + if cfg.Web.MapTileCacheDir == "" { + return fmt.Errorf("web.map_tile_cache_dir is required when web is enabled") + } + if cfg.Web.Admin.Username == "" { + return fmt.Errorf("web.admin.username is required when web is enabled") + } + if cfg.Web.Admin.Password == "" { + return fmt.Errorf("web.admin.password is required when web is enabled") + } + } + return nil +} + +func Write(path string, cfg *Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("encode config file %s: %w", path, err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("write config file %s: %w", path, err) + } + return nil +} + +// BuildTLS 根据配置构造 mochi listener 使用的 TLS 设置。 +func BuildTLS(cfg TLSConfig) (*cryptotls.Config, error) { + if !cfg.Enabled { + return nil, nil + } + if cfg.CertFile == "" { + return nil, fmt.Errorf("mqtt tls cert_file is required when tls is enabled") + } + if cfg.KeyFile == "" { + return nil, fmt.Errorf("mqtt tls key_file is required when tls is enabled") + } + + cert, err := cryptotls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("load mqtt tls certificate: %w", err) + } + return &cryptotls.Config{ + MinVersion: cryptotls.VersionTLS12, + Certificates: []cryptotls.Certificate{cert}, + }, nil +} diff --git a/config_test.go b/internal/config/config_test.go similarity index 79% rename from config_test.go rename to internal/config/config_test.go index 17da855..d9b49a5 100644 --- a/config_test.go +++ b/internal/config/config_test.go @@ -1,4 +1,4 @@ -package main +package config import ( "os" @@ -8,11 +8,11 @@ import ( ) func TestLoadConfigCreatesDefaultFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName) + path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) - cfg, err := loadConfig(path) + cfg, err := Load(path) if err != nil { - t.Fatalf("loadConfig() error = %v", err) + t.Fatalf("Load() error = %v", err) } if cfg.MQTT.Host != "0.0.0.0" { t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host) @@ -60,7 +60,7 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) { } func TestLoadConfigFillsMissingFields(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName) + path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -68,9 +68,9 @@ func TestLoadConfigFillsMissingFields(t *testing.T) { t.Fatal(err) } - cfg, err := loadConfig(path) + cfg, err := Load(path) if err != nil { - t.Fatalf("loadConfig() error = %v", err) + t.Fatalf("Load() error = %v", err) } if cfg.MQTT.Port != 1884 { t.Fatalf("port = %d, want 1884", cfg.MQTT.Port) @@ -98,7 +98,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) { } func TestLoadConfigPreservesExplicitFalse(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName) + path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -107,9 +107,9 @@ func TestLoadConfigPreservesExplicitFalse(t *testing.T) { t.Fatal(err) } - cfg, err := loadConfig(path) + cfg, err := Load(path) if err != nil { - t.Fatalf("loadConfig() error = %v", err) + t.Fatalf("Load() error = %v", err) } if cfg.MQTT.TLS.Enabled { t.Fatalf("tls enabled = true, want explicit false") @@ -120,7 +120,7 @@ func TestLoadConfigPreservesExplicitFalse(t *testing.T) { } func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName) + path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -129,9 +129,9 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) { t.Fatal(err) } - cfg, err := loadConfig(path) + cfg, err := Load(path) if err != nil { - t.Fatalf("loadConfig() error = %v", err) + t.Fatalf("Load() error = %v", err) } if cfg.Web.Enabled { t.Fatalf("web enabled = true, want explicit false") @@ -145,7 +145,7 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) { } func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) { - path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName) + path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } @@ -154,9 +154,9 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) { t.Fatal(err) } - _, err := loadConfig(path) + _, err := Load(path) if err == nil { - t.Fatalf("loadConfig() error = nil, want parse error") + t.Fatalf("Load() error = nil, want parse error") } data, readErr := os.ReadFile(path) if readErr != nil { @@ -218,10 +218,10 @@ func TestDefaultWebSocketPathForGOOS(t *testing.T) { } func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) { - cfg := defaultConfig() + cfg := Default() cfg.Web.SocketPath = filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock") - if !clearWebSocketPathOnUnsupportedGOOS(cfg, "windows") { - t.Fatalf("clearWebSocketPathOnUnsupportedGOOS() = false, want true") + if !ClearWebSocketPathOnUnsupportedGOOS(cfg, "windows") { + t.Fatalf("ClearWebSocketPathOnUnsupportedGOOS() = false, want true") } if cfg.Web.SocketPath != "" { t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath) @@ -231,8 +231,8 @@ func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) { } cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock" - if clearWebSocketPathOnUnsupportedGOOS(cfg, "linux") { - t.Fatalf("linux clearWebSocketPathOnUnsupportedGOOS() = true, want false") + if ClearWebSocketPathOnUnsupportedGOOS(cfg, "linux") { + t.Fatalf("linux ClearWebSocketPathOnUnsupportedGOOS() = true, want false") } if cfg.Web.SocketPath == "" { t.Fatalf("linux web socket path was cleared") @@ -256,102 +256,102 @@ func TestDefaultSQLitePathForGOOS(t *testing.T) { } func TestValidateConfigDatabase(t *testing.T) { - cfg := defaultConfig() + cfg := Default() cfg.Database.Driver = "postgres" - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.driver") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.driver") { t.Fatalf("invalid driver error = %v, want database.driver error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Database.SQLite.Path = "" - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.sqlite.path") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.sqlite.path") { t.Fatalf("missing sqlite path error = %v, want database.sqlite.path error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Database.Driver = "mysql" cfg.Database.MySQL.DSN = "" - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.mysql.dsn") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.mysql.dsn") { t.Fatalf("missing mysql dsn error = %v, want database.mysql.dsn error", err) } } func TestValidateConfigWeb(t *testing.T) { - cfg := defaultConfig() + cfg := Default() cfg.Web.Port = 0 - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web port") { t.Fatalf("invalid web port error = %v, want web port error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.PortEnabled = false cfg.Web.Port = 0 - if err := validateConfig(cfg); err != nil { + if err := Validate(cfg); err != nil { t.Fatalf("disabled web port with invalid port error = %v, want nil", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.SocketEnabled = false cfg.Web.SocketPath = "" - if err := validateConfig(cfg); err != nil { + if err := Validate(cfg); err != nil { t.Fatalf("disabled web socket with empty path error = %v, want nil", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.PortEnabled = false cfg.Web.SocketEnabled = true cfg.Web.SocketPath = "" - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") { t.Fatalf("missing web socket path error = %v, want web.socket_path error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.PortEnabled = false cfg.Web.SocketEnabled = false - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") { t.Fatalf("disabled web listeners error = %v, want web.port_enabled error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.StaticDir = "" - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.static_dir") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.static_dir") { t.Fatalf("missing web static dir error = %v, want web.static_dir error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.MapTileCacheDir = "" - if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") { + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") { t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err) } - cfg = defaultConfig() + cfg = Default() cfg.Web.Enabled = false cfg.Web.PortEnabled = false cfg.Web.SocketEnabled = false cfg.Web.Port = 0 cfg.Web.StaticDir = "" - if err := validateConfig(cfg); err != nil { + if err := Validate(cfg); err != nil { t.Fatalf("disabled web validate error = %v, want nil", err) } } func TestBuildTLSConfigDisabled(t *testing.T) { - cfg, err := buildTLSConfig(tlsConfig{}) + cfg, err := BuildTLS(TLSConfig{}) if err != nil { - t.Fatalf("buildTLSConfig() error = %v", err) + t.Fatalf("BuildTLS() error = %v", err) } if cfg != nil { - t.Fatalf("buildTLSConfig() = %#v, want nil", cfg) + t.Fatalf("BuildTLS() = %#v, want nil", cfg) } } func TestBuildTLSConfigRequiresCertAndKey(t *testing.T) { - _, err := buildTLSConfig(tlsConfig{Enabled: true}) + _, err := BuildTLS(TLSConfig{Enabled: true}) if err == nil || !strings.Contains(err.Error(), "cert_file") { t.Fatalf("missing cert error = %v, want cert_file error", err) } - _, err = buildTLSConfig(tlsConfig{Enabled: true, CertFile: "cert.pem"}) + _, err = BuildTLS(TLSConfig{Enabled: true, CertFile: "cert.pem"}) if err == nil || !strings.Contains(err.Error(), "key_file") { t.Fatalf("missing key error = %v, want key_file error", err) } diff --git a/blocking_store.go b/internal/store/blocking_store.go similarity index 63% rename from blocking_store.go rename to internal/store/blocking_store.go index ae3df15..827df22 100644 --- a/blocking_store.go +++ b/internal/store/blocking_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "errors" @@ -10,14 +10,14 @@ import ( "gorm.io/gorm" ) -const forbiddenWordMatchContains = "contains" +const ForbiddenWordMatchContains = "contains" -var errBlockingAlreadyExists = errors.New("blocking rule already exists") +var ErrBlockingAlreadyExists = errors.New("blocking rule already exists") -func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error) { - opts = normalizeListOptions(opts) - var rows []nodeBlockingRecord - q := s.db.Model(&nodeBlockingRecord{}). +func (s *Store) ListNodeBlocking(opts ListOptions) ([]NodeBlockingRecord, error) { + opts = NormalizeListOptions(opts) + var rows []NodeBlockingRecord + q := s.db.Model(&NodeBlockingRecord{}). Order("updated_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -25,17 +25,17 @@ func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error) return rows, q.Find(&rows).Error } -func (s *store) CountNodeBlocking(opts listOptions) (int64, error) { +func (s *Store) CountNodeBlocking(opts ListOptions) (int64, error) { var total int64 - return total, s.db.Model(&nodeBlockingRecord{}).Count(&total).Error + return total, s.db.Model(&NodeBlockingRecord{}).Count(&total).Error } -func (s *store) ListEnabledNodeBlocking() ([]nodeBlockingRecord, error) { - var rows []nodeBlockingRecord +func (s *Store) ListEnabledNodeBlocking() ([]NodeBlockingRecord, error) { + var rows []NodeBlockingRecord return rows, s.db.Where("enabled = ?", true).Find(&rows).Error } -func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) { +func (s *Store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*NodeBlockingRecord, error) { nodeID = strings.TrimSpace(nodeID) if nodeID == "" { return nil, fmt.Errorf("node id is required") @@ -43,14 +43,14 @@ func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, if err := s.ensureNodeBlockingUnique(0, nodeID); err != nil { return nil, err } - row := nodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled} + row := NodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled} if err := s.db.Create(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) { +func (s *Store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*NodeBlockingRecord, error) { if id == 0 { return nil, fmt.Errorf("blocking rule id is required") } @@ -65,14 +65,14 @@ func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, rea return nil, err } updates := map[string]any{"node_id": nodeID, "node_num": nodeNum, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()} - if err := s.db.Model(&nodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&NodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.getNodeBlockingByID(id) } -func (s *store) DeleteNodeBlocking(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&nodeBlockingRecord{}) +func (s *Store) DeleteNodeBlocking(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&NodeBlockingRecord{}) if result.Error != nil { return result.Error } @@ -82,10 +82,10 @@ func (s *store) DeleteNodeBlocking(id uint64) error { return nil } -func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) { - opts = normalizeListOptions(opts) - var rows []ipBlockingRecord - q := s.db.Model(&ipBlockingRecord{}). +func (s *Store) ListIPBlocking(opts ListOptions) ([]IPBlockingRecord, error) { + opts = NormalizeListOptions(opts) + var rows []IPBlockingRecord + q := s.db.Model(&IPBlockingRecord{}). Order("updated_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -93,17 +93,17 @@ func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) { return rows, q.Find(&rows).Error } -func (s *store) CountIPBlocking(opts listOptions) (int64, error) { +func (s *Store) CountIPBlocking(opts ListOptions) (int64, error) { var total int64 - return total, s.db.Model(&ipBlockingRecord{}).Count(&total).Error + return total, s.db.Model(&IPBlockingRecord{}).Count(&total).Error } -func (s *store) ListEnabledIPBlocking() ([]ipBlockingRecord, error) { - var rows []ipBlockingRecord +func (s *Store) ListEnabledIPBlocking() ([]IPBlockingRecord, error) { + var rows []IPBlockingRecord return rows, s.db.Where("enabled = ?", true).Find(&rows).Error } -func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) { +func (s *Store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*IPBlockingRecord, error) { value, err := normalizeIPBlockingValue(ipValue) if err != nil { return nil, err @@ -111,14 +111,14 @@ func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (* if err := s.ensureIPBlockingUnique(0, value); err != nil { return nil, err } - row := ipBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled} + row := IPBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled} if err := s.db.Create(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) { +func (s *Store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*IPBlockingRecord, error) { if id == 0 { return nil, fmt.Errorf("blocking rule id is required") } @@ -133,14 +133,14 @@ func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabl return nil, err } updates := map[string]any{"ip_value": value, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()} - if err := s.db.Model(&ipBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&IPBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.getIPBlockingByID(id) } -func (s *store) DeleteIPBlocking(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&ipBlockingRecord{}) +func (s *Store) DeleteIPBlocking(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&IPBlockingRecord{}) if result.Error != nil { return result.Error } @@ -150,10 +150,10 @@ func (s *store) DeleteIPBlocking(id uint64) error { return nil } -func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBlockingRecord, error) { - opts = normalizeListOptions(opts) - var rows []forbiddenWordBlockingRecord - q := s.db.Model(&forbiddenWordBlockingRecord{}). +func (s *Store) ListForbiddenWordBlocking(opts ListOptions) ([]ForbiddenWordBlockingRecord, error) { + opts = NormalizeListOptions(opts) + var rows []ForbiddenWordBlockingRecord + q := s.db.Model(&ForbiddenWordBlockingRecord{}). Order("updated_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -161,17 +161,17 @@ func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBloc return rows, q.Find(&rows).Error } -func (s *store) CountForbiddenWordBlocking(opts listOptions) (int64, error) { +func (s *Store) CountForbiddenWordBlocking(opts ListOptions) (int64, error) { var total int64 - return total, s.db.Model(&forbiddenWordBlockingRecord{}).Count(&total).Error + return total, s.db.Model(&ForbiddenWordBlockingRecord{}).Count(&total).Error } -func (s *store) ListEnabledForbiddenWordBlocking() ([]forbiddenWordBlockingRecord, error) { - var rows []forbiddenWordBlockingRecord +func (s *Store) ListEnabledForbiddenWordBlocking() ([]ForbiddenWordBlockingRecord, error) { + var rows []ForbiddenWordBlockingRecord return rows, s.db.Where("enabled = ?", true).Find(&rows).Error } -func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) { +func (s *Store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*ForbiddenWordBlockingRecord, error) { word = strings.TrimSpace(word) if word == "" { return nil, fmt.Errorf("forbidden word is required") @@ -183,14 +183,14 @@ func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitiv if err := s.ensureForbiddenWordBlockingUnique(0, word); err != nil { return nil, err } - row := forbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled} + row := ForbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled} if err := s.db.Create(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) { +func (s *Store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*ForbiddenWordBlockingRecord, error) { if id == 0 { return nil, fmt.Errorf("blocking rule id is required") } @@ -209,14 +209,14 @@ func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, c return nil, err } updates := map[string]any{"word": word, "match_type": matchType, "case_sensitive": caseSensitive, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()} - if err := s.db.Model(&forbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&ForbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.getForbiddenWordBlockingByID(id) } -func (s *store) DeleteForbiddenWordBlocking(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&forbiddenWordBlockingRecord{}) +func (s *Store) DeleteForbiddenWordBlocking(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&ForbiddenWordBlockingRecord{}) if result.Error != nil { return result.Error } @@ -226,39 +226,39 @@ func (s *store) DeleteForbiddenWordBlocking(id uint64) error { return nil } -func (s *store) getNodeBlockingByID(id uint64) (*nodeBlockingRecord, error) { - var row nodeBlockingRecord +func (s *Store) getNodeBlockingByID(id uint64) (*NodeBlockingRecord, error) { + var row NodeBlockingRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) getIPBlockingByID(id uint64) (*ipBlockingRecord, error) { - var row ipBlockingRecord +func (s *Store) getIPBlockingByID(id uint64) (*IPBlockingRecord, error) { + var row IPBlockingRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) getForbiddenWordBlockingByID(id uint64) (*forbiddenWordBlockingRecord, error) { - var row forbiddenWordBlockingRecord +func (s *Store) getForbiddenWordBlockingByID(id uint64) (*ForbiddenWordBlockingRecord, error) { + var row ForbiddenWordBlockingRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error { - var existing nodeBlockingRecord +func (s *Store) ensureNodeBlockingUnique(id uint64, nodeID string) error { + var existing NodeBlockingRecord q := s.db.Where("node_id = ?", nodeID) if id != 0 { q = q.Where("id <> ?", id) } err := q.Take(&existing).Error if err == nil { - return errBlockingAlreadyExists + return ErrBlockingAlreadyExists } if errors.Is(err, gorm.ErrRecordNotFound) { return nil @@ -266,15 +266,15 @@ func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error { return err } -func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error { - var existing ipBlockingRecord +func (s *Store) ensureIPBlockingUnique(id uint64, ipValue string) error { + var existing IPBlockingRecord q := s.db.Where("ip_value = ?", ipValue) if id != 0 { q = q.Where("id <> ?", id) } err := q.Take(&existing).Error if err == nil { - return errBlockingAlreadyExists + return ErrBlockingAlreadyExists } if errors.Is(err, gorm.ErrRecordNotFound) { return nil @@ -282,15 +282,15 @@ func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error { return err } -func (s *store) ensureForbiddenWordBlockingUnique(id uint64, word string) error { - var existing forbiddenWordBlockingRecord +func (s *Store) ensureForbiddenWordBlockingUnique(id uint64, word string) error { + var existing ForbiddenWordBlockingRecord q := s.db.Where("word = ?", word) if id != 0 { q = q.Where("id <> ?", id) } err := q.Take(&existing).Error if err == nil { - return errBlockingAlreadyExists + return ErrBlockingAlreadyExists } if errors.Is(err, gorm.ErrRecordNotFound) { return nil @@ -316,9 +316,9 @@ func normalizeIPBlockingValue(value string) (string, error) { func normalizeForbiddenWordMatchType(matchType string) (string, error) { matchType = strings.TrimSpace(matchType) if matchType == "" { - return forbiddenWordMatchContains, nil + return ForbiddenWordMatchContains, nil } - if matchType != forbiddenWordMatchContains { + if matchType != ForbiddenWordMatchContains { return "", fmt.Errorf("unsupported forbidden word match type") } return matchType, nil diff --git a/blocking_store_test.go b/internal/store/blocking_store_test.go similarity index 91% rename from blocking_store_test.go rename to internal/store/blocking_store_test.go index f35a784..1523888 100644 --- a/blocking_store_test.go +++ b/internal/store/blocking_store_test.go @@ -1,4 +1,4 @@ -package main +package store import ( "errors" @@ -20,8 +20,8 @@ func TestNodeBlockingCRUD(t *testing.T) { t.Fatalf("created node rule = %+v, want normalized fields", rule) } - if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) { - t.Fatalf("duplicate CreateNodeBlocking() error = %v, want errBlockingAlreadyExists", err) + if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) { + t.Fatalf("duplicate CreateNodeBlocking() error = %v, want ErrBlockingAlreadyExists", err) } updatedNum := int64(7) @@ -33,14 +33,14 @@ func TestNodeBlockingCRUD(t *testing.T) { t.Fatalf("updated node rule = %+v, want updated fields", updated) } - rows, err := st.ListNodeBlocking(listOptions{}) + rows, err := st.ListNodeBlocking(ListOptions{}) if err != nil { t.Fatalf("ListNodeBlocking() error = %v", err) } if len(rows) != 1 || rows[0].ID != rule.ID { t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows) } - total, err := st.CountNodeBlocking(listOptions{}) + total, err := st.CountNodeBlocking(ListOptions{}) if err != nil || total != 1 { t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err) } @@ -85,8 +85,8 @@ func TestIPBlockingCRUDAndValidation(t *testing.T) { t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue) } - if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) { - t.Fatalf("duplicate CreateIPBlocking() error = %v, want errBlockingAlreadyExists", err) + if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) { + t.Fatalf("duplicate CreateIPBlocking() error = %v, want ErrBlockingAlreadyExists", err) } if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil { t.Fatal("CreateIPBlocking(invalid) error = nil, want error") @@ -100,14 +100,14 @@ func TestIPBlockingCRUDAndValidation(t *testing.T) { t.Fatalf("updated ip rule = %+v, want updated fields", updated) } - rows, err := st.ListIPBlocking(listOptions{}) + rows, err := st.ListIPBlocking(ListOptions{}) if err != nil { t.Fatalf("ListIPBlocking() error = %v", err) } if len(rows) != 2 { t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows)) } - total, err := st.CountIPBlocking(listOptions{}) + total, err := st.CountIPBlocking(ListOptions{}) if err != nil || total != 2 { t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err) } @@ -164,12 +164,12 @@ func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) { if err != nil { t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) } - if rule.Word != "spam" || rule.MatchType != forbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled { + if rule.Word != "spam" || rule.MatchType != ForbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled { t.Fatalf("created word rule = %+v, want normalized fields", rule) } - if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) { - t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want errBlockingAlreadyExists", err) + if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) { + t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want ErrBlockingAlreadyExists", err) } if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil { t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error") @@ -186,14 +186,14 @@ func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) { t.Fatalf("updated word rule = %+v, want updated fields", updated) } - rows, err := st.ListForbiddenWordBlocking(listOptions{}) + rows, err := st.ListForbiddenWordBlocking(ListOptions{}) if err != nil { t.Fatalf("ListForbiddenWordBlocking() error = %v", err) } if len(rows) != 1 || rows[0].ID != rule.ID { t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows) } - total, err := st.CountForbiddenWordBlocking(listOptions{}) + total, err := st.CountForbiddenWordBlocking(ListOptions{}) if err != nil || total != 1 { t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err) } diff --git a/bot_direct_message_store.go b/internal/store/bot_direct_message_store.go similarity index 78% rename from bot_direct_message_store.go rename to internal/store/bot_direct_message_store.go index 2d49d29..aacc0d2 100644 --- a/bot_direct_message_store.go +++ b/internal/store/bot_direct_message_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "encoding/json" @@ -10,17 +10,17 @@ import ( "gorm.io/gorm" ) -type botDirectMessageListOptions struct { - listOptions +type BotDirectMessageListOptions struct { + ListOptions BotID uint64 PeerNodeNum int64 Direction string } // InsertBotDirectMessage 把一条机器人 DM(出向或入向)写入 bot_direct_messages 表。 -func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error { +func (s *Store) InsertBotDirectMessage(row *BotDirectMessageRecord) error { if s == nil || s.db == nil { - return fmt.Errorf("store is not configured") + return fmt.Errorf("Store is not configured") } if row == nil { return fmt.Errorf("bot direct message is required") @@ -32,9 +32,9 @@ func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error { } // UpdateBotDirectMessageStatus 更新一条出向 DM 的发送状态(pending → published/failed)。 -func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error { +func (s *Store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error { if s == nil || s.db == nil { - return fmt.Errorf("store is not configured") + return fmt.Errorf("Store is not configured") } if id == 0 { return fmt.Errorf("bot direct message id is required") @@ -44,7 +44,7 @@ func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string, "error": strings.TrimSpace(errText), "published_at": publishedAt, } - result := s.db.Model(&botDirectMessageRecord{}).Where("id = ?", id).Updates(updates) + result := s.db.Model(&BotDirectMessageRecord{}).Where("id = ?", id).Updates(updates) if result.Error != nil { return result.Error } @@ -55,9 +55,9 @@ func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string, } // ListBotDirectMessagesByConversation 按 (bot, peer) 反序拉取 DM 历史,给 /admin/bot/direct 页面。 -func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOptions) ([]botDirectMessageRecord, error) { +func (s *Store) ListBotDirectMessagesByConversation(opts BotDirectMessageListOptions) ([]BotDirectMessageRecord, error) { if s == nil || s.db == nil { - return nil, fmt.Errorf("store is not configured") + return nil, fmt.Errorf("Store is not configured") } if opts.BotID == 0 { return nil, fmt.Errorf("bot id is required") @@ -65,9 +65,9 @@ func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOpt if opts.PeerNodeNum == 0 { return nil, fmt.Errorf("peer node num is required") } - opts.listOptions = normalizeListOptions(opts.listOptions) - var rows []botDirectMessageRecord - q := s.db.Model(&botDirectMessageRecord{}). + opts.ListOptions = NormalizeListOptions(opts.ListOptions) + var rows []BotDirectMessageRecord + q := s.db.Model(&BotDirectMessageRecord{}). Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum). Order("created_at DESC"). Order("id DESC"). @@ -86,15 +86,15 @@ func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOpt } // CountBotDirectMessagesByConversation 返回会话总条数(前端无限滚动可用,可选)。 -func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOptions) (int64, error) { +func (s *Store) CountBotDirectMessagesByConversation(opts BotDirectMessageListOptions) (int64, error) { if s == nil || s.db == nil { - return 0, fmt.Errorf("store is not configured") + return 0, fmt.Errorf("Store is not configured") } if opts.BotID == 0 || opts.PeerNodeNum == 0 { return 0, fmt.Errorf("bot id and peer node num are required") } var total int64 - q := s.db.Model(&botDirectMessageRecord{}). + q := s.db.Model(&BotDirectMessageRecord{}). Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum) if opts.Direction != "" { q = q.Where("direction = ?", opts.Direction) @@ -110,9 +110,9 @@ func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOp // FindBotForIncomingPKIPacket 在 bot_direct_messages 写入路径上判断接收方是否为受管 bot。 // 返回的 bot 用于填充 BotID/BotNodeID/BotNodeNum;不命中时返回 ErrRecordNotFound。 -func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, error) { +func (s *Store) FindBotForIncomingPKIPacket(toNodeNum int64) (*BotNodeRecord, error) { if s == nil || s.db == nil { - return nil, fmt.Errorf("store is not configured") + return nil, fmt.Errorf("Store is not configured") } bot, err := s.GetBotNodeByNodeNum(toNodeNum) if err != nil { @@ -124,10 +124,10 @@ func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, er return bot, nil } -// botDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。 +// BotDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。 // LastMessageAt / LastText / LastDirection 描述会话最后一条消息,便于按时间排序与预览; // UnreadCount 仅对 inbound 计数(即未读消息数)。 -type botDirectConversation struct { +type BotDirectConversation struct { BotID uint64 `gorm:"column:bot_id"` PeerNodeID string `gorm:"column:peer_node_id"` PeerNodeNum int64 `gorm:"column:peer_node_num"` @@ -139,21 +139,21 @@ type botDirectConversation struct { } // ListBotDirectConversations 聚合给定 bot 下的所有 (peer) 会话,返回最后一条消息及未读数。 -// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 listOptions。 -func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]botDirectConversation, error) { +// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 ListOptions。 +func (s *Store) ListBotDirectConversations(botID uint64, opts ListOptions) ([]BotDirectConversation, error) { if s == nil || s.db == nil { - return nil, fmt.Errorf("store is not configured") + return nil, fmt.Errorf("Store is not configured") } if botID == 0 { return nil, fmt.Errorf("bot id is required") } - opts = normalizeListOptions(opts) - var rows []botDirectConversation + opts = NormalizeListOptions(opts) + var rows []BotDirectConversation // 先把每对会话的最后一条消息 ID 取出来,再把这条消息的元数据 join 回去; // 同时聚合 unread_count(inbound 且 read_at IS NULL)和 total_count。 // 这样的两步 join 避免在 GROUP BY 后引用非聚合列(MySQL 严格模式 / SQLite 兼容)。 - subLast := s.db.Model(&botDirectMessageRecord{}). - Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", botDirectMessageDirectionInbound). + subLast := s.db.Model(&BotDirectMessageRecord{}). + Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", BotDirectMessageDirectionInbound). Where("bot_id = ?", botID). Group("bot_id, peer_node_id, peer_node_num") q := s.db.Table("(?) AS agg", subLast). @@ -167,16 +167,16 @@ func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]bo } // MarkBotDirectMessagesRead 把 (bot, peer) 下未读的 inbound 消息全部标记为已读,返回更新行数。 -func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) { +func (s *Store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) { if s == nil || s.db == nil { - return 0, fmt.Errorf("store is not configured") + return 0, fmt.Errorf("Store is not configured") } if botID == 0 || peerNodeNum == 0 { return 0, fmt.Errorf("bot id and peer node num are required") } now := time.Now() - result := s.db.Model(&botDirectMessageRecord{}). - Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, botDirectMessageDirectionInbound). + result := s.db.Model(&BotDirectMessageRecord{}). + Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, BotDirectMessageDirectionInbound). Update("read_at", &now) if result.Error != nil { return 0, result.Error @@ -185,16 +185,16 @@ func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int6 } // CountBotDirectUnread 返回某个 bot 全部未读 inbound 消息总数(用于头部小红点)。 -func (s *store) CountBotDirectUnread(botID uint64) (int64, error) { +func (s *Store) CountBotDirectUnread(botID uint64) (int64, error) { if s == nil || s.db == nil { - return 0, fmt.Errorf("store is not configured") + return 0, fmt.Errorf("Store is not configured") } if botID == 0 { return 0, fmt.Errorf("bot id is required") } var total int64 - err := s.db.Model(&botDirectMessageRecord{}). - Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, botDirectMessageDirectionInbound). + err := s.db.Model(&BotDirectMessageRecord{}). + Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, BotDirectMessageDirectionInbound). Count(&total).Error return total, err } @@ -202,7 +202,7 @@ func (s *store) CountBotDirectUnread(botID uint64) (int64, error) { // isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。 // 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。 // 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。 -func isInboundBotDirectMessage(s *store, record map[string]any) bool { +func isInboundBotDirectMessage(s *Store, record map[string]any) bool { if s == nil || record == nil { return false } @@ -221,10 +221,10 @@ func isInboundBotDirectMessage(s *store, record map[string]any) bool { } // insertInboundBotDirectMessage 把一条入向 PKI DM 转写入 bot_direct_messages 表。 -// 失败时返回错误,由 dbWriteQueue 统一打印 db_error 事件。 -func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo mqttClientInfo) error { +// 失败时返回错误,由 WriteQueue 统一打印 db_error 事件。 +func insertInboundBotDirectMessage(s *Store, record map[string]any, clientInfo MQTTClientInfo) error { if s == nil { - return fmt.Errorf("store is not configured") + return fmt.Errorf("Store is not configured") } if record == nil { return fmt.Errorf("record is required") @@ -267,13 +267,13 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m contentPtr = &s } now := time.Now() - dm := &botDirectMessageRecord{ + dm := &BotDirectMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, PeerNodeID: peerNodeID, PeerNodeNum: int64(peerNum), - Direction: botDirectMessageDirectionInbound, + Direction: BotDirectMessageDirectionInbound, Topic: topic, PacketID: int64(packetID), Text: text, @@ -281,7 +281,7 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m PKIEncrypted: true, WantAck: wantAck, GatewayID: gatewayPtr, - Status: botMessageStatusPublished, + Status: BotMessageStatusPublished, ReceivedAt: &now, ContentJSON: contentPtr, } @@ -292,9 +292,9 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m // 同时将消息添加到 LLM 队列(忽略机器人自己发送的消息) if peerNodeID != bot.NodeID { - longName := nullableString(record["long_name"]) - shortName := nullableString(record["short_name"]) - channelID := nullableString(record["channel_id"]) + longName := NullableString(record["long_name"]) + shortName := NullableString(record["short_name"]) + channelID := NullableString(record["channel_id"]) _, err = s.EnqueueLLMMessage(LLMMessageQueueInput{ BotID: bot.ID, BotNodeID: bot.NodeID, diff --git a/internal/store/bot_pki_store.go b/internal/store/bot_pki_store.go new file mode 100644 index 0000000..bcb59b8 --- /dev/null +++ b/internal/store/bot_pki_store.go @@ -0,0 +1,48 @@ +package store + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "strings" + + "gorm.io/gorm" +) + +// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。 +func (s *Store) GetBotNodeByNodeNum(nodeNum int64) (*BotNodeRecord, error) { + if s == nil || s.db == nil { + return nil, errors.New("store not configured") + } + var row BotNodeRecord + if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return nil, err + } + return &row, nil +} + +// LookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥, +// 兼容 hex 与 base64 两种历史存储格式。 +func (s *Store) LookupNodeInfoPublicKey(nodeNum uint32) ([]byte, bool) { + var row NodeInfoRecord + if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil { + return nil, false + } + if row.PublicKey == nil { + return nil, false + } + value := strings.TrimSpace(*row.PublicKey) + if value == "" { + return nil, false + } + if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 { + return decoded, true + } + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 { + return decoded, true + } + return nil, false +} diff --git a/bot_store.go b/internal/store/bot_store.go similarity index 73% rename from bot_store.go rename to internal/store/bot_store.go index 9e9117d..66631f4 100644 --- a/bot_store.go +++ b/internal/store/bot_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "crypto/ecdh" @@ -17,19 +17,19 @@ import ( ) const ( - botDefaultTopicPrefix = "msh/CN" - botDefaultPSK = "AQ==" - botDefaultNodeInfoBroadcastSeconds = int64(3600) - botMessageTypeChannel = "channel" - botMessageTypeDirect = "direct" - botMessageStatusPending = "pending" - botMessageStatusPublished = "published" - botMessageStatusFailed = "failed" + BotDefaultTopicPrefix = "msh/CN" + BotDefaultPSK = "AQ==" + BotDefaultNodeInfoBroadcastSeconds = int64(3600) + BotMessageTypeChannel = "channel" + BotMessageTypeDirect = "direct" + BotMessageStatusPending = "pending" + BotMessageStatusPublished = "published" + BotMessageStatusFailed = "failed" ) -var errBotNodeAlreadyExists = errors.New("bot node already exists") +var ErrBotNodeAlreadyExists = errors.New("bot node already exists") -type botNodeInput struct { +type BotNodeInput struct { NodeNum *int64 LongName string ShortName string @@ -43,17 +43,17 @@ type botNodeInput struct { LLMIncludeChannelMessages bool } -type botMessageListOptions struct { - listOptions +type BotMessageListOptions struct { + ListOptions BotID uint64 MessageType string ChannelID string } -func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) { - opts = normalizeListOptions(opts) - var rows []botNodeRecord - q := s.db.Model(&botNodeRecord{}). +func (s *Store) ListBotNodes(opts ListOptions) ([]BotNodeRecord, error) { + opts = NormalizeListOptions(opts) + var rows []BotNodeRecord + q := s.db.Model(&BotNodeRecord{}). Order("updated_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -61,20 +61,20 @@ func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) { return rows, q.Find(&rows).Error } -func (s *store) CountBotNodes(opts listOptions) (int64, error) { +func (s *Store) CountBotNodes(opts ListOptions) (int64, error) { var total int64 - return total, s.db.Model(&botNodeRecord{}).Count(&total).Error + return total, s.db.Model(&BotNodeRecord{}).Count(&total).Error } -func (s *store) GetBotNode(id uint64) (*botNodeRecord, error) { - var row botNodeRecord +func (s *Store) GetBotNode(id uint64) (*BotNodeRecord, error) { + var row BotNodeRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) { +func (s *Store) CreateBotNode(input BotNodeInput) (*BotNodeRecord, error) { row, err := s.normalizedBotNodeRecord(input) if err != nil { return nil, err @@ -94,7 +94,7 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) { return row, nil } -func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, error) { +func (s *Store) UpdateBotNode(id uint64, input BotNodeInput) (*BotNodeRecord, error) { if id == 0 { return nil, fmt.Errorf("bot node id is required") } @@ -132,14 +132,14 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er "llm_include_channel_messages": row.LLMIncludeChannelMessages, "updated_at": time.Now(), } - if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.GetBotNode(id) } -func (s *store) DeleteBotNode(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&botNodeRecord{}) +func (s *Store) DeleteBotNode(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&BotNodeRecord{}) if result.Error != nil { return result.Error } @@ -149,13 +149,13 @@ func (s *store) DeleteBotNode(id uint64) error { return nil } -func (s *store) InsertBotMessage(row *botMessageRecord) error { +func (s *Store) InsertBotMessage(row *BotMessageRecord) error { return s.db.Create(row).Error } -func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error { +func (s *Store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error { updates := map[string]any{"status": status, "error": strings.TrimSpace(errText), "published_at": publishedAt} - result := s.db.Model(&botMessageRecord{}).Where("id = ?", id).Updates(updates) + result := s.db.Model(&BotMessageRecord{}).Where("id = ?", id).Updates(updates) if result.Error != nil { return result.Error } @@ -165,8 +165,8 @@ func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publis return nil } -func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error { - result := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()}) +func (s *Store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error { + result := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()}) if result.Error != nil { return result.Error } @@ -176,7 +176,7 @@ func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error { return nil } -func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) { +func (s *Store) RegenerateBotNodeKeys(id uint64) (*BotNodeRecord, error) { if id == 0 { return nil, fmt.Errorf("bot node id is required") } @@ -188,16 +188,16 @@ func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) { return nil, err } updates := map[string]any{"public_key": row.PublicKey, "private_key": row.PrivateKey, "updated_at": time.Now()} - if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.GetBotNode(id) } -func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, error) { - opts.listOptions = normalizeListOptions(opts.listOptions) - var rows []botMessageRecord - q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts). +func (s *Store) ListBotMessages(opts BotMessageListOptions) ([]BotMessageRecord, error) { + opts.ListOptions = NormalizeListOptions(opts.ListOptions) + var rows []BotMessageRecord + q := applyBotMessageFilters(s.db.Model(&BotMessageRecord{}), opts). Order("created_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -205,13 +205,13 @@ func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, return rows, q.Find(&rows).Error } -func (s *store) CountBotMessages(opts botMessageListOptions) (int64, error) { +func (s *Store) CountBotMessages(opts BotMessageListOptions) (int64, error) { var total int64 - q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts) + q := applyBotMessageFilters(s.db.Model(&BotMessageRecord{}), opts) return total, q.Count(&total).Error } -func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB { +func applyBotMessageFilters(q *gorm.DB, opts BotMessageListOptions) *gorm.DB { if opts.BotID != 0 { q = q.Where("bot_id = ?", opts.BotID) } @@ -230,20 +230,20 @@ func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB { return q } -func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, error) { +func (s *Store) normalizedBotNodeRecord(input BotNodeInput) (*BotNodeRecord, error) { longName := strings.TrimSpace(input.LongName) shortName := strings.TrimSpace(input.ShortName) channelID := strings.TrimSpace(input.DefaultChannelID) psk := strings.TrimSpace(input.PSK) if psk == "" { - psk = botDefaultPSK + psk = BotDefaultPSK } if _, err := mqtpp.ExpandPSK(psk); err != nil { return nil, err } topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/") if topicPrefix == "" { - topicPrefix = botDefaultTopicPrefix + topicPrefix = BotDefaultTopicPrefix } if longName == "" { return nil, fmt.Errorf("long name is required") @@ -262,7 +262,7 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err } interval := input.NodeInfoBroadcastIntervalSeconds if interval <= 0 { - interval = botDefaultNodeInfoBroadcastSeconds + interval = BotDefaultNodeInfoBroadcastSeconds } if interval < 60 { return nil, fmt.Errorf("nodeinfo broadcast interval must be at least 60 seconds") @@ -277,13 +277,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err } else { nodeNum = *input.NodeNum } - if err := validateBotNodeNum(nodeNum); err != nil { + if err := ValidateBotNodeNum(nodeNum); err != nil { return nil, err } - return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval, LLMQueueEnabled: input.LLMQueueEnabled, LLMIncludeChannelMessages: input.LLMIncludeChannelMessages}, nil + return &BotNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval, LLMQueueEnabled: input.LLMQueueEnabled, LLMIncludeChannelMessages: input.LLMIncludeChannelMessages}, nil } -func populateBotNodeKeys(row *botNodeRecord) error { +func populateBotNodeKeys(row *BotNodeRecord) error { privateKey, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { return err @@ -293,7 +293,7 @@ func populateBotNodeKeys(row *botNodeRecord) error { return nil } -func decodeBotPublicKey(row botNodeRecord) ([]byte, error) { +func DecodeBotPublicKey(row BotNodeRecord) ([]byte, error) { if strings.TrimSpace(row.PublicKey) == "" { return nil, nil } @@ -304,31 +304,31 @@ func decodeBotPublicKey(row botNodeRecord) ([]byte, error) { return key, nil } -func validateBotNodeNum(nodeNum int64) error { +func ValidateBotNodeNum(nodeNum int64) error { if nodeNum <= 0 || nodeNum >= int64(mqtpp.NodeNumBroadcast) { return fmt.Errorf("node num must be between 1 and 4294967294") } return nil } -func (s *store) generateBotNodeNum() (int64, error) { +func (s *Store) generateBotNodeNum() (int64, error) { for i := 0; i < 32; i++ { var buf [4]byte if _, err := rand.Read(buf[:]); err != nil { return 0, err } nodeNum := int64(binary.LittleEndian.Uint32(buf[:]) & 0x7fffffff) - if err := validateBotNodeNum(nodeNum); err != nil { + if err := ValidateBotNodeNum(nodeNum); err != nil { continue } if err := s.ensureBotNodeUnique(0, mqtpp.NodeNumToID(uint32(nodeNum)), nodeNum); err != nil { - if errors.Is(err, errBotNodeAlreadyExists) { + if errors.Is(err, ErrBotNodeAlreadyExists) { continue } return 0, err } if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum, mqtpp.NodeNumToID(uint32(nodeNum))); err != nil { - if errors.Is(err, errBotNodeAlreadyExists) { + if errors.Is(err, ErrBotNodeAlreadyExists) { continue } return 0, err @@ -338,15 +338,15 @@ func (s *store) generateBotNodeNum() (int64, error) { return 0, fmt.Errorf("generate bot node num failed") } -func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error { - var existing botNodeRecord +func (s *Store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error { + var existing BotNodeRecord q := s.db.Where("node_id = ? OR node_num = ?", nodeID, nodeNum) if id != 0 { q = q.Where("id <> ?", id) } err := q.Take(&existing).Error if err == nil { - return errBotNodeAlreadyExists + return ErrBotNodeAlreadyExists } if errors.Is(err, gorm.ErrRecordNotFound) { return nil @@ -354,8 +354,8 @@ func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) err return err } -func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error { - var existing nodeInfoRecord +func (s *Store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error { + var existing NodeInfoRecord q := s.db.Where("node_num = ?", nodeNum) if selfNodeID != "" { // 机器人自己广播 NodeInfo 后会以同样的 node_id/node_num 回写 nodeinfo; @@ -364,7 +364,7 @@ func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNode } err := q.Take(&existing).Error if err == nil { - return errBotNodeAlreadyExists + return ErrBotNodeAlreadyExists } if errors.Is(err, gorm.ErrRecordNotFound) { return nil diff --git a/db.go b/internal/store/db.go similarity index 80% rename from db.go rename to internal/store/db.go index c732543..da85517 100644 --- a/db.go +++ b/internal/store/db.go @@ -1,4 +1,4 @@ -package main +package store import ( "encoding/json" @@ -11,19 +11,16 @@ import ( "github.com/glebarez/sqlite" "gorm.io/driver/mysql" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/config" ) -const ( - databaseDriverSQLite = "sqlite" - databaseDriverMySQL = "mysql" -) - -type store struct { +type Store struct { db *gorm.DB driver string } -type mqttClientInfo struct { +type MQTTClientInfo struct { ClientID string Username string Listener string @@ -62,7 +59,7 @@ type MQTTClientRecordFields struct { MQTTRemotePort *string `gorm:"column:mqtt_remote_port"` } -type userRecord struct { +type UserRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Username string `gorm:"column:username;not null;uniqueIndex"` PasswordHash string `gorm:"column:password_hash;not null"` @@ -71,11 +68,11 @@ type userRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` } -func (userRecord) TableName() string { +func (UserRecord) TableName() string { return "users" } -type loginLogRecord struct { +type LoginLogRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Username string `gorm:"column:username;index"` UserID *uint64 `gorm:"column:user_id;index"` @@ -87,22 +84,22 @@ type loginLogRecord struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"` } -func (loginLogRecord) TableName() string { +func (LoginLogRecord) TableName() string { return "login_log" } -type helpContentRecord struct { +type HelpContentRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Markdown string `gorm:"column:markdown;type:text;not null"` CreatedBy string `gorm:"column:created_by;index"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"` } -func (helpContentRecord) TableName() string { +func (HelpContentRecord) TableName() string { return "help_content" } -type runtimeSettingRecord struct { +type RuntimeSettingRecord struct { Key string `gorm:"column:key;primaryKey;size:128;not null"` Value string `gorm:"column:value;type:text;not null"` ValueType string `gorm:"column:value_type;size:32;not null;index"` @@ -111,11 +108,11 @@ type runtimeSettingRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (runtimeSettingRecord) TableName() string { +func (RuntimeSettingRecord) TableName() string { return "runtime_settings" } -type mapTileSourceRecord struct { +type MapTileSourceRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Name string `gorm:"column:name;not null;uniqueIndex"` URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"` @@ -129,11 +126,11 @@ type mapTileSourceRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (mapTileSourceRecord) TableName() string { +func (MapTileSourceRecord) TableName() string { return "map_tile_sources" } -type discardDetailsRecord struct { +type DiscardDetailsRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Topic string `gorm:"column:topic"` Error string `gorm:"column:error"` @@ -149,11 +146,11 @@ type discardDetailsRecord struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"` } -func (discardDetailsRecord) TableName() string { +func (DiscardDetailsRecord) TableName() string { return "discard_details" } -type signRecord struct { +type SignRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` NodeID string `gorm:"column:node_id;not null;index"` LongName *string `gorm:"column:long_name"` @@ -162,11 +159,11 @@ type signRecord struct { SignTime time.Time `gorm:"column:sign_time;not null;index"` } -func (signRecord) TableName() string { +func (SignRecord) TableName() string { return "signs" } -type nodeBlockingRecord struct { +type NodeBlockingRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` NodeID string `gorm:"column:node_id;not null;uniqueIndex"` NodeNum *int64 `gorm:"column:node_num;index"` @@ -176,11 +173,11 @@ type nodeBlockingRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (nodeBlockingRecord) TableName() string { +func (NodeBlockingRecord) TableName() string { return "node_blocking" } -type ipBlockingRecord struct { +type IPBlockingRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` IPValue string `gorm:"column:ip_value;not null;uniqueIndex"` Reason string `gorm:"column:reason"` @@ -189,11 +186,11 @@ type ipBlockingRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (ipBlockingRecord) TableName() string { +func (IPBlockingRecord) TableName() string { return "ip_blocking" } -type forbiddenWordBlockingRecord struct { +type ForbiddenWordBlockingRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Word string `gorm:"column:word;not null;uniqueIndex"` MatchType string `gorm:"column:match_type;not null;index"` @@ -204,11 +201,11 @@ type forbiddenWordBlockingRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (forbiddenWordBlockingRecord) TableName() string { +func (ForbiddenWordBlockingRecord) TableName() string { return "forbidden_word_blocking" } -type mqttForwarderRecord struct { +type MQTTForwarderRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Name string `gorm:"column:name;not null;uniqueIndex"` Enabled bool `gorm:"column:enabled;not null;index"` @@ -228,11 +225,11 @@ type mqttForwarderRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (mqttForwarderRecord) TableName() string { +func (MQTTForwarderRecord) TableName() string { return "mqtt_forwarders" } -type mqttForwardTopicRecord struct { +type MQTTForwardTopicRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` ForwarderID uint64 `gorm:"column:forwarder_id;not null;index;uniqueIndex:idx_mqtt_forward_topic_unique,priority:1"` Topic string `gorm:"column:topic;not null;uniqueIndex:idx_mqtt_forward_topic_unique,priority:2"` @@ -246,11 +243,11 @@ type mqttForwardTopicRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (mqttForwardTopicRecord) TableName() string { +func (MQTTForwardTopicRecord) TableName() string { return "mqtt_forward_topics" } -type botNodeRecord struct { +type BotNodeRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` NodeID string `gorm:"column:node_id;not null;uniqueIndex"` NodeNum int64 `gorm:"column:node_num;not null;uniqueIndex"` @@ -272,11 +269,11 @@ type botNodeRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (botNodeRecord) TableName() string { +func (BotNodeRecord) TableName() string { return "bot_nodes" } -type botMessageRecord struct { +type BotMessageRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` BotID uint64 `gorm:"column:bot_id;not null;index:idx_bot_message_bot_created_at,priority:1"` BotNodeID string `gorm:"column:bot_node_id;not null;index"` @@ -297,18 +294,18 @@ type botMessageRecord struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_bot_message_bot_created_at,priority:2"` } -func (botMessageRecord) TableName() string { +func (BotMessageRecord) TableName() string { return "bot_messages" } -// botDirectMessageRecord 专门保存机器人参与的 PKI 私聊(DM)。 +// BotDirectMessageRecord 专门保存机器人参与的 PKI 私聊(DM)。 // // - 设计原因:text_message 表只存频道消息;DM 是端到端的,逻辑上属于 “一对会话”,需要按 // bot+对端聚合渲染,与 text_message 全表浏览的形态不一样。 // - direction = "outbound" 表示 bot → device;"inbound" 表示 device → bot。 // - 出向消息在发送时插入 status=pending,发送成功后更新为 published;入向消息默认直接 // published。两种方向都通过 bot_id/peer_node_num 索引快速回放会话。 -type botDirectMessageRecord struct { +type BotDirectMessageRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` BotID uint64 `gorm:"column:bot_id;not null;index:idx_bot_dm_bot_peer,priority:1;index:idx_bot_dm_bot_created_at,priority:1"` BotNodeID string `gorm:"column:bot_node_id;not null;index"` @@ -336,21 +333,21 @@ type botDirectMessageRecord struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_bot_dm_bot_created_at,priority:2"` } -func (botDirectMessageRecord) TableName() string { +func (BotDirectMessageRecord) TableName() string { return "bot_direct_messages" } const ( - botDirectMessageDirectionInbound = "inbound" - botDirectMessageDirectionOutbound = "outbound" + BotDirectMessageDirectionInbound = "inbound" + BotDirectMessageDirectionOutbound = "outbound" ) -// llmMessageQueueRecord 是 LLM 消息队列,用于暂存机器人收到的消息供 LLM 处理。 +// LLMMessageQueueRecord 是 LLM 消息队列,用于暂存机器人收到的消息供 LLM 处理。 // // - 每个队列绑定一个 BotID,消息包含节点信息和消息内容 // - deleted_at 用于标记软删除,实际保留一段时间供去重 // - received_at 是消息接收时间,processed_at 是 LLM 处理完成时间 -type llmMessageQueueRecord struct { +type LLMMessageQueueRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` BotID uint64 `gorm:"column:bot_id;not null;index:idx_llm_queue_bot_created,priority:1"` BotNodeID string `gorm:"column:bot_node_id;not null;index"` @@ -374,18 +371,18 @@ type llmMessageQueueRecord struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_llm_queue_bot_created,priority:2"` } -func (llmMessageQueueRecord) TableName() string { +func (LLMMessageQueueRecord) TableName() string { return "llm_message_queue" } const ( - llmMessageStatusPending = "pending" - llmMessageStatusProcessing = "processing" - llmMessageStatusProcessed = "processed" - llmMessageStatusError = "error" + LLMMessageStatusPending = "pending" + LLMMessageStatusProcessing = "processing" + LLMMessageStatusProcessed = "processed" + LLMMessageStatusError = "error" ) -type nodeInfoRecord struct { +type NodeInfoRecord struct { NodeID string `gorm:"column:node_id;primaryKey;not null"` NodeNum int64 `gorm:"column:node_num;not null;index"` UserID *string `gorm:"column:user_id"` @@ -400,11 +397,11 @@ type nodeInfoRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (nodeInfoRecord) TableName() string { +func (NodeInfoRecord) TableName() string { return "nodeinfo" } -type mapReportRecord struct { +type MapReportRecord struct { NodeID string `gorm:"column:node_id;primaryKey;not null"` NodeNum int64 `gorm:"column:node_num;not null;index"` LongName *string `gorm:"column:long_name"` @@ -425,11 +422,11 @@ type mapReportRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (mapReportRecord) TableName() string { +func (MapReportRecord) TableName() string { return "map_report" } -type textMessageRecord struct { +type TextMessageRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` FromID string `gorm:"column:from_id;not null"` FromNum int64 `gorm:"column:from_num;not null;index:idx_text_message_from_num_created_at,priority:1"` @@ -458,12 +455,12 @@ type textMessageRecord struct { CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_text_message_from_num_created_at,priority:2;index:idx_text_message_created_at"` } -func (textMessageRecord) TableName() string { +func (TextMessageRecord) TableName() string { return "text_message" } -// llmProviderRecord 保存 LLM API 配置,支持多个 AI 提供商 -type llmProviderRecord struct { +// LLMProviderRecord 保存 LLM API 配置,支持多个 AI 提供商 +type LLMProviderRecord struct { Name string `gorm:"column:name;primaryKey;size:64;not null"` // 配置名称,如 "default"、"openai"、"ark" 等 Active bool `gorm:"column:active;not null;index"` // 是否启用此配置 APIKey string `gorm:"column:api_key;type:text;not null"` // API 密钥 @@ -475,12 +472,12 @@ type llmProviderRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (llmProviderRecord) TableName() string { +func (LLMProviderRecord) TableName() string { return "llm_providers" } -// llmToolRouterRecord 保存工具路由的配置 -type llmToolRouterRecord struct { +// LLMToolRouterRecord 保存工具路由的配置 +type LLMToolRouterRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用工具路由 OpenAIName string `gorm:"column:openai_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name) @@ -491,12 +488,12 @@ type llmToolRouterRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (llmToolRouterRecord) TableName() string { +func (LLMToolRouterRecord) TableName() string { return "llm_tool_router" } -// llmPrimaryConfigRecord 保存主 AI 回复的配置 -type llmPrimaryConfigRecord struct { +// LLMPrimaryConfigRecord 保存主 AI 回复的配置 +type LLMPrimaryConfigRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用 AI 回复 ProviderName string `gorm:"column:provider_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name) @@ -508,11 +505,11 @@ type llmPrimaryConfigRecord struct { UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } -func (llmPrimaryConfigRecord) TableName() string { +func (LLMPrimaryConfigRecord) TableName() string { return "llm_primary_config" } -type positionRecord struct { +type PositionRecord struct { AppendPacketFields `gorm:"embedded"` MQTTClientRecordFields `gorm:"embedded"` Latitude *float64 `gorm:"column:latitude"` @@ -540,11 +537,11 @@ type positionRecord struct { PrecisionBits *int64 `gorm:"column:precision_bits"` } -func (positionRecord) TableName() string { +func (PositionRecord) TableName() string { return "position" } -type telemetryRecord struct { +type TelemetryRecord struct { AppendPacketFields `gorm:"embedded"` MQTTClientRecordFields `gorm:"embedded"` TelemetryTime *int64 `gorm:"column:telemetry_time"` @@ -552,37 +549,37 @@ type telemetryRecord struct { MetricsJSON *string `gorm:"column:metrics_json"` } -func (telemetryRecord) TableName() string { +func (TelemetryRecord) TableName() string { return "telemetry" } -type routingRecord struct { +type RoutingRecord struct { AppendPacketFields `gorm:"embedded"` MQTTClientRecordFields `gorm:"embedded"` } -func (routingRecord) TableName() string { +func (RoutingRecord) TableName() string { return "routing" } -type tracerouteRecord struct { +type TracerouteRecord struct { AppendPacketFields `gorm:"embedded"` MQTTClientRecordFields `gorm:"embedded"` } -func (tracerouteRecord) TableName() string { +func (TracerouteRecord) TableName() string { return "traceroute" } -func openStore(cfg databaseConfig) (*store, error) { +func OpenStore(cfg config.DatabaseConfig) (*Store, error) { var dialector gorm.Dialector switch cfg.Driver { - case databaseDriverSQLite: + case config.DriverSQLite: if err := os.MkdirAll(filepath.Dir(cfg.SQLite.Path), 0755); err != nil { return nil, fmt.Errorf("create sqlite directory %s: %w", filepath.Dir(cfg.SQLite.Path), err) } dialector = sqlite.Open(cfg.SQLite.Path) - case databaseDriverMySQL: + case config.DriverMySQL: dialector = mysql.Open(cfg.MySQL.DSN) default: return nil, fmt.Errorf("unsupported database driver %q", cfg.Driver) @@ -601,7 +598,7 @@ func openStore(cfg databaseConfig) (*store, error) { return nil, fmt.Errorf("ping %s database: %w", cfg.Driver, err) } - s := &store{db: db, driver: cfg.Driver} + s := &Store{db: db, driver: cfg.Driver} if err := s.migrate(); err != nil { sqlDB.Close() return nil, err @@ -609,7 +606,18 @@ func openStore(cfg databaseConfig) (*store, error) { return s, nil } -func (s *store) Close() error { +// DB 返回底层 gorm 句柄,供 ai 等子系统在受控范围内直接执行查询。 +// 应优先使用 Store 的高级方法;只有在确需新 schema 或自定义查询时才直接拿 DB。 +func (s *Store) DB() *gorm.DB { + return s.db +} + +// Driver 返回当前使用的数据库驱动名(与 config.DriverSQLite/MySQL 一致)。 +func (s *Store) Driver() string { + return s.driver +} + +func (s *Store) Close() error { if s == nil || s.db == nil { return nil } @@ -620,39 +628,39 @@ func (s *store) Close() error { return sqlDB.Close() } -func (s *store) migrate() error { +func (s *Store) migrate() error { return s.db.Transaction(func(tx *gorm.DB) error { migrator := tx.Migrator() for _, item := range []struct { label string model any }{ - {label: "users", model: &userRecord{}}, - {label: "login_log", model: &loginLogRecord{}}, - {label: "help_content", model: &helpContentRecord{}}, - {label: "runtime_settings", model: &runtimeSettingRecord{}}, - {label: "map_tile_sources", model: &mapTileSourceRecord{}}, - {label: "discard_details", model: &discardDetailsRecord{}}, - {label: "signs", model: &signRecord{}}, - {label: "node_blocking", model: &nodeBlockingRecord{}}, - {label: "ip_blocking", model: &ipBlockingRecord{}}, - {label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}}, - {label: "mqtt_forwarders", model: &mqttForwarderRecord{}}, - {label: "mqtt_forward_topics", model: &mqttForwardTopicRecord{}}, - {label: "bot_nodes", model: &botNodeRecord{}}, - {label: "bot_messages", model: &botMessageRecord{}}, - {label: "bot_direct_messages", model: &botDirectMessageRecord{}}, - {label: "llm_message_queue", model: &llmMessageQueueRecord{}}, - {label: "llm_providers", model: &llmProviderRecord{}}, - {label: "llm_tool_router", model: &llmToolRouterRecord{}}, - {label: "llm_primary_config", model: &llmPrimaryConfigRecord{}}, - {label: "nodeinfo", model: &nodeInfoRecord{}}, - {label: "map_report", model: &mapReportRecord{}}, - {label: "text_message", model: &textMessageRecord{}}, - {label: "position", model: &positionRecord{}}, - {label: "telemetry", model: &telemetryRecord{}}, - {label: "routing", model: &routingRecord{}}, - {label: "traceroute", model: &tracerouteRecord{}}, + {label: "users", model: &UserRecord{}}, + {label: "login_log", model: &LoginLogRecord{}}, + {label: "help_content", model: &HelpContentRecord{}}, + {label: "runtime_settings", model: &RuntimeSettingRecord{}}, + {label: "map_tile_sources", model: &MapTileSourceRecord{}}, + {label: "discard_details", model: &DiscardDetailsRecord{}}, + {label: "signs", model: &SignRecord{}}, + {label: "node_blocking", model: &NodeBlockingRecord{}}, + {label: "ip_blocking", model: &IPBlockingRecord{}}, + {label: "forbidden_word_blocking", model: &ForbiddenWordBlockingRecord{}}, + {label: "mqtt_forwarders", model: &MQTTForwarderRecord{}}, + {label: "mqtt_forward_topics", model: &MQTTForwardTopicRecord{}}, + {label: "bot_nodes", model: &BotNodeRecord{}}, + {label: "bot_messages", model: &BotMessageRecord{}}, + {label: "bot_direct_messages", model: &BotDirectMessageRecord{}}, + {label: "llm_message_queue", model: &LLMMessageQueueRecord{}}, + {label: "llm_providers", model: &LLMProviderRecord{}}, + {label: "llm_tool_router", model: &LLMToolRouterRecord{}}, + {label: "llm_primary_config", model: &LLMPrimaryConfigRecord{}}, + {label: "nodeinfo", model: &NodeInfoRecord{}}, + {label: "map_report", model: &MapReportRecord{}}, + {label: "text_message", model: &TextMessageRecord{}}, + {label: "position", model: &PositionRecord{}}, + {label: "telemetry", model: &TelemetryRecord{}}, + {label: "routing", model: &RoutingRecord{}}, + {label: "traceroute", model: &TracerouteRecord{}}, } { if !migrator.HasTable(item.model) { if err := migrator.CreateTable(item.model); err != nil { @@ -665,9 +673,9 @@ func (s *store) migrate() error { model any indexes []string }{ - {label: "text_message", model: &textMessageRecord{}, indexes: []string{"idx_text_message_from_num_created_at", "idx_text_message_created_at", "idx_text_message_packet_id"}}, - {label: "bot_direct_messages", model: &botDirectMessageRecord{}, indexes: []string{"idx_bot_dm_bot_peer", "idx_bot_dm_bot_created_at"}}, - {label: "llm_message_queue", model: &llmMessageQueueRecord{}, indexes: []string{"idx_llm_queue_bot_created"}}, + {label: "text_message", model: &TextMessageRecord{}, indexes: []string{"idx_text_message_from_num_created_at", "idx_text_message_created_at", "idx_text_message_packet_id"}}, + {label: "bot_direct_messages", model: &BotDirectMessageRecord{}, indexes: []string{"idx_bot_dm_bot_peer", "idx_bot_dm_bot_created_at"}}, + {label: "llm_message_queue", model: &LLMMessageQueueRecord{}, indexes: []string{"idx_llm_queue_bot_created"}}, } { if err := createMissingIndexes(migrator, item.model, item.label, item.indexes); err != nil { return err @@ -682,7 +690,7 @@ func (s *store) migrate() error { if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil { return err } - txStore := &store{db: tx, driver: s.driver} + txStore := &Store{db: tx, driver: s.driver} if err := txStore.EnsureDefaultMapTileSource(); err != nil { return err } @@ -700,11 +708,11 @@ func (s *store) migrate() error { } func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error { - if !migrator.HasTable(&botNodeRecord{}) { + if !migrator.HasTable(&BotNodeRecord{}) { return nil } - if !migrator.HasColumn(&botNodeRecord{}, "PSK") { - if driver == databaseDriverSQLite { + if !migrator.HasColumn(&BotNodeRecord{}, "PSK") { + if driver == config.DriverSQLite { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN psk TEXT NOT NULL DEFAULT 'AQ=='").Error; err != nil { return fmt.Errorf("migrate bot_nodes psk column: %w", err) } @@ -712,49 +720,49 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error return fmt.Errorf("migrate bot_nodes psk column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "PublicKey") { + if !migrator.HasColumn(&BotNodeRecord{}, "PublicKey") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN public_key text").Error; err != nil { return fmt.Errorf("migrate bot_nodes public_key column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "PrivateKey") { + if !migrator.HasColumn(&BotNodeRecord{}, "PrivateKey") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN private_key text").Error; err != nil { return fmt.Errorf("migrate bot_nodes private_key column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "NodeInfoBroadcastEnabled") { + if !migrator.HasColumn(&BotNodeRecord{}, "NodeInfoBroadcastEnabled") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN nodeinfo_broadcast_enabled numeric NOT NULL DEFAULT true").Error; err != nil { return fmt.Errorf("migrate bot_nodes nodeinfo_broadcast_enabled column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "NodeInfoBroadcastIntervalSeconds") { + if !migrator.HasColumn(&BotNodeRecord{}, "NodeInfoBroadcastIntervalSeconds") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN nodeinfo_broadcast_interval_seconds bigint NOT NULL DEFAULT 3600").Error; err != nil { return fmt.Errorf("migrate bot_nodes nodeinfo_broadcast_interval_seconds column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "LastNodeInfoBroadcastAt") { + if !migrator.HasColumn(&BotNodeRecord{}, "LastNodeInfoBroadcastAt") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN last_nodeinfo_broadcast_at datetime NULL").Error; err != nil { return fmt.Errorf("migrate bot_nodes last_nodeinfo_broadcast_at column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "LLMQueueEnabled") { + if !migrator.HasColumn(&BotNodeRecord{}, "LLMQueueEnabled") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN llm_queue_enabled numeric NOT NULL DEFAULT 1").Error; err != nil { return fmt.Errorf("migrate bot_nodes llm_queue_enabled column: %w", err) } } - if !migrator.HasColumn(&botNodeRecord{}, "LLMIncludeChannelMessages") { + if !migrator.HasColumn(&BotNodeRecord{}, "LLMIncludeChannelMessages") { if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN llm_include_channel_messages numeric NOT NULL DEFAULT 0").Error; err != nil { return fmt.Errorf("migrate bot_nodes llm_include_channel_messages column: %w", err) } } // 迁移 LLM 消息队列 reply 列 - if migrator.HasTable(&llmMessageQueueRecord{}) && !migrator.HasColumn(&llmMessageQueueRecord{}, "Reply") { + if migrator.HasTable(&LLMMessageQueueRecord{}) && !migrator.HasColumn(&LLMMessageQueueRecord{}, "Reply") { if err := tx.Exec("ALTER TABLE llm_message_queue ADD COLUMN reply text").Error; err != nil { return fmt.Errorf("migrate llm_message_queue reply column: %w", err) } } // 迁移 LLM 消息队列 message_type 列 - if migrator.HasTable(&llmMessageQueueRecord{}) && !migrator.HasColumn(&llmMessageQueueRecord{}, "MessageType") { + if migrator.HasTable(&LLMMessageQueueRecord{}) && !migrator.HasColumn(&LLMMessageQueueRecord{}, "MessageType") { if err := tx.Exec("ALTER TABLE llm_message_queue ADD COLUMN message_type text NOT NULL DEFAULT 'direct'").Error; err != nil { return fmt.Errorf("migrate llm_message_queue message_type column: %w", err) } @@ -763,19 +771,19 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error } func migrateBotDirectMessages(tx *gorm.DB, migrator gorm.Migrator) error { - if !migrator.HasTable(&botDirectMessageRecord{}) { + if !migrator.HasTable(&BotDirectMessageRecord{}) { return nil } - if !migrator.HasColumn(&botDirectMessageRecord{}, "ReadAt") { + if !migrator.HasColumn(&BotDirectMessageRecord{}, "ReadAt") { if err := tx.Exec("ALTER TABLE bot_direct_messages ADD COLUMN read_at datetime").Error; err != nil { return fmt.Errorf("migrate bot_direct_messages read_at column: %w", err) } } // 历史 outbound 消息默认视为已读,避免出现在未读统计里。 - if err := tx.Exec("UPDATE bot_direct_messages SET read_at = created_at WHERE direction = ? AND read_at IS NULL", botDirectMessageDirectionOutbound).Error; err != nil { + if err := tx.Exec("UPDATE bot_direct_messages SET read_at = created_at WHERE direction = ? AND read_at IS NULL", BotDirectMessageDirectionOutbound).Error; err != nil { return fmt.Errorf("backfill bot_direct_messages outbound read_at: %w", err) } - if !migrator.HasIndex(&botDirectMessageRecord{}, "idx_bot_direct_messages_read_at") { + if !migrator.HasIndex(&BotDirectMessageRecord{}, "idx_bot_direct_messages_read_at") { // HasIndex 用 struct 字段名映射的索引名匹配,column 标签写的 read_at 不一定生成此名, // 所以直接 IF NOT EXISTS 创建(SQLite + MySQL 都支持)。 if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_bot_direct_messages_read_at ON bot_direct_messages(read_at)").Error; err != nil { @@ -786,36 +794,36 @@ func migrateBotDirectMessages(tx *gorm.DB, migrator gorm.Migrator) error { } func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error { - if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") { - if driver == databaseDriverSQLite { + if !migrator.HasColumn(&MapTileSourceRecord{}, "ProxyEnabled") { + if driver == config.DriverSQLite { if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN proxy_enabled numeric NOT NULL DEFAULT true").Error; err != nil { return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err) } - } else if err := migrator.AddColumn(&mapTileSourceRecord{}, "ProxyEnabled"); err != nil { + } else if err := migrator.AddColumn(&MapTileSourceRecord{}, "ProxyEnabled"); err != nil { return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err) } } - if !migrator.HasColumn(&mapTileSourceRecord{}, "URLTemplateHash") { - if driver == databaseDriverSQLite { + if !migrator.HasColumn(&MapTileSourceRecord{}, "URLTemplateHash") { + if driver == config.DriverSQLite { if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN url_template_hash TEXT NOT NULL DEFAULT ''").Error; err != nil { return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err) } - } else if err := migrator.AddColumn(&mapTileSourceRecord{}, "URLTemplateHash"); err != nil { + } else if err := migrator.AddColumn(&MapTileSourceRecord{}, "URLTemplateHash"); err != nil { return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err) } } - var rows []mapTileSourceRecord - if err := tx.Model(&mapTileSourceRecord{}).Where("url_template_hash = '' OR url_template_hash IS NULL").Find(&rows).Error; err != nil { + var rows []MapTileSourceRecord + if err := tx.Model(&MapTileSourceRecord{}).Where("url_template_hash = '' OR url_template_hash IS NULL").Find(&rows).Error; err != nil { return fmt.Errorf("list map_tile_sources missing url_template_hash: %w", err) } for _, row := range rows { - if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", row.ID).Update("url_template_hash", mapTileSourceHash(row.URLTemplate)).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", row.ID).Update("url_template_hash", MapTileSourceHash(row.URLTemplate)).Error; err != nil { return fmt.Errorf("backfill map_tile_sources url_template_hash: %w", err) } } - if !migrator.HasIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash") { - if err := migrator.CreateIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash"); err != nil { + if !migrator.HasIndex(&MapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash") { + if err := migrator.CreateIndex(&MapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash"); err != nil { return fmt.Errorf("migrate map_tile_sources index idx_map_tile_sources_url_template_hash: %w", err) } } @@ -833,7 +841,7 @@ func createMissingIndexes(migrator gorm.Migrator, model any, label string, index return nil } -func (s *store) UpsertNodeInfo(record map[string]any) error { +func (s *Store) UpsertNodeInfo(record map[string]any) error { node, err := nodeInfoFromRecord(record) if err != nil { return err @@ -847,7 +855,7 @@ func (s *store) UpsertNodeInfo(record map[string]any) error { return nil } -func (s *store) UpsertMapReport(record map[string]any) error { +func (s *Store) UpsertMapReport(record map[string]any) error { report, err := mapReportFromRecord(record) if err != nil { return err @@ -861,9 +869,9 @@ func (s *store) UpsertMapReport(record map[string]any) error { return nil } -func (s *store) upsertNodeInfoRecord(node *nodeInfoRecord) error { +func (s *Store) upsertNodeInfoRecord(node *NodeInfoRecord) error { return s.db.Transaction(func(tx *gorm.DB) error { - var existing nodeInfoRecord + var existing NodeInfoRecord err := tx.Where("node_id = ?", node.NodeID).Take(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { if err := tx.Create(node).Error; err != nil { @@ -878,14 +886,14 @@ func (s *store) upsertNodeInfoRecord(node *nodeInfoRecord) error { }) } -func (s *store) upsertMapReportRecord(report *mapReportRecord) error { +func (s *Store) upsertMapReportRecord(report *MapReportRecord) error { return s.db.Transaction(func(tx *gorm.DB) error { return s.upsertMapReportRecordTx(tx, report) }) } -func (s *store) upsertMapReportRecordTx(tx *gorm.DB, report *mapReportRecord) error { - var existing mapReportRecord +func (s *Store) upsertMapReportRecordTx(tx *gorm.DB, report *MapReportRecord) error { + var existing MapReportRecord err := tx.Where("node_id = ?", report.NodeID).Take(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { if err := tx.Create(report).Error; err != nil { @@ -899,17 +907,17 @@ func (s *store) upsertMapReportRecordTx(tx *gorm.DB, report *mapReportRecord) er return s.updateMapReportRecord(tx, report) } -func (s *store) updateNodeInfoRecord(tx *gorm.DB, node *nodeInfoRecord) error { +func (s *Store) updateNodeInfoRecord(tx *gorm.DB, node *NodeInfoRecord) error { updates := nodeInfoUpdates(node) - return tx.Model(&nodeInfoRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error + return tx.Model(&NodeInfoRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error } -func (s *store) updateMapReportRecord(tx *gorm.DB, report *mapReportRecord) error { +func (s *Store) updateMapReportRecord(tx *gorm.DB, report *MapReportRecord) error { updates := mapReportUpdates(report) - return tx.Model(&mapReportRecord{}).Where("node_id = ?", report.NodeID).Updates(updates).Error + return tx.Model(&MapReportRecord{}).Where("node_id = ?", report.NodeID).Updates(updates).Error } -func (s *store) updateMapReportFromNodeInfo(node *nodeInfoRecord) error { +func (s *Store) updateMapReportFromNodeInfo(node *NodeInfoRecord) error { updates := map[string]any{ "node_num": node.NodeNum, "updated_at": time.Now(), @@ -918,10 +926,10 @@ func (s *store) updateMapReportFromNodeInfo(node *nodeInfoRecord) error { addStringUpdate(updates, "short_name", node.ShortName) addStringUpdate(updates, "hw_model", node.HWModel) addStringUpdate(updates, "role", node.Role) - return s.db.Model(&mapReportRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error + return s.db.Model(&MapReportRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error } -func (s *store) updateNodeInfoFromMapReport(report *mapReportRecord) error { +func (s *Store) updateNodeInfoFromMapReport(report *MapReportRecord) error { updates := map[string]any{ "node_num": report.NodeNum, "updated_at": time.Now(), @@ -930,10 +938,10 @@ func (s *store) updateNodeInfoFromMapReport(report *mapReportRecord) error { addStringUpdate(updates, "short_name", report.ShortName) addStringUpdate(updates, "hw_model", report.HWModel) addStringUpdate(updates, "role", report.Role) - return s.db.Model(&nodeInfoRecord{}).Where("node_id = ?", report.NodeID).Updates(updates).Error + return s.db.Model(&NodeInfoRecord{}).Where("node_id = ?", report.NodeID).Updates(updates).Error } -func nodeInfoUpdates(node *nodeInfoRecord) map[string]any { +func nodeInfoUpdates(node *NodeInfoRecord) map[string]any { updates := map[string]any{ "node_num": node.NodeNum, "content_json": node.ContentJSON, @@ -949,7 +957,7 @@ func nodeInfoUpdates(node *nodeInfoRecord) map[string]any { return updates } -func mapReportUpdates(report *mapReportRecord) map[string]any { +func mapReportUpdates(report *MapReportRecord) map[string]any { updates := map[string]any{ "node_num": report.NodeNum, "content_json": report.ContentJSON, @@ -971,7 +979,7 @@ func mapReportUpdates(report *mapReportRecord) map[string]any { return updates } -func (s *store) InsertTextMessage(record map[string]any, clientInfo mqttClientInfo) error { +func (s *Store) InsertTextMessage(record map[string]any, clientInfo MQTTClientInfo) error { message, err := textMessageFromRecord(record, clientInfo) if err != nil { return err @@ -982,7 +990,7 @@ func (s *store) InsertTextMessage(record map[string]any, clientInfo mqttClientIn return nil } -func (s *store) InsertPosition(record map[string]any, clientInfo mqttClientInfo) error { +func (s *Store) InsertPosition(record map[string]any, clientInfo MQTTClientInfo) error { position, err := positionFromRecord(record, clientInfo) if err != nil { return err @@ -998,8 +1006,8 @@ func (s *store) InsertPosition(record map[string]any, clientInfo mqttClientInfo) }) } -func (s *store) upsertMapReportFromPosition(tx *gorm.DB, position *positionRecord) error { - report := &mapReportRecord{ +func (s *Store) upsertMapReportFromPosition(tx *gorm.DB, position *PositionRecord) error { + report := &MapReportRecord{ NodeID: position.FromID, NodeNum: position.FromNum, Latitude: position.Latitude, @@ -1009,7 +1017,7 @@ func (s *store) upsertMapReportFromPosition(tx *gorm.DB, position *positionRecor ContentJSON: position.ContentJSON, } - var existing mapReportRecord + var existing MapReportRecord err := tx.Where("node_id = ?", position.FromID).Take(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { return tx.Create(report).Error @@ -1022,10 +1030,10 @@ func (s *store) upsertMapReportFromPosition(tx *gorm.DB, position *positionRecor addFloat64Update(updates, "longitude", position.Longitude) addInt64Update(updates, "altitude", position.Altitude) addInt64Update(updates, "position_precision", position.PrecisionBits) - return tx.Model(&mapReportRecord{}).Where("node_id = ?", position.FromID).Updates(updates).Error + return tx.Model(&MapReportRecord{}).Where("node_id = ?", position.FromID).Updates(updates).Error } -func (s *store) InsertTelemetry(record map[string]any, clientInfo mqttClientInfo) error { +func (s *Store) InsertTelemetry(record map[string]any, clientInfo MQTTClientInfo) error { telemetry, err := telemetryFromRecord(record, clientInfo) if err != nil { return err @@ -1036,7 +1044,7 @@ func (s *store) InsertTelemetry(record map[string]any, clientInfo mqttClientInfo return nil } -func (s *store) InsertRouting(record map[string]any, clientInfo mqttClientInfo) error { +func (s *Store) InsertRouting(record map[string]any, clientInfo MQTTClientInfo) error { routing, err := routingFromRecord(record, clientInfo) if err != nil { return err @@ -1047,7 +1055,7 @@ func (s *store) InsertRouting(record map[string]any, clientInfo mqttClientInfo) return nil } -func (s *store) InsertTraceroute(record map[string]any, clientInfo mqttClientInfo) error { +func (s *Store) InsertTraceroute(record map[string]any, clientInfo MQTTClientInfo) error { traceroute, err := tracerouteFromRecord(record, clientInfo) if err != nil { return err @@ -1058,7 +1066,7 @@ func (s *store) InsertTraceroute(record map[string]any, clientInfo mqttClientInf return nil } -func nodeInfoFromRecord(record map[string]any) (*nodeInfoRecord, error) { +func nodeInfoFromRecord(record map[string]any) (*NodeInfoRecord, error) { recordType, ok := record["type"].(string) if !ok || recordType != "nodeinfo" { return nil, fmt.Errorf("record type %v is not nodeinfo", record["type"]) @@ -1068,21 +1076,21 @@ func nodeInfoFromRecord(record map[string]any) (*nodeInfoRecord, error) { return nil, err } - return &nodeInfoRecord{ + return &NodeInfoRecord{ NodeID: nodeID, NodeNum: nodeNum, - UserID: nullableString(record["user_id"]), - LongName: nullableString(record["long_name"]), - ShortName: nullableString(record["short_name"]), - HWModel: nullableString(record["hw_model"]), - Role: nullableString(record["role"]), + UserID: NullableString(record["user_id"]), + LongName: NullableString(record["long_name"]), + ShortName: NullableString(record["short_name"]), + HWModel: NullableString(record["hw_model"]), + Role: NullableString(record["role"]), IsLicensed: nullableBool(record["is_licensed"]), - PublicKey: nullableString(record["public_key"]), + PublicKey: NullableString(record["public_key"]), ContentJSON: contentJSON, }, nil } -func mapReportFromRecord(record map[string]any) (*mapReportRecord, error) { +func mapReportFromRecord(record map[string]any) (*MapReportRecord, error) { recordType, ok := record["type"].(string) if !ok || recordType != "map_report" { return nil, fmt.Errorf("record type %v is not map_report", record["type"]) @@ -1092,16 +1100,16 @@ func mapReportFromRecord(record map[string]any) (*mapReportRecord, error) { return nil, err } - return &mapReportRecord{ + return &MapReportRecord{ NodeID: nodeID, NodeNum: nodeNum, - LongName: nullableString(record["long_name"]), - ShortName: nullableString(record["short_name"]), - HWModel: nullableString(record["hw_model"]), - Role: nullableString(record["role"]), - FirmwareVersion: nullableString(record["firmware_version"]), - Region: nullableString(record["region"]), - ModemPreset: nullableString(record["modem_preset"]), + LongName: NullableString(record["long_name"]), + ShortName: NullableString(record["short_name"]), + HWModel: NullableString(record["hw_model"]), + Role: NullableString(record["role"]), + FirmwareVersion: NullableString(record["firmware_version"]), + Region: NullableString(record["region"]), + ModemPreset: NullableString(record["modem_preset"]), Latitude: nullableFloat64(record["latitude"]), Longitude: nullableFloat64(record["longitude"]), Altitude: nullableInt64(record["altitude"]), @@ -1128,7 +1136,7 @@ func nodeRecordBase(record map[string]any, label string) (string, int64, string, return nodeID, nodeNum, string(contentJSON), nil } -func textMessageFromRecord(record map[string]any, clientInfo mqttClientInfo) (*textMessageRecord, error) { +func textMessageFromRecord(record map[string]any, clientInfo MQTTClientInfo) (*TextMessageRecord, error) { recordType, ok := record["type"].(string) if !ok || recordType != "text_message" { return nil, fmt.Errorf("record type %v is not text_message", record["type"]) @@ -1137,11 +1145,11 @@ func textMessageFromRecord(record map[string]any, clientInfo mqttClientInfo) (*t if err != nil { return nil, err } - return &textMessageRecord{ + return &TextMessageRecord{ FromID: common.FromID, FromNum: common.FromNum, - Text: nullableString(record["text"]), - PayloadHex: nullableString(record["payload_hex"]), + Text: NullableString(record["text"]), + PayloadHex: NullableString(record["payload_hex"]), Topic: common.Topic, ChannelID: common.ChannelID, GatewayID: common.GatewayID, @@ -1165,20 +1173,20 @@ func textMessageFromRecord(record map[string]any, clientInfo mqttClientInfo) (*t }, nil } -func positionFromRecord(record map[string]any, clientInfo mqttClientInfo) (*positionRecord, error) { +func positionFromRecord(record map[string]any, clientInfo MQTTClientInfo) (*PositionRecord, error) { common, clientFields, err := AppendPacketFieldsFromRecord(record, "position", clientInfo) if err != nil { return nil, err } - return &positionRecord{ + return &PositionRecord{ AppendPacketFields: common, MQTTClientRecordFields: clientFields, Latitude: nullableFloat64(record["latitude"]), Longitude: nullableFloat64(record["longitude"]), Altitude: nullableInt64(record["altitude"]), PositionTime: nullableInt64(record["time"]), - LocationSource: nullableStringValue(record["location_source"]), - AltitudeSource: nullableStringValue(record["altitude_source"]), + LocationSource: NullableStringValue(record["location_source"]), + AltitudeSource: NullableStringValue(record["altitude_source"]), Timestamp: nullableInt64(record["timestamp"]), TimestampMillisAdjust: nullableInt64(record["timestamp_millis_adjust"]), AltitudeHAE: nullableInt64(record["altitude_hae"]), @@ -1199,7 +1207,7 @@ func positionFromRecord(record map[string]any, clientInfo mqttClientInfo) (*posi }, nil } -func telemetryFromRecord(record map[string]any, clientInfo mqttClientInfo) (*telemetryRecord, error) { +func telemetryFromRecord(record map[string]any, clientInfo MQTTClientInfo) (*TelemetryRecord, error) { common, clientFields, err := AppendPacketFieldsFromRecord(record, "telemetry", clientInfo) if err != nil { return nil, err @@ -1208,32 +1216,32 @@ func telemetryFromRecord(record map[string]any, clientInfo mqttClientInfo) (*tel if err != nil { return nil, fmt.Errorf("encode telemetry metrics_json: %w", err) } - return &telemetryRecord{ + return &TelemetryRecord{ AppendPacketFields: common, MQTTClientRecordFields: clientFields, TelemetryTime: nullableInt64(record["time"]), - TelemetryType: nullableString(record["telemetry_type"]), + TelemetryType: NullableString(record["telemetry_type"]), MetricsJSON: metricsJSON, }, nil } -func routingFromRecord(record map[string]any, clientInfo mqttClientInfo) (*routingRecord, error) { +func routingFromRecord(record map[string]any, clientInfo MQTTClientInfo) (*RoutingRecord, error) { common, clientFields, err := AppendPacketFieldsFromRecord(record, "routing", clientInfo) if err != nil { return nil, err } - return &routingRecord{AppendPacketFields: common, MQTTClientRecordFields: clientFields}, nil + return &RoutingRecord{AppendPacketFields: common, MQTTClientRecordFields: clientFields}, nil } -func tracerouteFromRecord(record map[string]any, clientInfo mqttClientInfo) (*tracerouteRecord, error) { +func tracerouteFromRecord(record map[string]any, clientInfo MQTTClientInfo) (*TracerouteRecord, error) { common, clientFields, err := AppendPacketFieldsFromRecord(record, "traceroute", clientInfo) if err != nil { return nil, err } - return &tracerouteRecord{AppendPacketFields: common, MQTTClientRecordFields: clientFields}, nil + return &TracerouteRecord{AppendPacketFields: common, MQTTClientRecordFields: clientFields}, nil } -func AppendPacketFieldsFromRecord(record map[string]any, wantType string, clientInfo mqttClientInfo) (AppendPacketFields, MQTTClientRecordFields, error) { +func AppendPacketFieldsFromRecord(record map[string]any, wantType string, clientInfo MQTTClientInfo) (AppendPacketFields, MQTTClientRecordFields, error) { recordType, ok := record["type"].(string) if !ok || recordType != wantType { return AppendPacketFields{}, MQTTClientRecordFields{}, fmt.Errorf("record type %v is not %s", record["type"], wantType) @@ -1259,26 +1267,26 @@ func AppendPacketFieldsFromRecord(record map[string]any, wantType string, client FromID: fromID, FromNum: fromNum, Topic: topic, - ChannelID: nullableString(record["channel_id"]), - GatewayID: nullableString(record["gateway_id"]), + ChannelID: NullableString(record["channel_id"]), + GatewayID: NullableString(record["gateway_id"]), PacketID: nullableInt64(record["packet_id"]), - PacketTo: nullableString(record["packet_to"]), + PacketTo: NullableString(record["packet_to"]), PacketToNum: nullableInt64(record["packet_to_num"]), - Portnum: nullableString(record["portnum"]), + Portnum: NullableString(record["portnum"]), PayloadLen: nullableInt64(record["payload_len"]), - PayloadVariant: nullableString(record["payload_variant"]), + PayloadVariant: NullableString(record["payload_variant"]), ViaMQTT: nullableBool(record["via_mqtt"]), PKIEncrypted: nullableBool(record["pki_encrypted"]), DecryptSuccess: nullableBool(record["decrypt_success"]), - DecryptStatus: nullableString(record["decrypt_status"]), + DecryptStatus: NullableString(record["decrypt_status"]), ContentJSON: string(contentJSON), }, MQTTClientRecordFields{ - MQTTClientID: nullableString(clientInfo.ClientID), - MQTTUsername: nullableString(clientInfo.Username), - MQTTListener: nullableString(clientInfo.Listener), - MQTTRemoteAddr: nullableString(clientInfo.RemoteAddr), - MQTTRemoteHost: nullableString(clientInfo.RemoteHost), - MQTTRemotePort: nullableString(clientInfo.RemotePort), + MQTTClientID: NullableString(clientInfo.ClientID), + MQTTUsername: NullableString(clientInfo.Username), + MQTTListener: NullableString(clientInfo.Listener), + MQTTRemoteAddr: NullableString(clientInfo.RemoteAddr), + MQTTRemoteHost: NullableString(clientInfo.RemoteHost), + MQTTRemotePort: NullableString(clientInfo.RemotePort), }, nil } @@ -1311,7 +1319,7 @@ func int64FromAny(value any) (int64, error) { } } -func nullableString(value any) *string { +func NullableString(value any) *string { if value == nil { return nil } @@ -1322,7 +1330,7 @@ func nullableString(value any) *string { return &s } -func nullableStringValue(value any) *string { +func NullableStringValue(value any) *string { if value == nil { return nil } diff --git a/db_test.go b/internal/store/db_test.go similarity index 93% rename from db_test.go rename to internal/store/db_test.go index 6adc8a2..b4b6778 100644 --- a/db_test.go +++ b/internal/store/db_test.go @@ -1,4 +1,4 @@ -package main +package store import ( "database/sql" @@ -10,6 +10,8 @@ import ( "time" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/config" ) func TestOpenStoreCreatesTables(t *testing.T) { @@ -49,7 +51,7 @@ func TestCountSignsByDayFormatsDateString(t *testing.T) { t.Fatalf("CreateSign() error = %v", err) } - rows, err := st.CountSignsByDay(listOptions{}) + rows, err := st.CountSignsByDay(ListOptions{}) if err != nil { t.Fatalf("CountSignsByDay() error = %v", err) } @@ -164,7 +166,7 @@ func TestListMapReportsFiltersByBounds(t *testing.T) { minLat, maxLat := 10.0, 11.0 minLng, maxLng := 20.0, 21.0 - opts := listOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng} + opts := ListOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng} rows, err := st.ListMapReports(opts) if err != nil { t.Fatalf("ListMapReports() error = %v", err) @@ -206,7 +208,7 @@ func TestListMapReportsFiltersAcrossAntimeridian(t *testing.T) { minLat, maxLat := -10.0, 10.0 minLng, maxLng := 170.0, -170.0 - rows, err := st.ListMapReports(listOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}) + rows, err := st.ListMapReports(ListOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}) if err != nil { t.Fatalf("ListMapReports() error = %v", err) } @@ -239,8 +241,8 @@ func TestListMapReportViewportReturnsPointsBelowThreshold(t *testing.T) { minLat, maxLat := -1.0, 5.0 minLng, maxLng := -1.0, 5.0 - result, err := st.ListMapReportViewport(mapReportViewportOptions{ - ListOptions: listOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}, + result, err := st.ListMapReportViewport(MapReportViewportOptions{ + ListOptions: ListOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}, Zoom: 8, Limit: 1000, ClusterThreshold: 10, @@ -271,8 +273,8 @@ func TestListMapReportViewportReturnsClustersAboveThreshold(t *testing.T) { minLat, maxLat := 9.0, 11.0 minLng, maxLng := 19.0, 21.0 - result, err := st.ListMapReportViewport(mapReportViewportOptions{ - ListOptions: listOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}, + result, err := st.ListMapReportViewport(MapReportViewportOptions{ + ListOptions: ListOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}, Zoom: 4, Limit: 1000, ClusterThreshold: 2, @@ -498,7 +500,7 @@ func TestEnsureDefaultAdminCreatesAdminUser(t *testing.T) { if err != nil { t.Fatalf("GetUserByUsername() error = %v", err) } - if user.Role != adminRole { + if user.Role != AdminRole { t.Fatalf("role = %q, want admin", user.Role) } if user.PasswordHash == "admin" || user.PasswordHash == "" { @@ -536,7 +538,7 @@ func TestCreateAdminUserCreatesHashedAdmin(t *testing.T) { if err != nil { t.Fatalf("CreateAdminUser() error = %v", err) } - if user.Username != "new-admin" || user.Role != adminRole { + if user.Username != "new-admin" || user.Role != AdminRole { t.Fatalf("user = %#v, want new-admin admin", user) } if user.PasswordHash == "secret" || !verifyPassword(user.PasswordHash, "secret") { @@ -551,8 +553,8 @@ func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) { if _, err := st.CreateAdminUser("new-admin", "secret"); err != nil { t.Fatalf("first CreateAdminUser() error = %v", err) } - if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, errUserAlreadyExists) { - t.Fatalf("duplicate CreateAdminUser() error = %v, want errUserAlreadyExists", err) + if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, ErrUserAlreadyExists) { + t.Fatalf("duplicate CreateAdminUser() error = %v, want ErrUserAlreadyExists", err) } } @@ -591,14 +593,14 @@ func TestInsertAndListLoginLogs(t *testing.T) { defer st.Close() userID := uint64(1) - if err := st.InsertLoginLog(loginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { + if err := st.InsertLoginLog(LoginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { t.Fatalf("InsertLoginLog(success) error = %v", err) } - if err := st.InsertLoginLog(loginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { + if err := st.InsertLoginLog(LoginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { t.Fatalf("InsertLoginLog(failure) error = %v", err) } - logs, err := st.ListLoginLogs(listOptions{Limit: 10}) + logs, err := st.ListLoginLogs(ListOptions{Limit: 10}) if err != nil { t.Fatalf("ListLoginLogs() error = %v", err) } @@ -621,7 +623,7 @@ func TestInsertDiscardDetailsStoresRawBase64AndClientInfo(t *testing.T) { defer st.Close() raw := []byte{0xff, 0x00, 0x01} - clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} + clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} record := map[string]any{"topic": "msh/US/test", "error": "protobuf decode failed", "payload_len": len(raw)} if err := st.InsertDiscardDetails(record, raw, clientInfo); err != nil { t.Fatalf("InsertDiscardDetails() error = %v", err) @@ -647,13 +649,13 @@ func TestListDiscardDetailsOrdersNewestFirst(t *testing.T) { st := openTestStore(t) defer st.Close() - if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, mqttClientInfo{}); err != nil { + if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, MQTTClientInfo{}); err != nil { t.Fatalf("first InsertDiscardDetails() error = %v", err) } - if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, mqttClientInfo{}); err != nil { + if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, MQTTClientInfo{}); err != nil { t.Fatalf("second InsertDiscardDetails() error = %v", err) } - rows, err := st.ListDiscardDetails(listOptions{Limit: 10}) + rows, err := st.ListDiscardDetails(ListOptions{Limit: 10}) if err != nil { t.Fatalf("ListDiscardDetails() error = %v", err) } @@ -669,7 +671,7 @@ func TestInsertTextMessageAppendsRows(t *testing.T) { st := openTestStore(t) defer st.Close() - clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} + clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil { t.Fatalf("first InsertTextMessage() error = %v", err) } @@ -710,7 +712,7 @@ func TestDeleteTextMessageDeletesRows(t *testing.T) { st := openTestStore(t) defer st.Close() - if err := st.InsertTextMessage(textMessageTestRecord("hello"), mqttClientInfo{}); err != nil { + if err := st.InsertTextMessage(textMessageTestRecord("hello"), MQTTClientInfo{}); err != nil { t.Fatalf("InsertTextMessage() error = %v", err) } var id uint64 @@ -736,7 +738,7 @@ func TestInsertTextMessageStoresClientInfo(t *testing.T) { st := openTestStore(t) defer st.Close() - clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} + clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil { t.Fatalf("InsertTextMessage() error = %v", err) } @@ -756,7 +758,7 @@ func TestInsertTextMessageStoresPayloadHex(t *testing.T) { record := textMessageTestRecord(nil) record["payload_hex"] = "fffefd" - if err := st.InsertTextMessage(record, mqttClientInfo{}); err != nil { + if err := st.InsertTextMessage(record, MQTTClientInfo{}); err != nil { t.Fatalf("InsertTextMessage() error = %v", err) } @@ -777,16 +779,16 @@ func TestInsertTextMessageRequiresFields(t *testing.T) { st := openTestStore(t) defer st.Close() - if err := st.InsertTextMessage(map[string]any{"type": "nodeinfo"}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "text_message") { + if err := st.InsertTextMessage(map[string]any{"type": "nodeinfo"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "text_message") { t.Fatalf("wrong type error = %v, want text_message error", err) } - if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from_num": 1, "topic": "msh/test"}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "from") { + if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from_num": 1, "topic": "msh/test"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "from") { t.Fatalf("missing from error = %v, want from error", err) } - if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "topic": "msh/test"}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "from_num") { + if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "topic": "msh/test"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "from_num") { t.Fatalf("missing from_num error = %v, want from_num error", err) } - if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "from_num": 1}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "topic") { + if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "from_num": 1}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "topic") { t.Fatalf("missing topic error = %v, want topic error", err) } } @@ -795,7 +797,7 @@ func TestInsertPositionAppendsRows(t *testing.T) { st := openTestStore(t) defer st.Close() - clientInfo := mqttClientInfo{ClientID: "client-1", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} + clientInfo := MQTTClientInfo{ClientID: "client-1", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"} if err := st.InsertPosition(positionTestRecord(), clientInfo); err != nil { t.Fatalf("first InsertPosition() error = %v", err) } @@ -826,7 +828,7 @@ func TestInsertPositionCreatesMapReportWhenMissing(t *testing.T) { st := openTestStore(t) defer st.Close() - if err := st.InsertPosition(positionTestRecord(), mqttClientInfo{}); err != nil { + if err := st.InsertPosition(positionTestRecord(), MQTTClientInfo{}); err != nil { t.Fatalf("InsertPosition() error = %v", err) } @@ -854,7 +856,7 @@ func TestInsertPositionUpdatesExistingMapReportCoordinates(t *testing.T) { position["longitude"] = 120.75 position["altitude"] = int32(88) position["precision_bits"] = uint32(10) - if err := st.InsertPosition(position, mqttClientInfo{}); err != nil { + if err := st.InsertPosition(position, MQTTClientInfo{}); err != nil { t.Fatalf("InsertPosition() error = %v", err) } @@ -873,7 +875,7 @@ func TestInsertTelemetryAppendsRowsAndStoresMetricsJSON(t *testing.T) { st := openTestStore(t) defer st.Close() - if err := st.InsertTelemetry(telemetryTestRecord(), mqttClientInfo{}); err != nil { + if err := st.InsertTelemetry(telemetryTestRecord(), MQTTClientInfo{}); err != nil { t.Fatalf("InsertTelemetry() error = %v", err) } @@ -896,16 +898,16 @@ func TestInsertRoutingAndTracerouteAppendRows(t *testing.T) { st := openTestStore(t) defer st.Close() - if err := st.InsertRouting(routingTestRecord(), mqttClientInfo{}); err != nil { + if err := st.InsertRouting(routingTestRecord(), MQTTClientInfo{}); err != nil { t.Fatalf("first InsertRouting() error = %v", err) } - if err := st.InsertRouting(routingTestRecord(), mqttClientInfo{}); err != nil { + if err := st.InsertRouting(routingTestRecord(), MQTTClientInfo{}); err != nil { t.Fatalf("second InsertRouting() error = %v", err) } - if err := st.InsertTraceroute(tracerouteTestRecord(), mqttClientInfo{}); err != nil { + if err := st.InsertTraceroute(tracerouteTestRecord(), MQTTClientInfo{}); err != nil { t.Fatalf("first InsertTraceroute() error = %v", err) } - if err := st.InsertTraceroute(tracerouteTestRecord(), mqttClientInfo{}); err != nil { + if err := st.InsertTraceroute(tracerouteTestRecord(), MQTTClientInfo{}); err != nil { t.Fatalf("second InsertTraceroute() error = %v", err) } @@ -938,10 +940,10 @@ func TestInsertPacketTablesRequireFields(t *testing.T) { insert func(map[string]any) error record map[string]any }{ - {name: "position", insert: func(r map[string]any) error { return st.InsertPosition(r, mqttClientInfo{}) }, record: positionTestRecord()}, - {name: "telemetry", insert: func(r map[string]any) error { return st.InsertTelemetry(r, mqttClientInfo{}) }, record: telemetryTestRecord()}, - {name: "routing", insert: func(r map[string]any) error { return st.InsertRouting(r, mqttClientInfo{}) }, record: routingTestRecord()}, - {name: "traceroute", insert: func(r map[string]any) error { return st.InsertTraceroute(r, mqttClientInfo{}) }, record: tracerouteTestRecord()}, + {name: "position", insert: func(r map[string]any) error { return st.InsertPosition(r, MQTTClientInfo{}) }, record: positionTestRecord()}, + {name: "telemetry", insert: func(r map[string]any) error { return st.InsertTelemetry(r, MQTTClientInfo{}) }, record: telemetryTestRecord()}, + {name: "routing", insert: func(r map[string]any) error { return st.InsertRouting(r, MQTTClientInfo{}) }, record: routingTestRecord()}, + {name: "traceroute", insert: func(r map[string]any) error { return st.InsertTraceroute(r, MQTTClientInfo{}) }, record: tracerouteTestRecord()}, } for _, tt := range tests { @@ -971,19 +973,19 @@ func TestInsertPacketTablesRequireFields(t *testing.T) { } } -func openTestStore(t *testing.T) *store { +func openTestStore(t *testing.T) *Store { t.Helper() - st, err := openStore(databaseConfig{ - Driver: databaseDriverSQLite, - SQLite: sqliteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, + st, err := OpenStore(config.DatabaseConfig{ + Driver: config.DriverSQLite, + SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, }) if err != nil { - t.Fatalf("openStore() error = %v", err) + t.Fatalf("OpenStore() error = %v", err) } return st } -func rawTestDB(t *testing.T, st *store) *sql.DB { +func rawTestDB(t *testing.T, st *Store) *sql.DB { t.Helper() db, err := st.db.DB() if err != nil { diff --git a/db_write_queue.go b/internal/store/db_write_queue.go similarity index 59% rename from db_write_queue.go rename to internal/store/db_write_queue.go index fc5be49..0ca6b47 100644 --- a/db_write_queue.go +++ b/internal/store/db_write_queue.go @@ -1,94 +1,94 @@ -package main +package store import "sync" -type dbWriteQueue struct { - store *store - jobs chan dbWriteJob +type WriteQueue struct { + store *Store + jobs chan writeJob wg sync.WaitGroup } -type dbWriteJob struct { +type writeJob struct { typeName string from any run func() error errorEvent map[string]any } -func newDBWriteQueue(store *store) *dbWriteQueue { - if store == nil { +func NewWriteQueue(s *Store) *WriteQueue { + if s == nil { return nil } - q := &dbWriteQueue{ - store: store, - jobs: make(chan dbWriteJob, 1024), + q := &WriteQueue{ + store: s, + jobs: make(chan writeJob, 1024), } q.wg.Add(1) go q.run() return q } -func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClientInfo) { +func (q *WriteQueue) EnqueueRecord(record map[string]any, clientInfo MQTTClientInfo) { if q == nil { return } record = cloneDBWriteRecord(record) switch record["type"] { case "nodeinfo": - q.enqueue(dbWriteJob{typeName: "nodeinfo", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "nodeinfo", from: record["from"], run: func() error { return q.store.UpsertNodeInfo(record) }}) case "map_report": - q.enqueue(dbWriteJob{typeName: "map_report", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "map_report", from: record["from"], run: func() error { return q.store.UpsertMapReport(record) }}) case "text_message": // 私聊(PKI 加密、发往受管 bot)单独走 bot_direct_messages 表, // 不再写入 text_message 以避免和频道消息混在一起。 if isInboundBotDirectMessage(q.store, record) { - q.enqueue(dbWriteJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error { return insertInboundBotDirectMessage(q.store, record, clientInfo) }}) return } // 频道消息同时也写入 LLM 队列(如果启用的话) - q.enqueue(dbWriteJob{typeName: "llm_channel_message", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "llm_channel_message", from: record["from"], run: func() error { return enqueueChannelMessageToLLM(q.store, record) }}) - q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "text_message", from: record["from"], run: func() error { return q.store.InsertTextMessage(record, clientInfo) }}) case "position": - q.enqueue(dbWriteJob{typeName: "position", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "position", from: record["from"], run: func() error { return q.store.InsertPosition(record, clientInfo) }}) case "telemetry": - q.enqueue(dbWriteJob{typeName: "telemetry", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "telemetry", from: record["from"], run: func() error { return q.store.InsertTelemetry(record, clientInfo) }}) case "routing": - q.enqueue(dbWriteJob{typeName: "routing", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "routing", from: record["from"], run: func() error { return q.store.InsertRouting(record, clientInfo) }}) case "traceroute": - q.enqueue(dbWriteJob{typeName: "traceroute", from: record["from"], run: func() error { + q.enqueue(writeJob{typeName: "traceroute", from: record["from"], run: func() error { return q.store.InsertTraceroute(record, clientInfo) }}) } } -func (q *dbWriteQueue) EnqueueDiscard(record map[string]any, raw []byte, clientInfo mqttClientInfo) { +func (q *WriteQueue) EnqueueDiscard(record map[string]any, raw []byte, clientInfo MQTTClientInfo) { if q == nil { return } record = cloneDBWriteRecord(record) raw = append([]byte(nil), raw...) - q.enqueue(dbWriteJob{typeName: "discard_details", from: record["from"], errorEvent: map[string]any{"event": "db_error", "type": "discard_details", "topic": record["topic"]}, run: func() error { + q.enqueue(writeJob{typeName: "discard_details", from: record["from"], errorEvent: map[string]any{"event": "db_error", "type": "discard_details", "topic": record["topic"]}, run: func() error { return q.store.InsertDiscardDetails(record, raw, clientInfo) }}) } -func (q *dbWriteQueue) Close() { +func (q *WriteQueue) Close() { if q == nil { return } @@ -96,18 +96,18 @@ func (q *dbWriteQueue) Close() { q.wg.Wait() } -func (q *dbWriteQueue) Len() int { +func (q *WriteQueue) Len() int { if q == nil { return 0 } return len(q.jobs) } -func (q *dbWriteQueue) enqueue(job dbWriteJob) { +func (q *WriteQueue) enqueue(job writeJob) { q.jobs <- job } -func (q *dbWriteQueue) run() { +func (q *WriteQueue) run() { defer q.wg.Done() for job := range q.jobs { if err := job.run(); err != nil { diff --git a/db_write_queue_test.go b/internal/store/db_write_queue_test.go similarity index 84% rename from db_write_queue_test.go rename to internal/store/db_write_queue_test.go index a553b57..ed01fcc 100644 --- a/db_write_queue_test.go +++ b/internal/store/db_write_queue_test.go @@ -1,4 +1,4 @@ -package main +package store import ( "database/sql" @@ -11,7 +11,7 @@ func TestDBWriteQueueWritesRecordsAsync(t *testing.T) { queue := newDBWriteQueue(st) record := textMessageTestRecord("queued") - queue.EnqueueRecord(record, mqttClientInfo{ClientID: "client-1"}) + queue.EnqueueRecord(record, MQTTClientInfo{ClientID: "client-1"}) record["text"] = "mutated after enqueue" queue.Close() @@ -30,7 +30,7 @@ func TestDBWriteQueueWritesDiscardAsync(t *testing.T) { queue := newDBWriteQueue(st) record := map[string]any{"topic": "msh/test", "error": "bad packet"} - queue.EnqueueDiscard(record, []byte{1, 2, 3}, mqttClientInfo{RemoteAddr: "127.0.0.1:1883"}) + queue.EnqueueDiscard(record, []byte{1, 2, 3}, MQTTClientInfo{RemoteAddr: "127.0.0.1:1883"}) record["error"] = "mutated after enqueue" queue.Close() @@ -44,8 +44,8 @@ func TestDBWriteQueueWritesDiscardAsync(t *testing.T) { } func TestDBWriteQueueLen(t *testing.T) { - queue := &dbWriteQueue{jobs: make(chan dbWriteJob, 1)} - queue.enqueue(dbWriteJob{run: func() error { return nil }}) + queue := &WriteQueue{jobs: make(chan writeJob, 1)} + queue.enqueue(writeJob{run: func() error { return nil }}) if queue.Len() != 1 { t.Fatalf("queue.Len() = %d, want 1", queue.Len()) } @@ -56,7 +56,7 @@ func TestDBWriteQueueIgnoresUnsupportedRecordType(t *testing.T) { defer st.Close() queue := newDBWriteQueue(st) - queue.EnqueueRecord(map[string]any{"type": "empty_packet", "from": "!12345678"}, mqttClientInfo{}) + queue.EnqueueRecord(map[string]any{"type": "empty_packet", "from": "!12345678"}, MQTTClientInfo{}) queue.Close() var count int @@ -72,9 +72,9 @@ func TestDBWriteQueueNilStore(t *testing.T) { if queue := newDBWriteQueue(nil); queue != nil { t.Fatalf("newDBWriteQueue(nil) = %#v, want nil", queue) } - var queue *dbWriteQueue - queue.EnqueueRecord(textMessageTestRecord("ignored"), mqttClientInfo{}) - queue.EnqueueDiscard(map[string]any{"topic": "ignored"}, []byte{1}, mqttClientInfo{}) + var queue *WriteQueue + queue.EnqueueRecord(textMessageTestRecord("ignored"), MQTTClientInfo{}) + queue.EnqueueDiscard(map[string]any{"topic": "ignored"}, []byte{1}, MQTTClientInfo{}) queue.Close() } @@ -85,8 +85,8 @@ func TestDBWriteQueueRecordValidationErrorDoesNotStopWorker(t *testing.T) { queue := newDBWriteQueue(st) badRecord := textMessageTestRecord("bad") delete(badRecord, "from") - queue.EnqueueRecord(badRecord, mqttClientInfo{}) - queue.EnqueueRecord(textMessageTestRecord("good"), mqttClientInfo{}) + queue.EnqueueRecord(badRecord, MQTTClientInfo{}) + queue.EnqueueRecord(textMessageTestRecord("good"), MQTTClientInfo{}) queue.Close() var text string diff --git a/discard_store.go b/internal/store/discard_store.go similarity index 55% rename from discard_store.go rename to internal/store/discard_store.go index 89fd7ef..e364c4e 100644 --- a/discard_store.go +++ b/internal/store/discard_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "encoding/base64" @@ -6,7 +6,7 @@ import ( "fmt" ) -func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo mqttClientInfo) error { +func (s *Store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo MQTTClientInfo) error { details, err := discardDetailsFromRecord(record, raw, clientInfo) if err != nil { return err @@ -17,28 +17,28 @@ func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientIn return nil } -func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo mqttClientInfo) (*discardDetailsRecord, error) { +func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo MQTTClientInfo) (*DiscardDetailsRecord, error) { contentJSON, err := json.Marshal(record) if err != nil { return nil, fmt.Errorf("encode discard_details content_json: %w", err) } - return &discardDetailsRecord{ + return &DiscardDetailsRecord{ Topic: stringValue(record["topic"]), Error: stringValue(record["error"]), PayloadLen: int64(len(raw)), RawBase64: base64.StdEncoding.EncodeToString(raw), ContentJSON: string(contentJSON), - MQTTClientID: nullableStringValue(clientInfo.ClientID), - MQTTUsername: nullableStringValue(clientInfo.Username), - MQTTListener: nullableStringValue(clientInfo.Listener), - MQTTRemoteAddr: nullableStringValue(clientInfo.RemoteAddr), - MQTTRemoteHost: nullableStringValue(clientInfo.RemoteHost), - MQTTRemotePort: nullableStringValue(clientInfo.RemotePort), + MQTTClientID: NullableStringValue(clientInfo.ClientID), + MQTTUsername: NullableStringValue(clientInfo.Username), + MQTTListener: NullableStringValue(clientInfo.Listener), + MQTTRemoteAddr: NullableStringValue(clientInfo.RemoteAddr), + MQTTRemoteHost: NullableStringValue(clientInfo.RemoteHost), + MQTTRemotePort: NullableStringValue(clientInfo.RemotePort), }, nil } func stringValue(value any) string { - if s := nullableStringValue(value); s != nil { + if s := NullableStringValue(value); s != nil { return *s } return "" diff --git a/help_store.go b/internal/store/help_store.go similarity index 79% rename from help_store.go rename to internal/store/help_store.go index ac785d3..cf68c0d 100644 --- a/help_store.go +++ b/internal/store/help_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "fmt" @@ -7,7 +7,7 @@ import ( const maxHelpMarkdownBytes = 200 * 1024 -const defaultHelpMarkdown = `## 连接地址 +const DefaultHelpMarkdown = `## 连接地址 将 Meshtastic 设备连接到本服务提供的 MQTT broker。 @@ -29,15 +29,15 @@ const defaultHelpMarkdown = `## 连接地址 如果遇到 bug,请在 GitHub [提交 issue](https://github.com/wuwenfengmi1998/meshtastic_mqtt_server),或联系邮箱 [kevin@lmve.net](mailto:kevin@lmve.net)。` -func (s *store) GetLatestHelpContent() (*helpContentRecord, error) { - var row helpContentRecord +func (s *Store) GetLatestHelpContent() (*HelpContentRecord, error) { + var row HelpContentRecord if err := s.db.Order("id DESC").Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecord, error) { +func (s *Store) InsertHelpContent(markdown, createdBy string) (*HelpContentRecord, error) { markdown = strings.TrimSpace(markdown) createdBy = strings.TrimSpace(createdBy) if markdown == "" { @@ -46,7 +46,7 @@ func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecor if len([]byte(markdown)) > maxHelpMarkdownBytes { return nil, fmt.Errorf("markdown exceeds %d bytes", maxHelpMarkdownBytes) } - row := helpContentRecord{Markdown: markdown, CreatedBy: createdBy} + row := HelpContentRecord{Markdown: markdown, CreatedBy: createdBy} if err := s.db.Create(&row).Error; err != nil { return nil, err } diff --git a/internal/store/helpers.go b/internal/store/helpers.go new file mode 100644 index 0000000..0f1080b --- /dev/null +++ b/internal/store/helpers.go @@ -0,0 +1,45 @@ +package store + +import "golang.org/x/crypto/bcrypt" + +// AdminRole 是管理员账号在用户表里的角色字符串。其它包通过这个常量与 +// `users.role` 字段对齐,避免硬编码。 +const AdminRole = "admin" + +// printJSON 是 store 包内部的诊断输出钩子。当前实现为 noop——保持与 +// 重构前 main.go 的行为一致;如需启用调试,可在调用方替换。 +func printJSON(record map[string]any) { + _ = record +} + +// hashPassword 与 auth.go 中的散列实现保持一致(bcrypt 默认 cost)。 +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// uint32FromRecord 把 map[string]any 中的整型字段安全转换为 uint32。 +func uint32FromRecord(value any) (uint32, bool) { + switch v := value.(type) { + case uint32: + return v, true + case int: + if v >= 0 { + return uint32(v), true + } + case int64: + if v >= 0 { + return uint32(v), true + } + case uint64: + return uint32(v), true + case float64: + if v >= 0 { + return uint32(v), true + } + } + return 0, false +} diff --git a/llm_store.go b/internal/store/llm_store.go similarity index 81% rename from llm_store.go rename to internal/store/llm_store.go index f797359..0ff3e66 100644 --- a/llm_store.go +++ b/internal/store/llm_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "encoding/json" @@ -14,9 +14,9 @@ import ( // ============================================ // ListLLMProviders 列出所有 LLM Provider -func (s *store) ListLLMProviders(includeInactive bool) ([]llmProviderRecord, error) { - var rows []llmProviderRecord - query := s.db.Model(&llmProviderRecord{}) +func (s *Store) ListLLMProviders(includeInactive bool) ([]LLMProviderRecord, error) { + var rows []LLMProviderRecord + query := s.db.Model(&LLMProviderRecord{}) if !includeInactive { query = query.Where("active = ?", true) } @@ -27,8 +27,8 @@ func (s *store) ListLLMProviders(includeInactive bool) ([]llmProviderRecord, err } // GetLLMProvider 获取单个 LLM Provider -func (s *store) GetLLMProvider(name string) (*llmProviderRecord, error) { - var record llmProviderRecord +func (s *Store) GetLLMProvider(name string) (*LLMProviderRecord, error) { + var record LLMProviderRecord if err := s.db.Where("name = ?", name).Take(&record).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, err @@ -39,7 +39,7 @@ func (s *store) GetLLMProvider(name string) (*llmProviderRecord, error) { } // CreateLLMProvider 创建 LLM Provider -func (s *store) CreateLLMProvider(record *llmProviderRecord) error { +func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error { if err := s.db.Create(record).Error; err != nil { return fmt.Errorf("create llm provider %s: %w", record.Name, err) } @@ -47,16 +47,16 @@ func (s *store) CreateLLMProvider(record *llmProviderRecord) error { } // UpdateLLMProvider 更新 LLM Provider -func (s *store) UpdateLLMProvider(name string, updates map[string]any) error { - if err := s.db.Model(&llmProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil { +func (s *Store) UpdateLLMProvider(name string, updates map[string]any) error { + if err := s.db.Model(&LLMProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil { return fmt.Errorf("update llm provider %s: %w", name, err) } return nil } // DeleteLLMProvider 删除 LLM Provider -func (s *store) DeleteLLMProvider(name string) error { - if err := s.db.Where("name = ?", name).Delete(&llmProviderRecord{}).Error; err != nil { +func (s *Store) DeleteLLMProvider(name string) error { + if err := s.db.Where("name = ?", name).Delete(&LLMProviderRecord{}).Error; err != nil { return fmt.Errorf("delete llm provider %s: %w", name, err) } return nil @@ -64,7 +64,7 @@ func (s *store) DeleteLLMProvider(name string) error { // EnsureDefaultLLMProvider 确保存在默认 LLM Provider 配置 // 只有当数据库中完全没有任何 provider 配置时,才创建默认配置 -func (s *store) EnsureDefaultLLMProvider() error { +func (s *Store) EnsureDefaultLLMProvider() error { // 先检查是否已经有任何 provider 配置 providers, err := s.ListLLMProviders(true) if err != nil { @@ -74,7 +74,7 @@ func (s *store) EnsureDefaultLLMProvider() error { return nil // 已有配置,不创建默认 } // 创建默认配置 - defaultConfig := &llmProviderRecord{ + defaultConfig := &LLMProviderRecord{ Name: "default", Active: true, APIKey: "", @@ -91,8 +91,8 @@ func (s *store) EnsureDefaultLLMProvider() error { // ============================================ // GetLLMToolRouter 获取当前激活的 Tool Router 配置 -func (s *store) GetLLMToolRouter() (*llmToolRouterRecord, error) { - var record llmToolRouterRecord +func (s *Store) GetLLMToolRouter() (*LLMToolRouterRecord, error) { + var record LLMToolRouterRecord // 默认取第一条记录(ID 最小的),因为通常只需要一个配置 if err := s.db.Order("id ASC").First(&record).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -104,7 +104,7 @@ func (s *store) GetLLMToolRouter() (*llmToolRouterRecord, error) { } // CreateLLMToolRouter 创建 Tool Router 配置 -func (s *store) CreateLLMToolRouter(record *llmToolRouterRecord) error { +func (s *Store) CreateLLMToolRouter(record *LLMToolRouterRecord) error { if err := s.db.Create(record).Error; err != nil { return fmt.Errorf("create llm tool router: %w", err) } @@ -112,15 +112,15 @@ func (s *store) CreateLLMToolRouter(record *llmToolRouterRecord) error { } // UpdateLLMToolRouter 更新 Tool Router 配置 -func (s *store) UpdateLLMToolRouter(id uint64, updates map[string]any) error { - if err := s.db.Model(&llmToolRouterRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { +func (s *Store) UpdateLLMToolRouter(id uint64, updates map[string]any) error { + if err := s.db.Model(&LLMToolRouterRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return fmt.Errorf("update llm tool router %d: %w", id, err) } return nil } // EnsureDefaultLLMToolRouter 确保存在默认 Tool Router 配置 -func (s *store) EnsureDefaultLLMToolRouter() error { +func (s *Store) EnsureDefaultLLMToolRouter() error { _, err := s.GetLLMToolRouter() if err == nil { return nil // 已存在 @@ -129,7 +129,7 @@ func (s *store) EnsureDefaultLLMToolRouter() error { return err } // 创建默认配置 - defaultConfig := &llmToolRouterRecord{ + defaultConfig := &LLMToolRouterRecord{ Enabled: true, OpenAIName: "", Timeout: 30, @@ -144,8 +144,8 @@ func (s *store) EnsureDefaultLLMToolRouter() error { // ============================================ // GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置 -func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) { - var record llmPrimaryConfigRecord +func (s *Store) GetLLMPrimaryConfig() (*LLMPrimaryConfigRecord, error) { + var record LLMPrimaryConfigRecord // 默认取第一条记录(ID 最小的),因为通常只需要一个配置 if err := s.db.Order("id ASC").First(&record).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -158,7 +158,7 @@ func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) { // GetLLMPrimaryConfigSystemPrompt 获取主 AI 回复配置中的系统提示词 // 如果没有配置或出错,返回空字符串(autoreply service 会处理这种情况) -func (s *store) GetLLMPrimaryConfigSystemPrompt() (string, error) { +func (s *Store) GetLLMPrimaryConfigSystemPrompt() (string, error) { record, err := s.GetLLMPrimaryConfig() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -171,7 +171,7 @@ func (s *store) GetLLMPrimaryConfigSystemPrompt() (string, error) { } // GetLLMPrimaryConfigEnableTool 获取是否启用工具调用 -func (s *store) GetLLMPrimaryConfigEnableTool() (bool, error) { +func (s *Store) GetLLMPrimaryConfigEnableTool() (bool, error) { record, err := s.GetLLMPrimaryConfig() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -183,7 +183,7 @@ func (s *store) GetLLMPrimaryConfigEnableTool() (bool, error) { } // CreateLLMPrimaryConfig 创建主 AI 回复配置 -func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error { +func (s *Store) CreateLLMPrimaryConfig(record *LLMPrimaryConfigRecord) error { if err := s.db.Create(record).Error; err != nil { return fmt.Errorf("create llm primary config: %w", err) } @@ -191,15 +191,15 @@ func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error { } // UpdateLLMPrimaryConfig 更新主 AI 回复配置 -func (s *store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error { - if err := s.db.Model(&llmPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { +func (s *Store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error { + if err := s.db.Model(&LLMPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return fmt.Errorf("update llm primary config %d: %w", id, err) } return nil } // EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置 -func (s *store) EnsureDefaultLLMPrimaryConfig() error { +func (s *Store) EnsureDefaultLLMPrimaryConfig() error { _, err := s.GetLLMPrimaryConfig() if err == nil { return nil // 已存在 @@ -208,7 +208,7 @@ func (s *store) EnsureDefaultLLMPrimaryConfig() error { return err } // 创建默认配置 - defaultConfig := &llmPrimaryConfigRecord{ + defaultConfig := &LLMPrimaryConfigRecord{ Enabled: false, ProviderName: "", Timeout: 120, @@ -237,7 +237,7 @@ type LLMMessageQueueInput struct { } // EnqueueLLMMessage 将消息添加到 LLM 队列 -func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueRecord, error) { +func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) { var err error if input.BotID == 0 { @@ -269,16 +269,16 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR // packet_id > 0: 用 bot_id + packet_id 去重(频道消息) // packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id) // 只排除 pending/processing 状态的消息,允许 error 状态的消息重新入队 - var existing llmMessageQueueRecord + var existing LLMMessageQueueRecord if input.PacketID > 0 { // 频道消息:用 bot_id + packet_id 去重 err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL AND status IN (?, ?)", - input.BotID, input.PacketID, llmMessageStatusPending, llmMessageStatusProcessing). + input.BotID, input.PacketID, LLMMessageStatusPending, LLMMessageStatusProcessing). Take(&existing).Error } else { // 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝) err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND deleted_at IS NULL AND status IN (?, ?)", - input.BotID, input.FromNodeID, input.Text, llmMessageStatusPending, llmMessageStatusProcessing). + input.BotID, input.FromNodeID, input.Text, LLMMessageStatusPending, LLMMessageStatusProcessing). Take(&existing).Error } if err == nil { @@ -294,7 +294,7 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR if messageType == "" { messageType = "direct" } - record := &llmMessageQueueRecord{ + record := &LLMMessageQueueRecord{ BotID: input.BotID, BotNodeID: input.BotNodeID, BotNodeNum: input.BotNodeNum, @@ -307,7 +307,7 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR ChannelID: input.ChannelID, Topic: input.Topic, MessageType: messageType, - Status: llmMessageStatusPending, + Status: LLMMessageStatusPending, ReceivedAt: now, ContentJSON: input.ContentJSON, } @@ -319,9 +319,9 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR } // ListLLMMessages 列出 LLM 队列消息 -func (s *store) ListLLMMessages(opts listOptions, botID uint64, includeDeleted bool) ([]llmMessageQueueRecord, int64, error) { - var rows []llmMessageQueueRecord - query := s.db.Model(&llmMessageQueueRecord{}) +func (s *Store) ListLLMMessages(opts ListOptions, botID uint64, includeDeleted bool) ([]LLMMessageQueueRecord, int64, error) { + var rows []LLMMessageQueueRecord + query := s.db.Model(&LLMMessageQueueRecord{}) if botID > 0 { query = query.Where("bot_id = ?", botID) @@ -352,8 +352,8 @@ func (s *store) ListLLMMessages(opts listOptions, botID uint64, includeDeleted b } // GetLLMMessage 获取单条 LLM 消息 -func (s *store) GetLLMMessage(id uint64) (*llmMessageQueueRecord, error) { - var record llmMessageQueueRecord +func (s *Store) GetLLMMessage(id uint64) (*LLMMessageQueueRecord, error) { + var record LLMMessageQueueRecord if err := s.db.Where("id = ?", id).Take(&record).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, err @@ -364,50 +364,50 @@ func (s *store) GetLLMMessage(id uint64) (*llmMessageQueueRecord, error) { } // UpdateLLMMessageStatus 更新 LLM 消息状态 -func (s *store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error { +func (s *Store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error { updates := map[string]any{ "status": status, "error": errorMsg, } - if status == llmMessageStatusProcessed { + if status == LLMMessageStatusProcessed { now := time.Now() updates["processed_at"] = &now } - if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&LLMMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return fmt.Errorf("update llm message status %d: %w", id, err) } return nil } // SoftDeleteLLMMessage 软删除 LLM 消息 -func (s *store) SoftDeleteLLMMessage(id uint64) error { +func (s *Store) SoftDeleteLLMMessage(id uint64) error { now := time.Now() - if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil { + if err := s.db.Model(&LLMMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil { return fmt.Errorf("soft delete llm message %d: %w", id, err) } return nil } // SoftDeleteLLMMessagesByBot 软删除指定机器人的所有消息 -func (s *store) SoftDeleteLLMMessagesByBot(botID uint64) error { +func (s *Store) SoftDeleteLLMMessagesByBot(botID uint64) error { now := time.Now() - if err := s.db.Model(&llmMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil { + if err := s.db.Model(&LLMMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil { return fmt.Errorf("soft delete llm messages for bot %d: %w", botID, err) } return nil } // CleanupDeletedLLMMessages 清理已软删除超过指定时间的消息 -func (s *store) CleanupDeletedLLMMessages(before time.Time) (int64, error) { - result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&llmMessageQueueRecord{}) +func (s *Store) CleanupDeletedLLMMessages(before time.Time) (int64, error) { + result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&LLMMessageQueueRecord{}) if result.Error != nil { return 0, fmt.Errorf("cleanup deleted llm messages: %w", result.Error) } return result.RowsAffected, nil } -// llmMessageDTO 将数据库记录转换为 API 响应格式 -func llmMessageDTO(row llmMessageQueueRecord) map[string]any { +// LLMMessageDTO 将数据库记录转换为 API 响应格式 +func LLMMessageDTO(row LLMMessageQueueRecord) map[string]any { return map[string]any{ "id": row.ID, "bot_id": row.BotID, @@ -432,7 +432,7 @@ func llmMessageDTO(row llmMessageQueueRecord) map[string]any { // enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列 // 为每个启用了「包含频道消息」的机器人都创建一条独立的队列记录 -func enqueueChannelMessageToLLM(s *store, record map[string]any) error { +func enqueueChannelMessageToLLM(s *Store, record map[string]any) error { if s == nil { return nil } @@ -481,7 +481,7 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error { // 查询所有启用了 LLM 队列且包含频道消息的机器人 // SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询 - var bots []botNodeRecord + var bots []BotNodeRecord err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error if err != nil { return fmt.Errorf("query bots for channel message enqueue: %w", err) diff --git a/login_log_store.go b/internal/store/login_log_store.go similarity index 61% rename from login_log_store.go rename to internal/store/login_log_store.go index 92f6813..395aecb 100644 --- a/login_log_store.go +++ b/internal/store/login_log_store.go @@ -1,12 +1,12 @@ -package main +package store -func (s *store) InsertLoginLog(log loginLogRecord) error { +func (s *Store) InsertLoginLog(log LoginLogRecord) error { return s.db.Create(&log).Error } -func (s *store) ListLoginLogs(opts listOptions) ([]loginLogRecord, error) { - opts = normalizeListOptions(opts) - var rows []loginLogRecord +func (s *Store) ListLoginLogs(opts ListOptions) ([]LoginLogRecord, error) { + opts = NormalizeListOptions(opts) + var rows []LoginLogRecord q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset) if opts.Since != nil { q = q.Where("created_at >= ?", *opts.Since) diff --git a/map_source_store.go b/internal/store/map_source_store.go similarity index 72% rename from map_source_store.go rename to internal/store/map_source_store.go index fce0252..dba2454 100644 --- a/map_source_store.go +++ b/internal/store/map_source_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "crypto/sha256" @@ -22,13 +22,13 @@ const ( ) var ( - errMapTileSourceAlreadyExists = errors.New("map source already exists") - errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted") - errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled") - errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled") + ErrMapTileSourceAlreadyExists = errors.New("map source already exists") + ErrMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted") + ErrMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled") + ErrMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled") ) -type mapTileSourceInput struct { +type MapTileSourceInput struct { Name string URLTemplate string Attribution string @@ -38,10 +38,10 @@ type mapTileSourceInput struct { ProxyEnabled bool } -func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) { - opts = normalizeListOptions(opts) - var rows []mapTileSourceRecord - q := s.db.Model(&mapTileSourceRecord{}). +func (s *Store) ListMapTileSources(opts ListOptions) ([]MapTileSourceRecord, error) { + opts = NormalizeListOptions(opts) + var rows []MapTileSourceRecord + q := s.db.Model(&MapTileSourceRecord{}). Order("is_default DESC"). Order("updated_at DESC"). Order("id DESC"). @@ -50,14 +50,14 @@ func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, err return rows, q.Find(&rows).Error } -func (s *store) CountMapTileSources(opts listOptions) (int64, error) { +func (s *Store) CountMapTileSources(opts ListOptions) (int64, error) { var total int64 - return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error + return total, s.db.Model(&MapTileSourceRecord{}).Count(&total).Error } -func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) { - var rows []mapTileSourceRecord - if err := s.db.Model(&mapTileSourceRecord{}). +func (s *Store) ListEnabledMapTileSources() ([]MapTileSourceRecord, error) { + var rows []MapTileSourceRecord + if err := s.db.Model(&MapTileSourceRecord{}). Where("enabled = ?", true). Order("is_default DESC"). Order("updated_at DESC"). @@ -66,13 +66,13 @@ func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) { return nil, err } if len(rows) == 0 { - return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil + return []MapTileSourceRecord{defaultMapTileSourceRecord()}, nil } return rows, nil } -func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) { - var row mapTileSourceRecord +func (s *Store) GetDefaultMapTileSource() (*MapTileSourceRecord, error) { + var row MapTileSourceRecord err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { fallback := defaultMapTileSourceRecord() @@ -84,28 +84,28 @@ func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) { return &row, nil } -func (s *store) GetEnabledMapTileSourceByHash(hash string) (*mapTileSourceRecord, error) { - var row mapTileSourceRecord +func (s *Store) GetEnabledMapTileSourceByHash(hash string) (*MapTileSourceRecord, error) { + var row MapTileSourceRecord if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) { +func (s *Store) CreateMapTileSource(input MapTileSourceInput) (*MapTileSourceRecord, error) { row, err := mapTileSourceFromInput(input) if err != nil { return nil, err } if row.IsDefault && !row.Enabled { - return nil, errMapTileSourceDefaultMustBeEnabled + return nil, ErrMapTileSourceDefaultMustBeEnabled } if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil { return nil, err } if err := s.db.Transaction(func(tx *gorm.DB) error { if row.IsDefault { - if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil { return err } } @@ -116,7 +116,7 @@ func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRec return row, nil } -func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) { +func (s *Store) UpdateMapTileSource(id uint64, input MapTileSourceInput) (*MapTileSourceRecord, error) { if id == 0 { return nil, fmt.Errorf("map source id is required") } @@ -124,17 +124,17 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi if err != nil { return nil, err } - var updated mapTileSourceRecord + var updated MapTileSourceRecord if err := s.db.Transaction(func(tx *gorm.DB) error { - var existing mapTileSourceRecord + var existing MapTileSourceRecord if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil { return err } if existing.IsDefault && !row.Enabled { - return errMapTileSourceCannotDisableDefault + return ErrMapTileSourceCannotDisableDefault } if row.IsDefault && !row.Enabled { - return errMapTileSourceDefaultMustBeEnabled + return ErrMapTileSourceDefaultMustBeEnabled } if !row.IsDefault && existing.IsDefault { row.IsDefault = true @@ -143,7 +143,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi return err } if row.IsDefault { - if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil { return err } } @@ -158,7 +158,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi "proxy_enabled": row.ProxyEnabled, "updated_at": time.Now(), } - if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return err } return tx.Where("id = ?", id).Take(&updated).Error @@ -168,19 +168,19 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi return &updated, nil } -func (s *store) DeleteMapTileSource(id uint64) error { +func (s *Store) DeleteMapTileSource(id uint64) error { if id == 0 { return fmt.Errorf("map source id is required") } return s.db.Transaction(func(tx *gorm.DB) error { - var row mapTileSourceRecord + var row MapTileSourceRecord if err := tx.Where("id = ?", id).Take(&row).Error; err != nil { return err } if row.IsDefault { - return errMapTileSourceCannotDeleteDefault + return ErrMapTileSourceCannotDeleteDefault } - result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{}) + result := tx.Where("id = ?", id).Delete(&MapTileSourceRecord{}) if result.Error != nil { return result.Error } @@ -191,22 +191,22 @@ func (s *store) DeleteMapTileSource(id uint64) error { }) } -func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) { +func (s *Store) SetDefaultMapTileSource(id uint64) (*MapTileSourceRecord, error) { if id == 0 { return nil, fmt.Errorf("map source id is required") } - var row mapTileSourceRecord + var row MapTileSourceRecord if err := s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("id = ?", id).Take(&row).Error; err != nil { return err } if !row.Enabled { - return errMapTileSourceDefaultMustBeEnabled + return ErrMapTileSourceDefaultMustBeEnabled } - if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil { return err } - if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil { return err } return tx.Where("id = ?", id).Take(&row).Error @@ -216,10 +216,10 @@ func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) return &row, nil } -func (s *store) EnsureDefaultMapTileSource() error { +func (s *Store) EnsureDefaultMapTileSource() error { return s.db.Transaction(func(tx *gorm.DB) error { var count int64 - if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil { + if err := tx.Model(&MapTileSourceRecord{}).Count(&count).Error; err != nil { return err } if count == 0 { @@ -227,28 +227,28 @@ func (s *store) EnsureDefaultMapTileSource() error { return tx.Create(&row).Error } - var defaults []mapTileSourceRecord + var defaults []MapTileSourceRecord if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil { return err } if len(defaults) > 0 { - return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error + return tx.Model(&MapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error } - var enabled mapTileSourceRecord + var enabled MapTileSourceRecord err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error if err == nil { - return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error + return tx.Model(&MapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } row := defaultMapTileSourceRecord() - var existing mapTileSourceRecord + var existing MapTileSourceRecord err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error if err == nil { - return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error + return tx.Model(&MapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error } if !errors.Is(err, gorm.ErrRecordNotFound) { return err @@ -257,16 +257,16 @@ func (s *store) EnsureDefaultMapTileSource() error { }) } -func mapTileSourceHash(urlTemplate string) string { +func MapTileSourceHash(urlTemplate string) string { h := sha256.Sum256([]byte(urlTemplate)) return hex.EncodeToString(h[:]) } -func defaultMapTileSourceRecord() mapTileSourceRecord { - return mapTileSourceRecord{ +func defaultMapTileSourceRecord() MapTileSourceRecord { + return MapTileSourceRecord{ Name: defaultMapTileSourceName, URLTemplate: defaultMapTileSourceURLTemplate, - URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate), + URLTemplateHash: MapTileSourceHash(defaultMapTileSourceURLTemplate), Attribution: defaultMapTileSourceAttribution, MaxZoom: defaultMapTileSourceMaxZoom, Enabled: true, @@ -275,7 +275,7 @@ func defaultMapTileSourceRecord() mapTileSourceRecord { } } -func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) { +func mapTileSourceFromInput(input MapTileSourceInput) (*MapTileSourceRecord, error) { name := strings.TrimSpace(input.Name) if name == "" { return nil, fmt.Errorf("map source name is required") @@ -291,10 +291,10 @@ func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, err if maxZoom < 1 || maxZoom > 30 { return nil, fmt.Errorf("max zoom must be between 1 and 30") } - return &mapTileSourceRecord{ + return &MapTileSourceRecord{ Name: name, URLTemplate: urlTemplate, - URLTemplateHash: mapTileSourceHash(urlTemplate), + URLTemplateHash: MapTileSourceHash(urlTemplate), Attribution: strings.TrimSpace(input.Attribution), MaxZoom: maxZoom, Enabled: input.Enabled, @@ -337,19 +337,19 @@ func normalizeMapTileSourceURLTemplate(value string) (string, error) { return value, nil } -func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error { +func (s *Store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error { return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate) } func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error { - var existing mapTileSourceRecord + var existing MapTileSourceRecord q := tx.Where("name = ? OR url_template = ?", name, urlTemplate) if id != 0 { q = q.Where("id <> ?", id) } err := q.Take(&existing).Error if err == nil { - return errMapTileSourceAlreadyExists + return ErrMapTileSourceAlreadyExists } if errors.Is(err, gorm.ErrRecordNotFound) { return nil diff --git a/map_source_store_test.go b/internal/store/map_source_store_test.go similarity index 81% rename from map_source_store_test.go rename to internal/store/map_source_store_test.go index d8350ce..17c9218 100644 --- a/map_source_store_test.go +++ b/internal/store/map_source_store_test.go @@ -1,4 +1,4 @@ -package main +package store import ( "errors" @@ -25,13 +25,13 @@ func TestCreateMapTileSourceValidation(t *testing.T) { st := openTestStore(t) defer st.Close() - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { + if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error") } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { + if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error") } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { + if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { t.Fatal("CreateMapTileSource() credentials error = nil, want error") } } @@ -40,11 +40,11 @@ func TestListEnabledMapTileSources(t *testing.T) { st := openTestStore(t) defer st.Close() - disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) + disabled, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) if err != nil { t.Fatalf("CreateMapTileSource(disabled) error = %v", err) } - custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + custom, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource(custom) error = %v", err) } @@ -76,15 +76,15 @@ func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) { st := openTestStore(t) defer st.Close() - first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + first, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { - t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err) + if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, ErrMapTileSourceAlreadyExists) { + t.Fatalf("duplicate name error = %v, want ErrMapTileSourceAlreadyExists", err) } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { - t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err) + if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, ErrMapTileSourceAlreadyExists) { + t.Fatalf("duplicate url error = %v, want ErrMapTileSourceAlreadyExists", err) } updated, err := st.SetDefaultMapTileSource(first.ID) @@ -102,11 +102,11 @@ func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) { if oldDefault.ID != first.ID { t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID) } - if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) { - t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err) + if _, err := st.UpdateMapTileSource(first.ID, MapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, ErrMapTileSourceCannotDisableDefault) { + t.Fatalf("disable default error = %v, want ErrMapTileSourceCannotDisableDefault", err) } - if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) { - t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err) + if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, ErrMapTileSourceCannotDeleteDefault) { + t.Fatalf("delete default error = %v, want ErrMapTileSourceCannotDeleteDefault", err) } } @@ -114,11 +114,11 @@ func TestMapTileSourceHashIsSetOnCreate(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } - want := mapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png") + want := MapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png") if row.URLTemplateHash != want { t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want) } @@ -135,7 +135,7 @@ func TestMapTileSourceDefaultHasHash(t *testing.T) { if err != nil { t.Fatalf("GetDefaultMapTileSource() error = %v", err) } - want := mapTileSourceHash(defaultMapTileSourceURLTemplate) + want := MapTileSourceHash(defaultMapTileSourceURLTemplate) if row.URLTemplateHash != want { t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want) } @@ -145,7 +145,7 @@ func TestGetEnabledMapTileSourceByHash(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -163,7 +163,7 @@ func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) + row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -178,7 +178,7 @@ func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) + row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -203,7 +203,7 @@ func TestPublicMapTileSourceDTOProxyURL(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -226,7 +226,7 @@ func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) + row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -242,9 +242,9 @@ func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) { } func TestMapTileSourceHashFunction(t *testing.T) { - hash1 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") - hash2 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") - hash3 := mapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png") + hash1 := MapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") + hash2 := MapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") + hash3 := MapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png") if hash1 != hash2 { t.Fatal("hash should be deterministic") diff --git a/mqtt_forward_store.go b/internal/store/mqtt_forward_store.go similarity index 71% rename from mqtt_forward_store.go rename to internal/store/mqtt_forward_store.go index 37854f5..8125f35 100644 --- a/mqtt_forward_store.go +++ b/internal/store/mqtt_forward_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "errors" @@ -10,16 +10,16 @@ import ( ) const ( - mqttForwardDirectionSourceToTarget = "source_to_target" - mqttForwardDirectionBidirectional = "bidirectional" + MQTTForwardDirectionSourceToTarget = "source_to_target" + MQTTForwardDirectionBidirectional = "bidirectional" ) var ( - errMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists") - errMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists") + ErrMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists") + ErrMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists") ) -type mqttForwarderInput struct { +type MQTTForwarderInput struct { Name string Enabled bool SourceHost string @@ -36,7 +36,7 @@ type mqttForwarderInput struct { TargetTLS bool } -type mqttForwardTopicInput struct { +type MQTTForwardTopicInput struct { Topic string Enabled bool Direction string @@ -46,15 +46,15 @@ type mqttForwardTopicInput struct { Retain bool } -type mqttForwarderConfig struct { - Forwarder mqttForwarderRecord - Topics []mqttForwardTopicRecord +type MQTTForwarderConfig struct { + Forwarder MQTTForwarderRecord + Topics []MQTTForwardTopicRecord } -func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, error) { - opts = normalizeListOptions(opts) - var rows []mqttForwarderRecord - q := s.db.Model(&mqttForwarderRecord{}). +func (s *Store) ListMQTTForwarders(opts ListOptions) ([]MQTTForwarderRecord, error) { + opts = NormalizeListOptions(opts) + var rows []MQTTForwarderRecord + q := s.db.Model(&MQTTForwarderRecord{}). Order("updated_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -62,20 +62,20 @@ func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, err return rows, q.Find(&rows).Error } -func (s *store) CountMQTTForwarders(opts listOptions) (int64, error) { +func (s *Store) CountMQTTForwarders(opts ListOptions) (int64, error) { var total int64 - return total, s.db.Model(&mqttForwarderRecord{}).Count(&total).Error + return total, s.db.Model(&MQTTForwarderRecord{}).Count(&total).Error } -func (s *store) GetMQTTForwarder(id uint64) (*mqttForwarderRecord, error) { - var row mqttForwarderRecord +func (s *Store) GetMQTTForwarder(id uint64) (*MQTTForwarderRecord, error) { + var row MQTTForwarderRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRecord, error) { +func (s *Store) CreateMQTTForwarder(input MQTTForwarderInput) (*MQTTForwarderRecord, error) { row, err := mqttForwarderFromInput(input, nil) if err != nil { return nil, err @@ -89,7 +89,7 @@ func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRec return row, nil } -func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttForwarderRecord, error) { +func (s *Store) UpdateMQTTForwarder(id uint64, input MQTTForwarderInput) (*MQTTForwarderRecord, error) { if id == 0 { return nil, fmt.Errorf("mqtt forwarder id is required") } @@ -112,21 +112,21 @@ func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttF "target_password": row.TargetPassword, "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "updated_at": time.Now(), } - if err := s.db.Model(&mqttForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&MQTTForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.GetMQTTForwarder(id) } -func (s *store) DeleteMQTTForwarder(id uint64) error { +func (s *Store) DeleteMQTTForwarder(id uint64) error { if id == 0 { return fmt.Errorf("mqtt forwarder id is required") } return s.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("forwarder_id = ?", id).Delete(&mqttForwardTopicRecord{}).Error; err != nil { + if err := tx.Where("forwarder_id = ?", id).Delete(&MQTTForwardTopicRecord{}).Error; err != nil { return err } - result := tx.Where("id = ?", id).Delete(&mqttForwarderRecord{}) + result := tx.Where("id = ?", id).Delete(&MQTTForwarderRecord{}) if result.Error != nil { return result.Error } @@ -137,10 +137,10 @@ func (s *store) DeleteMQTTForwarder(id uint64) error { }) } -func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]mqttForwardTopicRecord, error) { - opts = normalizeListOptions(opts) - var rows []mqttForwardTopicRecord - q := s.db.Model(&mqttForwardTopicRecord{}). +func (s *Store) ListMQTTForwardTopics(forwarderID uint64, opts ListOptions) ([]MQTTForwardTopicRecord, error) { + opts = NormalizeListOptions(opts) + var rows []MQTTForwardTopicRecord + q := s.db.Model(&MQTTForwardTopicRecord{}). Where("forwarder_id = ?", forwarderID). Order("updated_at DESC"). Order("id DESC"). @@ -149,20 +149,20 @@ func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]m return rows, q.Find(&rows).Error } -func (s *store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) { +func (s *Store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) { var total int64 - return total, s.db.Model(&mqttForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error + return total, s.db.Model(&MQTTForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error } -func (s *store) GetMQTTForwardTopic(id uint64) (*mqttForwardTopicRecord, error) { - var row mqttForwardTopicRecord +func (s *Store) GetMQTTForwardTopic(id uint64) (*MQTTForwardTopicRecord, error) { + var row MQTTForwardTopicRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) { +func (s *Store) CreateMQTTForwardTopic(forwarderID uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) { if _, err := s.GetMQTTForwarder(forwarderID); err != nil { return nil, err } @@ -179,7 +179,7 @@ func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopi return row, nil } -func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) { +func (s *Store) UpdateMQTTForwardTopic(id uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) { if id == 0 { return nil, fmt.Errorf("mqtt forward topic id is required") } @@ -199,14 +199,14 @@ func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) ( "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "updated_at": time.Now(), } - if err := s.db.Model(&mqttForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&MQTTForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.GetMQTTForwardTopic(id) } -func (s *store) DeleteMQTTForwardTopic(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&mqttForwardTopicRecord{}) +func (s *Store) DeleteMQTTForwardTopic(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&MQTTForwardTopicRecord{}) if result.Error != nil { return result.Error } @@ -216,39 +216,39 @@ func (s *store) DeleteMQTTForwardTopic(id uint64) error { return nil } -func (s *store) GetMQTTForwarderConfig(id uint64) (*mqttForwarderConfig, error) { +func (s *Store) GetMQTTForwarderConfig(id uint64) (*MQTTForwarderConfig, error) { forwarder, err := s.GetMQTTForwarder(id) if err != nil { return nil, err } - var topics []mqttForwardTopicRecord + var topics []MQTTForwardTopicRecord if err := s.db.Where("forwarder_id = ? AND enabled = ?", id, true).Order("id ASC").Find(&topics).Error; err != nil { return nil, err } - return &mqttForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil + return &MQTTForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil } -func (s *store) ListEnabledMQTTForwarderConfigs() ([]mqttForwarderConfig, error) { - var forwarders []mqttForwarderRecord +func (s *Store) ListEnabledMQTTForwarderConfigs() ([]MQTTForwarderConfig, error) { + var forwarders []MQTTForwarderRecord if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&forwarders).Error; err != nil { return nil, err } - configs := make([]mqttForwarderConfig, 0, len(forwarders)) + configs := make([]MQTTForwarderConfig, 0, len(forwarders)) for _, forwarder := range forwarders { - var topics []mqttForwardTopicRecord + var topics []MQTTForwardTopicRecord if err := s.db.Where("forwarder_id = ? AND enabled = ?", forwarder.ID, true).Order("id ASC").Find(&topics).Error; err != nil { return nil, err } if len(topics) == 0 { continue } - configs = append(configs, mqttForwarderConfig{Forwarder: forwarder, Topics: topics}) + configs = append(configs, MQTTForwarderConfig{Forwarder: forwarder, Topics: topics}) } return configs, nil } -func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error { - var existing mqttForwarderRecord +func (s *Store) ensureMQTTForwarderNameUnique(id uint64, name string) error { + var existing MQTTForwarderRecord q := s.db.Where("name = ?", name) if id != 0 { q = q.Where("id <> ?", id) @@ -260,11 +260,11 @@ func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error { if err != nil { return err } - return errMQTTForwarderAlreadyExists + return ErrMQTTForwarderAlreadyExists } -func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error { - var existing mqttForwardTopicRecord +func (s *Store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error { + var existing MQTTForwardTopicRecord q := s.db.Where("forwarder_id = ? AND topic = ?", forwarderID, topic) if id != 0 { q = q.Where("id <> ?", id) @@ -276,10 +276,10 @@ func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic strin if err != nil { return err } - return errMQTTForwardTopicAlreadyExists + return ErrMQTTForwardTopicAlreadyExists } -func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRecord) (*mqttForwarderRecord, error) { +func mqttForwarderFromInput(input MQTTForwarderInput, existing *MQTTForwarderRecord) (*MQTTForwarderRecord, error) { name := strings.TrimSpace(input.Name) if name == "" { return nil, fmt.Errorf("mqtt forwarder name is required") @@ -298,7 +298,7 @@ func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRec if err := validateMQTTForwardPort(input.TargetPort, "target port"); err != nil { return nil, err } - row := &mqttForwarderRecord{ + row := &MQTTForwarderRecord{ Name: name, Enabled: input.Enabled, SourceHost: sourceHost, SourcePort: input.SourcePort, SourceUsername: strings.TrimSpace(input.SourceUsername), SourceClientID: strings.TrimSpace(input.SourceClientID), SourceTLS: input.SourceTLS, TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS, @@ -316,7 +316,7 @@ func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRec return row, nil } -func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) { +func mqttForwardTopicFromInput(forwarderID uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) { if forwarderID == 0 { return nil, fmt.Errorf("mqtt forwarder id is required") } @@ -331,7 +331,7 @@ func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput) if input.QoS < 0 || input.QoS > 2 { return nil, fmt.Errorf("qos must be 0, 1, or 2") } - return &mqttForwardTopicRecord{ + return &MQTTForwardTopicRecord{ ForwarderID: forwarderID, Topic: topic, Enabled: input.Enabled, Direction: direction, SourcePrefix: strings.Trim(strings.TrimSpace(input.SourcePrefix), "/"), TargetPrefix: strings.Trim(strings.TrimSpace(input.TargetPrefix), "/"), @@ -349,10 +349,10 @@ func validateMQTTForwardPort(port int, label string) error { func normalizeMQTTForwardDirection(direction string) (string, error) { direction = strings.TrimSpace(direction) if direction == "" { - direction = mqttForwardDirectionSourceToTarget + direction = MQTTForwardDirectionSourceToTarget } switch direction { - case mqttForwardDirectionSourceToTarget, mqttForwardDirectionBidirectional: + case MQTTForwardDirectionSourceToTarget, MQTTForwardDirectionBidirectional: return direction, nil default: return "", fmt.Errorf("invalid mqtt forward direction") diff --git a/runtime_settings_store.go b/internal/store/runtime_settings_store.go similarity index 68% rename from runtime_settings_store.go rename to internal/store/runtime_settings_store.go index eb25703..aee8bf2 100644 --- a/runtime_settings_store.go +++ b/internal/store/runtime_settings_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "errors" @@ -12,45 +12,45 @@ import ( ) const ( - runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding" - runtimeSettingLLMQueueEnabled = "llm.queue_enabled" - runtimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages" + RuntimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding" + RuntimeSettingLLMQueueEnabled = "llm.queue_enabled" + RuntimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages" runtimeSettingTypeBool = "bool" ) -type runtimeSettingsSnapshot struct { +type RuntimeSettingsSnapshot struct { AllowEncryptedForwarding bool LLMQueueEnabled bool LLMIncludeChannel bool } -func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) { - allowEncrypted, err := s.GetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false) +func (s *Store) GetRuntimeSettings() (RuntimeSettingsSnapshot, error) { + allowEncrypted, err := s.GetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, false) if err != nil { - return runtimeSettingsSnapshot{}, err + return RuntimeSettingsSnapshot{}, err } - llmQueueEnabled, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, true) + llmQueueEnabled, err := s.GetBoolRuntimeSetting(RuntimeSettingLLMQueueEnabled, true) if err != nil { - return runtimeSettingsSnapshot{}, err + return RuntimeSettingsSnapshot{}, err } - llmIncludeChannel, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, false) + llmIncludeChannel, err := s.GetBoolRuntimeSetting(RuntimeSettingLLMQueueIncludeChannel, false) if err != nil { - return runtimeSettingsSnapshot{}, err + return RuntimeSettingsSnapshot{}, err } - return runtimeSettingsSnapshot{ + return RuntimeSettingsSnapshot{ AllowEncryptedForwarding: allowEncrypted, LLMQueueEnabled: llmQueueEnabled, LLMIncludeChannel: llmIncludeChannel, }, nil } -func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) { +func (s *Store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) { key = strings.TrimSpace(key) if key == "" { return false, fmt.Errorf("runtime setting key is required") } - var row runtimeSettingRecord + var row RuntimeSettingRecord err := s.db.Where("`key` = ?", key).Take(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return defaultValue, nil @@ -68,13 +68,13 @@ func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, erro return value, nil } -func (s *store) SetBoolRuntimeSetting(key string, value bool, label string) (*runtimeSettingRecord, error) { +func (s *Store) SetBoolRuntimeSetting(key string, value bool, label string) (*RuntimeSettingRecord, error) { key = strings.TrimSpace(key) if key == "" { return nil, fmt.Errorf("runtime setting key is required") } - row := runtimeSettingRecord{ + row := RuntimeSettingRecord{ Key: key, Value: strconv.FormatBool(value), ValueType: runtimeSettingTypeBool, diff --git a/runtime_settings_store_test.go b/internal/store/runtime_settings_store_test.go similarity index 87% rename from runtime_settings_store_test.go rename to internal/store/runtime_settings_store_test.go index 67614a8..28b4155 100644 --- a/runtime_settings_store_test.go +++ b/internal/store/runtime_settings_store_test.go @@ -1,4 +1,4 @@ -package main +package store import "testing" @@ -14,7 +14,7 @@ func TestRuntimeSettingsDefaultAndUpdates(t *testing.T) { t.Fatalf("AllowEncryptedForwarding = true, want false") } - if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { + if _, err := st.SetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err) } settings, err = st.GetRuntimeSettings() @@ -25,7 +25,7 @@ func TestRuntimeSettingsDefaultAndUpdates(t *testing.T) { t.Fatalf("AllowEncryptedForwarding = false, want true") } - if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { + if _, err := st.SetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err) } settings, err = st.GetRuntimeSettings() diff --git a/sign_store.go b/internal/store/sign_store.go similarity index 66% rename from sign_store.go rename to internal/store/sign_store.go index e5ebe0b..6848c82 100644 --- a/sign_store.go +++ b/internal/store/sign_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "fmt" @@ -6,12 +6,14 @@ import ( "time" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/config" ) -func (s *store) ListSigns(opts listOptions) ([]signRecord, error) { - opts = normalizeListOptions(opts) - var rows []signRecord - q := applySignFilters(s.db.Model(&signRecord{}), opts). +func (s *Store) ListSigns(opts ListOptions) ([]SignRecord, error) { + opts = NormalizeListOptions(opts) + var rows []SignRecord + q := applySignFilters(s.db.Model(&SignRecord{}), opts). Order("sign_time DESC"). Order("id DESC"). Limit(opts.Limit). @@ -19,39 +21,39 @@ func (s *store) ListSigns(opts listOptions) ([]signRecord, error) { return rows, q.Find(&rows).Error } -type signDayCount struct { +type SignDayCount struct { Date string `gorm:"column:sign_date"` Count int64 `gorm:"column:count"` } -func (s *store) CountSigns(opts listOptions) (int64, error) { +func (s *Store) CountSigns(opts ListOptions) (int64, error) { var total int64 - q := applySignFilters(s.db.Model(&signRecord{}), opts) + q := applySignFilters(s.db.Model(&SignRecord{}), opts) return total, q.Count(&total).Error } -func (s *store) CountSignsByDay(opts listOptions) ([]signDayCount, error) { - var rows []signDayCount +func (s *Store) CountSignsByDay(opts ListOptions) ([]SignDayCount, error) { + var rows []SignDayCount dateExpr := "strftime('%Y-%m-%d', sign_time)" - if s.driver == databaseDriverMySQL { + if s.driver == config.DriverMySQL { dateExpr = "DATE_FORMAT(sign_time, '%Y-%m-%d')" } - q := applySignFilters(s.db.Model(&signRecord{}), opts). + q := applySignFilters(s.db.Model(&SignRecord{}), opts). Select(dateExpr + " AS sign_date, COUNT(*) AS count"). Group(dateExpr). Order("sign_date DESC") return rows, q.Scan(&rows).Error } -func (s *store) GetSignByID(id uint64) (*signRecord, error) { - var row signRecord +func (s *Store) GetSignByID(id uint64) (*SignRecord, error) { + var row SignRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*signRecord, error) { +func (s *Store) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*SignRecord, error) { nodeID = strings.TrimSpace(nodeID) signText = strings.TrimSpace(signText) if nodeID == "" { @@ -63,14 +65,14 @@ func (s *store) CreateSign(nodeID string, longName, shortName *string, signText if signTime.IsZero() { signTime = time.Now() } - row := signRecord{NodeID: nodeID, LongName: trimNullableString(longName), ShortName: trimNullableString(shortName), SignText: signText, SignTime: signTime} + row := SignRecord{NodeID: nodeID, LongName: trimNullableString(longName), ShortName: trimNullableString(shortName), SignText: signText, SignTime: signTime} if err := s.db.Create(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) UpdateSign(id uint64, nodeID string, longName, shortName *string, signText string, signTime time.Time) (*signRecord, error) { +func (s *Store) UpdateSign(id uint64, nodeID string, longName, shortName *string, signText string, signTime time.Time) (*SignRecord, error) { if id == 0 { return nil, fmt.Errorf("sign id is required") } @@ -89,14 +91,14 @@ func (s *store) UpdateSign(id uint64, nodeID string, longName, shortName *string return nil, err } updates := map[string]any{"node_id": nodeID, "long_name": trimNullableString(longName), "short_name": trimNullableString(shortName), "sign_text": signText, "sign_time": signTime} - if err := s.db.Model(&signRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := s.db.Model(&SignRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } return s.GetSignByID(id) } -func (s *store) DeleteSign(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&signRecord{}) +func (s *Store) DeleteSign(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&SignRecord{}) if result.Error != nil { return result.Error } @@ -106,7 +108,7 @@ func (s *store) DeleteSign(id uint64) error { return nil } -func applySignFilters(q *gorm.DB, opts listOptions) *gorm.DB { +func applySignFilters(q *gorm.DB, opts ListOptions) *gorm.DB { if opts.NodeID != "" { q = q.Where("node_id = ?", opts.NodeID) } diff --git a/store_query.go b/internal/store/store_query.go similarity index 64% rename from store_query.go rename to internal/store/store_query.go index 5f38037..4f9e3ec 100644 --- a/store_query.go +++ b/internal/store/store_query.go @@ -1,4 +1,4 @@ -package main +package store import ( "fmt" @@ -8,7 +8,7 @@ import ( "gorm.io/gorm" ) -type listOptions struct { +type ListOptions struct { Limit int Offset int NodeID string @@ -21,31 +21,31 @@ type listOptions struct { MaxLng *float64 } -type mapReportViewportOptions struct { - ListOptions listOptions +type MapReportViewportOptions struct { + ListOptions ListOptions Zoom int Limit int ClusterThreshold int TargetCells int } -type mapReportViewportResult struct { +type MapReportViewportResult struct { Mode string Total int64 - Points []mapReportRecord - Clusters []mapReportClusterRecord + Points []MapReportRecord + Clusters []MapReportClusterRecord Limit int Zoom int } -type mapReportClusterRecord struct { +type MapReportClusterRecord struct { ClusterID string Latitude float64 Longitude float64 Count int64 } -func (s *store) Ping() error { +func (s *Store) Ping() error { db, err := s.db.DB() if err != nil { return err @@ -53,7 +53,7 @@ func (s *store) Ping() error { return db.Ping() } -func normalizeListOptions(opts listOptions) listOptions { +func NormalizeListOptions(opts ListOptions) ListOptions { if opts.Limit <= 0 { opts.Limit = 100 } @@ -66,64 +66,64 @@ func normalizeListOptions(opts listOptions) listOptions { return opts } -func (s *store) ListNodeInfo(opts listOptions) ([]nodeInfoRecord, error) { - opts = normalizeListOptions(opts) - var rows []nodeInfoRecord - q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts). +func (s *Store) ListNodeInfo(opts ListOptions) ([]NodeInfoRecord, error) { + opts = NormalizeListOptions(opts) + var rows []NodeInfoRecord + q := applyNodeFilters(s.db.Model(&NodeInfoRecord{}), opts). Order("updated_at DESC"). Limit(opts.Limit). Offset(opts.Offset) return rows, q.Find(&rows).Error } -func (s *store) CountNodeInfo(opts listOptions) (int64, error) { +func (s *Store) CountNodeInfo(opts ListOptions) (int64, error) { var total int64 - q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts) + q := applyNodeFilters(s.db.Model(&NodeInfoRecord{}), opts) return total, q.Count(&total).Error } -func (s *store) GetNodeInfo(nodeID string) (*nodeInfoRecord, error) { - var row nodeInfoRecord +func (s *Store) GetNodeInfo(nodeID string) (*NodeInfoRecord, error) { + var row NodeInfoRecord if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) { - opts = normalizeListOptions(opts) - var rows []mapReportRecord - q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts). +func (s *Store) ListMapReports(opts ListOptions) ([]MapReportRecord, error) { + opts = NormalizeListOptions(opts) + var rows []MapReportRecord + q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts). Order("updated_at DESC"). Limit(opts.Limit). Offset(opts.Offset) return rows, q.Find(&rows).Error } -func (s *store) CountMapReports(opts listOptions) (int64, error) { +func (s *Store) CountMapReports(opts ListOptions) (int64, error) { var total int64 - q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts) + q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts) return total, q.Count(&total).Error } -func (s *store) GetMapReport(nodeID string) (*mapReportRecord, error) { - var row mapReportRecord +func (s *Store) GetMapReport(nodeID string) (*MapReportRecord, error) { + var row MapReportRecord if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil { return nil, err } return &row, nil } -func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReportViewportResult, error) { - opts = normalizeMapReportViewportOptions(opts) +func (s *Store) ListMapReportViewport(opts MapReportViewportOptions) (*MapReportViewportResult, error) { + opts = NormalizeMapReportViewportOptions(opts) total, err := s.CountMapReports(opts.ListOptions) if err != nil { return nil, err } - result := &mapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom} + result := &MapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom} if total <= int64(opts.ClusterThreshold) { - var points []mapReportRecord - q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions). + var points []MapReportRecord + q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts.ListOptions). Order("updated_at DESC"). Limit(opts.Limit) if err := q.Find(&points).Error; err != nil { @@ -142,8 +142,8 @@ func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReport return result, nil } -func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapReportClusterRecord, error) { - opts = normalizeMapReportViewportOptions(opts) +func (s *Store) ListMapReportClusters(opts MapReportViewportOptions) ([]MapReportClusterRecord, error) { + opts = NormalizeMapReportViewportOptions(opts) cellSize := mapReportClusterCellSize(opts.ListOptions, opts.TargetCells) var rows []struct { LatBucket int64 @@ -152,7 +152,7 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor Longitude float64 Count int64 } - q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions). + q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts.ListOptions). Select("CAST((latitude + 90.0) / ? AS INTEGER) AS lat_bucket, CAST((longitude + 180.0) / ? AS INTEGER) AS lng_bucket, AVG(latitude) AS latitude, AVG(longitude) AS longitude, COUNT(*) AS count", cellSize, cellSize). Group("lat_bucket, lng_bucket"). Order("count DESC"). @@ -160,9 +160,9 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor if err := q.Scan(&rows).Error; err != nil { return nil, err } - clusters := make([]mapReportClusterRecord, 0, len(rows)) + clusters := make([]MapReportClusterRecord, 0, len(rows)) for _, row := range rows { - clusters = append(clusters, mapReportClusterRecord{ + clusters = append(clusters, MapReportClusterRecord{ ClusterID: fmt.Sprintf("%d:%d", row.LatBucket, row.LngBucket), Latitude: row.Latitude, Longitude: row.Longitude, @@ -172,7 +172,7 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor return clusters, nil } -func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportViewportOptions { +func NormalizeMapReportViewportOptions(opts MapReportViewportOptions) MapReportViewportOptions { if opts.Limit <= 0 { opts.Limit = 1000 } @@ -194,7 +194,7 @@ func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportV return opts } -func mapReportClusterCellSize(opts listOptions, targetCells int) float64 { +func mapReportClusterCellSize(opts ListOptions, targetCells int) float64 { latSpan := 180.0 if opts.MinLat != nil && opts.MaxLat != nil { latSpan = *opts.MaxLat - *opts.MinLat @@ -215,13 +215,13 @@ func mapReportClusterCellSize(opts listOptions, targetCells int) float64 { return cellSize } -func (s *store) DeleteNode(nodeID string) error { +func (s *Store) DeleteNode(nodeID string) error { return s.db.Transaction(func(tx *gorm.DB) error { - nodeResult := tx.Where("node_id = ?", nodeID).Delete(&nodeInfoRecord{}) + nodeResult := tx.Where("node_id = ?", nodeID).Delete(&NodeInfoRecord{}) if nodeResult.Error != nil { return nodeResult.Error } - reportResult := tx.Where("node_id = ?", nodeID).Delete(&mapReportRecord{}) + reportResult := tx.Where("node_id = ?", nodeID).Delete(&MapReportRecord{}) if reportResult.Error != nil { return reportResult.Error } @@ -232,7 +232,7 @@ func (s *store) DeleteNode(nodeID string) error { }) } -func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB { +func applyNodeFilters(q *gorm.DB, opts ListOptions) *gorm.DB { if opts.NodeID != "" { q = q.Where("node_id = ?", opts.NodeID) } @@ -245,7 +245,7 @@ func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB { return q } -func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB { +func applyMapReportFilters(q *gorm.DB, opts ListOptions) *gorm.DB { q = applyNodeFilters(q, opts) if opts.MinLat != nil && opts.MaxLat != nil { q = q.Where("latitude IS NOT NULL AND latitude >= ? AND latitude <= ?", *opts.MinLat, *opts.MaxLat) @@ -260,15 +260,15 @@ func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB { return q } -func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) { - var rows []textMessageRecord +func (s *Store) ListTextMessages(opts ListOptions) ([]TextMessageRecord, error) { + var rows []TextMessageRecord return rows, s.listAppendRows(opts, &rows).Error } -func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) { - opts = normalizeListOptions(opts) - var rows []discardDetailsRecord - q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts). +func (s *Store) ListDiscardDetails(opts ListOptions) ([]DiscardDetailsRecord, error) { + opts = NormalizeListOptions(opts) + var rows []DiscardDetailsRecord + q := applyDiscardDetailsFilters(s.db.Model(&DiscardDetailsRecord{}), opts). Order("created_at DESC"). Order("id DESC"). Limit(opts.Limit). @@ -276,13 +276,13 @@ func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, er return rows, q.Find(&rows).Error } -func (s *store) CountDiscardDetails(opts listOptions) (int64, error) { +func (s *Store) CountDiscardDetails(opts ListOptions) (int64, error) { var total int64 - q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts) + q := applyDiscardDetailsFilters(s.db.Model(&DiscardDetailsRecord{}), opts) return total, q.Count(&total).Error } -func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB { +func applyDiscardDetailsFilters(q *gorm.DB, opts ListOptions) *gorm.DB { if opts.Since != nil { q = q.Where("created_at >= ?", *opts.Since) } @@ -292,8 +292,8 @@ func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB { return q } -func (s *store) DeleteTextMessage(id uint64) error { - result := s.db.Where("id = ?", id).Delete(&textMessageRecord{}) +func (s *Store) DeleteTextMessage(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&TextMessageRecord{}) if result.Error != nil { return result.Error } @@ -303,28 +303,28 @@ func (s *store) DeleteTextMessage(id uint64) error { return nil } -func (s *store) ListPositions(opts listOptions) ([]positionRecord, error) { - var rows []positionRecord +func (s *Store) ListPositions(opts ListOptions) ([]PositionRecord, error) { + var rows []PositionRecord return rows, s.listAppendRows(opts, &rows).Error } -func (s *store) ListTelemetry(opts listOptions) ([]telemetryRecord, error) { - var rows []telemetryRecord +func (s *Store) ListTelemetry(opts ListOptions) ([]TelemetryRecord, error) { + var rows []TelemetryRecord return rows, s.listAppendRows(opts, &rows).Error } -func (s *store) ListRouting(opts listOptions) ([]routingRecord, error) { - var rows []routingRecord +func (s *Store) ListRouting(opts ListOptions) ([]RoutingRecord, error) { + var rows []RoutingRecord return rows, s.listAppendRows(opts, &rows).Error } -func (s *store) ListTraceroute(opts listOptions) ([]tracerouteRecord, error) { - var rows []tracerouteRecord +func (s *Store) ListTraceroute(opts ListOptions) ([]TracerouteRecord, error) { + var rows []TracerouteRecord return rows, s.listAppendRows(opts, &rows).Error } -func (s *store) listAppendRows(opts listOptions, dest any) *gorm.DB { - opts = normalizeListOptions(opts) +func (s *Store) listAppendRows(opts ListOptions, dest any) *gorm.DB { + opts = NormalizeListOptions(opts) q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset) if opts.NodeID != "" { q = q.Where("from_id = ?", opts.NodeID) diff --git a/internal/store/test_helpers_test.go b/internal/store/test_helpers_test.go new file mode 100644 index 0000000..5d7492d --- /dev/null +++ b/internal/store/test_helpers_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// 测试 helper —— 为从 main 包搬过来的测试提供它们原本依赖的小写函数。 +// 这些 helper 不暴露给生产代码使用;它们的行为应当与 main 包对应实现保持一致。 + +// verifyPassword 复刻 auth.go 中的 bcrypt 校验,用于 user_store 的测试。 +func verifyPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// publicMapTileSourceDTO 复刻 admin_map_source_routes.go 中的同名函数, +// 仅供 map_source_store_test.go 验证 ProxyEnabled 时 URL 是否被改写。 +// 这里返回 map[string]any 而非 gin.H 以避免引入 gin 依赖。 +func publicMapTileSourceDTO(row MapTileSourceRecord) map[string]any { + urlTemplate := row.URLTemplate + if row.ProxyEnabled { + hash := row.URLTemplateHash + if hash == "" { + hash = MapTileSourceHash(row.URLTemplate) + } + urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}" + } + return map[string]any{ + "id": row.ID, + "name": row.Name, + "url_template": urlTemplate, + "attribution": row.Attribution, + "max_zoom": row.MaxZoom, + "enabled": row.Enabled, + "is_default": row.IsDefault, + "proxy_enabled": row.ProxyEnabled, + } +} + +// newDBWriteQueue 是 db_write_queue_test.go 期望的旧名字。重新导出供测试使用。 +var newDBWriteQueue = NewWriteQueue + +// 让 strings 不会被 import-but-not-used(如果上面用不到,就算了——保留以应对将来扩展) +var _ = strings.TrimSpace diff --git a/user_store.go b/internal/store/user_store.go similarity index 68% rename from user_store.go rename to internal/store/user_store.go index 37bfee8..e9f792c 100644 --- a/user_store.go +++ b/internal/store/user_store.go @@ -1,4 +1,4 @@ -package main +package store import ( "errors" @@ -9,30 +9,30 @@ import ( "gorm.io/gorm" ) -var errUserAlreadyExists = errors.New("user already exists") +var ErrUserAlreadyExists = errors.New("user already exists") -func (s *store) GetUserByUsername(username string) (*userRecord, error) { - var user userRecord +func (s *Store) GetUserByUsername(username string) (*UserRecord, error) { + var user UserRecord if err := s.db.Where("username = ?", username).Take(&user).Error; err != nil { return nil, err } return &user, nil } -func (s *store) GetUserByID(id uint64) (*userRecord, error) { - var user userRecord +func (s *Store) GetUserByID(id uint64) (*UserRecord, error) { + var user UserRecord if err := s.db.Where("id = ?", id).Take(&user).Error; err != nil { return nil, err } return &user, nil } -func (s *store) ListUsers() ([]userRecord, error) { - var users []userRecord +func (s *Store) ListUsers() ([]UserRecord, error) { + var users []UserRecord return users, s.db.Order("id ASC").Find(&users).Error } -func (s *store) CreateAdminUser(username, password string) (*userRecord, error) { +func (s *Store) CreateAdminUser(username, password string) (*UserRecord, error) { username = strings.TrimSpace(username) if username == "" { return nil, fmt.Errorf("username is required") @@ -41,7 +41,7 @@ func (s *store) CreateAdminUser(username, password string) (*userRecord, error) return nil, fmt.Errorf("password is required") } if _, err := s.GetUserByUsername(username); err == nil { - return nil, errUserAlreadyExists + return nil, ErrUserAlreadyExists } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } @@ -49,14 +49,14 @@ func (s *store) CreateAdminUser(username, password string) (*userRecord, error) if err != nil { return nil, fmt.Errorf("hash user password: %w", err) } - user := userRecord{Username: username, PasswordHash: hash, Role: adminRole} + user := UserRecord{Username: username, PasswordHash: hash, Role: AdminRole} if err := s.db.Create(&user).Error; err != nil { return nil, err } return &user, nil } -func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, error) { +func (s *Store) UpdateUserPassword(id uint64, password string) (*UserRecord, error) { if id == 0 { return nil, fmt.Errorf("user id is required") } @@ -71,15 +71,15 @@ func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, err if err != nil { return nil, fmt.Errorf("hash user password: %w", err) } - if err := s.db.Model(&userRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil { + if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil { return nil, err } user.PasswordHash = hash return s.GetUserByID(id) } -func (s *store) EnsureDefaultAdmin(username, password string) error { - var existing userRecord +func (s *Store) EnsureDefaultAdmin(username, password string) error { + var existing UserRecord err := s.db.Where("username = ?", username).Take(&existing).Error if err == nil { return nil @@ -91,7 +91,7 @@ func (s *store) EnsureDefaultAdmin(username, password string) error { if err != nil { return fmt.Errorf("hash admin password: %w", err) } - user := userRecord{Username: username, PasswordHash: hash, Role: adminRole} + user := UserRecord{Username: username, PasswordHash: hash, Role: AdminRole} if err := s.db.Create(&user).Error; err != nil { return fmt.Errorf("create default admin user: %w", err) } diff --git a/main.go b/main.go index 881a4d6..f2f5b52 100644 --- a/main.go +++ b/main.go @@ -207,7 +207,7 @@ func parseArgs() (*config, error) { if err != nil { return nil, err } - cfg.key = key + cfg.Key = key return cfg, nil } @@ -238,7 +238,7 @@ func run(cfg *config) error { if err != nil { return err } - botSender := newBotService(store, server, cfg.key) + botSender := newBotService(store, server, cfg.Key) mqttHook.autoAcker = botSender.MaybeAutoAck botCtx, stopBotBroadcaster := context.WithCancel(context.Background()) defer stopBotBroadcaster() @@ -301,7 +301,7 @@ func run(cfg *config) error { DataDir: cfg.DataDir, Enabled: cfg.AI.Enabled, ToolConfigStore: store, - }, store.db, botSenderAdapter) + }, store.DB(), botSenderAdapter) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err) } else { @@ -384,7 +384,7 @@ func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *me return nil, nil, "", err } hook := &meshtasticFilterHook{ - key: cfg.key, + key: cfg.Key, dbQueue: dbQueue, stats: stats, blocking: blocking, diff --git a/store_bridge.go b/store_bridge.go new file mode 100644 index 0000000..967893b --- /dev/null +++ b/store_bridge.go @@ -0,0 +1,145 @@ +package main + +// 桥接到 internal/store —— 让根目录其余文件无须修改即可继续使用旧的小写类型/函数名。 +// 当各领域包逐步迁出根目录后,可以删除这些别名。 + +import ( + storepkg "meshtastic_mqtt_server/internal/store" +) + +// ---- 类型别名 ---- + +type ( + store = storepkg.Store + mqttClientInfo = storepkg.MQTTClientInfo + dbWriteQueue = storepkg.WriteQueue + listOptions = storepkg.ListOptions + mapReportViewportOptions = storepkg.MapReportViewportOptions + mapReportViewportResult = storepkg.MapReportViewportResult + mapReportClusterRecord = storepkg.MapReportClusterRecord + userRecord = storepkg.UserRecord + loginLogRecord = storepkg.LoginLogRecord + helpContentRecord = storepkg.HelpContentRecord + runtimeSettingRecord = storepkg.RuntimeSettingRecord + mapTileSourceRecord = storepkg.MapTileSourceRecord + mapTileSourceInput = storepkg.MapTileSourceInput + discardDetailsRecord = storepkg.DiscardDetailsRecord + signRecord = storepkg.SignRecord + nodeBlockingRecord = storepkg.NodeBlockingRecord + ipBlockingRecord = storepkg.IPBlockingRecord + forbiddenWordBlockingRecord = storepkg.ForbiddenWordBlockingRecord + mqttForwarderRecord = storepkg.MQTTForwarderRecord + mqttForwarderInput = storepkg.MQTTForwarderInput + mqttForwardTopicRecord = storepkg.MQTTForwardTopicRecord + mqttForwardTopicInput = storepkg.MQTTForwardTopicInput + botNodeRecord = storepkg.BotNodeRecord + botNodeInput = storepkg.BotNodeInput + botMessageRecord = storepkg.BotMessageRecord + botDirectMessageRecord = storepkg.BotDirectMessageRecord + llmMessageQueueRecord = storepkg.LLMMessageQueueRecord + nodeInfoRecord = storepkg.NodeInfoRecord + mapReportRecord = storepkg.MapReportRecord + textMessageRecord = storepkg.TextMessageRecord + llmProviderRecord = storepkg.LLMProviderRecord + llmToolRouterRecord = storepkg.LLMToolRouterRecord + llmPrimaryConfigRecord = storepkg.LLMPrimaryConfigRecord + positionRecord = storepkg.PositionRecord + telemetryRecord = storepkg.TelemetryRecord + routingRecord = storepkg.RoutingRecord + tracerouteRecord = storepkg.TracerouteRecord +) + +// AppendPacketFields / MQTTClientRecordFields 已经是导出名,不需要别名。 +// 直接用包限定名供 root 文件调用: +type ( + AppendPacketFields = storepkg.AppendPacketFields + MQTTClientRecordFields = storepkg.MQTTClientRecordFields +) + +// 其它额外导出类型的别名(旧的小写形式仍被根目录文件直接使用)。 +type ( + runtimeSettingsSnapshot = storepkg.RuntimeSettingsSnapshot + mqttForwarderConfig = storepkg.MQTTForwarderConfig + botDirectMessageListOptions = storepkg.BotDirectMessageListOptions + botMessageListOptions = storepkg.BotMessageListOptions + botDirectConversation = storepkg.BotDirectConversation + signDayCount = storepkg.SignDayCount +) + +var errBlockingAlreadyExists = storepkg.ErrBlockingAlreadyExists +var errBotNodeAlreadyExists = storepkg.ErrBotNodeAlreadyExists +var ( + errMapTileSourceAlreadyExists = storepkg.ErrMapTileSourceAlreadyExists + errMapTileSourceCannotDeleteDefault = storepkg.ErrMapTileSourceCannotDeleteDefault + errMapTileSourceCannotDisableDefault = storepkg.ErrMapTileSourceCannotDisableDefault + errMapTileSourceDefaultMustBeEnabled = storepkg.ErrMapTileSourceDefaultMustBeEnabled +) + +func mapTileSourceHash(urlTemplate string) string { + return storepkg.MapTileSourceHash(urlTemplate) +} + +const ( + forbiddenWordMatchContains = storepkg.ForbiddenWordMatchContains + runtimeSettingAllowEncryptedForwarding = storepkg.RuntimeSettingAllowEncryptedForwarding + runtimeSettingLLMQueueEnabled = storepkg.RuntimeSettingLLMQueueEnabled + runtimeSettingLLMQueueIncludeChannel = storepkg.RuntimeSettingLLMQueueIncludeChannel + + botDefaultPSK = storepkg.BotDefaultPSK + botMessageTypeChannel = storepkg.BotMessageTypeChannel + botMessageTypeDirect = storepkg.BotMessageTypeDirect + botMessageStatusPending = storepkg.BotMessageStatusPending + botMessageStatusPublished = storepkg.BotMessageStatusPublished + botMessageStatusFailed = storepkg.BotMessageStatusFailed + botDirectMessageDirectionInbound = storepkg.BotDirectMessageDirectionInbound + botDirectMessageDirectionOutbound = storepkg.BotDirectMessageDirectionOutbound + botDefaultTopicPrefix = storepkg.BotDefaultTopicPrefix + + mqttForwardDirectionSourceToTarget = storepkg.MQTTForwardDirectionSourceToTarget + mqttForwardDirectionBidirectional = storepkg.MQTTForwardDirectionBidirectional +) + +const botDefaultNodeInfoBroadcastSeconds = storepkg.BotDefaultNodeInfoBroadcastSeconds + +func validateBotNodeNum(n int64) error { return storepkg.ValidateBotNodeNum(n) } + +var errUserAlreadyExists = storepkg.ErrUserAlreadyExists + +var ( + errMQTTForwarderAlreadyExists = storepkg.ErrMQTTForwarderAlreadyExists + errMQTTForwardTopicAlreadyExists = storepkg.ErrMQTTForwardTopicAlreadyExists +) + +func nullableString(v any) *string { return storepkg.NullableString(v) } +func nullableStringValue(v any) *string { return storepkg.NullableStringValue(v) } +func decodeBotPublicKey(row botNodeRecord) ([]byte, error) { + return storepkg.DecodeBotPublicKey(row) +} + +// LLM 消息队列状态字符串常量。 +const ( + llmMessageStatusPending = storepkg.LLMMessageStatusPending + llmMessageStatusProcessing = storepkg.LLMMessageStatusProcessing + llmMessageStatusProcessed = storepkg.LLMMessageStatusProcessed + llmMessageStatusError = storepkg.LLMMessageStatusError +) + +const defaultHelpMarkdown = storepkg.DefaultHelpMarkdown + +// 旧名 llmMessageDTO 现在通过 store 提供。 +func llmMessageDTO(row llmMessageQueueRecord) map[string]any { + return storepkg.LLMMessageDTO(row) +} + +// ---- 工厂函数包装 ---- + +func openStore(cfg databaseConfig) (*store, error) { return storepkg.OpenStore(cfg) } +func normalizeListOptions(o listOptions) listOptions { + return storepkg.NormalizeListOptions(o) +} +func normalizeMapReportViewportOptions(o mapReportViewportOptions) mapReportViewportOptions { + return storepkg.NormalizeMapReportViewportOptions(o) +} + +// newDBWriteQueue 在新包里更名,提供旧名字避免改 main.go。 +func newDBWriteQueue(s *store) *dbWriteQueue { return storepkg.NewWriteQueue(s) } diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..24a28bc --- /dev/null +++ b/test_helpers_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "path/filepath" + "testing" + + storepkg "meshtastic_mqtt_server/internal/store" +) + +// openTestStore 在根目录的测试中沿用旧函数名,但底层调用 internal/store 的实现。 +func openTestStore(t *testing.T) *store { + t.Helper() + st, err := storepkg.OpenStore(databaseConfig{ + Driver: databaseDriverSQLite, + SQLite: sqliteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, + }) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + return st +} From c527a9fd9a4bfa31ab94f8d0791b69b62ab6946e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Thu, 18 Jun 2026 14:23:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=8B=86?= =?UTF-8?q?=E5=87=BA=20auth=20/=20blocking=20/=20runtimesettings=20/=20hel?= =?UTF-8?q?p=20/=20mqttforward=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二批:把根目录中纯逻辑领域文件(cache、service、admin route)按业务边界 迁到 internal/ 下的子包。各子包暴露 RegisterRoutes 给 web 包调用,根目录 只留下一行 bridge 文件保留旧名字别名。 新增包 - internal/auth/ SessionClaims / Manager / RequireAdmin / HashPassword / VerifyPassword / AdminUserResponse 等。原 auth.go 中 被两个 admin route 依赖的 sessionClaims 现在以 auth. SessionClaims 形式被它们 import;不再被锁在 main 包。 - internal/blocking/ Cache + RegisterRoutes,以前散在 blocking_cache.go 和 admin_blocking_routes.go 里。 - internal/runtimesettings/ Cache + RegisterRoutes。 - internal/help/ RenderMarkdown / RegisterPublicRoutes / RegisterAdminRoutes(拆分原来的 registerHelpRoutes 和 registerAdminHelpRoutes 两条入口)。 - internal/mqttforward/ Manager / Reloader / Stats / RegisterRoutes。 forwarder runner、循环抑制 cache 等运行时逻辑随之迁入。 - internal/webutil/ ParseListOptions / WriteListResponse[WithTotal] / ParseMapReportListOptions / ParseMapReportViewportOptions 以及 PtrString/PtrInt64/... 等指针解引用 helper。 以前散在 web.go 中,现在被各 admin route 子包共享, 避免 internal/blocking → internal/web → internal/blocking 的循环依赖。 - internal/store/testutil/ OpenStore(t) helper,让其它包测试零样板拿到 store。 根目录新增 bridge 文件 - blocking_bridge.go / runtime_settings_bridge.go / help_bridge.go / mqttforward_bridge.go:用 type alias + thin wrapper 把上述子包的导出 名映射到旧的小写名(blockingCache、registerAdminBlockingRoutes 等), 让 main.go / web.go 等仍未迁出的文件无须改动。 修改 - auth.go 改为对 internal/auth 的 bridge;web.go 中 sessions.newCookie / clearCookie 改为 NewCookie / ClearCookie。 - main_test.go 中 BlockingViolationForRecord* 测试不再直接构造未导出字段, 改成走 store.CreateNodeBlocking → newBlockingCache 的真实路径。 - internal/mqttforward 把以前 *_store.go 中没有方法依赖的运行时类型 (forwarder runner、loop cache)和 admin route 一并归位;mqtt_status.go 暂时仍留在根目录(依赖 main 中的 mqttClientInfoFromClient)。 go build ./... / go test ./... 全部通过;测试数量未变。 Co-Authored-By: Claude --- auth.go | 148 ++--------- blocking_bridge.go | 21 ++ help_bridge.go | 16 ++ internal/auth/auth.go | 169 +++++++++++++ .../blocking/admin_blocking_routes.go | 35 +-- .../blocking/blocking_cache.go | 22 +- .../blocking/blocking_cache_test.go | 14 +- internal/blocking/test_helpers_test.go | 13 + .../help/admin_help_routes.go | 21 +- .../help/help_markdown.go | 6 +- .../mqttforward/admin_mqtt_forward_routes.go | 43 ++-- .../mqttforward/filter_stats.go | 12 +- .../mqttforward/mqtt_forward_manager.go | 81 +++--- .../admin_runtime_settings_routes.go | 25 +- .../runtimesettings/runtime_settings_cache.go | 55 ++++ .../runtime_settings_cache_test.go | 21 +- internal/store/testutil/testutil.go | 27 ++ internal/webutil/webutil.go | 236 ++++++++++++++++++ main_test.go | 38 ++- mqttforward_bridge.go | 26 ++ runtime_settings_bridge.go | 19 ++ runtime_settings_cache.go | 47 ---- web.go | 4 +- 23 files changed, 786 insertions(+), 313 deletions(-) create mode 100644 blocking_bridge.go create mode 100644 help_bridge.go create mode 100644 internal/auth/auth.go rename admin_blocking_routes.go => internal/blocking/admin_blocking_routes.go (84%) rename blocking_cache.go => internal/blocking/blocking_cache.go (87%) rename blocking_cache_test.go => internal/blocking/blocking_cache_test.go (92%) create mode 100644 internal/blocking/test_helpers_test.go rename admin_help_routes.go => internal/help/admin_help_routes.go (76%) rename help_markdown.go => internal/help/help_markdown.go (70%) rename admin_mqtt_forward_routes.go => internal/mqttforward/admin_mqtt_forward_routes.go (82%) rename filter_stats.go => internal/mqttforward/filter_stats.go (51%) rename mqtt_forward_manager.go => internal/mqttforward/mqtt_forward_manager.go (79%) rename admin_runtime_settings_routes.go => internal/runtimesettings/admin_runtime_settings_routes.go (58%) create mode 100644 internal/runtimesettings/runtime_settings_cache.go rename runtime_settings_cache_test.go => internal/runtimesettings/runtime_settings_cache_test.go (57%) create mode 100644 internal/store/testutil/testutil.go create mode 100644 internal/webutil/webutil.go create mode 100644 mqttforward_bridge.go create mode 100644 runtime_settings_bridge.go delete mode 100644 runtime_settings_cache.go diff --git a/auth.go b/auth.go index f924ac4..f946e78 100644 --- a/auth.go +++ b/auth.go @@ -1,147 +1,29 @@ package main +// 桥接到 internal/auth — 让根目录其余文件继续用旧的小写名(sessionManager、 +// sessionClaims、newSessionManager、requireAdmin、verifyPassword 等)。 + import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" + + authpkg "meshtastic_mqtt_server/internal/auth" ) -const ( - adminRole = "admin" - adminSessionCookie = "mesh_admin_session" +// 类型别名 +type ( + sessionManager = authpkg.Manager + sessionClaims = authpkg.SessionClaims + adminUserDTO = authpkg.AdminUserDTO ) -type adminUserDTO struct { - Username string `json:"username"` - Role string `json:"role"` -} - -type sessionClaims struct { - UserID uint64 `json:"user_id"` - Username string `json:"username"` - Role string `json:"role"` - Expires int64 `json:"expires"` -} - -type sessionManager struct { - secret []byte - secure bool - ttl time.Duration -} +const adminRole = authpkg.AdminRole func newSessionManager(cfg webAdminConfig) (*sessionManager, error) { - secret := strings.TrimSpace(cfg.SessionSecret) - if secret == "" { - generated := make([]byte, 32) - if _, err := rand.Read(generated); err != nil { - return nil, fmt.Errorf("generate admin session secret: %w", err) - } - return &sessionManager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil - } - return &sessionManager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil + return authpkg.NewManager(cfg) } -func hashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", err - } - return string(hash), nil -} +func verifyPassword(hash, password string) bool { return authpkg.VerifyPassword(hash, password) } -func verifyPassword(hash, password string) bool { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil -} +func adminUserResponse(user userRecord) adminUserDTO { return authpkg.AdminUserResponse(user) } -func adminUserResponse(user userRecord) adminUserDTO { - return adminUserDTO{Username: user.Username, Role: user.Role} -} - -func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) { - claims := sessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()} - data, err := json.Marshal(claims) - if err != nil { - return nil, err - } - payload := base64.RawURLEncoding.EncodeToString(data) - signature := sm.sign(payload) - return &http.Cookie{ - Name: adminSessionCookie, - Value: payload + "." + signature, - Path: "/", - MaxAge: int(sm.ttl.Seconds()), - HttpOnly: true, - Secure: sm.secure, - SameSite: http.SameSiteLaxMode, - }, nil -} - -func (sm *sessionManager) clearCookie() *http.Cookie { - return &http.Cookie{ - Name: adminSessionCookie, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: sm.secure, - SameSite: http.SameSiteLaxMode, - } -} - -func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, error) { - cookie, err := c.Cookie(adminSessionCookie) - if err != nil { - return nil, err - } - parts := strings.Split(cookie, ".") - if len(parts) != 2 { - return nil, errors.New("invalid session") - } - if !hmac.Equal([]byte(parts[1]), []byte(sm.sign(parts[0]))) { - return nil, errors.New("invalid session signature") - } - data, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return nil, err - } - var claims sessionClaims - if err := json.Unmarshal(data, &claims); err != nil { - return nil, err - } - if claims.Expires <= time.Now().Unix() { - return nil, errors.New("session expired") - } - if claims.Role != adminRole { - return nil, errors.New("admin required") - } - return &claims, nil -} - -func (sm *sessionManager) sign(payload string) string { - mac := hmac.New(sha256.New, sm.secret) - mac.Write([]byte(payload)) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) -} - -func requireAdmin(sm *sessionManager) gin.HandlerFunc { - return func(c *gin.Context) { - claims, err := sm.claimsFromRequest(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"}) - c.Abort() - return - } - c.Set("admin_claims", claims) - c.Next() - } -} +func requireAdmin(sm *sessionManager) gin.HandlerFunc { return authpkg.RequireAdmin(sm) } diff --git a/blocking_bridge.go b/blocking_bridge.go new file mode 100644 index 0000000..eee61e9 --- /dev/null +++ b/blocking_bridge.go @@ -0,0 +1,21 @@ +package main + +// 桥接到 internal/blocking — 让根目录其余文件可以继续使用旧名字 +// blockingCache / newBlockingCache / registerAdminBlockingRoutes, +// 而无须改动 main.go / web.go 等十几处调用点。 + +import ( + "github.com/gin-gonic/gin" + + blockingpkg "meshtastic_mqtt_server/internal/blocking" +) + +type blockingCache = blockingpkg.Cache + +func newBlockingCache(s *store) (*blockingCache, error) { + return blockingpkg.New(s) +} + +func registerAdminBlockingRoutes(r gin.IRouter, s *store, b *blockingCache) { + blockingpkg.RegisterRoutes(r, s, b) +} diff --git a/help_bridge.go b/help_bridge.go new file mode 100644 index 0000000..82ae746 --- /dev/null +++ b/help_bridge.go @@ -0,0 +1,16 @@ +package main + +// 桥接到 internal/help — 让 web.go 中的 registerHelpRoutes / +// registerAdminHelpRoutes / renderHelpMarkdown 旧名字仍可用。 + +import ( + "github.com/gin-gonic/gin" + + helppkg "meshtastic_mqtt_server/internal/help" +) + +func registerHelpRoutes(r gin.IRouter, s *store) { helppkg.RegisterPublicRoutes(r, s) } +func registerAdminHelpRoutes(r gin.IRouter, s *store) { helppkg.RegisterAdminRoutes(r, s) } +func renderHelpMarkdown(markdown string) (string, error) { + return helppkg.RenderMarkdown(markdown) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..03ad6d8 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,169 @@ +// Package auth 实现 admin 端 cookie session 与密码散列。 +// +// 拆离自原来 main 包的 auth.go:让所有 admin route 包都可以直接 import 这里的 +// SessionClaims / Manager / RequireAdmin,而不是被锁在根 main 包里。 +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "meshtastic_mqtt_server/internal/config" + "meshtastic_mqtt_server/internal/store" +) + +const ( + AdminRole = "admin" + adminSessionCookie = "mesh_admin_session" + + // AdminClaimsKey 是中间件挂在 gin.Context 上的 key,handler 可用 + // `c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)` 取出。 + AdminClaimsKey = "admin_claims" +) + +// AdminUserDTO 是 /me /login 等接口返回给前端的最小用户视图。 +type AdminUserDTO struct { + Username string `json:"username"` + Role string `json:"role"` +} + +// SessionClaims 是 cookie 中持久化的会话内容。 +type SessionClaims struct { + UserID uint64 `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + Expires int64 `json:"expires"` +} + +// Manager 持有签名密钥与 cookie 配置,是发布 / 校验 cookie 的入口。 +type Manager struct { + secret []byte + secure bool + ttl time.Duration +} + +// NewManager 根据配置构造 Manager。如果 SessionSecret 空,会随机生成 32 字节。 +func NewManager(cfg config.WebAdminConfig) (*Manager, error) { + secret := strings.TrimSpace(cfg.SessionSecret) + if secret == "" { + generated := make([]byte, 32) + if _, err := rand.Read(generated); err != nil { + return nil, fmt.Errorf("generate admin session secret: %w", err) + } + return &Manager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil + } + return &Manager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil +} + +// HashPassword 用 bcrypt 默认 cost 散列;用于建账号、改密码。 +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword 校验明文密码是否与散列匹配。 +func VerifyPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// AdminUserResponse 把 store.UserRecord 转成对外 DTO。 +func AdminUserResponse(user store.UserRecord) AdminUserDTO { + return AdminUserDTO{Username: user.Username, Role: user.Role} +} + +// NewCookie 为已登录用户构造一份带签名的 session cookie。 +func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) { + claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()} + data, err := json.Marshal(claims) + if err != nil { + return nil, err + } + payload := base64.RawURLEncoding.EncodeToString(data) + signature := sm.sign(payload) + return &http.Cookie{ + Name: adminSessionCookie, + Value: payload + "." + signature, + Path: "/", + MaxAge: int(sm.ttl.Seconds()), + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + }, nil +} + +// ClearCookie 返回一个把 cookie 立即清掉的 *http.Cookie,供 logout 使用。 +func (sm *Manager) ClearCookie() *http.Cookie { + return &http.Cookie{ + Name: adminSessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + } +} + +// ClaimsFromRequest 校验请求 cookie 的签名 / 过期 / 角色。 +func (sm *Manager) ClaimsFromRequest(c *gin.Context) (*SessionClaims, error) { + cookie, err := c.Cookie(adminSessionCookie) + if err != nil { + return nil, err + } + parts := strings.Split(cookie, ".") + if len(parts) != 2 { + return nil, errors.New("invalid session") + } + if !hmac.Equal([]byte(parts[1]), []byte(sm.sign(parts[0]))) { + return nil, errors.New("invalid session signature") + } + data, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, err + } + var claims SessionClaims + if err := json.Unmarshal(data, &claims); err != nil { + return nil, err + } + if claims.Expires <= time.Now().Unix() { + return nil, errors.New("session expired") + } + if claims.Role != AdminRole { + return nil, errors.New("admin required") + } + return &claims, nil +} + +func (sm *Manager) sign(payload string) string { + mac := hmac.New(sha256.New, sm.secret) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。 +func RequireAdmin(sm *Manager) gin.HandlerFunc { + return func(c *gin.Context) { + claims, err := sm.ClaimsFromRequest(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"}) + c.Abort() + return + } + c.Set(AdminClaimsKey, claims) + c.Next() + } +} diff --git a/admin_blocking_routes.go b/internal/blocking/admin_blocking_routes.go similarity index 84% rename from admin_blocking_routes.go rename to internal/blocking/admin_blocking_routes.go index 176d293..b09ee1c 100644 --- a/admin_blocking_routes.go +++ b/internal/blocking/admin_blocking_routes.go @@ -1,4 +1,4 @@ -package main +package blocking import ( "errors" @@ -7,6 +7,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type nodeBlockingRequest struct { @@ -30,7 +33,7 @@ type forbiddenWordBlockingRequest struct { Enabled bool `json:"enabled"` } -func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blockingCache) { +func RegisterRoutes(r gin.IRouter, store *storepkg.Store, blocking *Cache) { reloadBlocking := func() error { if blocking == nil { return nil @@ -39,17 +42,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking } r.GET("/blocking/nodes", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListNodeBlocking(opts) if err != nil { - writeListResponse(c, rows, opts, err, nodeBlockingDTO) + webutil.WriteListResponse(c, rows, opts, err, nodeBlockingDTO) return } total, err := store.CountNodeBlocking(opts) - writeListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO) }) r.POST("/blocking/nodes", func(c *gin.Context) { var req nodeBlockingRequest @@ -82,17 +85,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking }) r.GET("/blocking/ips", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListIPBlocking(opts) if err != nil { - writeListResponse(c, rows, opts, err, ipBlockingDTO) + webutil.WriteListResponse(c, rows, opts, err, ipBlockingDTO) return } total, err := store.CountIPBlocking(opts) - writeListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO) }) r.POST("/blocking/ips", func(c *gin.Context) { var req ipBlockingRequest @@ -125,17 +128,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking }) r.GET("/blocking/words", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListForbiddenWordBlocking(opts) if err != nil { - writeListResponse(c, rows, opts, err, forbiddenWordBlockingDTO) + webutil.WriteListResponse(c, rows, opts, err, forbiddenWordBlockingDTO) return } total, err := store.CountForbiddenWordBlocking(opts) - writeListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO) }) r.POST("/blocking/words", func(c *gin.Context) { var req forbiddenWordBlockingRequest @@ -178,7 +181,7 @@ func parseBlockingID(c *gin.Context) (uint64, bool) { } func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H, afterSuccess func() error) { - if errors.Is(err, errBlockingAlreadyExists) { + if errors.Is(err, storepkg.ErrBlockingAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"}) return } @@ -217,14 +220,14 @@ func writeBlockingDeleteResponse(c *gin.Context, err error, afterSuccess func() c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func nodeBlockingDTO(row nodeBlockingRecord) gin.H { - return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": ptrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} +func nodeBlockingDTO(row storepkg.NodeBlockingRecord) gin.H { + return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": webutil.PtrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } -func ipBlockingDTO(row ipBlockingRecord) gin.H { +func ipBlockingDTO(row storepkg.IPBlockingRecord) gin.H { return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } -func forbiddenWordBlockingDTO(row forbiddenWordBlockingRecord) gin.H { +func forbiddenWordBlockingDTO(row storepkg.ForbiddenWordBlockingRecord) gin.H { return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } diff --git a/blocking_cache.go b/internal/blocking/blocking_cache.go similarity index 87% rename from blocking_cache.go rename to internal/blocking/blocking_cache.go index 48fe0a4..f2c686c 100644 --- a/blocking_cache.go +++ b/internal/blocking/blocking_cache.go @@ -1,4 +1,4 @@ -package main +package blocking import ( "fmt" @@ -6,9 +6,11 @@ import ( "strconv" "strings" "sync" + + storepkg "meshtastic_mqtt_server/internal/store" ) -type blockingCache struct { +type Cache struct { mu sync.RWMutex nodes map[string]struct{} nodeNums map[int64]struct{} @@ -24,15 +26,15 @@ type forbiddenWordRule struct { caseSensitive bool } -func newBlockingCache(store *store) (*blockingCache, error) { - cache := &blockingCache{} +func New(store *storepkg.Store) (*Cache, error) { + cache := &Cache{} if err := cache.Reload(store); err != nil { return nil, err } return cache, nil } -func (c *blockingCache) Reload(store *store) error { +func (c *Cache) Reload(store *storepkg.Store) error { if store == nil { return fmt.Errorf("store is required") } @@ -81,7 +83,7 @@ func (c *blockingCache) Reload(store *store) error { words := make([]forbiddenWordRule, 0, len(wordRows)) for _, row := range wordRows { word := strings.TrimSpace(row.Word) - if word == "" || row.MatchType != forbiddenWordMatchContains { + if word == "" || row.MatchType != storepkg.ForbiddenWordMatchContains { continue } words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive}) @@ -97,7 +99,7 @@ func (c *blockingCache) Reload(store *store) error { return nil } -func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool { +func (c *Cache) IsNodeBlocked(nodeID any, nodeNum any) bool { if c == nil { return false } @@ -118,7 +120,7 @@ func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool { return false } -func (c *blockingCache) IsIPBlocked(host string) bool { +func (c *Cache) IsIPBlocked(host string) bool { if c == nil { return false } @@ -144,7 +146,7 @@ func (c *blockingCache) IsIPBlocked(host string) bool { return false } -func (c *blockingCache) FindForbiddenWord(text any) (string, bool) { +func (c *Cache) FindForbiddenWord(text any) (string, bool) { if c == nil { return "", false } @@ -157,7 +159,7 @@ func (c *blockingCache) FindForbiddenWord(text any) (string, bool) { defer c.mu.RUnlock() foldedText := "" for _, rule := range c.words { - if rule.matchType != forbiddenWordMatchContains { + if rule.matchType != storepkg.ForbiddenWordMatchContains { continue } if rule.caseSensitive { diff --git a/blocking_cache_test.go b/internal/blocking/blocking_cache_test.go similarity index 92% rename from blocking_cache_test.go rename to internal/blocking/blocking_cache_test.go index 691c1cf..9ddffc4 100644 --- a/blocking_cache_test.go +++ b/internal/blocking/blocking_cache_test.go @@ -1,4 +1,4 @@ -package main +package blocking import "testing" @@ -27,9 +27,9 @@ func TestBlockingCacheLoadsEnabledRules(t *testing.T) { t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err) } - cache, err := newBlockingCache(st) + cache, err := New(st) if err != nil { - t.Fatalf("newBlockingCache() error = %v", err) + t.Fatalf("New() error = %v", err) } if !cache.IsNodeBlocked("!12345678", nil) { @@ -65,9 +65,9 @@ func TestBlockingCacheIPExactAndCIDR(t *testing.T) { if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil { t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err) } - cache, err := newBlockingCache(st) + cache, err := New(st) if err != nil { - t.Fatalf("newBlockingCache() error = %v", err) + t.Fatalf("New() error = %v", err) } if !cache.IsIPBlocked("127.0.0.1") { @@ -88,9 +88,9 @@ func TestBlockingCacheForbiddenWordCaseSensitivity(t *testing.T) { if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil { t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err) } - cache, err := newBlockingCache(st) + cache, err := New(st) if err != nil { - t.Fatalf("newBlockingCache() error = %v", err) + t.Fatalf("New() error = %v", err) } if _, ok := cache.FindForbiddenWord("lowercase spam"); ok { diff --git a/internal/blocking/test_helpers_test.go b/internal/blocking/test_helpers_test.go new file mode 100644 index 0000000..d1a8309 --- /dev/null +++ b/internal/blocking/test_helpers_test.go @@ -0,0 +1,13 @@ +package blocking + +import ( + "testing" + + "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/store/testutil" +) + +// openTestStore 委托到 store/testutil,让本包的测试代码保持简洁。 +func openTestStore(t *testing.T) *store.Store { + return testutil.OpenStore(t) +} diff --git a/admin_help_routes.go b/internal/help/admin_help_routes.go similarity index 76% rename from admin_help_routes.go rename to internal/help/admin_help_routes.go index a70a109..014491c 100644 --- a/admin_help_routes.go +++ b/internal/help/admin_help_routes.go @@ -1,4 +1,4 @@ -package main +package help import ( "errors" @@ -7,13 +7,17 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/auth" + storepkg "meshtastic_mqtt_server/internal/store" ) type helpContentRequest struct { Markdown string `json:"markdown"` } -func registerHelpRoutes(r gin.IRouter, store *store) { +// RegisterPublicRoutes 把对外可见的 GET /help 挂到给定路由组下。 +func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) { r.GET("/help", func(c *gin.Context) { item, err := latestHelpContentDTO(store) if err != nil { @@ -24,7 +28,8 @@ func registerHelpRoutes(r gin.IRouter, store *store) { }) } -func registerAdminHelpRoutes(r gin.IRouter, store *store) { +// RegisterAdminRoutes 注册管理员侧 /help、/help、/help/preview 这三条路由。 +func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) { r.GET("/help", func(c *gin.Context) { item, err := latestHelpContentDTO(store) if err != nil { @@ -39,7 +44,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"}) return } - claims := c.MustGet("admin_claims").(*sessionClaims) + claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims) row, err := store.InsertHelpContent(req.Markdown, claims.Username) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -58,7 +63,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"}) return } - html, err := renderHelpMarkdown(req.Markdown) + html, err := RenderMarkdown(req.Markdown) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -67,10 +72,10 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) { }) } -func latestHelpContentDTO(store *store) (gin.H, error) { +func latestHelpContentDTO(store *storepkg.Store) (gin.H, error) { row, err := store.GetLatestHelpContent() if errors.Is(err, gorm.ErrRecordNotFound) { - return helpContentDTO(0, defaultHelpMarkdown, "", nil) + return helpContentDTO(0, storepkg.DefaultHelpMarkdown, "", nil) } if err != nil { return nil, err @@ -79,7 +84,7 @@ func latestHelpContentDTO(store *store) (gin.H, error) { } func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) { - html, err := renderHelpMarkdown(markdown) + html, err := RenderMarkdown(markdown) if err != nil { return nil, err } diff --git a/help_markdown.go b/internal/help/help_markdown.go similarity index 70% rename from help_markdown.go rename to internal/help/help_markdown.go index d0d752f..4d06285 100644 --- a/help_markdown.go +++ b/internal/help/help_markdown.go @@ -1,4 +1,4 @@ -package main +package help import ( "bytes" @@ -9,7 +9,9 @@ import ( "github.com/yuin/goldmark/extension" ) -func renderHelpMarkdown(markdown string) (string, error) { +// RenderMarkdown 把 GFM markdown 转成净化后的 HTML,供 admin 编辑器预览 +// 与 /help 路由直接渲染。 +func RenderMarkdown(markdown string) (string, error) { var buf bytes.Buffer md := goldmark.New(goldmark.WithExtensions(extension.GFM)) if err := md.Convert([]byte(markdown), &buf); err != nil { diff --git a/admin_mqtt_forward_routes.go b/internal/mqttforward/admin_mqtt_forward_routes.go similarity index 82% rename from admin_mqtt_forward_routes.go rename to internal/mqttforward/admin_mqtt_forward_routes.go index bf946ea..9bb3688 100644 --- a/admin_mqtt_forward_routes.go +++ b/internal/mqttforward/admin_mqtt_forward_routes.go @@ -1,4 +1,4 @@ -package main +package mqttforward import ( "errors" @@ -7,6 +7,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type mqttForwarderRequest struct { @@ -38,19 +41,19 @@ type mqttForwardTopicRequest struct { Retain bool `json:"retain"` } -func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttForwardReloader) { +func RegisterRoutes(r gin.IRouter, store *storepkg.Store, forwarder Reloader) { r.GET("/mqtt-forward/forwarders", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListMQTTForwarders(opts) if err != nil { - writeListResponse(c, rows, opts, err, mqttForwarderDTO) + webutil.WriteListResponse(c, rows, opts, err, mqttForwarderDTO) return } total, err := store.CountMQTTForwarders(opts) - writeListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO) }) r.POST("/mqtt-forward/forwarders", func(c *gin.Context) { var req mqttForwarderRequest @@ -106,17 +109,17 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF if !ok { return } - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListMQTTForwardTopics(id, opts) if err != nil { - writeListResponse(c, rows, opts, err, mqttForwardTopicDTO) + webutil.WriteListResponse(c, rows, opts, err, mqttForwardTopicDTO) return } total, err := store.CountMQTTForwardTopics(id) - writeListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO) }) r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) { id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id") @@ -168,7 +171,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF }) }) r.GET("/mqtt-forward/status", func(c *gin.Context) { - items := []mqttForwardRuntimeStatus{} + items := []RuntimeStatus{} if forwarder != nil { items = forwarder.Status() } @@ -176,7 +179,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF }) } -func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput { +func mqttForwarderInputFromRequest(req mqttForwarderRequest) storepkg.MQTTForwarderInput { sourcePassword := req.SourcePassword if req.SourcePasswordClear { empty := "" @@ -187,11 +190,11 @@ func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput empty := "" targetPassword = &empty } - return mqttForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS} + return storepkg.MQTTForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS} } -func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) mqttForwardTopicInput { - return mqttForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain} +func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) storepkg.MQTTForwardTopicInput { + return storepkg.MQTTForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain} } func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) { @@ -203,15 +206,15 @@ func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) { return id, true } -func reloadMQTTForwarder(forwarder mqttForwardReloader, id uint64) error { +func reloadMQTTForwarder(forwarder Reloader, id uint64) error { if forwarder == nil { return nil } return forwarder.ReloadForwarder(id) } -func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwarderRecord, err error, afterSuccess func() error) { - if errors.Is(err, errMQTTForwarderAlreadyExists) { +func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwarderRecord, err error, afterSuccess func() error) { + if errors.Is(err, storepkg.ErrMQTTForwarderAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"}) return } @@ -232,8 +235,8 @@ func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwa c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)}) } -func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *mqttForwardTopicRecord, err error, afterSuccess func() error) { - if errors.Is(err, errMQTTForwardTopicAlreadyExists) { +func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwardTopicRecord, err error, afterSuccess func() error) { + if errors.Is(err, storepkg.ErrMQTTForwardTopicAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"}) return } @@ -272,10 +275,10 @@ func writeMQTTForwardDeleteResponse(c *gin.Context, err error, afterSuccess func c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func mqttForwarderDTO(row mqttForwarderRecord) gin.H { +func mqttForwarderDTO(row storepkg.MQTTForwarderRecord) gin.H { return gin.H{"id": row.ID, "name": row.Name, "enabled": row.Enabled, "source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername, "source_password_set": row.SourcePassword != "", "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS, "target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername, "target_password_set": row.TargetPassword != "", "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } -func mqttForwardTopicDTO(row mqttForwardTopicRecord) gin.H { +func mqttForwardTopicDTO(row storepkg.MQTTForwardTopicRecord) gin.H { return gin.H{"id": row.ID, "forwarder_id": row.ForwarderID, "topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction, "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } diff --git a/filter_stats.go b/internal/mqttforward/filter_stats.go similarity index 51% rename from filter_stats.go rename to internal/mqttforward/filter_stats.go index 27ba54f..e5d38ff 100644 --- a/filter_stats.go +++ b/internal/mqttforward/filter_stats.go @@ -1,32 +1,32 @@ -package main +package mqttforward import "sync/atomic" -type meshtasticMessageStats struct { +type Stats struct { forwarded atomic.Int64 dropped atomic.Int64 } -func (s *meshtasticMessageStats) IncForwarded() { +func (s *Stats) IncForwarded() { if s != nil { s.forwarded.Add(1) } } -func (s *meshtasticMessageStats) IncDropped() { +func (s *Stats) IncDropped() { if s != nil { s.dropped.Add(1) } } -func (s *meshtasticMessageStats) Forwarded() int64 { +func (s *Stats) Forwarded() int64 { if s == nil { return 0 } return s.forwarded.Load() } -func (s *meshtasticMessageStats) Dropped() int64 { +func (s *Stats) Dropped() int64 { if s == nil { return 0 } diff --git a/mqtt_forward_manager.go b/internal/mqttforward/mqtt_forward_manager.go similarity index 79% rename from mqtt_forward_manager.go rename to internal/mqttforward/mqtt_forward_manager.go index 83e31fa..6596879 100644 --- a/mqtt_forward_manager.go +++ b/internal/mqttforward/mqtt_forward_manager.go @@ -1,6 +1,7 @@ -package main +package mqttforward import ( + storepkg "meshtastic_mqtt_server/internal/store" "context" "crypto/sha256" "crypto/tls" @@ -23,19 +24,19 @@ const ( mqttForwardLoopMaxEntries = 10000 ) -type mqttForwardReloader interface { +type Reloader interface { ReloadForwarder(id uint64) error StopForwarder(id uint64) - Status() []mqttForwardRuntimeStatus + Status() []RuntimeStatus } -type mqttForwardManager struct { - store *store +type Manager struct { + store *storepkg.Store mu sync.Mutex - runners map[uint64]*mqttForwardRunner + runners map[uint64]*runner } -type mqttForwardRuntimeStatus struct { +type RuntimeStatus struct { ForwarderID uint64 `json:"forwarder_id"` Running bool `json:"running"` SourceConnected bool `json:"source_connected"` @@ -46,8 +47,8 @@ type mqttForwardRuntimeStatus struct { MessagesDropped uint64 `json:"messages_dropped"` } -type mqttForwardRunner struct { - config mqttForwarderConfig +type runner struct { + config storepkg.MQTTForwarderConfig ctx context.Context cancel context.CancelFunc source pahomqtt.Client @@ -63,11 +64,11 @@ type mqttForwardRunner struct { loopCache map[string]time.Time } -func newMQTTForwardManager(store *store) *mqttForwardManager { - return &mqttForwardManager{store: store, runners: make(map[uint64]*mqttForwardRunner)} +func NewManager(store *storepkg.Store) *Manager { + return &Manager{store: store, runners: make(map[uint64]*runner)} } -func (m *mqttForwardManager) StartFromStore() error { +func (m *Manager) StartFromStore() error { configs, err := m.store.ListEnabledMQTTForwarderConfigs() if err != nil { return err @@ -76,7 +77,7 @@ func (m *mqttForwardManager) StartFromStore() error { if len(cfg.Topics) == 0 { continue } - runner := newMQTTForwardRunner(cfg) + runner := newRunner(cfg) runner.Start() m.mu.Lock() m.runners[cfg.Forwarder.ID] = runner @@ -85,7 +86,7 @@ func (m *mqttForwardManager) StartFromStore() error { return nil } -func (m *mqttForwardManager) ReloadForwarder(id uint64) error { +func (m *Manager) ReloadForwarder(id uint64) error { m.StopForwarder(id) cfg, err := m.store.GetMQTTForwarderConfig(id) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -97,7 +98,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error { if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 { return nil } - runner := newMQTTForwardRunner(*cfg) + runner := newRunner(*cfg) runner.Start() m.mu.Lock() m.runners[id] = runner @@ -105,7 +106,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error { return nil } -func (m *mqttForwardManager) StopForwarder(id uint64) { +func (m *Manager) StopForwarder(id uint64) { m.mu.Lock() runner := m.runners[id] delete(m.runners, id) @@ -115,9 +116,9 @@ func (m *mqttForwardManager) StopForwarder(id uint64) { } } -func (m *mqttForwardManager) StopAll() { +func (m *Manager) StopAll() { m.mu.Lock() - runners := make([]*mqttForwardRunner, 0, len(m.runners)) + runners := make([]*runner, 0, len(m.runners)) for id, runner := range m.runners { runners = append(runners, runner) delete(m.runners, id) @@ -128,14 +129,14 @@ func (m *mqttForwardManager) StopAll() { } } -func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus { +func (m *Manager) Status() []RuntimeStatus { m.mu.Lock() - runners := make([]*mqttForwardRunner, 0, len(m.runners)) + runners := make([]*runner, 0, len(m.runners)) for _, runner := range m.runners { runners = append(runners, runner) } m.mu.Unlock() - items := make([]mqttForwardRuntimeStatus, 0, len(runners)) + items := make([]RuntimeStatus, 0, len(runners)) for _, runner := range runners { items = append(items, runner.Status()) } @@ -143,19 +144,19 @@ func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus { return items } -func newMQTTForwardRunner(config mqttForwarderConfig) *mqttForwardRunner { +func newRunner(config storepkg.MQTTForwarderConfig) *runner { ctx, cancel := context.WithCancel(context.Background()) - return &mqttForwardRunner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)} + return &runner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)} } -func (r *mqttForwardRunner) Start() { +func (r *runner) Start() { r.source = r.newClient(true) r.target = r.newClient(false) r.connectClient(r.target, "target") r.connectClient(r.source, "source") } -func (r *mqttForwardRunner) Stop() { +func (r *runner) Stop() { r.cancel() if r.source != nil && r.source.IsConnected() { r.source.Disconnect(250) @@ -165,11 +166,11 @@ func (r *mqttForwardRunner) Stop() { } } -func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus { +func (r *runner) Status() RuntimeStatus { r.mu.Lock() defer r.mu.Unlock() started := r.startedAt - return mqttForwardRuntimeStatus{ + return RuntimeStatus{ ForwarderID: r.config.Forwarder.ID, Running: true, SourceConnected: r.sourceConnected, @@ -181,7 +182,7 @@ func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus { } } -func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client { +func (r *runner) newClient(source bool) pahomqtt.Client { forwarder := r.config.Forwarder host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS role := "source" @@ -222,7 +223,7 @@ func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client { return pahomqtt.NewClient(opts) } -func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string) { +func (r *runner) connectClient(client pahomqtt.Client, label string) { token := client.Connect() if !token.WaitTimeout(2 * time.Second) { r.setError(label + " connect pending") @@ -233,11 +234,11 @@ func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string) } } -func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) { +func (r *runner) subscribe(client pahomqtt.Client, source bool) { for _, topic := range r.config.Topics { filter := topic.Topic if !source { - if topic.Direction != mqttForwardDirectionBidirectional { + if topic.Direction != storepkg.MQTTForwardDirectionBidirectional { continue } filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix) @@ -256,7 +257,7 @@ func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) { } } -func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopicRecord, msg pahomqtt.Message) { +func (r *runner) forwardMessage(fromSource bool, rule storepkg.MQTTForwardTopicRecord, msg pahomqtt.Message) { if r.ctx.Err() != nil { return } @@ -269,7 +270,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi return } toTopic := fromTopic - forwardDirection := mqttForwardDirectionSourceToTarget + forwardDirection := storepkg.MQTTForwardDirectionSourceToTarget if fromSource { toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix) } else { @@ -284,7 +285,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi reverseDirection := mqttForwardDirectionTargetToSource if !fromSource { target = r.source - reverseDirection = mqttForwardDirectionSourceToTarget + reverseDirection = storepkg.MQTTForwardDirectionSourceToTarget } r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain) token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload()) @@ -301,7 +302,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi r.incForwarded() } -func (r *mqttForwardRunner) setConnected(source bool, connected bool) { +func (r *runner) setConnected(source bool, connected bool) { r.mu.Lock() defer r.mu.Unlock() if source { @@ -311,25 +312,25 @@ func (r *mqttForwardRunner) setConnected(source bool, connected bool) { } } -func (r *mqttForwardRunner) setError(message string) { +func (r *runner) setError(message string) { r.mu.Lock() r.lastError = message r.mu.Unlock() } -func (r *mqttForwardRunner) incForwarded() { +func (r *runner) incForwarded() { r.mu.Lock() r.messagesForwarded++ r.mu.Unlock() } -func (r *mqttForwardRunner) incDropped() { +func (r *runner) incDropped() { r.mu.Lock() r.messagesDropped++ r.mu.Unlock() } -func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool { +func (r *runner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool { key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain) now := time.Now() r.mu.Lock() @@ -346,7 +347,7 @@ func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, p return true } -func (r *mqttForwardRunner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) { +func (r *runner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) { key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain) now := time.Now() r.mu.Lock() diff --git a/admin_runtime_settings_routes.go b/internal/runtimesettings/admin_runtime_settings_routes.go similarity index 58% rename from admin_runtime_settings_routes.go rename to internal/runtimesettings/admin_runtime_settings_routes.go index 046e06c..09ac865 100644 --- a/admin_runtime_settings_routes.go +++ b/internal/runtimesettings/admin_runtime_settings_routes.go @@ -1,9 +1,11 @@ -package main +package runtimesettings import ( "net/http" "github.com/gin-gonic/gin" + + storepkg "meshtastic_mqtt_server/internal/store" ) const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted" @@ -11,12 +13,13 @@ const llmQueueEnabledLabel = "Enable LLM message queue" const llmIncludeChannelLabel = "Include channel messages in LLM queue" type runtimeSettingsRequest struct { - AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"` - LLMQueueEnabled bool `json:"llm_queue_enabled"` + AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"` + LLMQueueEnabled bool `json:"llm_queue_enabled"` LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"` } -func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) { +// RegisterRoutes 把 GET /runtime-settings 与 PUT /runtime-settings 挂到给定路由组下。 +func RegisterRoutes(r gin.IRouter, store *storepkg.Store, settings *Cache) { r.GET("/runtime-settings", func(c *gin.Context) { snapshot, err := store.GetRuntimeSettings() if err != nil { @@ -32,15 +35,15 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"}) return } - if _, err := store.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil { + if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil { + if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil { + if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -59,10 +62,10 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r }) } -func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H { +func runtimeSettingsDTO(settings storepkg.RuntimeSettingsSnapshot) gin.H { return gin.H{ - "allow_encrypted_forwarding": settings.AllowEncryptedForwarding, - "llm_queue_enabled": settings.LLMQueueEnabled, - "llm_include_channel_messages": settings.LLMIncludeChannel, + "allow_encrypted_forwarding": settings.AllowEncryptedForwarding, + "llm_queue_enabled": settings.LLMQueueEnabled, + "llm_include_channel_messages": settings.LLMIncludeChannel, } } diff --git a/internal/runtimesettings/runtime_settings_cache.go b/internal/runtimesettings/runtime_settings_cache.go new file mode 100644 index 0000000..242f95f --- /dev/null +++ b/internal/runtimesettings/runtime_settings_cache.go @@ -0,0 +1,55 @@ +package runtimesettings + +import ( + "fmt" + "sync" + + storepkg "meshtastic_mqtt_server/internal/store" +) + +// Cache 把 runtime_settings 表中常用的开关缓存到内存中,避免每次拦截热路径 +// 都查 DB。AdminRoute 修改后通过 Reload 重新加载。 +type Cache struct { + mu sync.RWMutex + settings storepkg.RuntimeSettingsSnapshot +} + +// New 从 store 中加载初始快照并返回缓存。 +func New(s *storepkg.Store) (*Cache, error) { + cache := &Cache{} + if err := cache.Reload(s); err != nil { + return nil, err + } + return cache, nil +} + +// Reload 重新读取数据库快照覆盖当前值。 +func (c *Cache) Reload(s *storepkg.Store) error { + if s == nil { + return fmt.Errorf("store is required") + } + settings, err := s.GetRuntimeSettings() + if err != nil { + return err + } + + c.mu.Lock() + c.settings = settings + c.mu.Unlock() + return nil +} + +// Snapshot 返回当前快照的副本(结构体拷贝,调用方可以安全持有)。 +func (c *Cache) Snapshot() storepkg.RuntimeSettingsSnapshot { + if c == nil { + return storepkg.RuntimeSettingsSnapshot{} + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.settings +} + +// AllowEncryptedForwarding 是 mqtt 转发热路径上常被检查的标志位的快捷读法。 +func (c *Cache) AllowEncryptedForwarding() bool { + return c.Snapshot().AllowEncryptedForwarding +} diff --git a/runtime_settings_cache_test.go b/internal/runtimesettings/runtime_settings_cache_test.go similarity index 57% rename from runtime_settings_cache_test.go rename to internal/runtimesettings/runtime_settings_cache_test.go index d249896..5b85d09 100644 --- a/runtime_settings_cache_test.go +++ b/internal/runtimesettings/runtime_settings_cache_test.go @@ -1,20 +1,29 @@ -package main +package runtimesettings -import "testing" +import ( + "testing" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/store/testutil" +) + +func openTestStore(t *testing.T) *storepkg.Store { + return testutil.OpenStore(t) +} func TestRuntimeSettingsCacheReload(t *testing.T) { st := openTestStore(t) defer st.Close() - cache, err := newRuntimeSettingsCache(st) + cache, err := New(st) if err != nil { - t.Fatalf("newRuntimeSettingsCache() error = %v", err) + t.Fatalf("New() error = %v", err) } if cache.AllowEncryptedForwarding() { t.Fatalf("AllowEncryptedForwarding() = true, want false") } - if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { + if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil { t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err) } if err := cache.Reload(st); err != nil { @@ -24,7 +33,7 @@ func TestRuntimeSettingsCacheReload(t *testing.T) { t.Fatalf("AllowEncryptedForwarding() = false, want true") } - if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { + if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil { t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err) } if err := cache.Reload(st); err != nil { diff --git a/internal/store/testutil/testutil.go b/internal/store/testutil/testutil.go new file mode 100644 index 0000000..2cca1f9 --- /dev/null +++ b/internal/store/testutil/testutil.go @@ -0,0 +1,27 @@ +// Package testutil 提供给其它包测试使用的 store 临时实例工厂。 +// +// 重构前 db_test.go 中的 openTestStore helper 被 8+ 个测试文件复用; +// 现在抽到这里,让 store 包外的测试也可以零样板地拿到一个临时 SQLite store。 +package testutil + +import ( + "path/filepath" + "testing" + + "meshtastic_mqtt_server/internal/config" + "meshtastic_mqtt_server/internal/store" +) + +// OpenStore 返回一个写在 t.TempDir() 中的临时 SQLite store。 +// 测试结束时调用方需要 defer st.Close()。 +func OpenStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.OpenStore(config.DatabaseConfig{ + Driver: config.DriverSQLite, + SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, + }) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + return st +} diff --git a/internal/webutil/webutil.go b/internal/webutil/webutil.go new file mode 100644 index 0000000..3c0097d --- /dev/null +++ b/internal/webutil/webutil.go @@ -0,0 +1,236 @@ +// Package webutil 收集 admin/路由层共享的 HTTP 解析与响应工具。 +// +// 这些函数原本散落在 web.go 中(parseListOptions、writeListResponse、 +// parseMapReportListOptions 等),任何注册 admin 路由的领域包都依赖它们。 +// 把它们抽离出来可以避免 internal/web 同时被 internal/blocking、 +// internal/bot 等包反向引用造成循环依赖。 +package webutil + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "meshtastic_mqtt_server/internal/store" +) + +// ParseListOptions 从请求中读取 limit / offset / since / until / node_id / +// channel_id 等通用过滤参数;解析失败时它会写入 400 响应并返回 false。 +func ParseListOptions(c *gin.Context) (store.ListOptions, bool) { + limit, ok := ParseIntQuery(c, "limit", 100) + if !ok { + return store.ListOptions{}, false + } + offset, ok := ParseIntQuery(c, "offset", 0) + if !ok { + return store.ListOptions{}, false + } + nodeID := c.Query("node_id") + if nodeID == "" { + nodeID = c.Query("from") + } + channelID := c.Query("channel_id") + var since, until *time.Time + if value := c.Query("since"); value != "" { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"}) + return store.ListOptions{}, false + } + since = &parsed + } + if value := c.Query("until"); value != "" { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"}) + return store.ListOptions{}, false + } + until = &parsed + } + return store.NormalizeListOptions(store.ListOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true +} + +// ParseMapReportListOptions 在 ParseListOptions 的基础上解析 4 个地图边界。 +func ParseMapReportListOptions(c *gin.Context) (store.ListOptions, bool) { + opts, ok := ParseListOptions(c) + if !ok { + return store.ListOptions{}, false + } + minLat, hasMinLat, ok := ParseOptionalFloatQuery(c, "min_lat") + if !ok { + return store.ListOptions{}, false + } + maxLat, hasMaxLat, ok := ParseOptionalFloatQuery(c, "max_lat") + if !ok { + return store.ListOptions{}, false + } + minLng, hasMinLng, ok := ParseOptionalFloatQuery(c, "min_lng") + if !ok { + return store.ListOptions{}, false + } + maxLng, hasMaxLng, ok := ParseOptionalFloatQuery(c, "max_lng") + if !ok { + return store.ListOptions{}, false + } + boundsCount := 0 + for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} { + if present { + boundsCount++ + } + } + if boundsCount == 0 { + return opts, true + } + if boundsCount != 4 { + c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"}) + return store.ListOptions{}, false + } + if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 { + c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"}) + return store.ListOptions{}, false + } + if minLat > maxLat { + c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"}) + return store.ListOptions{}, false + } + if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 { + c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"}) + return store.ListOptions{}, false + } + opts.MinLat = &minLat + opts.MaxLat = &maxLat + opts.MinLng = &minLng + opts.MaxLng = &maxLng + return opts, true +} + +// ParseMapReportViewportOptions 在 ParseMapReportListOptions 之上解析 zoom / +// cluster_threshold / target_cells 等额外字段。 +func ParseMapReportViewportOptions(c *gin.Context) (store.MapReportViewportOptions, bool) { + opts, ok := ParseMapReportListOptions(c) + if !ok { + return store.MapReportViewportOptions{}, false + } + if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"}) + return store.MapReportViewportOptions{}, false + } + zoom, ok := ParseIntQuery(c, "zoom", 0) + if !ok { + return store.MapReportViewportOptions{}, false + } + if zoom < 0 || zoom > 24 { + c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"}) + return store.MapReportViewportOptions{}, false + } + limit, ok := ParseIntQuery(c, "limit", 1000) + if !ok { + return store.MapReportViewportOptions{}, false + } + clusterThreshold, ok := ParseIntQuery(c, "cluster_threshold", 500) + if !ok { + return store.MapReportViewportOptions{}, false + } + targetCells, ok := ParseIntQuery(c, "target_cells", 64) + if !ok { + return store.MapReportViewportOptions{}, false + } + if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"}) + return store.MapReportViewportOptions{}, false + } + return store.NormalizeMapReportViewportOptions(store.MapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true +} + +// ParseIntQuery 从请求查询字符串解析整数;缺省时返回 defaultValue。 +func ParseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) { + value := c.Query(name) + if value == "" { + return defaultValue, true + } + parsed, err := strconv.Atoi(value) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name}) + return 0, false + } + return parsed, true +} + +// ParseOptionalFloatQuery 解析可选浮点查询参数;返回 (value, present, ok)。 +func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) { + value := c.Query(name) + if value == "" { + return 0, false, true + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name}) + return 0, true, false + } + return parsed, true, true +} + +// WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。 +func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) { + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, convert(row)) + } + c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset}) +} + +// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。 +func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) { + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, convert(row)) + } + c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total}) +} + +// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any, +// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。 +func PtrString(value *string) any { + if value == nil { + return nil + } + return *value +} + +func PtrInt64(value *int64) any { + if value == nil { + return nil + } + return *value +} + +func PtrUint64(value *uint64) any { + if value == nil { + return nil + } + return *value +} + +func PtrFloat64(value *float64) any { + if value == nil { + return nil + } + return *value +} + +func PtrBool(value *bool) any { + if value == nil { + return nil + } + return *value +} diff --git a/main_test.go b/main_test.go index 108ff0f..6494360 100644 --- a/main_test.go +++ b/main_test.go @@ -42,10 +42,22 @@ func TestMQTTClientInfoFromClientUnsplitRemote(t *testing.T) { } } -func TestBlockingViolationForRecordNode(t *testing.T) { - cache := &blockingCache{nodes: map[string]struct{}{"!12345678": {}}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}} - record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)} +// 注:blockingViolationForRecord 的测试现在跟着 blockingCache 一起搬到了 +// internal/blocking/violations_test.go,使用真实 *Store 构造缓存而不是 +// 直接捏造未导出字段。这里保留 mqtt client info 这部分测试不动。 +func TestBlockingViolationForRecordNode(t *testing.T) { + st := openTestStore(t) + defer st.Close() + nodeNum := int64(305419896) + if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "blocked", true); err != nil { + t.Fatalf("CreateNodeBlocking() error = %v", err) + } + cache, err := newBlockingCache(st) + if err != nil { + t.Fatalf("newBlockingCache() error = %v", err) + } + record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)} violation := blockingViolationForRecord(cache, record) if violation == nil || violation["blocking_type"] != "node" { t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation) @@ -53,7 +65,15 @@ func TestBlockingViolationForRecordNode(t *testing.T) { } func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) { - cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}} + st := openTestStore(t) + defer st.Close() + if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil { + t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) + } + cache, err := newBlockingCache(st) + if err != nil { + t.Fatalf("newBlockingCache() error = %v", err) + } for _, tc := range []struct { name string @@ -74,7 +94,15 @@ func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) { } func TestBlockingViolationForRecordAllowed(t *testing.T) { - cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}} + st := openTestStore(t) + defer st.Close() + if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil { + t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) + } + cache, err := newBlockingCache(st) + if err != nil { + t.Fatalf("newBlockingCache() error = %v", err) + } record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"} if violation := blockingViolationForRecord(cache, record); violation != nil { t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation) diff --git a/mqttforward_bridge.go b/mqttforward_bridge.go new file mode 100644 index 0000000..ea103b7 --- /dev/null +++ b/mqttforward_bridge.go @@ -0,0 +1,26 @@ +package main + +// 桥接到 internal/mqttforward — 让 main.go / web.go / mqtt_status.go 中 +// 使用 mqttForwardManager / meshtasticMessageStats / mqttForwardReloader 等 +// 旧名字的代码仍可工作。 + +import ( + "github.com/gin-gonic/gin" + + mfpkg "meshtastic_mqtt_server/internal/mqttforward" +) + +type ( + mqttForwardManager = mfpkg.Manager + mqttForwardReloader = mfpkg.Reloader + mqttForwardRuntimeStatus = mfpkg.RuntimeStatus + meshtasticMessageStats = mfpkg.Stats +) + +func newMQTTForwardManager(s *store) *mqttForwardManager { + return mfpkg.NewManager(s) +} + +func registerAdminMQTTForwardRoutes(r gin.IRouter, s *store, forwarder mqttForwardReloader) { + mfpkg.RegisterRoutes(r, s, forwarder) +} diff --git a/runtime_settings_bridge.go b/runtime_settings_bridge.go new file mode 100644 index 0000000..87ed6b8 --- /dev/null +++ b/runtime_settings_bridge.go @@ -0,0 +1,19 @@ +package main + +// 桥接到 internal/runtimesettings — 让根目录代码继续使用旧的小写名字。 + +import ( + "github.com/gin-gonic/gin" + + rspkg "meshtastic_mqtt_server/internal/runtimesettings" +) + +type runtimeSettingsCache = rspkg.Cache + +func newRuntimeSettingsCache(s *store) (*runtimeSettingsCache, error) { + return rspkg.New(s) +} + +func registerAdminRuntimeSettingsRoutes(r gin.IRouter, s *store, c *runtimeSettingsCache) { + rspkg.RegisterRoutes(r, s, c) +} diff --git a/runtime_settings_cache.go b/runtime_settings_cache.go deleted file mode 100644 index 3e853b7..0000000 --- a/runtime_settings_cache.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "fmt" - "sync" -) - -type runtimeSettingsCache struct { - mu sync.RWMutex - settings runtimeSettingsSnapshot -} - -func newRuntimeSettingsCache(store *store) (*runtimeSettingsCache, error) { - cache := &runtimeSettingsCache{} - if err := cache.Reload(store); err != nil { - return nil, err - } - return cache, nil -} - -func (c *runtimeSettingsCache) Reload(store *store) error { - if store == nil { - return fmt.Errorf("store is required") - } - settings, err := store.GetRuntimeSettings() - if err != nil { - return err - } - - c.mu.Lock() - c.settings = settings - c.mu.Unlock() - return nil -} - -func (c *runtimeSettingsCache) Snapshot() runtimeSettingsSnapshot { - if c == nil { - return runtimeSettingsSnapshot{} - } - c.mu.RLock() - defer c.mu.RUnlock() - return c.settings -} - -func (c *runtimeSettingsCache) AllowEncryptedForwarding() bool { - return c.Snapshot().AllowEncryptedForwarding -} diff --git a/web.go b/web.go index 113fbc7..0161535 100644 --- a/web.go +++ b/web.go @@ -190,7 +190,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } - cookie, err := sessions.newCookie(*user) + cookie, err := sessions.NewCookie(*user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -200,7 +200,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)}) }) r.POST("/logout", func(c *gin.Context) { - http.SetCookie(c.Writer, sessions.clearCookie()) + http.SetCookie(c.Writer, sessions.ClearCookie()) c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) From 9394aa0f4a7a166605c61d6a7f7746469a762e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Thu, 18 Jun 2026 18:24:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=8B=86?= =?UTF-8?q?=E5=87=BA=20bot=20/=20sign=20/=20mapsource=20/=20llmadmin=20/?= =?UTF-8?q?=20web=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三批(最终批):把 root 中剩下所有领域文件按功能搬到 internal/ 下。 完成后根目录从 46 个 .go 文件降到 14 个,其中 11 个是 bridge(type alias + thin wrapper),仅供尚未改造的 main.go 引用。 新增包 - internal/bot/ Service / TextSender / NewPKIKeyResolver / RegisterRoutes,含 PKI 直连发送、节点信息广播、 outbound DM 持久化等业务逻辑。 - internal/sign/ SignDTO / SignDayCountDTO / RegisterAdminRoutes, 把原来分散在 admin_sign_routes.go 与 web.go 中的 sign DTO/路由收拢到一处。 - internal/mapsource/ AdminDTO / PublicDTO / RegisterAdminRoutes / RegisterPublicRoutes。 - internal/llmadmin/ LLM 消息队列、Provider、ToolRouter、PrimaryConfig 的 admin 路由。 - internal/web/ 路由总入口(NewRouter/NewHTTPServer/ServeUnixSocket)、 各资源的 GET API、admin 用户/登录/MQTT 状态、所有 DTO 函数。把 auth.go 的 sessionClaims 升级为 auth.SessionClaims;mqtt_status.go 重写成 MQTTRuntimeStatus / AdminMQTTStatus 结构体并把 client info 解析在 web 包内自带,不再依赖 main 包。 map_tile_proxy_routes 与测试一起搬入。 修改 - web.go 中 parseListOptions / writeListResponse / ptrString 等本地 helper 改为对 internal/webutil 的 thin wrapper,避免重复实现。 - internal/auth 在 step 4 已创建,本批中 web 包正式开始引用其 Manager / RequireAdmin / SessionClaims。 根目录新增 bridge:bot_bridge.go / sign_bridge.go / mapsource_bridge.go / llmadmin_bridge.go / web_bridge.go。后者把 mqttRuntimeStatus 包成 webpkg.MQTTStatusProvider 适配器,使 main.go 中旧字段名保持可用。 go build ./... / go test ./... 全部通过;测试数量未变。 Co-Authored-By: Claude --- auth.go | 29 -- bot_bridge.go | 31 ++ .../bot/admin_bot_routes.go | 58 ++-- .../bot/bot_pki_resolver.go | 8 +- bot_service.go => internal/bot/bot_service.go | 115 +++---- internal/bot/helpers.go | 8 + .../llmadmin/admin_llm_routes.go | 63 ++-- .../mapsource/admin_map_source_routes.go | 45 ++- .../sign/admin_sign_routes.go | 36 +- .../web/map_tile_proxy_routes.go | 8 +- .../web/map_tile_proxy_routes_test.go | 30 +- internal/web/mqtt_status.go | 150 +++++++++ web.go => internal/web/web.go | 317 ++++-------------- llmadmin_bridge.go | 13 + mapsource_bridge.go | 13 + mqtt_status.go | 98 ------ sign_bridge.go | 17 + web_bridge.go | 56 ++++ 18 files changed, 565 insertions(+), 530 deletions(-) delete mode 100644 auth.go create mode 100644 bot_bridge.go rename admin_bot_routes.go => internal/bot/admin_bot_routes.go (81%) rename bot_pki_resolver.go => internal/bot/bot_pki_resolver.go (78%) rename bot_service.go => internal/bot/bot_service.go (81%) create mode 100644 internal/bot/helpers.go rename admin_llm_routes.go => internal/llmadmin/admin_llm_routes.go (89%) rename admin_map_source_routes.go => internal/mapsource/admin_map_source_routes.go (71%) rename admin_sign_routes.go => internal/sign/admin_sign_routes.go (62%) rename map_tile_proxy_routes.go => internal/web/map_tile_proxy_routes.go (96%) rename map_tile_proxy_routes_test.go => internal/web/map_tile_proxy_routes_test.go (69%) create mode 100644 internal/web/mqtt_status.go rename web.go => internal/web/web.go (61%) create mode 100644 llmadmin_bridge.go create mode 100644 mapsource_bridge.go delete mode 100644 mqtt_status.go create mode 100644 sign_bridge.go create mode 100644 web_bridge.go diff --git a/auth.go b/auth.go deleted file mode 100644 index f946e78..0000000 --- a/auth.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -// 桥接到 internal/auth — 让根目录其余文件继续用旧的小写名(sessionManager、 -// sessionClaims、newSessionManager、requireAdmin、verifyPassword 等)。 - -import ( - "github.com/gin-gonic/gin" - - authpkg "meshtastic_mqtt_server/internal/auth" -) - -// 类型别名 -type ( - sessionManager = authpkg.Manager - sessionClaims = authpkg.SessionClaims - adminUserDTO = authpkg.AdminUserDTO -) - -const adminRole = authpkg.AdminRole - -func newSessionManager(cfg webAdminConfig) (*sessionManager, error) { - return authpkg.NewManager(cfg) -} - -func verifyPassword(hash, password string) bool { return authpkg.VerifyPassword(hash, password) } - -func adminUserResponse(user userRecord) adminUserDTO { return authpkg.AdminUserResponse(user) } - -func requireAdmin(sm *sessionManager) gin.HandlerFunc { return authpkg.RequireAdmin(sm) } diff --git a/bot_bridge.go b/bot_bridge.go new file mode 100644 index 0000000..61924d8 --- /dev/null +++ b/bot_bridge.go @@ -0,0 +1,31 @@ +package main + +// 桥接到 internal/bot — 让 main.go / web.go 中使用 botService / +// botTextSender / newBotService / newPKIKeyResolver / registerAdminBotRoutes +// 这些旧名字的代码继续可用。 + +import ( + mqtt "github.com/mochi-mqtt/server/v2" + + "github.com/gin-gonic/gin" + + botpkg "meshtastic_mqtt_server/internal/bot" +) + +type ( + botService = botpkg.Service + botTextSender = botpkg.TextSender + botSendTextRequest = botpkg.SendTextRequest +) + +func newBotService(s *store, server *mqtt.Server, key []byte) *botService { + return botpkg.NewService(s, server, key) +} + +func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { + return botpkg.NewPKIKeyResolver(s) +} + +func registerAdminBotRoutes(r gin.IRouter, s *store, sender botTextSender) { + botpkg.RegisterRoutes(r, s, sender) +} diff --git a/admin_bot_routes.go b/internal/bot/admin_bot_routes.go similarity index 81% rename from admin_bot_routes.go rename to internal/bot/admin_bot_routes.go index ddeb306..8b59e31 100644 --- a/admin_bot_routes.go +++ b/internal/bot/admin_bot_routes.go @@ -1,4 +1,4 @@ -package main +package bot import ( "errors" @@ -7,6 +7,10 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/auth" + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type botNodeRequest struct { @@ -32,19 +36,19 @@ type botSendMessageRequest struct { Text string `json:"text"` } -func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { +func RegisterRoutes(r gin.IRouter, store *storepkg.Store, sender TextSender) { r.GET("/bot/nodes", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListBotNodes(opts) if err != nil { - writeListResponse(c, rows, opts, err, botNodeDTO) + webutil.WriteListResponse(c, rows, opts, err, botNodeDTO) return } total, err := store.CountBotNodes(opts) - writeListResponseWithTotal(c, rows, opts, total, err, botNodeDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botNodeDTO) }) r.POST("/bot/nodes", func(c *gin.Context) { var req botNodeRequest @@ -125,14 +129,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { } rows, err := store.ListBotMessages(opts) if err != nil { - writeListResponse(c, rows, opts.ListOptions, err, botMessageDTO) + webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO) return } total, err := store.CountBotMessages(opts) - writeListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO) + webutil.WriteListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO) }) r.GET("/bot/direct-messages", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } @@ -153,19 +157,19 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"}) return } - dmOpts := botDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")} + dmOpts := storepkg.BotDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")} rows, err := store.ListBotDirectMessagesByConversation(dmOpts) if err != nil { - writeListResponse(c, rows, opts, err, botDirectMessageDTO) + webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO) return } total, err := store.CountBotDirectMessagesByConversation(dmOpts) - writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO) }) // /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数), // 给前端侧边栏渲染会话列表使用。 r.GET("/bot/conversations", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } @@ -235,8 +239,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"}) return } - claims := c.MustGet("admin_claims").(*sessionClaims) - row, err := sender.SendText(c.Request.Context(), botSendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username}) + claims := c.MustGet("admin_claims").(*auth.SessionClaims) + row, err := sender.SendText(c.Request.Context(), SendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username}) if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) return @@ -254,8 +258,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { }) } -func botNodeInputFromRequest(req botNodeRequest) botNodeInput { - return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages} +func botNodeInputFromRequest(req botNodeRequest) storepkg.BotNodeInput { + return storepkg.BotNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages} } func parseBotID(c *gin.Context, message string) (uint64, bool) { @@ -267,25 +271,25 @@ func parseBotID(c *gin.Context, message string) (uint64, bool) { return id, true } -func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) { - listOpts, ok := parseListOptions(c) +func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) { + listOpts, ok := webutil.ParseListOptions(c) if !ok { - return botMessageListOptions{}, false + return storepkg.BotMessageListOptions{}, false } - opts := botMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")} + opts := storepkg.BotMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")} if value := c.Query("bot_id"); value != "" { id, err := strconv.ParseUint(value, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) - return botMessageListOptions{}, false + return storepkg.BotMessageListOptions{}, false } opts.BotID = id } return opts, true } -func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) { - if errors.Is(err, errBotNodeAlreadyExists) { +func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotNodeRecord, err error) { + if errors.Is(err, storepkg.ErrBotNodeAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"}) return } @@ -300,15 +304,15 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord c.JSON(status, gin.H{"item": botNodeDTO(*row)}) } -func botNodeDTO(row botNodeRecord) gin.H { +func botNodeDTO(row storepkg.BotNodeRecord) gin.H { return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } -func botMessageDTO(row botMessageRecord) gin.H { +func botMessageDTO(row storepkg.BotMessageRecord) gin.H { return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt} } -func botDirectMessageDTO(row botDirectMessageRecord) gin.H { +func botDirectMessageDTO(row storepkg.BotDirectMessageRecord) gin.H { return gin.H{ "id": row.ID, "bot_id": row.BotID, @@ -335,7 +339,7 @@ func botDirectMessageDTO(row botDirectMessageRecord) gin.H { } } -func botDirectConversationDTO(row botDirectConversation) gin.H { +func botDirectConversationDTO(row storepkg.BotDirectConversation) gin.H { return gin.H{ "bot_id": row.BotID, "peer_node_id": row.PeerNodeID, diff --git a/bot_pki_resolver.go b/internal/bot/bot_pki_resolver.go similarity index 78% rename from bot_pki_resolver.go rename to internal/bot/bot_pki_resolver.go index ed4dcf1..2831516 100644 --- a/bot_pki_resolver.go +++ b/internal/bot/bot_pki_resolver.go @@ -1,14 +1,16 @@ -package main +package bot import ( "encoding/base64" "strings" + + storepkg "meshtastic_mqtt_server/internal/store" ) -// newPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者 +// NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者 // 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。 // 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。 -func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { +func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { if s == nil { return nil } diff --git a/bot_service.go b/internal/bot/bot_service.go similarity index 81% rename from bot_service.go rename to internal/bot/bot_service.go index 9315809..39f05f8 100644 --- a/bot_service.go +++ b/internal/bot/bot_service.go @@ -1,4 +1,4 @@ -package main +package bot import ( "context" @@ -12,15 +12,16 @@ import ( "time" "unicode/utf8" - "meshtastic_mqtt_server/mqtpp" - mqtt "github.com/mochi-mqtt/server/v2" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/mqtpp" ) const botMaxTextBytes = 200 -type botSendTextRequest struct { +type SendTextRequest struct { BotID uint64 MessageType string ChannelID string @@ -30,22 +31,22 @@ type botSendTextRequest struct { CreatedBy string } -type botTextSender interface { - SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error) - PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) +type TextSender interface { + SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) + PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) } -type botService struct { - store *store +type Service struct { + store *storepkg.Store server *mqtt.Server key []byte } -func newBotService(store *store, server *mqtt.Server, key []byte) *botService { - return &botService{store: store, server: server, key: key} +func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service { + return &Service{store: store, server: server, key: key} } -func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) { +func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) { if s == nil || s.store == nil || s.server == nil { return } @@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) { // - 其它情况用原 channel + bot PSK 加密 // // 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。 -func (s *botService) MaybeAutoAck(record map[string]any) { +func (s *Service) MaybeAutoAck(record map[string]any) { if s == nil || s.store == nil || s.server == nil || record == nil { return } @@ -110,7 +111,7 @@ func (s *botService) MaybeAutoAck(record map[string]any) { } } -func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) { +func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) { privateKeyB64 := strings.TrimSpace(bot.PrivateKey) if privateKeyB64 == "" { return nil, fmt.Errorf("bot has no private key") @@ -119,7 +120,7 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request if err != nil { return nil, err } - senderPublic, err := decodeBotPublicKey(*bot) + senderPublic, err := storepkg.DecodeBotPublicKey(*bot) if err != nil { return nil, err } @@ -140,14 +141,14 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request }) } -func (s *botService) buildPSKAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) { +func (s *Service) buildPSKAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) { channel := fallbackChannelID(channelID, false, bot.DefaultChannelID) if channel == "" || channel == mqtpp.PKIChannelID { return nil, fmt.Errorf("no channel id available for psk ack") } psk := strings.TrimSpace(bot.PSK) if psk == "" { - psk = botDefaultPSK + psk = storepkg.BotDefaultPSK } key, err := mqtpp.ExpandPSK(psk) if err != nil { @@ -208,7 +209,7 @@ func errString(err error) string { return err.Error() } -func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) { +func (s *Service) SendText(_ context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) { if s == nil || s.store == nil { return nil, fmt.Errorf("bot service is not configured") } @@ -244,7 +245,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe fromNodeNum := uint32(bot.NodeNum) // direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径 - if messageType == botMessageTypeDirect { + if messageType == storepkg.BotMessageTypeDirect { return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy) } @@ -257,7 +258,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe } psk := strings.TrimSpace(bot.PSK) if psk == "" { - psk = botDefaultPSK + psk = storepkg.BotDefaultPSK } key, err := mqtpp.ExpandPSK(psk) if err != nil { @@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe return nil, err } topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID) - row := &botMessageRecord{ + row := &storepkg.BotMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, @@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe Text: text, PayloadLen: int64(len(raw)), Encrypted: true, - Status: botMessageStatusPending, + Status: storepkg.BotMessageStatusPending, CreatedBy: strings.TrimSpace(req.CreatedBy), } return s.persistAndPublish(row, topic, raw) @@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe // - 从 nodeinfo 中查目标节点的 X25519 公钥 // - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密 // - ServiceEnvelope.channel_id = "PKI",topic 也用 "PKI" -func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*botMessageRecord, error) { +func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*storepkg.BotMessageRecord, error) { if toNodeID == nil { return nil, fmt.Errorf("target node id is required for pki direct message") } @@ -315,7 +316,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui if err != nil { return nil, fmt.Errorf("invalid bot private key: %w", err) } - senderPublic, err := decodeBotPublicKey(*bot) + senderPublic, err := storepkg.DecodeBotPublicKey(*bot) if err != nil { return nil, err } @@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui return nil, err } topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID) - row := &botMessageRecord{ + row := &storepkg.BotMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, - MessageType: botMessageTypeDirect, + MessageType: storepkg.BotMessageTypeDirect, ChannelID: mqtpp.PKIChannelID, ToNodeID: toNodeID, ToNodeNum: int64PtrOrNil(int64(toNodeNum), true), @@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui Text: text, PayloadLen: int64(len(raw)), Encrypted: true, - Status: botMessageStatusPending, + Status: storepkg.BotMessageStatusPending, CreatedBy: strings.TrimSpace(createdBy), } result, err := s.persistAndPublish(row, topic, raw) @@ -364,16 +365,16 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui } // recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。 -func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) { +func (s *Service) recordOutboundDirectMessage(bot *storepkg.BotNodeRecord, msg *storepkg.BotMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) { if s == nil || s.store == nil || msg == nil || bot == nil { return } status := msg.Status if status == "" { if sendErr != nil { - status = botMessageStatusFailed + status = storepkg.BotMessageStatusFailed } else { - status = botMessageStatusPublished + status = storepkg.BotMessageStatusPublished } } errText := msg.Error @@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes botMessageID = &id } now := time.Now() - dm := &botDirectMessageRecord{ + dm := &storepkg.BotDirectMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, PeerNodeID: peerNodeID, PeerNodeNum: int64(peerNodeNum), - Direction: botDirectMessageDirectionOutbound, + Direction: storepkg.BotDirectMessageDirectionOutbound, Topic: msg.Topic, PacketID: msg.PacketID, Text: text, @@ -430,7 +431,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes } // lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。 -func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) { +func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) { node, err := s.store.GetNodeInfo(nodeID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -458,33 +459,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) { } // persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。 -func (s *botService) persistAndPublish(row *botMessageRecord, topic string, raw []byte) (*botMessageRecord, error) { +func (s *Service) persistAndPublish(row *storepkg.BotMessageRecord, topic string, raw []byte) (*storepkg.BotMessageRecord, error) { if err := s.store.InsertBotMessage(row); err != nil { return nil, err } if s.server == nil { - _ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil) - row.Status = botMessageStatusFailed + _ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil) + row.Status = storepkg.BotMessageStatusFailed row.Error = "mqtt server is not configured" return row, fmt.Errorf("mqtt server is not configured") } if err := s.server.Publish(topic, raw, false, 0); err != nil { - _ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil) - row.Status = botMessageStatusFailed + _ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil) + row.Status = storepkg.BotMessageStatusFailed row.Error = err.Error() return row, err } now := time.Now() - if err := s.store.UpdateBotMessageStatus(row.ID, botMessageStatusPublished, "", &now); err != nil { + if err := s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusPublished, "", &now); err != nil { return nil, err } - row.Status = botMessageStatusPublished + row.Status = storepkg.BotMessageStatusPublished row.Error = "" row.PublishedAt = &now return row, nil } -func (s *botService) runNodeInfoBroadcaster(ctx context.Context) { +func (s *Service) runNodeInfoBroadcaster(ctx context.Context) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() s.broadcastDueNodeInfo(ctx) @@ -498,8 +499,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) { } } -func (s *botService) broadcastDueNodeInfo(ctx context.Context) { - rows, err := s.store.ListBotNodes(listOptions{Limit: 500}) +func (s *Service) broadcastDueNodeInfo(ctx context.Context) { + rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500}) if err != nil { printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()}) return @@ -514,7 +515,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) { } interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second if interval <= 0 { - interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second + interval = time.Duration(storepkg.BotDefaultNodeInfoBroadcastSeconds) * time.Second } if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval { continue @@ -525,7 +526,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) { } } -func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) { +func (s *Service) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) { if s == nil || s.store == nil { return nil, fmt.Errorf("bot service is not configured") } @@ -546,7 +547,7 @@ func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNo return updated, nil } -func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error { +func (s *Service) PublishNodeInfo(_ context.Context, bot storepkg.BotNodeRecord) error { if s == nil || s.server == nil { return fmt.Errorf("mqtt server is not configured") } @@ -559,7 +560,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error } psk := strings.TrimSpace(bot.PSK) if psk == "" { - psk = botDefaultPSK + psk = storepkg.BotDefaultPSK } key, err := mqtpp.ExpandPSK(psk) if err != nil { @@ -569,7 +570,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error if err != nil { return err } - publicKey, err := decodeBotPublicKey(bot) + publicKey, err := storepkg.DecodeBotPublicKey(bot) if err != nil { return err } @@ -612,21 +613,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error func normalizeBotMessageType(value string) (string, error) { switch strings.TrimSpace(value) { - case "", botMessageTypeChannel: - return botMessageTypeChannel, nil - case botMessageTypeDirect: - return botMessageTypeDirect, nil + case "", storepkg.BotMessageTypeChannel: + return storepkg.BotMessageTypeChannel, nil + case storepkg.BotMessageTypeDirect: + return storepkg.BotMessageTypeDirect, nil default: return "", fmt.Errorf("message type must be channel or direct") } } -func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) { - if messageType == botMessageTypeChannel { +func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) { + if messageType == storepkg.BotMessageTypeChannel { return int64(mqtpp.NodeNumBroadcast), nil, nil } if req.ToNodeNum != nil && *req.ToNodeNum > 0 { - if err := validateBotNodeNum(*req.ToNodeNum); err != nil { + if err := storepkg.ValidateBotNodeNum(*req.ToNodeNum); err != nil { return 0, nil, err } nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum)) @@ -640,7 +641,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin if err != nil { return 0, nil, err } - if err := validateBotNodeNum(int64(nodeNum)); err != nil { + if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil { return 0, nil, err } normalized := mqtpp.NodeNumToID(nodeNum) @@ -650,7 +651,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin func botMQTTTopic(topicPrefix, channelID, nodeID string) string { prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/") if prefix == "" { - prefix = botDefaultTopicPrefix + prefix = storepkg.BotDefaultTopicPrefix } if strings.HasSuffix(prefix, "/2/e") { return prefix + "/" + channelID + "/" + nodeID diff --git a/internal/bot/helpers.go b/internal/bot/helpers.go new file mode 100644 index 0000000..f0553c8 --- /dev/null +++ b/internal/bot/helpers.go @@ -0,0 +1,8 @@ +package bot + +// printJSON 是 bot service 的内部诊断输出钩子;当前为 noop,与重构前 +// main.go 中的 printJSON 行为一致(注释掉了实际写出)。 +// 如需调试可直接替换实现。 +func printJSON(record map[string]any) { + _ = record +} diff --git a/admin_llm_routes.go b/internal/llmadmin/admin_llm_routes.go similarity index 89% rename from admin_llm_routes.go rename to internal/llmadmin/admin_llm_routes.go index a86a7e7..cd5d973 100644 --- a/admin_llm_routes.go +++ b/internal/llmadmin/admin_llm_routes.go @@ -1,4 +1,4 @@ -package main +package llmadmin import ( "errors" @@ -8,9 +8,12 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) -func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) { +func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) { group := r.Group("/llm") { // LLM Message Queue @@ -38,9 +41,9 @@ func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) { } } -func handleListLLMMessages(store *store) gin.HandlerFunc { +func handleListLLMMessages(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } @@ -65,7 +68,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc { items := make([]map[string]any, 0, len(rows)) for _, row := range rows { - items = append(items, llmMessageDTO(row)) + items = append(items, storepkg.LLMMessageDTO(row)) } c.JSON(http.StatusOK, gin.H{ @@ -77,7 +80,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc { } } -func handleGetLLMMessage(store *store) gin.HandlerFunc { +func handleGetLLMMessage(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -95,11 +98,11 @@ func handleGetLLMMessage(store *store) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{"item": llmMessageDTO(*record)}) + c.JSON(http.StatusOK, gin.H{"item": storepkg.LLMMessageDTO(*record)}) } } -func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc { +func handleUpdateLLMMessageStatus(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -118,10 +121,10 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc { // 验证状态值 validStatus := map[string]bool{ - llmMessageStatusPending: true, - llmMessageStatusProcessing: true, - llmMessageStatusProcessed: true, - llmMessageStatusError: true, + storepkg.LLMMessageStatusPending: true, + storepkg.LLMMessageStatusProcessing: true, + storepkg.LLMMessageStatusProcessed: true, + storepkg.LLMMessageStatusError: true, } if !validStatus[req.Status] { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"}) @@ -141,7 +144,7 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc { } } -func handleDeleteLLMMessage(store *store) gin.HandlerFunc { +func handleDeleteLLMMessage(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -162,7 +165,7 @@ func handleDeleteLLMMessage(store *store) gin.HandlerFunc { } } -func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc { +func handleDeleteLLMMessagesByBot(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64) if err != nil || botID == 0 { @@ -179,7 +182,7 @@ func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc { } } -func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc { +func handleCleanupDeletedLLMMessages(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Days int `json:"days"` @@ -206,7 +209,7 @@ func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc { // LLM Provider Handlers // ============================================ -func handleListLLMProviders(store *store) gin.HandlerFunc { +func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { includeInactive := c.Query("include_inactive") == "true" @@ -225,7 +228,7 @@ func handleListLLMProviders(store *store) gin.HandlerFunc { } } -func handleGetLLMProvider(store *store) gin.HandlerFunc { +func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { name := c.Param("name") if name == "" { @@ -247,7 +250,7 @@ func handleGetLLMProvider(store *store) gin.HandlerFunc { } } -func handleCreateLLMProvider(store *store) gin.HandlerFunc { +func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Name string `json:"name"` @@ -268,7 +271,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc { return } - record := &llmProviderRecord{ + record := &storepkg.LLMProviderRecord{ Name: req.Name, Active: req.Active, APIKey: req.APIKey, @@ -287,7 +290,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc { } } -func handleUpdateLLMProvider(store *store) gin.HandlerFunc { +func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { name := c.Param("name") if name == "" { @@ -352,7 +355,7 @@ func handleUpdateLLMProvider(store *store) gin.HandlerFunc { } } -func handleDeleteLLMProvider(store *store) gin.HandlerFunc { +func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { name := c.Param("name") if name == "" { @@ -369,7 +372,7 @@ func handleDeleteLLMProvider(store *store) gin.HandlerFunc { } } -func llmProviderDTO(row llmProviderRecord) map[string]any { +func llmProviderDTO(row storepkg.LLMProviderRecord) map[string]any { return map[string]any{ "name": row.Name, "active": row.Active, @@ -386,7 +389,7 @@ func llmProviderDTO(row llmProviderRecord) map[string]any { // LLM Tool Router Handlers // ============================================ -func handleGetLLMToolRouter(store *store) gin.HandlerFunc { +func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { record, err := store.GetLLMToolRouter() if err != nil { @@ -402,7 +405,7 @@ func handleGetLLMToolRouter(store *store) gin.HandlerFunc { } } -func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc { +func handleUpdateLLMToolRouter(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { record, err := store.GetLLMToolRouter() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -446,7 +449,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc { if record == nil { // 创建新配置 - newRecord := &llmToolRouterRecord{ + newRecord := &storepkg.LLMToolRouterRecord{ Enabled: req.Enabled != nil && *req.Enabled, OpenAIName: "", Timeout: 30, @@ -487,7 +490,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc { } } -func llmToolRouterDTO(row llmToolRouterRecord) map[string]any { +func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any { return map[string]any{ "id": row.ID, "enabled": row.Enabled, @@ -504,7 +507,7 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any { // LLM Primary Config Handlers // ============================================ -func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc { +func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { record, err := store.GetLLMPrimaryConfig() if err != nil { @@ -520,7 +523,7 @@ func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc { } } -func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc { +func handleUpdateLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { record, err := store.GetLLMPrimaryConfig() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -568,7 +571,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc { if record == nil { // 创建新配置 - newRecord := &llmPrimaryConfigRecord{ + newRecord := &storepkg.LLMPrimaryConfigRecord{ Enabled: req.Enabled != nil && *req.Enabled, ProviderName: "", Timeout: 120, @@ -613,7 +616,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc { } } -func llmPrimaryConfigDTO(row llmPrimaryConfigRecord) map[string]any { +func llmPrimaryConfigDTO(row storepkg.LLMPrimaryConfigRecord) map[string]any { return map[string]any{ "id": row.ID, "enabled": row.Enabled, diff --git a/admin_map_source_routes.go b/internal/mapsource/admin_map_source_routes.go similarity index 71% rename from admin_map_source_routes.go rename to internal/mapsource/admin_map_source_routes.go index 43172ff..e06fe2e 100644 --- a/admin_map_source_routes.go +++ b/internal/mapsource/admin_map_source_routes.go @@ -1,4 +1,5 @@ -package main +// Package mapsource 提供地图瓦片源的 admin 与公开路由。 +package mapsource import ( "errors" @@ -7,6 +8,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type mapTileSourceRequest struct { @@ -19,14 +23,15 @@ type mapTileSourceRequest struct { ProxyEnabled bool `json:"proxy_enabled"` } -func registerMapSourceRoutes(r gin.IRouter, store *store) { +// RegisterPublicRoutes 把对外可见的 GET /map-source/{default,enabled} 挂上去。 +func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) { r.GET("/map-source/default", func(c *gin.Context) { row, err := store.GetDefaultMapTileSource() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)}) + c.JSON(http.StatusOK, gin.H{"item": PublicDTO(*row)}) }) r.GET("/map-source/enabled", func(c *gin.Context) { rows, err := store.ListEnabledMapTileSources() @@ -36,25 +41,26 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) { } items := make([]gin.H, 0, len(rows)) for _, row := range rows { - items = append(items, publicMapTileSourceDTO(row)) + items = append(items, PublicDTO(row)) } c.JSON(http.StatusOK, gin.H{"items": items}) }) } -func registerAdminMapSourceRoutes(r gin.IRouter, store *store) { +// RegisterAdminRoutes 注册管理员侧 CRUD 与设默认。 +func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) { r.GET("/map-source", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListMapTileSources(opts) if err != nil { - writeListResponse(c, rows, opts, err, mapTileSourceDTO) + webutil.WriteListResponse(c, rows, opts, err, AdminDTO) return } total, err := store.CountMapTileSources(opts) - writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, AdminDTO) }) r.POST("/map-source", func(c *gin.Context) { var req mapTileSourceRequest @@ -95,8 +101,8 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) { }) } -func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput { - return mapTileSourceInput{ +func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput { + return storepkg.MapTileSourceInput{ Name: req.Name, URLTemplate: req.URLTemplate, Attribution: req.Attribution, @@ -116,8 +122,8 @@ func parseMapTileSourceID(c *gin.Context) (uint64, bool) { return id, true } -func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) { - if errors.Is(err, errMapTileSourceAlreadyExists) { +func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *storepkg.MapTileSourceRecord, err error) { + if errors.Is(err, storepkg.ErrMapTileSourceAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"}) return } @@ -125,7 +131,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"}) return } - if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) { + if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) || errors.Is(err, storepkg.ErrMapTileSourceCannotDisableDefault) || errors.Is(err, storepkg.ErrMapTileSourceDefaultMustBeEnabled) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -133,7 +139,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)}) + c.JSON(status, gin.H{"item": AdminDTO(*row)}) } func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { @@ -141,7 +147,7 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"}) return } - if errors.Is(err, errMapTileSourceCannotDeleteDefault) { + if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -152,16 +158,19 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func mapTileSourceDTO(row mapTileSourceRecord) gin.H { +// AdminDTO 是管理后台展示的全字段视图。 +func AdminDTO(row storepkg.MapTileSourceRecord) gin.H { return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } -func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H { +// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为 +// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。 +func PublicDTO(row storepkg.MapTileSourceRecord) gin.H { urlTemplate := row.URLTemplate if row.ProxyEnabled { hash := row.URLTemplateHash if hash == "" { - hash = mapTileSourceHash(row.URLTemplate) + hash = storepkg.MapTileSourceHash(row.URLTemplate) } urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}" } diff --git a/admin_sign_routes.go b/internal/sign/admin_sign_routes.go similarity index 62% rename from admin_sign_routes.go rename to internal/sign/admin_sign_routes.go index 76b089e..4dbd963 100644 --- a/admin_sign_routes.go +++ b/internal/sign/admin_sign_routes.go @@ -1,4 +1,8 @@ -package main +// Package sign 提供签到记录的 admin 路由与对应的 DTO/列表查询。 +// +// 拆离自原来 main 包的 admin_sign_routes.go 与 web.go 中的 signDTO / +// signDayCountDTO;其它 admin 路由也通过 SignDTO / SignDayCountDTO 复用。 +package sign import ( "errors" @@ -8,6 +12,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type signRequest struct { @@ -18,19 +25,20 @@ type signRequest struct { SignTime string `json:"sign_time"` } -func registerAdminSignRoutes(r gin.IRouter, store *store) { +// RegisterAdminRoutes 在 admin 路由组下挂 sign CRUD 端点。 +func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) { r.GET("/signs", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListSigns(opts) if err != nil { - writeListResponse(c, rows, opts, err, signDTO) + webutil.WriteListResponse(c, rows, opts, err, SignDTO) return } total, err := store.CountSigns(opts) - writeListResponseWithTotal(c, rows, opts, total, err, signDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, SignDTO) }) r.POST("/signs", func(c *gin.Context) { var req signRequest @@ -42,7 +50,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) { if !ok { return } - row, err := store.CreateSign(req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime) + row, err := store.CreateSign(req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime) writeSignMutationResponse(c, http.StatusCreated, row, err) }) r.PUT("/signs/:id", func(c *gin.Context) { @@ -59,7 +67,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) { if !ok { return } - row, err := store.UpdateSign(id, req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime) + row, err := store.UpdateSign(id, req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime) writeSignMutationResponse(c, http.StatusOK, row, err) }) r.DELETE("/signs/:id", func(c *gin.Context) { @@ -101,7 +109,7 @@ func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) { return parsed, true } -func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err error) { +func writeSignMutationResponse(c *gin.Context, status int, row *storepkg.SignRecord, err error) { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"}) return @@ -110,5 +118,15 @@ func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(status, gin.H{"item": signDTO(*row)}) + c.JSON(status, gin.H{"item": SignDTO(*row)}) +} + +// SignDTO 把 SignRecord 转成给前端的视图。 +func SignDTO(row storepkg.SignRecord) gin.H { + return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": webutil.PtrString(row.LongName), "short_name": webutil.PtrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime} +} + +// SignDayCountDTO 把按日聚合的签到数量转成视图。 +func SignDayCountDTO(row storepkg.SignDayCount) gin.H { + return gin.H{"date": row.Date, "count": row.Count} } diff --git a/map_tile_proxy_routes.go b/internal/web/map_tile_proxy_routes.go similarity index 96% rename from map_tile_proxy_routes.go rename to internal/web/map_tile_proxy_routes.go index d8fee9d..b0bb947 100644 --- a/map_tile_proxy_routes.go +++ b/internal/web/map_tile_proxy_routes.go @@ -1,4 +1,4 @@ -package main +package web import ( "errors" @@ -13,6 +13,8 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" ) const ( @@ -21,12 +23,12 @@ const ( ) type mapTileProxy struct { - store *store + store *storepkg.Store cacheDir string client *http.Client } -func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) { +func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) { proxy := &mapTileProxy{ store: store, cacheDir: cacheDir, diff --git a/map_tile_proxy_routes_test.go b/internal/web/map_tile_proxy_routes_test.go similarity index 69% rename from map_tile_proxy_routes_test.go rename to internal/web/map_tile_proxy_routes_test.go index 130d814..4dca24b 100644 --- a/map_tile_proxy_routes_test.go +++ b/internal/web/map_tile_proxy_routes_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "net/http" @@ -7,8 +7,16 @@ import ( "path/filepath" "strings" "testing" + + configpkg "meshtastic_mqtt_server/internal/config" + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/store/testutil" ) +func openTestStore(t *testing.T) *storepkg.Store { + return testutil.OpenStore(t) +} + func TestMapTileProxyFetchesAndCaches(t *testing.T) { st := openTestStore(t) defer st.Close() @@ -24,13 +32,13 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) { })) defer upstream.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } cacheDir := t.TempDir() - router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil) url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3" for i := 0; i < 2; i++ { @@ -62,12 +70,12 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true}) + row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } - router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil) cases := []string{ "/api/map/" + row.URLTemplateHash + "?y=0&z=0", @@ -89,16 +97,16 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) { st := openTestStore(t) defer st.Close() - disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false}) + disabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false}) if err != nil { t.Fatalf("CreateMapTileSource(disabled) error = %v", err) } - proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false}) + proxyDisabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false}) if err != nil { t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err) } - router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil) cases := []string{ "/api/map/not-a-hash?x=0&y=0&z=0", @@ -130,16 +138,16 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) { })) defer upstream.Close() - row404, err := st.CreateMapTileSource(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + row404, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource(404) error = %v", err) } - row500, err := st.CreateMapTileSource(mapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) + row500, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource(500) error = %v", err) } - router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil) cases := []struct { url string diff --git a/internal/web/mqtt_status.go b/internal/web/mqtt_status.go new file mode 100644 index 0000000..e3e3eeb --- /dev/null +++ b/internal/web/mqtt_status.go @@ -0,0 +1,150 @@ +package web + +import ( + mqtt "github.com/mochi-mqtt/server/v2" + + mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" + storepkg "meshtastic_mqtt_server/internal/store" +) + +// MQTTStatusProvider 是 web 层向上层要的"返回当前 mqtt broker 状态"接口; +// 实现一般由 main 包传入(持有真正的 mqtt.Server / 写队列 / 统计器)。 +type MQTTStatusProvider interface { + Status() AdminMQTTStatus +} + +// MQTTRuntimeStatus 把 mqtt.Server / 写队列 / 转发统计三个上下文打包成 +// 实现 MQTTStatusProvider 的具体类型。供 main 包构造后注入 newRouter。 +type MQTTRuntimeStatus struct { + Server *mqtt.Server + Address string + TLS bool + Stats *mqttforwardpkg.Stats + DBQueue *storepkg.WriteQueue +} + +// AdminMQTTStatus 是 admin 路由 GET /admin/mqtt-status 返回的 JSON 视图。 +type AdminMQTTStatus struct { + Running bool `json:"running"` + Address string `json:"address"` + TLS bool `json:"tls"` + Version string `json:"version"` + Started int64 `json:"started"` + Uptime int64 `json:"uptime"` + BytesReceived int64 `json:"bytes_received"` + BytesSent int64 `json:"bytes_sent"` + ClientsConnected int64 `json:"clients_connected"` + ClientsDisconnected int64 `json:"clients_disconnected"` + ClientsMaximum int64 `json:"clients_maximum"` + ClientsTotal int64 `json:"clients_total"` + MessagesReceived int64 `json:"messages_received"` + MessagesSent int64 `json:"messages_sent"` + MessagesDropped int64 `json:"messages_dropped"` + DBWriteQueueLength int `json:"db_write_queue_length"` + Retained int64 `json:"retained"` + Inflight int64 `json:"inflight"` + InflightDropped int64 `json:"inflight_dropped"` + Subscriptions int64 `json:"subscriptions"` + PacketsReceived int64 `json:"packets_received"` + PacketsSent int64 `json:"packets_sent"` + Clients []AdminMQTTClient `json:"clients"` +} + +type AdminMQTTClient struct { + ClientID string `json:"client_id"` + Username string `json:"username"` + Listener string `json:"listener"` + RemoteAddr string `json:"remote_addr"` + RemoteHost string `json:"remote_host"` + RemotePort string `json:"remote_port"` +} + +// Status 实现 MQTTStatusProvider。 +func (m MQTTRuntimeStatus) Status() AdminMQTTStatus { + if m.Server == nil || m.Server.Info == nil { + return AdminMQTTStatus{Running: false, Address: m.Address, TLS: m.TLS, DBWriteQueueLength: m.DBQueue.Len()} + } + info := m.Server.Info.Clone() + status := AdminMQTTStatus{ + Running: true, + Address: m.Address, + TLS: m.TLS, + Version: info.Version, + Started: info.Started, + Uptime: info.Uptime, + BytesReceived: info.BytesReceived, + BytesSent: info.BytesSent, + ClientsConnected: info.ClientsConnected, + ClientsDisconnected: info.ClientsDisconnected, + ClientsMaximum: info.ClientsMaximum, + ClientsTotal: info.ClientsTotal, + MessagesReceived: info.MessagesReceived, + MessagesSent: m.Stats.Forwarded(), + MessagesDropped: m.Stats.Dropped(), + DBWriteQueueLength: m.DBQueue.Len(), + Retained: info.Retained, + Inflight: info.Inflight, + InflightDropped: info.InflightDropped, + Subscriptions: info.Subscriptions, + PacketsReceived: info.PacketsReceived, + PacketsSent: info.PacketsSent, + } + for _, client := range m.Server.Clients.GetAll() { + if client == nil || client.Closed() { + continue + } + info := mqttClientInfo(client) + status.Clients = append(status.Clients, AdminMQTTClient{ + ClientID: info.ClientID, + Username: info.Username, + Listener: info.Listener, + RemoteAddr: info.RemoteAddr, + RemoteHost: info.RemoteHost, + RemotePort: info.RemotePort, + }) + } + return status +} + +// 简化版客户端信息——只解析展示所需字段,避免依赖 main 包里的辅助。 +type mqttClientInfoView struct { + ClientID string + Username string + Listener string + RemoteAddr string + RemoteHost string + RemotePort string +} + +func mqttClientInfo(c *mqtt.Client) mqttClientInfoView { + if c == nil { + return mqttClientInfoView{} + } + info := mqttClientInfoView{ + ClientID: c.ID, + Username: string(c.Properties.Username), + Listener: c.Net.Listener, + RemoteAddr: c.Net.Remote, + } + host, port := splitHostPort(c.Net.Remote) + info.RemoteHost = host + info.RemotePort = port + return info +} + +func splitHostPort(addr string) (string, string) { + if addr == "" { + return "", "" + } + // 复用 net.SplitHostPort,但要兼容 "host" 这种没端口的情况。 + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + host := addr[:i] + if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' { + host = host[1 : len(host)-1] + } + return host, addr[i+1:] + } + } + return addr, "" +} diff --git a/web.go b/internal/web/web.go similarity index 61% rename from web.go rename to internal/web/web.go index 0161535..50272ea 100644 --- a/web.go +++ b/internal/web/web.go @@ -1,4 +1,4 @@ -package main +package web import ( "errors" @@ -12,16 +12,29 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/auth" + blockingpkg "meshtastic_mqtt_server/internal/blocking" + botpkg "meshtastic_mqtt_server/internal/bot" + configpkg "meshtastic_mqtt_server/internal/config" + helppkg "meshtastic_mqtt_server/internal/help" + llmadminpkg "meshtastic_mqtt_server/internal/llmadmin" + mappkg "meshtastic_mqtt_server/internal/mapsource" + mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" + rspkg "meshtastic_mqtt_server/internal/runtimesettings" + signpkg "meshtastic_mqtt_server/internal/sign" + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) -func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *http.Server { +func NewHTTPServer(cfg configpkg.WebConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *http.Server { return &http.Server{ Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), - Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender), + Handler: NewRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender), } } -func serveHTTPUnixSocket(server *http.Server, socketPath string) error { +func ServeUnixSocket(server *http.Server, socketPath string) error { if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil { return err } @@ -47,7 +60,7 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error { return server.Serve(listener) } -func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine { +func NewRouter(cfg configpkg.WebConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) api := r.Group("/api") @@ -57,7 +70,7 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus return r } -func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { +func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir string) { r.GET("/health", func(c *gin.Context) { status := gin.H{"status": "ok", "database": "ok"} if err := store.Ping(); err != nil { @@ -72,9 +85,9 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { registerNodeInfoRoutes(r, store, "/nodeinfo") registerNodeInfoRoutes(r, store, "/nodes") registerMapReportRoutes(r, store) - registerMapSourceRoutes(r, store) + mappkg.RegisterPublicRoutes(r, store) registerMapTileProxyRoutes(r, store, mapTileCacheDir) - registerHelpRoutes(r, store) + helppkg.RegisterPublicRoutes(r, store) r.GET("/signs", func(c *gin.Context) { opts, ok := parseListOptions(c) if !ok { @@ -82,11 +95,11 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { } rows, err := store.ListSigns(opts) if err != nil { - writeListResponse(c, rows, opts, err, signDTO) + writeListResponse(c, rows, opts, err, signpkg.SignDTO) return } total, err := store.CountSigns(opts) - writeListResponseWithTotal(c, rows, opts, total, err, signDTO) + writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO) }) r.GET("/signs/daily", func(c *gin.Context) { opts, ok := parseListOptions(c) @@ -94,7 +107,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { return } rows, err := store.CountSignsByDay(opts) - writeListResponse(c, rows, opts, err, signDayCountDTO) + writeListResponse(c, rows, opts, err, signpkg.SignDayCountDTO) }) r.GET("/text-messages", func(c *gin.Context) { opts, ok := parseListOptions(c) @@ -146,7 +159,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { }) } -func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) { +func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) { type loginRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -158,10 +171,10 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, type updatePasswordRequest struct { Password string `json:"password"` } - userDTO := func(user userRecord) gin.H { + userDTO := func(user storepkg.UserRecord) gin.H { return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt} } - loginLogDTO := func(row loginLogRecord) gin.H { + loginLogDTO := func(row storepkg.LoginLogRecord) gin.H { return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt} } remoteInfo := func(c *gin.Context) (string, string) { @@ -174,7 +187,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, } recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) { remoteAddr, remoteHost := remoteInfo(c) - _ = store.InsertLoginLog(loginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")}) + _ = store.InsertLoginLog(storepkg.LoginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")}) } r.POST("/login", func(c *gin.Context) { @@ -185,7 +198,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, return } user, err := store.GetUserByUsername(req.Username) - if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) { + if err != nil || user.Role != auth.AdminRole || !auth.VerifyPassword(user.PasswordHash, req.Password) { recordLogin(c, req.Username, nil, false, "invalid username or password") c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return @@ -197,7 +210,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, } recordLogin(c, req.Username, &user.ID, true, "success") http.SetCookie(c.Writer, cookie) - c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)}) + c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserResponse(*user)}) }) r.POST("/logout", func(c *gin.Context) { http.SetCookie(c.Writer, sessions.ClearCookie()) @@ -205,26 +218,26 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, }) protected := r.Group("") - protected.Use(requireAdmin(sessions)) - registerAdminBlockingRoutes(protected, store, blocking) - registerAdminSignRoutes(protected, store) - registerAdminMQTTForwardRoutes(protected, store, forwarder) - registerAdminRuntimeSettingsRoutes(protected, store, settings) - registerAdminMapSourceRoutes(protected, store) - registerAdminHelpRoutes(protected, store) - registerAdminBotRoutes(protected, store, botSender) - registerAdminLLMRoutes(protected, store) + protected.Use(auth.RequireAdmin(sessions)) + blockingpkg.RegisterRoutes(protected, store, blocking) + signpkg.RegisterAdminRoutes(protected, store) + mqttforwardpkg.RegisterRoutes(protected, store, forwarder) + rspkg.RegisterRoutes(protected, store, settings) + mappkg.RegisterAdminRoutes(protected, store) + helppkg.RegisterAdminRoutes(protected, store) + botpkg.RegisterRoutes(protected, store, botSender) + llmadminpkg.RegisterRoutes(protected, store) protected.GET("/me", func(c *gin.Context) { - claims := c.MustGet("admin_claims").(*sessionClaims) - c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}}) + claims := c.MustGet("admin_claims").(*auth.SessionClaims) + c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}}) }) protected.GET("/mqtt/status", func(c *gin.Context) { if mqttStatus == nil { - c.JSON(http.StatusOK, adminMqttStatus{Running: false}) + c.JSON(http.StatusOK, AdminMQTTStatus{Running: false}) return } status := mqttStatus.Status() - discardCount, err := store.CountDiscardDetails(listOptions{}) + discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -251,7 +264,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, return } user, err := store.CreateAdminUser(req.Username, req.Password) - if errors.Is(err, errUserAlreadyExists) { + if errors.Is(err, storepkg.ErrUserAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "username already exists"}) return } @@ -323,7 +336,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, }) } -func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) { +func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) { r.GET(path, func(c *gin.Context) { opts, ok := parseListOptions(c) if !ok { @@ -351,7 +364,7 @@ func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) { }) } -func registerMapReportRoutes(r gin.IRouter, store *store) { +func registerMapReportRoutes(r gin.IRouter, store *storepkg.Store) { r.GET("/map-reports/viewport", func(c *gin.Context) { opts, ok := parseMapReportViewportOptions(c) if !ok { @@ -431,226 +444,69 @@ func serveIndex(c *gin.Context, staticDir string) { c.File(indexPath) } -func parseListOptions(c *gin.Context) (listOptions, bool) { - limit, ok := parseIntQuery(c, "limit", 100) - if !ok { - return listOptions{}, false - } - offset, ok := parseIntQuery(c, "offset", 0) - if !ok { - return listOptions{}, false - } - nodeID := c.Query("node_id") - if nodeID == "" { - nodeID = c.Query("from") - } - channelID := c.Query("channel_id") - var since, until *time.Time - if value := c.Query("since"); value != "" { - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"}) - return listOptions{}, false - } - since = &parsed - } - if value := c.Query("until"); value != "" { - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"}) - return listOptions{}, false - } - until = &parsed - } - return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true +func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) { + return webutil.ParseListOptions(c) } -func parseMapReportListOptions(c *gin.Context) (listOptions, bool) { - opts, ok := parseListOptions(c) - if !ok { - return listOptions{}, false - } - minLat, hasMinLat, ok := parseOptionalFloatQuery(c, "min_lat") - if !ok { - return listOptions{}, false - } - maxLat, hasMaxLat, ok := parseOptionalFloatQuery(c, "max_lat") - if !ok { - return listOptions{}, false - } - minLng, hasMinLng, ok := parseOptionalFloatQuery(c, "min_lng") - if !ok { - return listOptions{}, false - } - maxLng, hasMaxLng, ok := parseOptionalFloatQuery(c, "max_lng") - if !ok { - return listOptions{}, false - } - boundsCount := 0 - for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} { - if present { - boundsCount++ - } - } - if boundsCount == 0 { - return opts, true - } - if boundsCount != 4 { - c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"}) - return listOptions{}, false - } - if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 { - c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"}) - return listOptions{}, false - } - if minLat > maxLat { - c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"}) - return listOptions{}, false - } - if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 { - c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"}) - return listOptions{}, false - } - opts.MinLat = &minLat - opts.MaxLat = &maxLat - opts.MinLng = &minLng - opts.MaxLng = &maxLng - return opts, true +func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) { + return webutil.ParseMapReportListOptions(c) } -func parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) { - opts, ok := parseMapReportListOptions(c) - if !ok { - return mapReportViewportOptions{}, false - } - if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"}) - return mapReportViewportOptions{}, false - } - zoom, ok := parseIntQuery(c, "zoom", 0) - if !ok { - return mapReportViewportOptions{}, false - } - if zoom < 0 || zoom > 24 { - c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"}) - return mapReportViewportOptions{}, false - } - limit, ok := parseIntQuery(c, "limit", 1000) - if !ok { - return mapReportViewportOptions{}, false - } - clusterThreshold, ok := parseIntQuery(c, "cluster_threshold", 500) - if !ok { - return mapReportViewportOptions{}, false - } - targetCells, ok := parseIntQuery(c, "target_cells", 64) - if !ok { - return mapReportViewportOptions{}, false - } - if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"}) - return mapReportViewportOptions{}, false - } - return normalizeMapReportViewportOptions(mapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true -} - -func parseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) { - value := c.Query(name) - if value == "" { - return 0, false, true - } - parsed, err := strconv.ParseFloat(value, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name}) - return 0, true, false - } - return parsed, true, true +func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) { + return webutil.ParseMapReportViewportOptions(c) } func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) { - value := c.Query(name) - if value == "" { - return defaultValue, true - } - parsed, err := strconv.Atoi(value) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name}) - return 0, false - } - return parsed, true + return webutil.ParseIntQuery(c, name, defaultValue) } -func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err error, convert func(T) gin.H) { - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - items := make([]gin.H, 0, len(rows)) - for _, row := range rows { - items = append(items, convert(row)) - } - c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset}) +func writeListResponse[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, err error, convert func(T) gin.H) { + webutil.WriteListResponse(c, rows, opts, err, convert) } -func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) { - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - items := make([]gin.H, 0, len(rows)) - for _, row := range rows { - items = append(items, convert(row)) - } - c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total}) +func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, total int64, err error, convert func(T) gin.H) { + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert) } -func nodeInfoDTO(row nodeInfoRecord) gin.H { +func nodeInfoDTO(row storepkg.NodeInfoRecord) gin.H { return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "user_id": ptrString(row.UserID), "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "is_licensed": ptrBool(row.IsLicensed), "public_key": ptrString(row.PublicKey), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON} } -func mapReportDTO(row mapReportRecord) gin.H { +func mapReportDTO(row storepkg.MapReportRecord) gin.H { return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON} } -func mapReportViewportPointDTO(row mapReportRecord) gin.H { +func mapReportViewportPointDTO(row storepkg.MapReportRecord) gin.H { item := mapReportDTO(row) item["type"] = "point" return item } -func mapReportClusterDTO(row mapReportClusterRecord) gin.H { +func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H { return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count} } -func signDTO(row signRecord) gin.H { - return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime} -} - -func signDayCountDTO(row signDayCount) gin.H { - return gin.H{"date": row.Date, "count": row.Count} -} - -func textMessageDTO(row textMessageRecord) gin.H { +func textMessageDTO(row storepkg.TextMessageRecord) gin.H { return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON} } -func discardDetailsDTO(row discardDetailsRecord) gin.H { +func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H { return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON} } -func positionDTO(row positionRecord) gin.H { +func positionDTO(row storepkg.PositionRecord) gin.H { return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON} } -func telemetryDTO(row telemetryRecord) gin.H { +func telemetryDTO(row storepkg.TelemetryRecord) gin.H { return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "telemetry_type": ptrString(row.TelemetryType), "metrics_json": ptrString(row.MetricsJSON), "created_at": row.CreatedAt, "content_json": row.ContentJSON} } -func routingDTO(row routingRecord) gin.H { +func routingDTO(row storepkg.RoutingRecord) gin.H { return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON) } -func tracerouteDTO(row tracerouteRecord) gin.H { +func tracerouteDTO(row storepkg.TracerouteRecord) gin.H { return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON) } @@ -658,37 +514,8 @@ func appendPacketDTO(id uint64, fromID string, fromNum int64, packetID *int64, p return gin.H{"id": id, "from_id": fromID, "from_num": fromNum, "packet_id": ptrInt64(packetID), "portnum": ptrString(portnum), "created_at": createdAt, "content_json": contentJSON} } -func ptrString(value *string) any { - if value == nil { - return nil - } - return *value -} - -func ptrInt64(value *int64) any { - if value == nil { - return nil - } - return *value -} - -func ptrUint64(value *uint64) any { - if value == nil { - return nil - } - return *value -} - -func ptrFloat64(value *float64) any { - if value == nil { - return nil - } - return *value -} - -func ptrBool(value *bool) any { - if value == nil { - return nil - } - return *value -} +func ptrString(value *string) any { return webutil.PtrString(value) } +func ptrInt64(value *int64) any { return webutil.PtrInt64(value) } +func ptrUint64(value *uint64) any { return webutil.PtrUint64(value) } +func ptrFloat64(value *float64) any { return webutil.PtrFloat64(value) } +func ptrBool(value *bool) any { return webutil.PtrBool(value) } diff --git a/llmadmin_bridge.go b/llmadmin_bridge.go new file mode 100644 index 0000000..287d0c4 --- /dev/null +++ b/llmadmin_bridge.go @@ -0,0 +1,13 @@ +package main + +// 桥接到 internal/llmadmin — 让 web.go 中的 registerAdminLLMRoutes 旧名仍可用。 + +import ( + "github.com/gin-gonic/gin" + + llmadminpkg "meshtastic_mqtt_server/internal/llmadmin" +) + +func registerAdminLLMRoutes(r *gin.RouterGroup, s *store) { + llmadminpkg.RegisterRoutes(r, s) +} diff --git a/mapsource_bridge.go b/mapsource_bridge.go new file mode 100644 index 0000000..772e6ad --- /dev/null +++ b/mapsource_bridge.go @@ -0,0 +1,13 @@ +package main + +// 桥接到 internal/mapsource — 让 web.go 中的 registerMapSourceRoutes / +// registerAdminMapSourceRoutes 旧名字仍可用。 + +import ( + "github.com/gin-gonic/gin" + + mspkg "meshtastic_mqtt_server/internal/mapsource" +) + +func registerMapSourceRoutes(r gin.IRouter, s *store) { mspkg.RegisterPublicRoutes(r, s) } +func registerAdminMapSourceRoutes(r gin.IRouter, s *store) { mspkg.RegisterAdminRoutes(r, s) } diff --git a/mqtt_status.go b/mqtt_status.go deleted file mode 100644 index 4b3a53c..0000000 --- a/mqtt_status.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - mqtt "github.com/mochi-mqtt/server/v2" -) - -type mqttStatusProvider interface { - Status() adminMqttStatus -} - -type mqttRuntimeStatus struct { - server *mqtt.Server - address string - tls bool - stats *meshtasticMessageStats - dbQueue *dbWriteQueue -} - -type adminMqttStatus struct { - Running bool `json:"running"` - Address string `json:"address"` - TLS bool `json:"tls"` - Version string `json:"version"` - Started int64 `json:"started"` - Uptime int64 `json:"uptime"` - BytesReceived int64 `json:"bytes_received"` - BytesSent int64 `json:"bytes_sent"` - ClientsConnected int64 `json:"clients_connected"` - ClientsDisconnected int64 `json:"clients_disconnected"` - ClientsMaximum int64 `json:"clients_maximum"` - ClientsTotal int64 `json:"clients_total"` - MessagesReceived int64 `json:"messages_received"` - MessagesSent int64 `json:"messages_sent"` - MessagesDropped int64 `json:"messages_dropped"` - DBWriteQueueLength int `json:"db_write_queue_length"` - Retained int64 `json:"retained"` - Inflight int64 `json:"inflight"` - InflightDropped int64 `json:"inflight_dropped"` - Subscriptions int64 `json:"subscriptions"` - PacketsReceived int64 `json:"packets_received"` - PacketsSent int64 `json:"packets_sent"` - Clients []adminMqttClient `json:"clients"` -} - -type adminMqttClient struct { - ClientID string `json:"client_id"` - Username string `json:"username"` - Listener string `json:"listener"` - RemoteAddr string `json:"remote_addr"` - RemoteHost string `json:"remote_host"` - RemotePort string `json:"remote_port"` -} - -func (m mqttRuntimeStatus) Status() adminMqttStatus { - if m.server == nil || m.server.Info == nil { - return adminMqttStatus{Running: false, Address: m.address, TLS: m.tls, DBWriteQueueLength: m.dbQueue.Len()} - } - info := m.server.Info.Clone() - status := adminMqttStatus{ - Running: true, - Address: m.address, - TLS: m.tls, - Version: info.Version, - Started: info.Started, - Uptime: info.Uptime, - BytesReceived: info.BytesReceived, - BytesSent: info.BytesSent, - ClientsConnected: info.ClientsConnected, - ClientsDisconnected: info.ClientsDisconnected, - ClientsMaximum: info.ClientsMaximum, - ClientsTotal: info.ClientsTotal, - MessagesReceived: info.MessagesReceived, - MessagesSent: m.stats.Forwarded(), - MessagesDropped: m.stats.Dropped(), - DBWriteQueueLength: m.dbQueue.Len(), - Retained: info.Retained, - Inflight: info.Inflight, - InflightDropped: info.InflightDropped, - Subscriptions: info.Subscriptions, - PacketsReceived: info.PacketsReceived, - PacketsSent: info.PacketsSent, - } - for _, client := range m.server.Clients.GetAll() { - if client == nil || client.Closed() { - continue - } - clientInfo := mqttClientInfoFromClient(client) - status.Clients = append(status.Clients, adminMqttClient{ - ClientID: clientInfo.ClientID, - Username: clientInfo.Username, - Listener: clientInfo.Listener, - RemoteAddr: clientInfo.RemoteAddr, - RemoteHost: clientInfo.RemoteHost, - RemotePort: clientInfo.RemotePort, - }) - } - return status -} diff --git a/sign_bridge.go b/sign_bridge.go new file mode 100644 index 0000000..1a13ecb --- /dev/null +++ b/sign_bridge.go @@ -0,0 +1,17 @@ +package main + +// 桥接到 internal/sign — 让 web.go 中 registerAdminSignRoutes / signDTO / +// signDayCountDTO 旧名字仍可用。 + +import ( + "github.com/gin-gonic/gin" + + signpkg "meshtastic_mqtt_server/internal/sign" +) + +func registerAdminSignRoutes(r gin.IRouter, s *store) { + signpkg.RegisterAdminRoutes(r, s) +} + +func signDTO(row signRecord) gin.H { return signpkg.SignDTO(row) } +func signDayCountDTO(row signDayCount) gin.H { return signpkg.SignDayCountDTO(row) } diff --git a/web_bridge.go b/web_bridge.go new file mode 100644 index 0000000..ddd57f0 --- /dev/null +++ b/web_bridge.go @@ -0,0 +1,56 @@ +package main + +// 桥接到 internal/web — 让 main.go 中使用 newSessionManager / newRouter / +// mqttRuntimeStatus / serveHTTPUnixSocket 这些旧名字的代码继续编译。 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + mqtt "github.com/mochi-mqtt/server/v2" + + "meshtastic_mqtt_server/internal/auth" + mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" + storepkg "meshtastic_mqtt_server/internal/store" + webpkg "meshtastic_mqtt_server/internal/web" +) + +// 旧类型/旧函数名 → 新位置的别名。 + +type mqttRuntimeStatusInternal = webpkg.MQTTRuntimeStatus + +// mqttRuntimeStatus 旧名字保持小写、字段也是小写——这里用一个适配类型把 +// main 包的旧字段写法包到 web 包导出的大写字段上。 +type mqttRuntimeStatus struct { + server *mqtt.Server + address string + tls bool + stats *meshtasticMessageStats + dbQueue *dbWriteQueue +} + +// 让 mqttRuntimeStatus 自动实现 webpkg.MQTTStatusProvider,把请求转给真正的实现。 +func (m mqttRuntimeStatus) Status() webpkg.AdminMQTTStatus { + return webpkg.MQTTRuntimeStatus{ + Server: m.server, + Address: m.address, + TLS: m.tls, + Stats: m.stats, + DBQueue: m.dbQueue, + }.Status() +} + +// 让旧代码里 `mqttforwardpkg.Stats` 别名留作 main 包内可见。 +var _ *mqttforwardpkg.Stats = (*meshtasticMessageStats)(nil) + +func newSessionManager(cfg webAdminConfig) (*auth.Manager, error) { + return auth.NewManager(cfg) +} + +func newRouter(cfg webConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus webpkg.MQTTStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine { + return webpkg.NewRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender) +} + +func serveHTTPUnixSocket(server *http.Server, socketPath string) error { + return webpkg.ServeUnixSocket(server, socketPath) +} From 46916d9a9307a32a7c4c93f0474e1a802b595fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Thu, 18 Jun 2026 18:30:00 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9Amain.go=20?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=20import=20=E5=90=84=20internal/=20=E5=AD=90?= =?UTF-8?q?=E5=8C=85=EF=BC=8C=E5=88=A0=E9=99=A4=E5=85=A8=E9=83=A8=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最后一步:把 main.go 里所有指向桥接小写名的引用(store / config / blocking / bot / runtimesettings / mqttforward / web 等)改写成直接调用 internal/* 子包的导出 API,从而拆掉所有 *_bridge.go。 变更点 - main.go 顶部 import 区改为引用 11 个 internal/ 子包:auth、blocking、 bot、config、mqttforward、runtimesettings、store、web 等。 - meshtasticFilterHook 字段类型改为 *storepkg.WriteQueue / *blockingpkg.Cache / *rspkg.Cache / *mqttforwardpkg.Stats;不再依赖 main 包内别名。 - mqttClientInfoFromClient 返回 storepkg.MQTTClientInfo(之前为 main 包别名)。 - run() 里 newSessionManager → auth.NewManager;newRouter → webpkg.NewRouter; serveHTTPUnixSocket → webpkg.ServeUnixSocket;mqttRuntimeStatus → webpkg.MQTTRuntimeStatus;botSendTextRequest → botpkg.SendTextRequest; newBlockingCache / newRuntimeSettingsCache / newMQTTForwardManager 都改为 对应包的 New / NewManager。 - main_test.go 里的 BlockingViolationForRecord 测试改用 testutil.OpenStore + blockingpkg.New,全部通过 store.Store 的公开 API 构造数据,不再需要 根目录 test_helpers_test.go。 删除根目录文件 - config.go、auth.go(已删)、blocking_bridge.go、bot_bridge.go、 help_bridge.go、llmadmin_bridge.go、mapsource_bridge.go、 mqttforward_bridge.go、runtime_settings_bridge.go、sign_bridge.go、 store_bridge.go、web_bridge.go、test_helpers_test.go。 最终结果 - 根目录只剩 main.go (~370 行) 和 main_test.go (~110 行)。 - internal/ 下 13 个独立子包:auth / blocking / bot / config / help / llmadmin / mapsource / mqttforward / runtimesettings / sign / store / store/testutil / web / webutil。 - go build ./... / go test ./... 全部通过;测试数量与重构前一致; go build . 可以产出可执行二进制(约 50 MB)。 Co-Authored-By: Claude --- blocking_bridge.go | 21 ------ bot_bridge.go | 31 -------- config.go | 43 ----------- help_bridge.go | 16 ---- llmadmin_bridge.go | 13 ---- main.go | 100 +++++++++++++------------ main_test.go | 29 ++++---- mapsource_bridge.go | 13 ---- mqttforward_bridge.go | 26 ------- runtime_settings_bridge.go | 19 ----- sign_bridge.go | 17 ----- store_bridge.go | 145 ------------------------------------- test_helpers_test.go | 21 ------ web_bridge.go | 56 -------------- 14 files changed, 70 insertions(+), 480 deletions(-) delete mode 100644 blocking_bridge.go delete mode 100644 bot_bridge.go delete mode 100644 config.go delete mode 100644 help_bridge.go delete mode 100644 llmadmin_bridge.go delete mode 100644 mapsource_bridge.go delete mode 100644 mqttforward_bridge.go delete mode 100644 runtime_settings_bridge.go delete mode 100644 sign_bridge.go delete mode 100644 store_bridge.go delete mode 100644 test_helpers_test.go delete mode 100644 web_bridge.go diff --git a/blocking_bridge.go b/blocking_bridge.go deleted file mode 100644 index eee61e9..0000000 --- a/blocking_bridge.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -// 桥接到 internal/blocking — 让根目录其余文件可以继续使用旧名字 -// blockingCache / newBlockingCache / registerAdminBlockingRoutes, -// 而无须改动 main.go / web.go 等十几处调用点。 - -import ( - "github.com/gin-gonic/gin" - - blockingpkg "meshtastic_mqtt_server/internal/blocking" -) - -type blockingCache = blockingpkg.Cache - -func newBlockingCache(s *store) (*blockingCache, error) { - return blockingpkg.New(s) -} - -func registerAdminBlockingRoutes(r gin.IRouter, s *store, b *blockingCache) { - blockingpkg.RegisterRoutes(r, s, b) -} diff --git a/bot_bridge.go b/bot_bridge.go deleted file mode 100644 index 61924d8..0000000 --- a/bot_bridge.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -// 桥接到 internal/bot — 让 main.go / web.go 中使用 botService / -// botTextSender / newBotService / newPKIKeyResolver / registerAdminBotRoutes -// 这些旧名字的代码继续可用。 - -import ( - mqtt "github.com/mochi-mqtt/server/v2" - - "github.com/gin-gonic/gin" - - botpkg "meshtastic_mqtt_server/internal/bot" -) - -type ( - botService = botpkg.Service - botTextSender = botpkg.TextSender - botSendTextRequest = botpkg.SendTextRequest -) - -func newBotService(s *store, server *mqtt.Server, key []byte) *botService { - return botpkg.NewService(s, server, key) -} - -func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { - return botpkg.NewPKIKeyResolver(s) -} - -func registerAdminBotRoutes(r gin.IRouter, s *store, sender botTextSender) { - botpkg.RegisterRoutes(r, s, sender) -} diff --git a/config.go b/config.go deleted file mode 100644 index d8af479..0000000 --- a/config.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - cryptotls "crypto/tls" - - cfgpkg "meshtastic_mqtt_server/internal/config" -) - -// 桥接到 internal/config — 让根目录其余文件无须修改即可继续使用旧的非导出名字。 - -const ( - configFileName = cfgpkg.FileName - databaseDriverSQLite = cfgpkg.DriverSQLite - databaseDriverMySQL = cfgpkg.DriverMySQL -) - -// 旧的小写类型名通过别名继续可用。 -type ( - config = cfgpkg.Config - mqttConfig = cfgpkg.MQTTConfig - tlsConfig = cfgpkg.TLSConfig - meshtasticConfig = cfgpkg.MeshtasticConfig - databaseConfig = cfgpkg.DatabaseConfig - sqliteConfig = cfgpkg.SQLiteConfig - mysqlConfig = cfgpkg.MySQLConfig - webConfig = cfgpkg.WebConfig - webAdminConfig = cfgpkg.WebAdminConfig - aiConfig = cfgpkg.AIConfig -) - -func defaultConfig() *config { return cfgpkg.Default() } -func defaultConfigDir() string { return cfgpkg.DefaultDir() } -func defaultConfigPath() string { return cfgpkg.DefaultPath() } -func loadConfig(path string) (*config, error) { return cfgpkg.Load(path) } -func writeConfig(path string, cfg *config) error { return cfgpkg.Write(path, cfg) } -func validateConfig(cfg *config) error { return cfgpkg.Validate(cfg) } -func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool { - return cfgpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, goos) -} - -func buildTLSConfig(cfg tlsConfig) (*cryptotls.Config, error) { - return cfgpkg.BuildTLS(cfg) -} diff --git a/help_bridge.go b/help_bridge.go deleted file mode 100644 index 82ae746..0000000 --- a/help_bridge.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -// 桥接到 internal/help — 让 web.go 中的 registerHelpRoutes / -// registerAdminHelpRoutes / renderHelpMarkdown 旧名字仍可用。 - -import ( - "github.com/gin-gonic/gin" - - helppkg "meshtastic_mqtt_server/internal/help" -) - -func registerHelpRoutes(r gin.IRouter, s *store) { helppkg.RegisterPublicRoutes(r, s) } -func registerAdminHelpRoutes(r gin.IRouter, s *store) { helppkg.RegisterAdminRoutes(r, s) } -func renderHelpMarkdown(markdown string) (string, error) { - return helppkg.RenderMarkdown(markdown) -} diff --git a/llmadmin_bridge.go b/llmadmin_bridge.go deleted file mode 100644 index 287d0c4..0000000 --- a/llmadmin_bridge.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -// 桥接到 internal/llmadmin — 让 web.go 中的 registerAdminLLMRoutes 旧名仍可用。 - -import ( - "github.com/gin-gonic/gin" - - llmadminpkg "meshtastic_mqtt_server/internal/llmadmin" -) - -func registerAdminLLMRoutes(r *gin.RouterGroup, s *store) { - llmadminpkg.RegisterRoutes(r, s) -} diff --git a/main.go b/main.go index f2f5b52..adf63c0 100644 --- a/main.go +++ b/main.go @@ -14,15 +14,23 @@ import ( "syscall" "time" - "meshtastic_mqtt_server/ai" - "meshtastic_mqtt_server/autoreply" - "meshtastic_mqtt_server/llm" - "meshtastic_mqtt_server/mqtpp" - mqtt "github.com/mochi-mqtt/server/v2" - "github.com/mochi-mqtt/server/v2/hooks/auth" + mqttauth "github.com/mochi-mqtt/server/v2/hooks/auth" "github.com/mochi-mqtt/server/v2/listeners" "github.com/mochi-mqtt/server/v2/packets" + + "meshtastic_mqtt_server/ai" + "meshtastic_mqtt_server/autoreply" + "meshtastic_mqtt_server/internal/auth" + blockingpkg "meshtastic_mqtt_server/internal/blocking" + botpkg "meshtastic_mqtt_server/internal/bot" + configpkg "meshtastic_mqtt_server/internal/config" + mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" + rspkg "meshtastic_mqtt_server/internal/runtimesettings" + storepkg "meshtastic_mqtt_server/internal/store" + webpkg "meshtastic_mqtt_server/internal/web" + "meshtastic_mqtt_server/llm" + "meshtastic_mqtt_server/mqtpp" ) const ( @@ -39,10 +47,10 @@ const ( type meshtasticFilterHook struct { mqtt.HookBase key []byte - dbQueue *dbWriteQueue - stats *meshtasticMessageStats - blocking *blockingCache - settings *runtimeSettingsCache + dbQueue *storepkg.WriteQueue + stats *mqttforwardpkg.Stats + blocking *blockingpkg.Cache + settings *rspkg.Cache pkiResolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) autoAcker func(record map[string]any) } @@ -107,7 +115,7 @@ func (h *meshtasticFilterHook) rejectPublish(cl *mqtt.Client, pk packets.Packet, h.dbQueue.EnqueueDiscard(record, pk.Payload, mqttClientInfoFromClient(cl)) } -func blockingViolationForRecord(blocking *blockingCache, record map[string]any) map[string]any { +func blockingViolationForRecord(blocking *blockingpkg.Cache, record map[string]any) map[string]any { if blocking == nil || record == nil { return nil } @@ -129,12 +137,12 @@ func blockingViolationForRecord(blocking *blockingCache, record map[string]any) return nil } -func mqttClientInfoFromClient(cl *mqtt.Client) mqttClientInfo { +func mqttClientInfoFromClient(cl *mqtt.Client) storepkg.MQTTClientInfo { if cl == nil { - return mqttClientInfo{} + return storepkg.MQTTClientInfo{} } - info := mqttClientInfo{ + info := storepkg.MQTTClientInfo{ ClientID: cl.ID, Username: string(cl.Properties.Username), Listener: cl.Net.Listener, @@ -166,8 +174,8 @@ func main() { } // parseArgs 加载配置文件、解析命令行覆盖项,并展开 Meshtastic channel PSK。 -func parseArgs() (*config, error) { - cfg, err := loadConfig(defaultConfigPath()) +func parseArgs() (*configpkg.Config, error) { + cfg, err := configpkg.Load(configpkg.DefaultPath()) if err != nil { return nil, err } @@ -198,9 +206,9 @@ func parseArgs() (*config, error) { if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" { cfg.Web.Admin.SessionSecret = value } - clearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) + configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) - if err := validateConfig(cfg); err != nil { + if err := configpkg.Validate(cfg); err != nil { return nil, err } key, err := mqtpp.ExpandPSK(cfg.Meshtastic.PSK) @@ -212,38 +220,38 @@ func parseArgs() (*config, error) { } // run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。 -func run(cfg *config) error { - store, err := openStore(cfg.Database) +func run(cfg *configpkg.Config) error { + store, err := storepkg.OpenStore(cfg.Database) if err != nil { return err } defer store.Close() - dbQueue := newDBWriteQueue(store) + dbQueue := storepkg.NewWriteQueue(store) defer dbQueue.Close() if err := store.EnsureDefaultAdmin(cfg.Web.Admin.Username, cfg.Web.Admin.Password); err != nil { return err } - blocking, err := newBlockingCache(store) + blocking, err := blockingpkg.New(store) if err != nil { return err } - settings, err := newRuntimeSettingsCache(store) + settings, err := rspkg.New(store) if err != nil { return err } - messageStats := &meshtasticMessageStats{} + messageStats := &mqttforwardpkg.Stats{} server, mqttHook, mqttAddr, err := startMQTTServer(cfg, store, dbQueue, messageStats, blocking, settings) if err != nil { return err } - botSender := newBotService(store, server, cfg.Key) + botSender := botpkg.NewService(store, server, cfg.Key) mqttHook.autoAcker = botSender.MaybeAutoAck botCtx, stopBotBroadcaster := context.WithCancel(context.Background()) defer stopBotBroadcaster() botSender.StartNodeInfoBroadcaster(botCtx) - forwardManager := newMQTTForwardManager(store) + forwardManager := mqttforwardpkg.NewManager(store) if err := forwardManager.StartFromStore(); err != nil { server.Close() return err @@ -262,12 +270,12 @@ func run(cfg *config) error { providerConfigs := make([]llm.ProviderConfig, 0, len(llmProviders)) for _, p := range llmProviders { providerConfigs = append(providerConfigs, llm.ProviderConfig{ - Name: p.Name, - Active: p.Active, - APIKey: p.APIKey, - BaseURL: p.BaseURL, - Model: p.Model, - Timeout: p.Timeout, + Name: p.Name, + Active: p.Active, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Model: p.Model, + Timeout: p.Timeout, ContextWindowTokens: p.ContextWindowTokens, }) } @@ -276,7 +284,7 @@ func run(cfg *config) error { botSenderAdapter := autoreply.NewBotServiceAdapter( // SendDirectText: 发送私聊消息 func(ctx context.Context, botID uint64, toNodeNum int64, text string) error { - _, err := botSender.SendText(ctx, botSendTextRequest{ + _, err := botSender.SendText(ctx, botpkg.SendTextRequest{ BotID: botID, MessageType: "direct", ToNodeNum: &toNodeNum, @@ -286,7 +294,7 @@ func run(cfg *config) error { }, // SendChannelText: 发送频道消息 func(ctx context.Context, botID uint64, channelID string, text string) error { - _, err := botSender.SendText(ctx, botSendTextRequest{ + _, err := botSender.SendText(ctx, botpkg.SendTextRequest{ BotID: botID, MessageType: "channel", ChannelID: channelID, @@ -297,10 +305,10 @@ func run(cfg *config) error { ) aiService, err = ai.NewService(ai.Config{ - LLMProviders: providerConfigs, - DataDir: cfg.DataDir, - Enabled: cfg.AI.Enabled, - ToolConfigStore: store, + LLMProviders: providerConfigs, + DataDir: cfg.DataDir, + Enabled: cfg.AI.Enabled, + ToolConfigStore: store, }, store.DB(), botSenderAdapter) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err) @@ -319,12 +327,12 @@ func run(cfg *config) error { var httpServers []*http.Server errCh := make(chan error, 2) if cfg.Web.Enabled { - sessions, err := newSessionManager(cfg.Web.Admin) + sessions, err := auth.NewManager(cfg.Web.Admin) if err != nil { return err } - mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue} - handler := newRouter(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender) + mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, DBQueue: dbQueue} + handler := webpkg.NewRouter(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender) webAddresses := []string{} if cfg.Web.PortEnabled { httpServer := &http.Server{ @@ -344,7 +352,7 @@ func run(cfg *config) error { httpServers = append(httpServers, httpServer) webAddresses = append(webAddresses, cfg.Web.SocketPath) go func() { - if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := webpkg.ServeUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() @@ -378,9 +386,9 @@ func run(cfg *config) error { return runErr } -func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, *meshtasticFilterHook, string, error) { +func startMQTTServer(cfg *configpkg.Config, store *storepkg.Store, dbQueue *storepkg.WriteQueue, stats *mqttforwardpkg.Stats, blocking *blockingpkg.Cache, settings *rspkg.Cache) (*mqtt.Server, *meshtasticFilterHook, string, error) { server := mqtt.New(&mqtt.Options{InlineClient: true}) - if err := server.AddHook(new(auth.AllowHook), nil); err != nil { + if err := server.AddHook(new(mqttauth.AllowHook), nil); err != nil { return nil, nil, "", err } hook := &meshtasticFilterHook{ @@ -389,14 +397,14 @@ func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *me stats: stats, blocking: blocking, settings: settings, - pkiResolver: newPKIKeyResolver(store), + pkiResolver: botpkg.NewPKIKeyResolver(store), } if err := server.AddHook(hook, nil); err != nil { return nil, nil, "", err } addr := net.JoinHostPort(cfg.MQTT.Host, strconv.Itoa(cfg.MQTT.Port)) - tlsConfig, err := buildTLSConfig(cfg.MQTT.TLS) + tlsConfig, err := configpkg.BuildTLS(cfg.MQTT.TLS) if err != nil { return nil, nil, "", err } diff --git a/main_test.go b/main_test.go index 6494360..544f329 100644 --- a/main_test.go +++ b/main_test.go @@ -4,11 +4,15 @@ import ( "testing" mqtt "github.com/mochi-mqtt/server/v2" + + blockingpkg "meshtastic_mqtt_server/internal/blocking" + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/store/testutil" ) func TestMQTTClientInfoFromClientNil(t *testing.T) { info := mqttClientInfoFromClient(nil) - if info != (mqttClientInfo{}) { + if info != (storepkg.MQTTClientInfo{}) { t.Fatalf("info = %#v, want zero value", info) } } @@ -42,20 +46,19 @@ func TestMQTTClientInfoFromClientUnsplitRemote(t *testing.T) { } } -// 注:blockingViolationForRecord 的测试现在跟着 blockingCache 一起搬到了 -// internal/blocking/violations_test.go,使用真实 *Store 构造缓存而不是 -// 直接捏造未导出字段。这里保留 mqtt client info 这部分测试不动。 +// blockingViolationForRecord 的测试用真实 *Store + blocking.Cache 走完整路径, +// 不依赖 cache 的未导出字段。 func TestBlockingViolationForRecordNode(t *testing.T) { - st := openTestStore(t) + st := testutil.OpenStore(t) defer st.Close() nodeNum := int64(305419896) if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "blocked", true); err != nil { t.Fatalf("CreateNodeBlocking() error = %v", err) } - cache, err := newBlockingCache(st) + cache, err := blockingpkg.New(st) if err != nil { - t.Fatalf("newBlockingCache() error = %v", err) + t.Fatalf("blocking.New() error = %v", err) } record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)} violation := blockingViolationForRecord(cache, record) @@ -65,14 +68,14 @@ func TestBlockingViolationForRecordNode(t *testing.T) { } func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) { - st := openTestStore(t) + st := testutil.OpenStore(t) defer st.Close() if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil { t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) } - cache, err := newBlockingCache(st) + cache, err := blockingpkg.New(st) if err != nil { - t.Fatalf("newBlockingCache() error = %v", err) + t.Fatalf("blocking.New() error = %v", err) } for _, tc := range []struct { @@ -94,14 +97,14 @@ func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) { } func TestBlockingViolationForRecordAllowed(t *testing.T) { - st := openTestStore(t) + st := testutil.OpenStore(t) defer st.Close() if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil { t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) } - cache, err := newBlockingCache(st) + cache, err := blockingpkg.New(st) if err != nil { - t.Fatalf("newBlockingCache() error = %v", err) + t.Fatalf("blocking.New() error = %v", err) } record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"} if violation := blockingViolationForRecord(cache, record); violation != nil { diff --git a/mapsource_bridge.go b/mapsource_bridge.go deleted file mode 100644 index 772e6ad..0000000 --- a/mapsource_bridge.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -// 桥接到 internal/mapsource — 让 web.go 中的 registerMapSourceRoutes / -// registerAdminMapSourceRoutes 旧名字仍可用。 - -import ( - "github.com/gin-gonic/gin" - - mspkg "meshtastic_mqtt_server/internal/mapsource" -) - -func registerMapSourceRoutes(r gin.IRouter, s *store) { mspkg.RegisterPublicRoutes(r, s) } -func registerAdminMapSourceRoutes(r gin.IRouter, s *store) { mspkg.RegisterAdminRoutes(r, s) } diff --git a/mqttforward_bridge.go b/mqttforward_bridge.go deleted file mode 100644 index ea103b7..0000000 --- a/mqttforward_bridge.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -// 桥接到 internal/mqttforward — 让 main.go / web.go / mqtt_status.go 中 -// 使用 mqttForwardManager / meshtasticMessageStats / mqttForwardReloader 等 -// 旧名字的代码仍可工作。 - -import ( - "github.com/gin-gonic/gin" - - mfpkg "meshtastic_mqtt_server/internal/mqttforward" -) - -type ( - mqttForwardManager = mfpkg.Manager - mqttForwardReloader = mfpkg.Reloader - mqttForwardRuntimeStatus = mfpkg.RuntimeStatus - meshtasticMessageStats = mfpkg.Stats -) - -func newMQTTForwardManager(s *store) *mqttForwardManager { - return mfpkg.NewManager(s) -} - -func registerAdminMQTTForwardRoutes(r gin.IRouter, s *store, forwarder mqttForwardReloader) { - mfpkg.RegisterRoutes(r, s, forwarder) -} diff --git a/runtime_settings_bridge.go b/runtime_settings_bridge.go deleted file mode 100644 index 87ed6b8..0000000 --- a/runtime_settings_bridge.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -// 桥接到 internal/runtimesettings — 让根目录代码继续使用旧的小写名字。 - -import ( - "github.com/gin-gonic/gin" - - rspkg "meshtastic_mqtt_server/internal/runtimesettings" -) - -type runtimeSettingsCache = rspkg.Cache - -func newRuntimeSettingsCache(s *store) (*runtimeSettingsCache, error) { - return rspkg.New(s) -} - -func registerAdminRuntimeSettingsRoutes(r gin.IRouter, s *store, c *runtimeSettingsCache) { - rspkg.RegisterRoutes(r, s, c) -} diff --git a/sign_bridge.go b/sign_bridge.go deleted file mode 100644 index 1a13ecb..0000000 --- a/sign_bridge.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -// 桥接到 internal/sign — 让 web.go 中 registerAdminSignRoutes / signDTO / -// signDayCountDTO 旧名字仍可用。 - -import ( - "github.com/gin-gonic/gin" - - signpkg "meshtastic_mqtt_server/internal/sign" -) - -func registerAdminSignRoutes(r gin.IRouter, s *store) { - signpkg.RegisterAdminRoutes(r, s) -} - -func signDTO(row signRecord) gin.H { return signpkg.SignDTO(row) } -func signDayCountDTO(row signDayCount) gin.H { return signpkg.SignDayCountDTO(row) } diff --git a/store_bridge.go b/store_bridge.go deleted file mode 100644 index 967893b..0000000 --- a/store_bridge.go +++ /dev/null @@ -1,145 +0,0 @@ -package main - -// 桥接到 internal/store —— 让根目录其余文件无须修改即可继续使用旧的小写类型/函数名。 -// 当各领域包逐步迁出根目录后,可以删除这些别名。 - -import ( - storepkg "meshtastic_mqtt_server/internal/store" -) - -// ---- 类型别名 ---- - -type ( - store = storepkg.Store - mqttClientInfo = storepkg.MQTTClientInfo - dbWriteQueue = storepkg.WriteQueue - listOptions = storepkg.ListOptions - mapReportViewportOptions = storepkg.MapReportViewportOptions - mapReportViewportResult = storepkg.MapReportViewportResult - mapReportClusterRecord = storepkg.MapReportClusterRecord - userRecord = storepkg.UserRecord - loginLogRecord = storepkg.LoginLogRecord - helpContentRecord = storepkg.HelpContentRecord - runtimeSettingRecord = storepkg.RuntimeSettingRecord - mapTileSourceRecord = storepkg.MapTileSourceRecord - mapTileSourceInput = storepkg.MapTileSourceInput - discardDetailsRecord = storepkg.DiscardDetailsRecord - signRecord = storepkg.SignRecord - nodeBlockingRecord = storepkg.NodeBlockingRecord - ipBlockingRecord = storepkg.IPBlockingRecord - forbiddenWordBlockingRecord = storepkg.ForbiddenWordBlockingRecord - mqttForwarderRecord = storepkg.MQTTForwarderRecord - mqttForwarderInput = storepkg.MQTTForwarderInput - mqttForwardTopicRecord = storepkg.MQTTForwardTopicRecord - mqttForwardTopicInput = storepkg.MQTTForwardTopicInput - botNodeRecord = storepkg.BotNodeRecord - botNodeInput = storepkg.BotNodeInput - botMessageRecord = storepkg.BotMessageRecord - botDirectMessageRecord = storepkg.BotDirectMessageRecord - llmMessageQueueRecord = storepkg.LLMMessageQueueRecord - nodeInfoRecord = storepkg.NodeInfoRecord - mapReportRecord = storepkg.MapReportRecord - textMessageRecord = storepkg.TextMessageRecord - llmProviderRecord = storepkg.LLMProviderRecord - llmToolRouterRecord = storepkg.LLMToolRouterRecord - llmPrimaryConfigRecord = storepkg.LLMPrimaryConfigRecord - positionRecord = storepkg.PositionRecord - telemetryRecord = storepkg.TelemetryRecord - routingRecord = storepkg.RoutingRecord - tracerouteRecord = storepkg.TracerouteRecord -) - -// AppendPacketFields / MQTTClientRecordFields 已经是导出名,不需要别名。 -// 直接用包限定名供 root 文件调用: -type ( - AppendPacketFields = storepkg.AppendPacketFields - MQTTClientRecordFields = storepkg.MQTTClientRecordFields -) - -// 其它额外导出类型的别名(旧的小写形式仍被根目录文件直接使用)。 -type ( - runtimeSettingsSnapshot = storepkg.RuntimeSettingsSnapshot - mqttForwarderConfig = storepkg.MQTTForwarderConfig - botDirectMessageListOptions = storepkg.BotDirectMessageListOptions - botMessageListOptions = storepkg.BotMessageListOptions - botDirectConversation = storepkg.BotDirectConversation - signDayCount = storepkg.SignDayCount -) - -var errBlockingAlreadyExists = storepkg.ErrBlockingAlreadyExists -var errBotNodeAlreadyExists = storepkg.ErrBotNodeAlreadyExists -var ( - errMapTileSourceAlreadyExists = storepkg.ErrMapTileSourceAlreadyExists - errMapTileSourceCannotDeleteDefault = storepkg.ErrMapTileSourceCannotDeleteDefault - errMapTileSourceCannotDisableDefault = storepkg.ErrMapTileSourceCannotDisableDefault - errMapTileSourceDefaultMustBeEnabled = storepkg.ErrMapTileSourceDefaultMustBeEnabled -) - -func mapTileSourceHash(urlTemplate string) string { - return storepkg.MapTileSourceHash(urlTemplate) -} - -const ( - forbiddenWordMatchContains = storepkg.ForbiddenWordMatchContains - runtimeSettingAllowEncryptedForwarding = storepkg.RuntimeSettingAllowEncryptedForwarding - runtimeSettingLLMQueueEnabled = storepkg.RuntimeSettingLLMQueueEnabled - runtimeSettingLLMQueueIncludeChannel = storepkg.RuntimeSettingLLMQueueIncludeChannel - - botDefaultPSK = storepkg.BotDefaultPSK - botMessageTypeChannel = storepkg.BotMessageTypeChannel - botMessageTypeDirect = storepkg.BotMessageTypeDirect - botMessageStatusPending = storepkg.BotMessageStatusPending - botMessageStatusPublished = storepkg.BotMessageStatusPublished - botMessageStatusFailed = storepkg.BotMessageStatusFailed - botDirectMessageDirectionInbound = storepkg.BotDirectMessageDirectionInbound - botDirectMessageDirectionOutbound = storepkg.BotDirectMessageDirectionOutbound - botDefaultTopicPrefix = storepkg.BotDefaultTopicPrefix - - mqttForwardDirectionSourceToTarget = storepkg.MQTTForwardDirectionSourceToTarget - mqttForwardDirectionBidirectional = storepkg.MQTTForwardDirectionBidirectional -) - -const botDefaultNodeInfoBroadcastSeconds = storepkg.BotDefaultNodeInfoBroadcastSeconds - -func validateBotNodeNum(n int64) error { return storepkg.ValidateBotNodeNum(n) } - -var errUserAlreadyExists = storepkg.ErrUserAlreadyExists - -var ( - errMQTTForwarderAlreadyExists = storepkg.ErrMQTTForwarderAlreadyExists - errMQTTForwardTopicAlreadyExists = storepkg.ErrMQTTForwardTopicAlreadyExists -) - -func nullableString(v any) *string { return storepkg.NullableString(v) } -func nullableStringValue(v any) *string { return storepkg.NullableStringValue(v) } -func decodeBotPublicKey(row botNodeRecord) ([]byte, error) { - return storepkg.DecodeBotPublicKey(row) -} - -// LLM 消息队列状态字符串常量。 -const ( - llmMessageStatusPending = storepkg.LLMMessageStatusPending - llmMessageStatusProcessing = storepkg.LLMMessageStatusProcessing - llmMessageStatusProcessed = storepkg.LLMMessageStatusProcessed - llmMessageStatusError = storepkg.LLMMessageStatusError -) - -const defaultHelpMarkdown = storepkg.DefaultHelpMarkdown - -// 旧名 llmMessageDTO 现在通过 store 提供。 -func llmMessageDTO(row llmMessageQueueRecord) map[string]any { - return storepkg.LLMMessageDTO(row) -} - -// ---- 工厂函数包装 ---- - -func openStore(cfg databaseConfig) (*store, error) { return storepkg.OpenStore(cfg) } -func normalizeListOptions(o listOptions) listOptions { - return storepkg.NormalizeListOptions(o) -} -func normalizeMapReportViewportOptions(o mapReportViewportOptions) mapReportViewportOptions { - return storepkg.NormalizeMapReportViewportOptions(o) -} - -// newDBWriteQueue 在新包里更名,提供旧名字避免改 main.go。 -func newDBWriteQueue(s *store) *dbWriteQueue { return storepkg.NewWriteQueue(s) } diff --git a/test_helpers_test.go b/test_helpers_test.go deleted file mode 100644 index 24a28bc..0000000 --- a/test_helpers_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "path/filepath" - "testing" - - storepkg "meshtastic_mqtt_server/internal/store" -) - -// openTestStore 在根目录的测试中沿用旧函数名,但底层调用 internal/store 的实现。 -func openTestStore(t *testing.T) *store { - t.Helper() - st, err := storepkg.OpenStore(databaseConfig{ - Driver: databaseDriverSQLite, - SQLite: sqliteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")}, - }) - if err != nil { - t.Fatalf("OpenStore() error = %v", err) - } - return st -} diff --git a/web_bridge.go b/web_bridge.go deleted file mode 100644 index ddd57f0..0000000 --- a/web_bridge.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -// 桥接到 internal/web — 让 main.go 中使用 newSessionManager / newRouter / -// mqttRuntimeStatus / serveHTTPUnixSocket 这些旧名字的代码继续编译。 - -import ( - "net/http" - - "github.com/gin-gonic/gin" - mqtt "github.com/mochi-mqtt/server/v2" - - "meshtastic_mqtt_server/internal/auth" - mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" - storepkg "meshtastic_mqtt_server/internal/store" - webpkg "meshtastic_mqtt_server/internal/web" -) - -// 旧类型/旧函数名 → 新位置的别名。 - -type mqttRuntimeStatusInternal = webpkg.MQTTRuntimeStatus - -// mqttRuntimeStatus 旧名字保持小写、字段也是小写——这里用一个适配类型把 -// main 包的旧字段写法包到 web 包导出的大写字段上。 -type mqttRuntimeStatus struct { - server *mqtt.Server - address string - tls bool - stats *meshtasticMessageStats - dbQueue *dbWriteQueue -} - -// 让 mqttRuntimeStatus 自动实现 webpkg.MQTTStatusProvider,把请求转给真正的实现。 -func (m mqttRuntimeStatus) Status() webpkg.AdminMQTTStatus { - return webpkg.MQTTRuntimeStatus{ - Server: m.server, - Address: m.address, - TLS: m.tls, - Stats: m.stats, - DBQueue: m.dbQueue, - }.Status() -} - -// 让旧代码里 `mqttforwardpkg.Stats` 别名留作 main 包内可见。 -var _ *mqttforwardpkg.Stats = (*meshtasticMessageStats)(nil) - -func newSessionManager(cfg webAdminConfig) (*auth.Manager, error) { - return auth.NewManager(cfg) -} - -func newRouter(cfg webConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus webpkg.MQTTStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine { - return webpkg.NewRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender) -} - -func serveHTTPUnixSocket(server *http.Server, socketPath string) error { - return webpkg.ServeUnixSocket(server, socketPath) -}