重构:拆出 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 (
"errors"
@@ -7,6 +7,10 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"meshtastic_mqtt_server/internal/auth"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
)
type botNodeRequest struct {
@@ -32,19 +36,19 @@ type botSendMessageRequest struct {
Text string `json:"text"`
}
func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, sender TextSender) {
r.GET("/bot/nodes", func(c *gin.Context) {
opts, ok := parseListOptions(c)
opts, ok := webutil.ParseListOptions(c)
if !ok {
return
}
rows, err := store.ListBotNodes(opts)
if err != nil {
writeListResponse(c, rows, opts, err, botNodeDTO)
webutil.WriteListResponse(c, rows, opts, err, botNodeDTO)
return
}
total, err := store.CountBotNodes(opts)
writeListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
})
r.POST("/bot/nodes", func(c *gin.Context) {
var req botNodeRequest
@@ -125,14 +129,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
}
rows, err := store.ListBotMessages(opts)
if err != nil {
writeListResponse(c, rows, opts.ListOptions, err, botMessageDTO)
webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO)
return
}
total, err := store.CountBotMessages(opts)
writeListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO)
webutil.WriteListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO)
})
r.GET("/bot/direct-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c)
opts, ok := webutil.ParseListOptions(c)
if !ok {
return
}
@@ -153,19 +157,19 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
return
}
dmOpts := botDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
dmOpts := storepkg.BotDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
if err != nil {
writeListResponse(c, rows, opts, err, botDirectMessageDTO)
webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO)
return
}
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
})
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
// 给前端侧边栏渲染会话列表使用。
r.GET("/bot/conversations", func(c *gin.Context) {
opts, ok := parseListOptions(c)
opts, ok := webutil.ParseListOptions(c)
if !ok {
return
}
@@ -235,8 +239,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
return
}
claims := c.MustGet("admin_claims").(*sessionClaims)
row, err := sender.SendText(c.Request.Context(), botSendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
row, err := sender.SendText(c.Request.Context(), SendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
return
@@ -254,8 +258,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
})
}
func botNodeInputFromRequest(req botNodeRequest) botNodeInput {
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages}
func botNodeInputFromRequest(req botNodeRequest) storepkg.BotNodeInput {
return storepkg.BotNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages}
}
func parseBotID(c *gin.Context, message string) (uint64, bool) {
@@ -267,25 +271,25 @@ func parseBotID(c *gin.Context, message string) (uint64, bool) {
return id, true
}
func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) {
listOpts, ok := parseListOptions(c)
func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) {
listOpts, ok := webutil.ParseListOptions(c)
if !ok {
return botMessageListOptions{}, false
return storepkg.BotMessageListOptions{}, false
}
opts := botMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
opts := storepkg.BotMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
if value := c.Query("bot_id"); value != "" {
id, err := strconv.ParseUint(value, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
return botMessageListOptions{}, false
return storepkg.BotMessageListOptions{}, false
}
opts.BotID = id
}
return opts, true
}
func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) {
if errors.Is(err, errBotNodeAlreadyExists) {
func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotNodeRecord, err error) {
if errors.Is(err, storepkg.ErrBotNodeAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"})
return
}
@@ -300,15 +304,15 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord
c.JSON(status, gin.H{"item": botNodeDTO(*row)})
}
func botNodeDTO(row botNodeRecord) gin.H {
func botNodeDTO(row storepkg.BotNodeRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func botMessageDTO(row botMessageRecord) gin.H {
func botMessageDTO(row storepkg.BotMessageRecord) gin.H {
return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt}
}
func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
func botDirectMessageDTO(row storepkg.BotDirectMessageRecord) gin.H {
return gin.H{
"id": row.ID,
"bot_id": row.BotID,
@@ -335,7 +339,7 @@ func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
}
}
func botDirectConversationDTO(row botDirectConversation) gin.H {
func botDirectConversationDTO(row storepkg.BotDirectConversation) gin.H {
return gin.H{
"bot_id": row.BotID,
"peer_node_id": row.PeerNodeID,
@@ -1,14 +1,16 @@
package main
package bot
import (
"encoding/base64"
"strings"
storepkg "meshtastic_mqtt_server/internal/store"
)
// newPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者
// NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者
// 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。
// 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。
func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
if s == nil {
return nil
}
+58 -57
View File
@@ -1,4 +1,4 @@
package main
package bot
import (
"context"
@@ -12,15 +12,16 @@ import (
"time"
"unicode/utf8"
"meshtastic_mqtt_server/mqtpp"
mqtt "github.com/mochi-mqtt/server/v2"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/mqtpp"
)
const botMaxTextBytes = 200
type botSendTextRequest struct {
type SendTextRequest struct {
BotID uint64
MessageType string
ChannelID string
@@ -30,22 +31,22 @@ type botSendTextRequest struct {
CreatedBy string
}
type botTextSender interface {
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error)
PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error)
type TextSender interface {
SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
}
type botService struct {
store *store
type Service struct {
store *storepkg.Store
server *mqtt.Server
key []byte
}
func newBotService(store *store, server *mqtt.Server, key []byte) *botService {
return &botService{store: store, server: server, key: key}
func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
return &Service{store: store, server: server, key: key}
}
func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) {
if s == nil || s.store == nil || s.server == nil {
return
}
@@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
// - 其它情况用原 channel + bot PSK 加密
//
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
func (s *botService) MaybeAutoAck(record map[string]any) {
func (s *Service) MaybeAutoAck(record map[string]any) {
if s == nil || s.store == nil || s.server == nil || record == nil {
return
}
@@ -110,7 +111,7 @@ func (s *botService) MaybeAutoAck(record map[string]any) {
}
}
func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
if privateKeyB64 == "" {
return nil, fmt.Errorf("bot has no private key")
@@ -119,7 +120,7 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
if err != nil {
return nil, err
}
senderPublic, err := decodeBotPublicKey(*bot)
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
if err != nil {
return nil, err
}
@@ -140,14 +141,14 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
})
}
func (s *botService) buildPSKAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
func (s *Service) buildPSKAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
if channel == "" || channel == mqtpp.PKIChannelID {
return nil, fmt.Errorf("no channel id available for psk ack")
}
psk := strings.TrimSpace(bot.PSK)
if psk == "" {
psk = botDefaultPSK
psk = storepkg.BotDefaultPSK
}
key, err := mqtpp.ExpandPSK(psk)
if err != nil {
@@ -208,7 +209,7 @@ func errString(err error) string {
return err.Error()
}
func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) {
func (s *Service) SendText(_ context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("bot service is not configured")
}
@@ -244,7 +245,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
fromNodeNum := uint32(bot.NodeNum)
// direct 私聊走 PKIchannel 群聊保留旧的 AES-CTR + PSK 路径
if messageType == botMessageTypeDirect {
if messageType == storepkg.BotMessageTypeDirect {
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
}
@@ -257,7 +258,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
}
psk := strings.TrimSpace(bot.PSK)
if psk == "" {
psk = botDefaultPSK
psk = storepkg.BotDefaultPSK
}
key, err := mqtpp.ExpandPSK(psk)
if err != nil {
@@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
return nil, err
}
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
row := &botMessageRecord{
row := &storepkg.BotMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
@@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
Text: text,
PayloadLen: int64(len(raw)),
Encrypted: true,
Status: botMessageStatusPending,
Status: storepkg.BotMessageStatusPending,
CreatedBy: strings.TrimSpace(req.CreatedBy),
}
return s.persistAndPublish(row, topic, raw)
@@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
// - 从 nodeinfo 中查目标节点的 X25519 公钥
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
// - ServiceEnvelope.channel_id = "PKI"topic 也用 "PKI"
func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*botMessageRecord, error) {
func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*storepkg.BotMessageRecord, error) {
if toNodeID == nil {
return nil, fmt.Errorf("target node id is required for pki direct message")
}
@@ -315,7 +316,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
if err != nil {
return nil, fmt.Errorf("invalid bot private key: %w", err)
}
senderPublic, err := decodeBotPublicKey(*bot)
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
if err != nil {
return nil, err
}
@@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
return nil, err
}
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
row := &botMessageRecord{
row := &storepkg.BotMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
MessageType: botMessageTypeDirect,
MessageType: storepkg.BotMessageTypeDirect,
ChannelID: mqtpp.PKIChannelID,
ToNodeID: toNodeID,
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
@@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
Text: text,
PayloadLen: int64(len(raw)),
Encrypted: true,
Status: botMessageStatusPending,
Status: storepkg.BotMessageStatusPending,
CreatedBy: strings.TrimSpace(createdBy),
}
result, err := s.persistAndPublish(row, topic, raw)
@@ -364,16 +365,16 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
}
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
func (s *Service) recordOutboundDirectMessage(bot *storepkg.BotNodeRecord, msg *storepkg.BotMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
if s == nil || s.store == nil || msg == nil || bot == nil {
return
}
status := msg.Status
if status == "" {
if sendErr != nil {
status = botMessageStatusFailed
status = storepkg.BotMessageStatusFailed
} else {
status = botMessageStatusPublished
status = storepkg.BotMessageStatusPublished
}
}
errText := msg.Error
@@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
botMessageID = &id
}
now := time.Now()
dm := &botDirectMessageRecord{
dm := &storepkg.BotDirectMessageRecord{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
PeerNodeID: peerNodeID,
PeerNodeNum: int64(peerNodeNum),
Direction: botDirectMessageDirectionOutbound,
Direction: storepkg.BotDirectMessageDirectionOutbound,
Topic: msg.Topic,
PacketID: msg.PacketID,
Text: text,
@@ -430,7 +431,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
}
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
node, err := s.store.GetNodeInfo(nodeID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -458,33 +459,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
}
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
func (s *botService) persistAndPublish(row *botMessageRecord, topic string, raw []byte) (*botMessageRecord, error) {
func (s *Service) persistAndPublish(row *storepkg.BotMessageRecord, topic string, raw []byte) (*storepkg.BotMessageRecord, error) {
if err := s.store.InsertBotMessage(row); err != nil {
return nil, err
}
if s.server == nil {
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil)
row.Status = botMessageStatusFailed
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil)
row.Status = storepkg.BotMessageStatusFailed
row.Error = "mqtt server is not configured"
return row, fmt.Errorf("mqtt server is not configured")
}
if err := s.server.Publish(topic, raw, false, 0); err != nil {
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil)
row.Status = botMessageStatusFailed
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil)
row.Status = storepkg.BotMessageStatusFailed
row.Error = err.Error()
return row, err
}
now := time.Now()
if err := s.store.UpdateBotMessageStatus(row.ID, botMessageStatusPublished, "", &now); err != nil {
if err := s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusPublished, "", &now); err != nil {
return nil, err
}
row.Status = botMessageStatusPublished
row.Status = storepkg.BotMessageStatusPublished
row.Error = ""
row.PublishedAt = &now
return row, nil
}
func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
func (s *Service) runNodeInfoBroadcaster(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
s.broadcastDueNodeInfo(ctx)
@@ -498,8 +499,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
}
}
func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
rows, err := s.store.ListBotNodes(listOptions{Limit: 500})
func (s *Service) broadcastDueNodeInfo(ctx context.Context) {
rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500})
if err != nil {
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
return
@@ -514,7 +515,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
}
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
if interval <= 0 {
interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second
interval = time.Duration(storepkg.BotDefaultNodeInfoBroadcastSeconds) * time.Second
}
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
continue
@@ -525,7 +526,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
}
}
func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) {
func (s *Service) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("bot service is not configured")
}
@@ -546,7 +547,7 @@ func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNo
return updated, nil
}
func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error {
func (s *Service) PublishNodeInfo(_ context.Context, bot storepkg.BotNodeRecord) error {
if s == nil || s.server == nil {
return fmt.Errorf("mqtt server is not configured")
}
@@ -559,7 +560,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
}
psk := strings.TrimSpace(bot.PSK)
if psk == "" {
psk = botDefaultPSK
psk = storepkg.BotDefaultPSK
}
key, err := mqtpp.ExpandPSK(psk)
if err != nil {
@@ -569,7 +570,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
if err != nil {
return err
}
publicKey, err := decodeBotPublicKey(bot)
publicKey, err := storepkg.DecodeBotPublicKey(bot)
if err != nil {
return err
}
@@ -612,21 +613,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
func normalizeBotMessageType(value string) (string, error) {
switch strings.TrimSpace(value) {
case "", botMessageTypeChannel:
return botMessageTypeChannel, nil
case botMessageTypeDirect:
return botMessageTypeDirect, nil
case "", storepkg.BotMessageTypeChannel:
return storepkg.BotMessageTypeChannel, nil
case storepkg.BotMessageTypeDirect:
return storepkg.BotMessageTypeDirect, nil
default:
return "", fmt.Errorf("message type must be channel or direct")
}
}
func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) {
if messageType == botMessageTypeChannel {
func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) {
if messageType == storepkg.BotMessageTypeChannel {
return int64(mqtpp.NodeNumBroadcast), nil, nil
}
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
if err := validateBotNodeNum(*req.ToNodeNum); err != nil {
if err := storepkg.ValidateBotNodeNum(*req.ToNodeNum); err != nil {
return 0, nil, err
}
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
@@ -640,7 +641,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
if err != nil {
return 0, nil, err
}
if err := validateBotNodeNum(int64(nodeNum)); err != nil {
if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
return 0, nil, err
}
normalized := mqtpp.NodeNumToID(nodeNum)
@@ -650,7 +651,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
if prefix == "" {
prefix = botDefaultTopicPrefix
prefix = storepkg.BotDefaultTopicPrefix
}
if strings.HasSuffix(prefix, "/2/e") {
return prefix + "/" + channelID + "/" + nodeID
+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 (
"errors"
@@ -8,9 +8,12 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
)
func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
group := r.Group("/llm")
{
// LLM Message Queue
@@ -38,9 +41,9 @@ func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
}
}
func handleListLLMMessages(store *store) gin.HandlerFunc {
func handleListLLMMessages(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
opts, ok := parseListOptions(c)
opts, ok := webutil.ParseListOptions(c)
if !ok {
return
}
@@ -65,7 +68,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc {
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
items = append(items, llmMessageDTO(row))
items = append(items, storepkg.LLMMessageDTO(row))
}
c.JSON(http.StatusOK, gin.H{
@@ -77,7 +80,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc {
}
}
func handleGetLLMMessage(store *store) gin.HandlerFunc {
func handleGetLLMMessage(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -95,11 +98,11 @@ func handleGetLLMMessage(store *store) gin.HandlerFunc {
return
}
c.JSON(http.StatusOK, gin.H{"item": llmMessageDTO(*record)})
c.JSON(http.StatusOK, gin.H{"item": storepkg.LLMMessageDTO(*record)})
}
}
func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
func handleUpdateLLMMessageStatus(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -118,10 +121,10 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
// 验证状态值
validStatus := map[string]bool{
llmMessageStatusPending: true,
llmMessageStatusProcessing: true,
llmMessageStatusProcessed: true,
llmMessageStatusError: true,
storepkg.LLMMessageStatusPending: true,
storepkg.LLMMessageStatusProcessing: true,
storepkg.LLMMessageStatusProcessed: true,
storepkg.LLMMessageStatusError: true,
}
if !validStatus[req.Status] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"})
@@ -141,7 +144,7 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
}
}
func handleDeleteLLMMessage(store *store) gin.HandlerFunc {
func handleDeleteLLMMessage(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
@@ -162,7 +165,7 @@ func handleDeleteLLMMessage(store *store) gin.HandlerFunc {
}
}
func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc {
func handleDeleteLLMMessagesByBot(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
if err != nil || botID == 0 {
@@ -179,7 +182,7 @@ func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc {
}
}
func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
func handleCleanupDeletedLLMMessages(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Days int `json:"days"`
@@ -206,7 +209,7 @@ func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
// LLM Provider Handlers
// ============================================
func handleListLLMProviders(store *store) gin.HandlerFunc {
func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
includeInactive := c.Query("include_inactive") == "true"
@@ -225,7 +228,7 @@ func handleListLLMProviders(store *store) gin.HandlerFunc {
}
}
func handleGetLLMProvider(store *store) gin.HandlerFunc {
func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if name == "" {
@@ -247,7 +250,7 @@ func handleGetLLMProvider(store *store) gin.HandlerFunc {
}
}
func handleCreateLLMProvider(store *store) gin.HandlerFunc {
func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name string `json:"name"`
@@ -268,7 +271,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc {
return
}
record := &llmProviderRecord{
record := &storepkg.LLMProviderRecord{
Name: req.Name,
Active: req.Active,
APIKey: req.APIKey,
@@ -287,7 +290,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc {
}
}
func handleUpdateLLMProvider(store *store) gin.HandlerFunc {
func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if name == "" {
@@ -352,7 +355,7 @@ func handleUpdateLLMProvider(store *store) gin.HandlerFunc {
}
}
func handleDeleteLLMProvider(store *store) gin.HandlerFunc {
func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if name == "" {
@@ -369,7 +372,7 @@ func handleDeleteLLMProvider(store *store) gin.HandlerFunc {
}
}
func llmProviderDTO(row llmProviderRecord) map[string]any {
func llmProviderDTO(row storepkg.LLMProviderRecord) map[string]any {
return map[string]any{
"name": row.Name,
"active": row.Active,
@@ -386,7 +389,7 @@ func llmProviderDTO(row llmProviderRecord) map[string]any {
// LLM Tool Router Handlers
// ============================================
func handleGetLLMToolRouter(store *store) gin.HandlerFunc {
func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
record, err := store.GetLLMToolRouter()
if err != nil {
@@ -402,7 +405,7 @@ func handleGetLLMToolRouter(store *store) gin.HandlerFunc {
}
}
func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
func handleUpdateLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
record, err := store.GetLLMToolRouter()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -446,7 +449,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
if record == nil {
// 创建新配置
newRecord := &llmToolRouterRecord{
newRecord := &storepkg.LLMToolRouterRecord{
Enabled: req.Enabled != nil && *req.Enabled,
OpenAIName: "",
Timeout: 30,
@@ -487,7 +490,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
}
}
func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any {
return map[string]any{
"id": row.ID,
"enabled": row.Enabled,
@@ -504,7 +507,7 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
// LLM Primary Config Handlers
// ============================================
func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc {
func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
record, err := store.GetLLMPrimaryConfig()
if err != nil {
@@ -520,7 +523,7 @@ func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc {
}
}
func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
func handleUpdateLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
return func(c *gin.Context) {
record, err := store.GetLLMPrimaryConfig()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -568,7 +571,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
if record == nil {
// 创建新配置
newRecord := &llmPrimaryConfigRecord{
newRecord := &storepkg.LLMPrimaryConfigRecord{
Enabled: req.Enabled != nil && *req.Enabled,
ProviderName: "",
Timeout: 120,
@@ -613,7 +616,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
}
}
func llmPrimaryConfigDTO(row llmPrimaryConfigRecord) map[string]any {
func llmPrimaryConfigDTO(row storepkg.LLMPrimaryConfigRecord) map[string]any {
return map[string]any{
"id": row.ID,
"enabled": row.Enabled,
@@ -1,4 +1,5 @@
package main
// Package mapsource 提供地图瓦片源的 admin 与公开路由。
package mapsource
import (
"errors"
@@ -7,6 +8,9 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
)
type mapTileSourceRequest struct {
@@ -19,14 +23,15 @@ type mapTileSourceRequest struct {
ProxyEnabled bool `json:"proxy_enabled"`
}
func registerMapSourceRoutes(r gin.IRouter, store *store) {
// RegisterPublicRoutes 把对外可见的 GET /map-source/{default,enabled} 挂上去。
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/map-source/default", func(c *gin.Context) {
row, err := store.GetDefaultMapTileSource()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
c.JSON(http.StatusOK, gin.H{"item": PublicDTO(*row)})
})
r.GET("/map-source/enabled", func(c *gin.Context) {
rows, err := store.ListEnabledMapTileSources()
@@ -36,25 +41,26 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) {
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, publicMapTileSourceDTO(row))
items = append(items, PublicDTO(row))
}
c.JSON(http.StatusOK, gin.H{"items": items})
})
}
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
// RegisterAdminRoutes 注册管理员侧 CRUD 与设默认。
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/map-source", func(c *gin.Context) {
opts, ok := parseListOptions(c)
opts, ok := webutil.ParseListOptions(c)
if !ok {
return
}
rows, err := store.ListMapTileSources(opts)
if err != nil {
writeListResponse(c, rows, opts, err, mapTileSourceDTO)
webutil.WriteListResponse(c, rows, opts, err, AdminDTO)
return
}
total, err := store.CountMapTileSources(opts)
writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO)
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, AdminDTO)
})
r.POST("/map-source", func(c *gin.Context) {
var req mapTileSourceRequest
@@ -95,8 +101,8 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
})
}
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput {
return mapTileSourceInput{
func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput {
return storepkg.MapTileSourceInput{
Name: req.Name,
URLTemplate: req.URLTemplate,
Attribution: req.Attribution,
@@ -116,8 +122,8 @@ func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
return id, true
}
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) {
if errors.Is(err, errMapTileSourceAlreadyExists) {
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *storepkg.MapTileSourceRecord, err error) {
if errors.Is(err, storepkg.ErrMapTileSourceAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
return
}
@@ -125,7 +131,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) {
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) || errors.Is(err, storepkg.ErrMapTileSourceCannotDisableDefault) || errors.Is(err, storepkg.ErrMapTileSourceDefaultMustBeEnabled) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -133,7 +139,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)})
c.JSON(status, gin.H{"item": AdminDTO(*row)})
}
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
@@ -141,7 +147,7 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if errors.Is(err, errMapTileSourceCannotDeleteDefault) {
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -152,16 +158,19 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
// AdminDTO 是管理后台展示的全字段视图。
func AdminDTO(row storepkg.MapTileSourceRecord) gin.H {
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H {
// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为
// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
urlTemplate := row.URLTemplate
if row.ProxyEnabled {
hash := row.URLTemplateHash
if hash == "" {
hash = mapTileSourceHash(row.URLTemplate)
hash = storepkg.MapTileSourceHash(row.URLTemplate)
}
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
}
@@ -1,4 +1,8 @@
package main
// Package sign 提供签到记录的 admin 路由与对应的 DTO/列表查询。
//
// 拆离自原来 main 包的 admin_sign_routes.go 与 web.go 中的 signDTO /
// signDayCountDTO;其它 admin 路由也通过 SignDTO / SignDayCountDTO 复用。
package sign
import (
"errors"
@@ -8,6 +12,9 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
)
type signRequest struct {
@@ -18,19 +25,20 @@ type signRequest struct {
SignTime string `json:"sign_time"`
}
func registerAdminSignRoutes(r gin.IRouter, store *store) {
// RegisterAdminRoutes 在 admin 路由组下挂 sign CRUD 端点。
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/signs", func(c *gin.Context) {
opts, ok := parseListOptions(c)
opts, ok := webutil.ParseListOptions(c)
if !ok {
return
}
rows, err := store.ListSigns(opts)
if err != nil {
writeListResponse(c, rows, opts, err, signDTO)
webutil.WriteListResponse(c, rows, opts, err, SignDTO)
return
}
total, err := store.CountSigns(opts)
writeListResponseWithTotal(c, rows, opts, total, err, signDTO)
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, SignDTO)
})
r.POST("/signs", func(c *gin.Context) {
var req signRequest
@@ -42,7 +50,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) {
if !ok {
return
}
row, err := store.CreateSign(req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime)
row, err := store.CreateSign(req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime)
writeSignMutationResponse(c, http.StatusCreated, row, err)
})
r.PUT("/signs/:id", func(c *gin.Context) {
@@ -59,7 +67,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) {
if !ok {
return
}
row, err := store.UpdateSign(id, req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime)
row, err := store.UpdateSign(id, req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime)
writeSignMutationResponse(c, http.StatusOK, row, err)
})
r.DELETE("/signs/:id", func(c *gin.Context) {
@@ -101,7 +109,7 @@ func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) {
return parsed, true
}
func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err error) {
func writeSignMutationResponse(c *gin.Context, status int, row *storepkg.SignRecord, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
return
@@ -110,5 +118,15 @@ func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(status, gin.H{"item": signDTO(*row)})
c.JSON(status, gin.H{"item": SignDTO(*row)})
}
// SignDTO 把 SignRecord 转成给前端的视图。
func SignDTO(row storepkg.SignRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": webutil.PtrString(row.LongName), "short_name": webutil.PtrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime}
}
// SignDayCountDTO 把按日聚合的签到数量转成视图。
func SignDayCountDTO(row storepkg.SignDayCount) gin.H {
return gin.H{"date": row.Date, "count": row.Count}
}
@@ -1,4 +1,4 @@
package main
package web
import (
"errors"
@@ -13,6 +13,8 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
)
const (
@@ -21,12 +23,12 @@ const (
)
type mapTileProxy struct {
store *store
store *storepkg.Store
cacheDir string
client *http.Client
}
func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) {
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
proxy := &mapTileProxy{
store: store,
cacheDir: cacheDir,
@@ -1,4 +1,4 @@
package main
package web
import (
"net/http"
@@ -7,8 +7,16 @@ import (
"path/filepath"
"strings"
"testing"
configpkg "meshtastic_mqtt_server/internal/config"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/store/testutil"
)
func openTestStore(t *testing.T) *storepkg.Store {
return testutil.OpenStore(t)
}
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
st := openTestStore(t)
defer st.Close()
@@ -24,13 +32,13 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) {
}))
defer upstream.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
cacheDir := t.TempDir()
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil)
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil)
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
for i := 0; i < 2; i++ {
@@ -62,12 +70,12 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
@@ -89,16 +97,16 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
st := openTestStore(t)
defer st.Close()
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
disabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
}
proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
proxyDisabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/not-a-hash?x=0&y=0&z=0",
@@ -130,16 +138,16 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) {
}))
defer upstream.Close()
row404, err := st.CreateMapTileSource(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
row404, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(404) error = %v", err)
}
row500, err := st.CreateMapTileSource(mapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
row500, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(500) error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
cases := []struct {
url string
+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 (
"errors"
@@ -12,16 +12,29 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"meshtastic_mqtt_server/internal/auth"
blockingpkg "meshtastic_mqtt_server/internal/blocking"
botpkg "meshtastic_mqtt_server/internal/bot"
configpkg "meshtastic_mqtt_server/internal/config"
helppkg "meshtastic_mqtt_server/internal/help"
llmadminpkg "meshtastic_mqtt_server/internal/llmadmin"
mappkg "meshtastic_mqtt_server/internal/mapsource"
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
signpkg "meshtastic_mqtt_server/internal/sign"
storepkg "meshtastic_mqtt_server/internal/store"
"meshtastic_mqtt_server/internal/webutil"
)
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *http.Server {
func NewHTTPServer(cfg configpkg.WebConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *http.Server {
return &http.Server{
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
Handler: NewRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
}
}
func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
func ServeUnixSocket(server *http.Server, socketPath string) error {
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
return err
}
@@ -47,7 +60,7 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
return server.Serve(listener)
}
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine {
func NewRouter(cfg configpkg.WebConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api")
@@ -57,7 +70,7 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
return r
}
func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir string) {
r.GET("/health", func(c *gin.Context) {
status := gin.H{"status": "ok", "database": "ok"}
if err := store.Ping(); err != nil {
@@ -72,9 +85,9 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
registerNodeInfoRoutes(r, store, "/nodeinfo")
registerNodeInfoRoutes(r, store, "/nodes")
registerMapReportRoutes(r, store)
registerMapSourceRoutes(r, store)
mappkg.RegisterPublicRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
registerHelpRoutes(r, store)
helppkg.RegisterPublicRoutes(r, store)
r.GET("/signs", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
@@ -82,11 +95,11 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
}
rows, err := store.ListSigns(opts)
if err != nil {
writeListResponse(c, rows, opts, err, signDTO)
writeListResponse(c, rows, opts, err, signpkg.SignDTO)
return
}
total, err := store.CountSigns(opts)
writeListResponseWithTotal(c, rows, opts, total, err, signDTO)
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
})
r.GET("/signs/daily", func(c *gin.Context) {
opts, ok := parseListOptions(c)
@@ -94,7 +107,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
return
}
rows, err := store.CountSignsByDay(opts)
writeListResponse(c, rows, opts, err, signDayCountDTO)
writeListResponse(c, rows, opts, err, signpkg.SignDayCountDTO)
})
r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c)
@@ -146,7 +159,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
})
}
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) {
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -158,10 +171,10 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
type updatePasswordRequest struct {
Password string `json:"password"`
}
userDTO := func(user userRecord) gin.H {
userDTO := func(user storepkg.UserRecord) gin.H {
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
}
loginLogDTO := func(row loginLogRecord) gin.H {
loginLogDTO := func(row storepkg.LoginLogRecord) gin.H {
return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt}
}
remoteInfo := func(c *gin.Context) (string, string) {
@@ -174,7 +187,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
}
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
remoteAddr, remoteHost := remoteInfo(c)
_ = store.InsertLoginLog(loginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
_ = store.InsertLoginLog(storepkg.LoginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
}
r.POST("/login", func(c *gin.Context) {
@@ -185,7 +198,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
return
}
user, err := store.GetUserByUsername(req.Username)
if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) {
if err != nil || user.Role != auth.AdminRole || !auth.VerifyPassword(user.PasswordHash, req.Password) {
recordLogin(c, req.Username, nil, false, "invalid username or password")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
@@ -197,7 +210,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
}
recordLogin(c, req.Username, &user.ID, true, "success")
http.SetCookie(c.Writer, cookie)
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserResponse(*user)})
})
r.POST("/logout", func(c *gin.Context) {
http.SetCookie(c.Writer, sessions.ClearCookie())
@@ -205,26 +218,26 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
})
protected := r.Group("")
protected.Use(requireAdmin(sessions))
registerAdminBlockingRoutes(protected, store, blocking)
registerAdminSignRoutes(protected, store)
registerAdminMQTTForwardRoutes(protected, store, forwarder)
registerAdminRuntimeSettingsRoutes(protected, store, settings)
registerAdminMapSourceRoutes(protected, store)
registerAdminHelpRoutes(protected, store)
registerAdminBotRoutes(protected, store, botSender)
registerAdminLLMRoutes(protected, store)
protected.Use(auth.RequireAdmin(sessions))
blockingpkg.RegisterRoutes(protected, store, blocking)
signpkg.RegisterAdminRoutes(protected, store)
mqttforwardpkg.RegisterRoutes(protected, store, forwarder)
rspkg.RegisterRoutes(protected, store, settings)
mappkg.RegisterAdminRoutes(protected, store)
helppkg.RegisterAdminRoutes(protected, store)
botpkg.RegisterRoutes(protected, store, botSender)
llmadminpkg.RegisterRoutes(protected, store)
protected.GET("/me", func(c *gin.Context) {
claims := c.MustGet("admin_claims").(*sessionClaims)
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
})
protected.GET("/mqtt/status", func(c *gin.Context) {
if mqttStatus == nil {
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
c.JSON(http.StatusOK, AdminMQTTStatus{Running: false})
return
}
status := mqttStatus.Status()
discardCount, err := store.CountDiscardDetails(listOptions{})
discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -251,7 +264,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
return
}
user, err := store.CreateAdminUser(req.Username, req.Password)
if errors.Is(err, errUserAlreadyExists) {
if errors.Is(err, storepkg.ErrUserAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
return
}
@@ -323,7 +336,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
})
}
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
r.GET(path, func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
@@ -351,7 +364,7 @@ func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
})
}
func registerMapReportRoutes(r gin.IRouter, store *store) {
func registerMapReportRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/map-reports/viewport", func(c *gin.Context) {
opts, ok := parseMapReportViewportOptions(c)
if !ok {
@@ -431,226 +444,69 @@ func serveIndex(c *gin.Context, staticDir string) {
c.File(indexPath)
}
func parseListOptions(c *gin.Context) (listOptions, bool) {
limit, ok := parseIntQuery(c, "limit", 100)
if !ok {
return listOptions{}, false
}
offset, ok := parseIntQuery(c, "offset", 0)
if !ok {
return listOptions{}, false
}
nodeID := c.Query("node_id")
if nodeID == "" {
nodeID = c.Query("from")
}
channelID := c.Query("channel_id")
var since, until *time.Time
if value := c.Query("since"); value != "" {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"})
return listOptions{}, false
}
since = &parsed
}
if value := c.Query("until"); value != "" {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"})
return listOptions{}, false
}
until = &parsed
}
return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true
func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
return webutil.ParseListOptions(c)
}
func parseMapReportListOptions(c *gin.Context) (listOptions, bool) {
opts, ok := parseListOptions(c)
if !ok {
return listOptions{}, false
}
minLat, hasMinLat, ok := parseOptionalFloatQuery(c, "min_lat")
if !ok {
return listOptions{}, false
}
maxLat, hasMaxLat, ok := parseOptionalFloatQuery(c, "max_lat")
if !ok {
return listOptions{}, false
}
minLng, hasMinLng, ok := parseOptionalFloatQuery(c, "min_lng")
if !ok {
return listOptions{}, false
}
maxLng, hasMaxLng, ok := parseOptionalFloatQuery(c, "max_lng")
if !ok {
return listOptions{}, false
}
boundsCount := 0
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
if present {
boundsCount++
}
}
if boundsCount == 0 {
return opts, true
}
if boundsCount != 4 {
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
return listOptions{}, false
}
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
return listOptions{}, false
}
if minLat > maxLat {
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
return listOptions{}, false
}
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
return listOptions{}, false
}
opts.MinLat = &minLat
opts.MaxLat = &maxLat
opts.MinLng = &minLng
opts.MaxLng = &maxLng
return opts, true
func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
return webutil.ParseMapReportListOptions(c)
}
func parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) {
opts, ok := parseMapReportListOptions(c)
if !ok {
return mapReportViewportOptions{}, false
}
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
return mapReportViewportOptions{}, false
}
zoom, ok := parseIntQuery(c, "zoom", 0)
if !ok {
return mapReportViewportOptions{}, false
}
if zoom < 0 || zoom > 24 {
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
return mapReportViewportOptions{}, false
}
limit, ok := parseIntQuery(c, "limit", 1000)
if !ok {
return mapReportViewportOptions{}, false
}
clusterThreshold, ok := parseIntQuery(c, "cluster_threshold", 500)
if !ok {
return mapReportViewportOptions{}, false
}
targetCells, ok := parseIntQuery(c, "target_cells", 64)
if !ok {
return mapReportViewportOptions{}, false
}
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
return mapReportViewportOptions{}, false
}
return normalizeMapReportViewportOptions(mapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
}
func parseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
value := c.Query(name)
if value == "" {
return 0, false, true
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
return 0, true, false
}
return parsed, true, true
func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) {
return webutil.ParseMapReportViewportOptions(c)
}
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
value := c.Query(name)
if value == "" {
return defaultValue, true
}
parsed, err := strconv.Atoi(value)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
return 0, false
}
return parsed, true
return webutil.ParseIntQuery(c, name, defaultValue)
}
func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err error, convert func(T) gin.H) {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, convert(row))
}
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
func writeListResponse[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, err error, convert func(T) gin.H) {
webutil.WriteListResponse(c, rows, opts, err, convert)
}
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, convert(row))
}
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, total int64, err error, convert func(T) gin.H) {
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert)
}
func nodeInfoDTO(row nodeInfoRecord) gin.H {
func nodeInfoDTO(row storepkg.NodeInfoRecord) gin.H {
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "user_id": ptrString(row.UserID), "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "is_licensed": ptrBool(row.IsLicensed), "public_key": ptrString(row.PublicKey), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
}
func mapReportDTO(row mapReportRecord) gin.H {
func mapReportDTO(row storepkg.MapReportRecord) gin.H {
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
}
func mapReportViewportPointDTO(row mapReportRecord) gin.H {
func mapReportViewportPointDTO(row storepkg.MapReportRecord) gin.H {
item := mapReportDTO(row)
item["type"] = "point"
return item
}
func mapReportClusterDTO(row mapReportClusterRecord) gin.H {
func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H {
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
}
func signDTO(row signRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime}
}
func signDayCountDTO(row signDayCount) gin.H {
return gin.H{"date": row.Date, "count": row.Count}
}
func textMessageDTO(row textMessageRecord) gin.H {
func textMessageDTO(row storepkg.TextMessageRecord) gin.H {
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
}
func discardDetailsDTO(row discardDetailsRecord) gin.H {
func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
}
func positionDTO(row positionRecord) gin.H {
func positionDTO(row storepkg.PositionRecord) gin.H {
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
}
func telemetryDTO(row telemetryRecord) gin.H {
func telemetryDTO(row storepkg.TelemetryRecord) gin.H {
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "telemetry_type": ptrString(row.TelemetryType), "metrics_json": ptrString(row.MetricsJSON), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
}
func routingDTO(row routingRecord) gin.H {
func routingDTO(row storepkg.RoutingRecord) gin.H {
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
}
func tracerouteDTO(row tracerouteRecord) gin.H {
func tracerouteDTO(row storepkg.TracerouteRecord) gin.H {
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
}
@@ -658,37 +514,8 @@ func appendPacketDTO(id uint64, fromID string, fromNum int64, packetID *int64, p
return gin.H{"id": id, "from_id": fromID, "from_num": fromNum, "packet_id": ptrInt64(packetID), "portnum": ptrString(portnum), "created_at": createdAt, "content_json": contentJSON}
}
func ptrString(value *string) any {
if value == nil {
return nil
}
return *value
}
func ptrInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func ptrUint64(value *uint64) any {
if value == nil {
return nil
}
return *value
}
func ptrFloat64(value *float64) any {
if value == nil {
return nil
}
return *value
}
func ptrBool(value *bool) any {
if value == nil {
return nil
}
return *value
}
func ptrString(value *string) any { return webutil.PtrString(value) }
func ptrInt64(value *int64) any { return webutil.PtrInt64(value) }
func ptrUint64(value *uint64) any { return webutil.PtrUint64(value) }
func ptrFloat64(value *float64) any { return webutil.PtrFloat64(value) }
func ptrBool(value *bool) any { return webutil.PtrBool(value) }
+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)
}