重构:拆出 bot / sign / mapsource / llmadmin / web 包

第三批(最终批):把 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:24:14 +08:00
co-authored by Claude
parent c527a9fd9a
commit 9394aa0f4a
18 changed files with 565 additions and 530 deletions
-29
View File
@@ -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) }
+31
View File
@@ -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)
}
@@ -1,4 +1,4 @@
package main package bot
import ( import (
"errors" "errors"
@@ -7,6 +7,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"meshtastic_mqtt_server/internal/auth"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
) )
type botNodeRequest struct { type botNodeRequest struct {
@@ -32,19 +36,19 @@ type botSendMessageRequest struct {
Text string `json:"text"` 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) { r.GET("/bot/nodes", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListBotNodes(opts) rows, err := store.ListBotNodes(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, botNodeDTO) webutil.WriteListResponse(c, rows, opts, err, botNodeDTO)
return return
} }
total, err := store.CountBotNodes(opts) 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) { r.POST("/bot/nodes", func(c *gin.Context) {
var req botNodeRequest var req botNodeRequest
@@ -125,14 +129,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
} }
rows, err := store.ListBotMessages(opts) rows, err := store.ListBotMessages(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts.ListOptions, err, botMessageDTO) webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO)
return return
} }
total, err := store.CountBotMessages(opts) 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) { r.GET("/bot/direct-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
return 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) rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, botDirectMessageDTO) webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO)
return return
} }
total, err := store.CountBotDirectMessagesByConversation(dmOpts) total, err := store.CountBotDirectMessagesByConversation(dmOpts)
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO) webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
}) })
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数), // /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
// 给前端侧边栏渲染会话列表使用。 // 给前端侧边栏渲染会话列表使用。
r.GET("/bot/conversations", func(c *gin.Context) { r.GET("/bot/conversations", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
return return
} }
claims := c.MustGet("admin_claims").(*sessionClaims) claims := c.MustGet("admin_claims").(*auth.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}) 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
return return
@@ -254,8 +258,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
}) })
} }
func botNodeInputFromRequest(req botNodeRequest) botNodeInput { func botNodeInputFromRequest(req botNodeRequest) storepkg.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} 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) { 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 return id, true
} }
func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) { func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) {
listOpts, ok := parseListOptions(c) listOpts, ok := webutil.ParseListOptions(c)
if !ok { 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 != "" { if value := c.Query("bot_id"); value != "" {
id, err := strconv.ParseUint(value, 10, 64) id, err := strconv.ParseUint(value, 10, 64)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
return botMessageListOptions{}, false return storepkg.BotMessageListOptions{}, false
} }
opts.BotID = id opts.BotID = id
} }
return opts, true return opts, true
} }
func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) { func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotNodeRecord, err error) {
if errors.Is(err, errBotNodeAlreadyExists) { if errors.Is(err, storepkg.ErrBotNodeAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"}) c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"})
return return
} }
@@ -300,15 +304,15 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord
c.JSON(status, gin.H{"item": botNodeDTO(*row)}) 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} 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} 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{ return gin.H{
"id": row.ID, "id": row.ID,
"bot_id": row.BotID, "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{ return gin.H{
"bot_id": row.BotID, "bot_id": row.BotID,
"peer_node_id": row.PeerNodeID, "peer_node_id": row.PeerNodeID,
@@ -1,14 +1,16 @@
package main package bot
import ( import (
"encoding/base64" "encoding/base64"
"strings" "strings"
storepkg "meshtastic_mqtt_server/internal/store"
) )
// newPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者 // NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者
// 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。 // 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。
// 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。 // 返回 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 { if s == nil {
return nil return nil
} }
+58 -57
View File
@@ -1,4 +1,4 @@
package main package bot
import ( import (
"context" "context"
@@ -12,15 +12,16 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"meshtastic_mqtt_server/mqtpp"
mqtt "github.com/mochi-mqtt/server/v2" mqtt "github.com/mochi-mqtt/server/v2"
"gorm.io/gorm" "gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/mqtpp"
) )
const botMaxTextBytes = 200 const botMaxTextBytes = 200
type botSendTextRequest struct { type SendTextRequest struct {
BotID uint64 BotID uint64
MessageType string MessageType string
ChannelID string ChannelID string
@@ -30,22 +31,22 @@ type botSendTextRequest struct {
CreatedBy string CreatedBy string
} }
type botTextSender interface { type TextSender interface {
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error) SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
} }
type botService struct { type Service struct {
store *store store *storepkg.Store
server *mqtt.Server server *mqtt.Server
key []byte key []byte
} }
func newBotService(store *store, server *mqtt.Server, key []byte) *botService { func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
return &botService{store: store, server: server, key: key} 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 { if s == nil || s.store == nil || s.server == nil {
return return
} }
@@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
// - 其它情况用原 channel + bot PSK 加密 // - 其它情况用原 channel + bot PSK 加密
// //
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。 // 解析失败、目标不是受管 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 { if s == nil || s.store == nil || s.server == nil || record == nil {
return 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) privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
if privateKeyB64 == "" { if privateKeyB64 == "" {
return nil, fmt.Errorf("bot has no private key") 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 { if err != nil {
return nil, err return nil, err
} }
senderPublic, err := decodeBotPublicKey(*bot) senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
if err != nil { if err != nil {
return nil, err 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) channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
if channel == "" || channel == mqtpp.PKIChannelID { if channel == "" || channel == mqtpp.PKIChannelID {
return nil, fmt.Errorf("no channel id available for psk ack") return nil, fmt.Errorf("no channel id available for psk ack")
} }
psk := strings.TrimSpace(bot.PSK) psk := strings.TrimSpace(bot.PSK)
if psk == "" { if psk == "" {
psk = botDefaultPSK psk = storepkg.BotDefaultPSK
} }
key, err := mqtpp.ExpandPSK(psk) key, err := mqtpp.ExpandPSK(psk)
if err != nil { if err != nil {
@@ -208,7 +209,7 @@ func errString(err error) string {
return err.Error() 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 { if s == nil || s.store == nil {
return nil, fmt.Errorf("bot service is not configured") 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) fromNodeNum := uint32(bot.NodeNum)
// direct 私聊走 PKIchannel 群聊保留旧的 AES-CTR + PSK 路径 // direct 私聊走 PKIchannel 群聊保留旧的 AES-CTR + PSK 路径
if messageType == botMessageTypeDirect { if messageType == storepkg.BotMessageTypeDirect {
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy) 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) psk := strings.TrimSpace(bot.PSK)
if psk == "" { if psk == "" {
psk = botDefaultPSK psk = storepkg.BotDefaultPSK
} }
key, err := mqtpp.ExpandPSK(psk) key, err := mqtpp.ExpandPSK(psk)
if err != nil { if err != nil {
@@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
return nil, err return nil, err
} }
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID) topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
row := &botMessageRecord{ row := &storepkg.BotMessageRecord{
BotID: bot.ID, BotID: bot.ID,
BotNodeID: bot.NodeID, BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum, BotNodeNum: bot.NodeNum,
@@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
Text: text, Text: text,
PayloadLen: int64(len(raw)), PayloadLen: int64(len(raw)),
Encrypted: true, Encrypted: true,
Status: botMessageStatusPending, Status: storepkg.BotMessageStatusPending,
CreatedBy: strings.TrimSpace(req.CreatedBy), CreatedBy: strings.TrimSpace(req.CreatedBy),
} }
return s.persistAndPublish(row, topic, raw) return s.persistAndPublish(row, topic, raw)
@@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
// - 从 nodeinfo 中查目标节点的 X25519 公钥 // - 从 nodeinfo 中查目标节点的 X25519 公钥
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密 // - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
// - ServiceEnvelope.channel_id = "PKI"topic 也用 "PKI" // - 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 { if toNodeID == nil {
return nil, fmt.Errorf("target node id is required for pki direct message") 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 { if err != nil {
return nil, fmt.Errorf("invalid bot private key: %w", err) return nil, fmt.Errorf("invalid bot private key: %w", err)
} }
senderPublic, err := decodeBotPublicKey(*bot) senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
return nil, err return nil, err
} }
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID) topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
row := &botMessageRecord{ row := &storepkg.BotMessageRecord{
BotID: bot.ID, BotID: bot.ID,
BotNodeID: bot.NodeID, BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum, BotNodeNum: bot.NodeNum,
MessageType: botMessageTypeDirect, MessageType: storepkg.BotMessageTypeDirect,
ChannelID: mqtpp.PKIChannelID, ChannelID: mqtpp.PKIChannelID,
ToNodeID: toNodeID, ToNodeID: toNodeID,
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true), ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
@@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
Text: text, Text: text,
PayloadLen: int64(len(raw)), PayloadLen: int64(len(raw)),
Encrypted: true, Encrypted: true,
Status: botMessageStatusPending, Status: storepkg.BotMessageStatusPending,
CreatedBy: strings.TrimSpace(createdBy), CreatedBy: strings.TrimSpace(createdBy),
} }
result, err := s.persistAndPublish(row, topic, raw) 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。失败仅打日志。 // 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 { if s == nil || s.store == nil || msg == nil || bot == nil {
return return
} }
status := msg.Status status := msg.Status
if status == "" { if status == "" {
if sendErr != nil { if sendErr != nil {
status = botMessageStatusFailed status = storepkg.BotMessageStatusFailed
} else { } else {
status = botMessageStatusPublished status = storepkg.BotMessageStatusPublished
} }
} }
errText := msg.Error errText := msg.Error
@@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
botMessageID = &id botMessageID = &id
} }
now := time.Now() now := time.Now()
dm := &botDirectMessageRecord{ dm := &storepkg.BotDirectMessageRecord{
BotID: bot.ID, BotID: bot.ID,
BotNodeID: bot.NodeID, BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum, BotNodeNum: bot.NodeNum,
PeerNodeID: peerNodeID, PeerNodeID: peerNodeID,
PeerNodeNum: int64(peerNodeNum), PeerNodeNum: int64(peerNodeNum),
Direction: botDirectMessageDirectionOutbound, Direction: storepkg.BotDirectMessageDirectionOutbound,
Topic: msg.Topic, Topic: msg.Topic,
PacketID: msg.PacketID, PacketID: msg.PacketID,
Text: text, Text: text,
@@ -430,7 +431,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
} }
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。 // 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) node, err := s.store.GetNodeInfo(nodeID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -458,33 +459,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
} }
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。 // 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 { if err := s.store.InsertBotMessage(row); err != nil {
return nil, err return nil, err
} }
if s.server == nil { if s.server == nil {
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil) _ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil)
row.Status = botMessageStatusFailed row.Status = storepkg.BotMessageStatusFailed
row.Error = "mqtt server is not configured" row.Error = "mqtt server is not configured"
return row, fmt.Errorf("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 { if err := s.server.Publish(topic, raw, false, 0); err != nil {
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil) _ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil)
row.Status = botMessageStatusFailed row.Status = storepkg.BotMessageStatusFailed
row.Error = err.Error() row.Error = err.Error()
return row, err return row, err
} }
now := time.Now() 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 return nil, err
} }
row.Status = botMessageStatusPublished row.Status = storepkg.BotMessageStatusPublished
row.Error = "" row.Error = ""
row.PublishedAt = &now row.PublishedAt = &now
return row, nil return row, nil
} }
func (s *botService) runNodeInfoBroadcaster(ctx context.Context) { func (s *Service) runNodeInfoBroadcaster(ctx context.Context) {
ticker := time.NewTicker(time.Minute) ticker := time.NewTicker(time.Minute)
defer ticker.Stop() defer ticker.Stop()
s.broadcastDueNodeInfo(ctx) s.broadcastDueNodeInfo(ctx)
@@ -498,8 +499,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
} }
} }
func (s *botService) broadcastDueNodeInfo(ctx context.Context) { func (s *Service) broadcastDueNodeInfo(ctx context.Context) {
rows, err := s.store.ListBotNodes(listOptions{Limit: 500}) rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500})
if err != nil { if err != nil {
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()}) printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
return return
@@ -514,7 +515,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
} }
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
if interval <= 0 { 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 { if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
continue 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 { if s == nil || s.store == nil {
return nil, fmt.Errorf("bot service is not configured") 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 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 { if s == nil || s.server == nil {
return fmt.Errorf("mqtt server is not configured") 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) psk := strings.TrimSpace(bot.PSK)
if psk == "" { if psk == "" {
psk = botDefaultPSK psk = storepkg.BotDefaultPSK
} }
key, err := mqtpp.ExpandPSK(psk) key, err := mqtpp.ExpandPSK(psk)
if err != nil { if err != nil {
@@ -569,7 +570,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
if err != nil { if err != nil {
return err return err
} }
publicKey, err := decodeBotPublicKey(bot) publicKey, err := storepkg.DecodeBotPublicKey(bot)
if err != nil { if err != nil {
return err return err
} }
@@ -612,21 +613,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
func normalizeBotMessageType(value string) (string, error) { func normalizeBotMessageType(value string) (string, error) {
switch strings.TrimSpace(value) { switch strings.TrimSpace(value) {
case "", botMessageTypeChannel: case "", storepkg.BotMessageTypeChannel:
return botMessageTypeChannel, nil return storepkg.BotMessageTypeChannel, nil
case botMessageTypeDirect: case storepkg.BotMessageTypeDirect:
return botMessageTypeDirect, nil return storepkg.BotMessageTypeDirect, nil
default: default:
return "", fmt.Errorf("message type must be channel or direct") return "", fmt.Errorf("message type must be channel or direct")
} }
} }
func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) { func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) {
if messageType == botMessageTypeChannel { if messageType == storepkg.BotMessageTypeChannel {
return int64(mqtpp.NodeNumBroadcast), nil, nil return int64(mqtpp.NodeNumBroadcast), nil, nil
} }
if req.ToNodeNum != nil && *req.ToNodeNum > 0 { 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 return 0, nil, err
} }
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum)) nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
@@ -640,7 +641,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
if err := validateBotNodeNum(int64(nodeNum)); err != nil { if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
return 0, nil, err return 0, nil, err
} }
normalized := mqtpp.NodeNumToID(nodeNum) normalized := mqtpp.NodeNumToID(nodeNum)
@@ -650,7 +651,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
func botMQTTTopic(topicPrefix, channelID, nodeID string) string { func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/") prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
if prefix == "" { if prefix == "" {
prefix = botDefaultTopicPrefix prefix = storepkg.BotDefaultTopicPrefix
} }
if strings.HasSuffix(prefix, "/2/e") { if strings.HasSuffix(prefix, "/2/e") {
return prefix + "/" + channelID + "/" + nodeID return prefix + "/" + channelID + "/" + nodeID
+8
View File
@@ -0,0 +1,8 @@
package bot
// printJSON 是 bot service 的内部诊断输出钩子;当前为 noop,与重构前
// main.go 中的 printJSON 行为一致(注释掉了实际写出)。
// 如需调试可直接替换实现。
func printJSON(record map[string]any) {
_ = record
}
@@ -1,4 +1,4 @@
package main package llmadmin
import ( import (
"errors" "errors"
@@ -8,9 +8,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "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") group := r.Group("/llm")
{ {
// LLM Message Queue // 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) { return func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
@@ -65,7 +68,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc {
items := make([]map[string]any, 0, len(rows)) items := make([]map[string]any, 0, len(rows))
for _, row := range rows { for _, row := range rows {
items = append(items, llmMessageDTO(row)) items = append(items, storepkg.LLMMessageDTO(row))
} }
c.JSON(http.StatusOK, gin.H{ 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) { return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 { if err != nil || id == 0 {
@@ -95,11 +98,11 @@ func handleGetLLMMessage(store *store) gin.HandlerFunc {
return 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) { return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 { if err != nil || id == 0 {
@@ -118,10 +121,10 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
// 验证状态值 // 验证状态值
validStatus := map[string]bool{ validStatus := map[string]bool{
llmMessageStatusPending: true, storepkg.LLMMessageStatusPending: true,
llmMessageStatusProcessing: true, storepkg.LLMMessageStatusProcessing: true,
llmMessageStatusProcessed: true, storepkg.LLMMessageStatusProcessed: true,
llmMessageStatusError: true, storepkg.LLMMessageStatusError: true,
} }
if !validStatus[req.Status] { if !validStatus[req.Status] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"}) 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) { return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 { 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) { return func(c *gin.Context) {
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64) botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
if err != nil || botID == 0 { 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) { return func(c *gin.Context) {
var req struct { var req struct {
Days int `json:"days"` Days int `json:"days"`
@@ -206,7 +209,7 @@ func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
// LLM Provider Handlers // LLM Provider Handlers
// ============================================ // ============================================
func handleListLLMProviders(store *store) gin.HandlerFunc { func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
includeInactive := c.Query("include_inactive") == "true" 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) { return func(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
if 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) { return func(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
@@ -268,7 +271,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc {
return return
} }
record := &llmProviderRecord{ record := &storepkg.LLMProviderRecord{
Name: req.Name, Name: req.Name,
Active: req.Active, Active: req.Active,
APIKey: req.APIKey, 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) { return func(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
if 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) { return func(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
if 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{ return map[string]any{
"name": row.Name, "name": row.Name,
"active": row.Active, "active": row.Active,
@@ -386,7 +389,7 @@ func llmProviderDTO(row llmProviderRecord) map[string]any {
// LLM Tool Router Handlers // LLM Tool Router Handlers
// ============================================ // ============================================
func handleGetLLMToolRouter(store *store) gin.HandlerFunc { func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
record, err := store.GetLLMToolRouter() record, err := store.GetLLMToolRouter()
if err != nil { 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) { return func(c *gin.Context) {
record, err := store.GetLLMToolRouter() record, err := store.GetLLMToolRouter()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -446,7 +449,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
if record == nil { if record == nil {
// 创建新配置 // 创建新配置
newRecord := &llmToolRouterRecord{ newRecord := &storepkg.LLMToolRouterRecord{
Enabled: req.Enabled != nil && *req.Enabled, Enabled: req.Enabled != nil && *req.Enabled,
OpenAIName: "", OpenAIName: "",
Timeout: 30, 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{ return map[string]any{
"id": row.ID, "id": row.ID,
"enabled": row.Enabled, "enabled": row.Enabled,
@@ -504,7 +507,7 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
// LLM Primary Config Handlers // LLM Primary Config Handlers
// ============================================ // ============================================
func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc { func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
record, err := store.GetLLMPrimaryConfig() record, err := store.GetLLMPrimaryConfig()
if err != nil { 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) { return func(c *gin.Context) {
record, err := store.GetLLMPrimaryConfig() record, err := store.GetLLMPrimaryConfig()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -568,7 +571,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
if record == nil { if record == nil {
// 创建新配置 // 创建新配置
newRecord := &llmPrimaryConfigRecord{ newRecord := &storepkg.LLMPrimaryConfigRecord{
Enabled: req.Enabled != nil && *req.Enabled, Enabled: req.Enabled != nil && *req.Enabled,
ProviderName: "", ProviderName: "",
Timeout: 120, 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{ return map[string]any{
"id": row.ID, "id": row.ID,
"enabled": row.Enabled, "enabled": row.Enabled,
@@ -1,4 +1,5 @@
package main // Package mapsource 提供地图瓦片源的 admin 与公开路由。
package mapsource
import ( import (
"errors" "errors"
@@ -7,6 +8,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
) )
type mapTileSourceRequest struct { type mapTileSourceRequest struct {
@@ -19,14 +23,15 @@ type mapTileSourceRequest struct {
ProxyEnabled bool `json:"proxy_enabled"` 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) { r.GET("/map-source/default", func(c *gin.Context) {
row, err := store.GetDefaultMapTileSource() row, err := store.GetDefaultMapTileSource()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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) { r.GET("/map-source/enabled", func(c *gin.Context) {
rows, err := store.ListEnabledMapTileSources() rows, err := store.ListEnabledMapTileSources()
@@ -36,25 +41,26 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) {
} }
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, row := range rows { for _, row := range rows {
items = append(items, publicMapTileSourceDTO(row)) items = append(items, PublicDTO(row))
} }
c.JSON(http.StatusOK, gin.H{"items": items}) 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) { r.GET("/map-source", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListMapTileSources(opts) rows, err := store.ListMapTileSources(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, mapTileSourceDTO) webutil.WriteListResponse(c, rows, opts, err, AdminDTO)
return return
} }
total, err := store.CountMapTileSources(opts) 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) { r.POST("/map-source", func(c *gin.Context) {
var req mapTileSourceRequest var req mapTileSourceRequest
@@ -95,8 +101,8 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
}) })
} }
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput { func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput {
return mapTileSourceInput{ return storepkg.MapTileSourceInput{
Name: req.Name, Name: req.Name,
URLTemplate: req.URLTemplate, URLTemplate: req.URLTemplate,
Attribution: req.Attribution, Attribution: req.Attribution,
@@ -116,8 +122,8 @@ func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
return id, true return id, true
} }
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) { func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *storepkg.MapTileSourceRecord, err error) {
if errors.Is(err, errMapTileSourceAlreadyExists) { if errors.Is(err, storepkg.ErrMapTileSourceAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
return 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"}) c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -133,7 +139,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)}) c.JSON(status, gin.H{"item": AdminDTO(*row)})
} }
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { 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"}) c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return return
} }
if errors.Is(err, errMapTileSourceCannotDeleteDefault) { if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -152,16 +158,19 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) 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} 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 urlTemplate := row.URLTemplate
if row.ProxyEnabled { if row.ProxyEnabled {
hash := row.URLTemplateHash hash := row.URLTemplateHash
if hash == "" { if hash == "" {
hash = mapTileSourceHash(row.URLTemplate) hash = storepkg.MapTileSourceHash(row.URLTemplate)
} }
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}" urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
} }
@@ -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 ( import (
"errors" "errors"
@@ -8,6 +12,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
) )
type signRequest struct { type signRequest struct {
@@ -18,19 +25,20 @@ type signRequest struct {
SignTime string `json:"sign_time"` 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) { r.GET("/signs", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := webutil.ParseListOptions(c)
if !ok { if !ok {
return return
} }
rows, err := store.ListSigns(opts) rows, err := store.ListSigns(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, signDTO) webutil.WriteListResponse(c, rows, opts, err, SignDTO)
return return
} }
total, err := store.CountSigns(opts) 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) { r.POST("/signs", func(c *gin.Context) {
var req signRequest var req signRequest
@@ -42,7 +50,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) {
if !ok { if !ok {
return 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) writeSignMutationResponse(c, http.StatusCreated, row, err)
}) })
r.PUT("/signs/:id", func(c *gin.Context) { r.PUT("/signs/:id", func(c *gin.Context) {
@@ -59,7 +67,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) {
if !ok { if !ok {
return 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) writeSignMutationResponse(c, http.StatusOK, row, err)
}) })
r.DELETE("/signs/:id", func(c *gin.Context) { 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 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
return return
@@ -110,5 +118,15 @@ func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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}
} }
@@ -1,4 +1,4 @@
package main package web
import ( import (
"errors" "errors"
@@ -13,6 +13,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
) )
const ( const (
@@ -21,12 +23,12 @@ const (
) )
type mapTileProxy struct { type mapTileProxy struct {
store *store store *storepkg.Store
cacheDir string cacheDir string
client *http.Client client *http.Client
} }
func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) { func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
proxy := &mapTileProxy{ proxy := &mapTileProxy{
store: store, store: store,
cacheDir: cacheDir, cacheDir: cacheDir,
@@ -1,4 +1,4 @@
package main package web
import ( import (
"net/http" "net/http"
@@ -7,8 +7,16 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "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) { func TestMapTileProxyFetchesAndCaches(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
@@ -24,13 +32,13 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) {
})) }))
defer upstream.Close() 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 { if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err) t.Fatalf("CreateMapTileSource() error = %v", err)
} }
cacheDir := t.TempDir() 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" url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
@@ -62,12 +70,12 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() 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 { if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err) 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{ cases := []string{
"/api/map/" + row.URLTemplateHash + "?y=0&z=0", "/api/map/" + row.URLTemplateHash + "?y=0&z=0",
@@ -89,16 +97,16 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() 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 { if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err) 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 { if err != nil {
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err) 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{ cases := []string{
"/api/map/not-a-hash?x=0&y=0&z=0", "/api/map/not-a-hash?x=0&y=0&z=0",
@@ -130,16 +138,16 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) {
})) }))
defer upstream.Close() 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 { if err != nil {
t.Fatalf("CreateMapTileSource(404) error = %v", err) 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 { if err != nil {
t.Fatalf("CreateMapTileSource(500) error = %v", err) 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 { cases := []struct {
url string url string
+150
View File
@@ -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, ""
}
+72 -245
View File
@@ -1,4 +1,4 @@
package main package web
import ( import (
"errors" "errors"
@@ -12,16 +12,29 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "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{ return &http.Server{
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), 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 { if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
return err return err
} }
@@ -47,7 +60,7 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
return server.Serve(listener) 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 := gin.New()
r.Use(gin.Logger(), gin.Recovery()) r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api") api := r.Group("/api")
@@ -57,7 +70,7 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
return r 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) { r.GET("/health", func(c *gin.Context) {
status := gin.H{"status": "ok", "database": "ok"} status := gin.H{"status": "ok", "database": "ok"}
if err := store.Ping(); err != nil { 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, "/nodeinfo")
registerNodeInfoRoutes(r, store, "/nodes") registerNodeInfoRoutes(r, store, "/nodes")
registerMapReportRoutes(r, store) registerMapReportRoutes(r, store)
registerMapSourceRoutes(r, store) mappkg.RegisterPublicRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir) registerMapTileProxyRoutes(r, store, mapTileCacheDir)
registerHelpRoutes(r, store) helppkg.RegisterPublicRoutes(r, store)
r.GET("/signs", func(c *gin.Context) { r.GET("/signs", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)
if !ok { if !ok {
@@ -82,11 +95,11 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
} }
rows, err := store.ListSigns(opts) rows, err := store.ListSigns(opts)
if err != nil { if err != nil {
writeListResponse(c, rows, opts, err, signDTO) writeListResponse(c, rows, opts, err, signpkg.SignDTO)
return return
} }
total, err := store.CountSigns(opts) 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) { r.GET("/signs/daily", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)
@@ -94,7 +107,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
return return
} }
rows, err := store.CountSignsByDay(opts) 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) { r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c) 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 { type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -158,10 +171,10 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
type updatePasswordRequest struct { type updatePasswordRequest struct {
Password string `json:"password"` 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} 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} 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) { 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) { recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
remoteAddr, remoteHost := remoteInfo(c) 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) { r.POST("/login", func(c *gin.Context) {
@@ -185,7 +198,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
return return
} }
user, err := store.GetUserByUsername(req.Username) 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") recordLogin(c, req.Username, nil, false, "invalid username or password")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return return
@@ -197,7 +210,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
} }
recordLogin(c, req.Username, &user.ID, true, "success") recordLogin(c, req.Username, &user.ID, true, "success")
http.SetCookie(c.Writer, cookie) 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) { r.POST("/logout", func(c *gin.Context) {
http.SetCookie(c.Writer, sessions.ClearCookie()) http.SetCookie(c.Writer, sessions.ClearCookie())
@@ -205,26 +218,26 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
}) })
protected := r.Group("") protected := r.Group("")
protected.Use(requireAdmin(sessions)) protected.Use(auth.RequireAdmin(sessions))
registerAdminBlockingRoutes(protected, store, blocking) blockingpkg.RegisterRoutes(protected, store, blocking)
registerAdminSignRoutes(protected, store) signpkg.RegisterAdminRoutes(protected, store)
registerAdminMQTTForwardRoutes(protected, store, forwarder) mqttforwardpkg.RegisterRoutes(protected, store, forwarder)
registerAdminRuntimeSettingsRoutes(protected, store, settings) rspkg.RegisterRoutes(protected, store, settings)
registerAdminMapSourceRoutes(protected, store) mappkg.RegisterAdminRoutes(protected, store)
registerAdminHelpRoutes(protected, store) helppkg.RegisterAdminRoutes(protected, store)
registerAdminBotRoutes(protected, store, botSender) botpkg.RegisterRoutes(protected, store, botSender)
registerAdminLLMRoutes(protected, store) llmadminpkg.RegisterRoutes(protected, store)
protected.GET("/me", func(c *gin.Context) { protected.GET("/me", func(c *gin.Context) {
claims := c.MustGet("admin_claims").(*sessionClaims) claims := c.MustGet("admin_claims").(*auth.SessionClaims)
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}}) c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
}) })
protected.GET("/mqtt/status", func(c *gin.Context) { protected.GET("/mqtt/status", func(c *gin.Context) {
if mqttStatus == nil { if mqttStatus == nil {
c.JSON(http.StatusOK, adminMqttStatus{Running: false}) c.JSON(http.StatusOK, AdminMQTTStatus{Running: false})
return return
} }
status := mqttStatus.Status() status := mqttStatus.Status()
discardCount, err := store.CountDiscardDetails(listOptions{}) discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -251,7 +264,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
return return
} }
user, err := store.CreateAdminUser(req.Username, req.Password) 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"}) c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
return 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) { r.GET(path, func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)
if !ok { 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) { r.GET("/map-reports/viewport", func(c *gin.Context) {
opts, ok := parseMapReportViewportOptions(c) opts, ok := parseMapReportViewportOptions(c)
if !ok { if !ok {
@@ -431,226 +444,69 @@ func serveIndex(c *gin.Context, staticDir string) {
c.File(indexPath) c.File(indexPath)
} }
func parseListOptions(c *gin.Context) (listOptions, bool) { func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
limit, ok := parseIntQuery(c, "limit", 100) return webutil.ParseListOptions(c)
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 parseMapReportListOptions(c *gin.Context) (listOptions, bool) { func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
opts, ok := parseListOptions(c) return webutil.ParseMapReportListOptions(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 parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) { func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) {
opts, ok := parseMapReportListOptions(c) return webutil.ParseMapReportViewportOptions(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 parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) { func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
value := c.Query(name) return webutil.ParseIntQuery(c, name, defaultValue)
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
} }
func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err error, convert func(T) gin.H) { func writeListResponse[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, err error, convert func(T) gin.H) {
if err != nil { webutil.WriteListResponse(c, rows, opts, err, convert)
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 writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) { func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, total int64, err error, convert func(T) gin.H) {
if err != nil { webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert)
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 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} 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} 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 := mapReportDTO(row)
item["type"] = "point" item["type"] = "point"
return item 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} return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
} }
func signDTO(row signRecord) gin.H { func textMessageDTO(row storepkg.TextMessageRecord) 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 {
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} 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} 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} 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} 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) 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) 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} 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 { func ptrString(value *string) any { return webutil.PtrString(value) }
if value == nil { func ptrInt64(value *int64) any { return webutil.PtrInt64(value) }
return nil func ptrUint64(value *uint64) any { return webutil.PtrUint64(value) }
} func ptrFloat64(value *float64) any { return webutil.PtrFloat64(value) }
return *value func ptrBool(value *bool) any { return webutil.PtrBool(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
}
+13
View File
@@ -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)
}
+13
View File
@@ -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) }
-98
View File
@@ -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
}
+17
View File
@@ -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) }
+56
View File
@@ -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)
}