重构:拆出 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:
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,15 +12,16 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"meshtastic_mqtt_server/mqtpp"
|
||||
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/mqtpp"
|
||||
)
|
||||
|
||||
const botMaxTextBytes = 200
|
||||
|
||||
type botSendTextRequest struct {
|
||||
type SendTextRequest struct {
|
||||
BotID uint64
|
||||
MessageType string
|
||||
ChannelID string
|
||||
@@ -30,22 +31,22 @@ type botSendTextRequest struct {
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type botTextSender interface {
|
||||
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error)
|
||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error)
|
||||
type TextSender interface {
|
||||
SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
|
||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
|
||||
}
|
||||
|
||||
type botService struct {
|
||||
store *store
|
||||
type Service struct {
|
||||
store *storepkg.Store
|
||||
server *mqtt.Server
|
||||
key []byte
|
||||
}
|
||||
|
||||
func newBotService(store *store, server *mqtt.Server, key []byte) *botService {
|
||||
return &botService{store: store, server: server, key: key}
|
||||
func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
|
||||
return &Service{store: store, server: server, key: key}
|
||||
}
|
||||
|
||||
func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
if s == nil || s.store == nil || s.server == nil {
|
||||
return
|
||||
}
|
||||
@@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
// - 其它情况用原 channel + bot PSK 加密
|
||||
//
|
||||
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
|
||||
func (s *botService) MaybeAutoAck(record map[string]any) {
|
||||
func (s *Service) MaybeAutoAck(record map[string]any) {
|
||||
if s == nil || s.store == nil || s.server == nil || record == nil {
|
||||
return
|
||||
}
|
||||
@@ -110,7 +111,7 @@ func (s *botService) MaybeAutoAck(record map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
|
||||
func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
|
||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||
if privateKeyB64 == "" {
|
||||
return nil, fmt.Errorf("bot has no private key")
|
||||
@@ -119,7 +120,7 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
senderPublic, err := decodeBotPublicKey(*bot)
|
||||
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -140,14 +141,14 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
|
||||
})
|
||||
}
|
||||
|
||||
func (s *botService) buildPSKAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
|
||||
func (s *Service) buildPSKAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
|
||||
channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
|
||||
if channel == "" || channel == mqtpp.PKIChannelID {
|
||||
return nil, fmt.Errorf("no channel id available for psk ack")
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
@@ -208,7 +209,7 @@ func errString(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) {
|
||||
func (s *Service) SendText(_ context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("bot service is not configured")
|
||||
}
|
||||
@@ -244,7 +245,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
fromNodeNum := uint32(bot.NodeNum)
|
||||
|
||||
// direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径
|
||||
if messageType == botMessageTypeDirect {
|
||||
if messageType == storepkg.BotMessageTypeDirect {
|
||||
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
|
||||
}
|
||||
|
||||
@@ -257,7 +258,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
@@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
return nil, err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
|
||||
row := &botMessageRecord{
|
||||
row := &storepkg.BotMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
@@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
Text: text,
|
||||
PayloadLen: int64(len(raw)),
|
||||
Encrypted: true,
|
||||
Status: botMessageStatusPending,
|
||||
Status: storepkg.BotMessageStatusPending,
|
||||
CreatedBy: strings.TrimSpace(req.CreatedBy),
|
||||
}
|
||||
return s.persistAndPublish(row, topic, raw)
|
||||
@@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
// - 从 nodeinfo 中查目标节点的 X25519 公钥
|
||||
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
|
||||
// - ServiceEnvelope.channel_id = "PKI",topic 也用 "PKI"
|
||||
func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*botMessageRecord, error) {
|
||||
func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*storepkg.BotMessageRecord, error) {
|
||||
if toNodeID == nil {
|
||||
return nil, fmt.Errorf("target node id is required for pki direct message")
|
||||
}
|
||||
@@ -315,7 +316,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
||||
}
|
||||
senderPublic, err := decodeBotPublicKey(*bot)
|
||||
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
return nil, err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
|
||||
row := &botMessageRecord{
|
||||
row := &storepkg.BotMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
MessageType: botMessageTypeDirect,
|
||||
MessageType: storepkg.BotMessageTypeDirect,
|
||||
ChannelID: mqtpp.PKIChannelID,
|
||||
ToNodeID: toNodeID,
|
||||
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
|
||||
@@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
Text: text,
|
||||
PayloadLen: int64(len(raw)),
|
||||
Encrypted: true,
|
||||
Status: botMessageStatusPending,
|
||||
Status: storepkg.BotMessageStatusPending,
|
||||
CreatedBy: strings.TrimSpace(createdBy),
|
||||
}
|
||||
result, err := s.persistAndPublish(row, topic, raw)
|
||||
@@ -364,16 +365,16 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
}
|
||||
|
||||
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
|
||||
func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
|
||||
func (s *Service) recordOutboundDirectMessage(bot *storepkg.BotNodeRecord, msg *storepkg.BotMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
|
||||
if s == nil || s.store == nil || msg == nil || bot == nil {
|
||||
return
|
||||
}
|
||||
status := msg.Status
|
||||
if status == "" {
|
||||
if sendErr != nil {
|
||||
status = botMessageStatusFailed
|
||||
status = storepkg.BotMessageStatusFailed
|
||||
} else {
|
||||
status = botMessageStatusPublished
|
||||
status = storepkg.BotMessageStatusPublished
|
||||
}
|
||||
}
|
||||
errText := msg.Error
|
||||
@@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
||||
botMessageID = &id
|
||||
}
|
||||
now := time.Now()
|
||||
dm := &botDirectMessageRecord{
|
||||
dm := &storepkg.BotDirectMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
PeerNodeID: peerNodeID,
|
||||
PeerNodeNum: int64(peerNodeNum),
|
||||
Direction: botDirectMessageDirectionOutbound,
|
||||
Direction: storepkg.BotDirectMessageDirectionOutbound,
|
||||
Topic: msg.Topic,
|
||||
PacketID: msg.PacketID,
|
||||
Text: text,
|
||||
@@ -430,7 +431,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
||||
}
|
||||
|
||||
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
|
||||
func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
node, err := s.store.GetNodeInfo(nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -458,33 +459,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
|
||||
func (s *botService) persistAndPublish(row *botMessageRecord, topic string, raw []byte) (*botMessageRecord, error) {
|
||||
func (s *Service) persistAndPublish(row *storepkg.BotMessageRecord, topic string, raw []byte) (*storepkg.BotMessageRecord, error) {
|
||||
if err := s.store.InsertBotMessage(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.server == nil {
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil)
|
||||
row.Status = botMessageStatusFailed
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil)
|
||||
row.Status = storepkg.BotMessageStatusFailed
|
||||
row.Error = "mqtt server is not configured"
|
||||
return row, fmt.Errorf("mqtt server is not configured")
|
||||
}
|
||||
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil)
|
||||
row.Status = botMessageStatusFailed
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil)
|
||||
row.Status = storepkg.BotMessageStatusFailed
|
||||
row.Error = err.Error()
|
||||
return row, err
|
||||
}
|
||||
now := time.Now()
|
||||
if err := s.store.UpdateBotMessageStatus(row.ID, botMessageStatusPublished, "", &now); err != nil {
|
||||
if err := s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusPublished, "", &now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Status = botMessageStatusPublished
|
||||
row.Status = storepkg.BotMessageStatusPublished
|
||||
row.Error = ""
|
||||
row.PublishedAt = &now
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
func (s *Service) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
s.broadcastDueNodeInfo(ctx)
|
||||
@@ -498,8 +499,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
rows, err := s.store.ListBotNodes(listOptions{Limit: 500})
|
||||
func (s *Service) broadcastDueNodeInfo(ctx context.Context) {
|
||||
rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
|
||||
return
|
||||
@@ -514,7 +515,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
}
|
||||
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||
interval = time.Duration(storepkg.BotDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||
}
|
||||
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
|
||||
continue
|
||||
@@ -525,7 +526,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) {
|
||||
func (s *Service) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("bot service is not configured")
|
||||
}
|
||||
@@ -546,7 +547,7 @@ func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNo
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error {
|
||||
func (s *Service) PublishNodeInfo(_ context.Context, bot storepkg.BotNodeRecord) error {
|
||||
if s == nil || s.server == nil {
|
||||
return fmt.Errorf("mqtt server is not configured")
|
||||
}
|
||||
@@ -559,7 +560,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
@@ -569,7 +570,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey, err := decodeBotPublicKey(bot)
|
||||
publicKey, err := storepkg.DecodeBotPublicKey(bot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -612,21 +613,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
||||
|
||||
func normalizeBotMessageType(value string) (string, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", botMessageTypeChannel:
|
||||
return botMessageTypeChannel, nil
|
||||
case botMessageTypeDirect:
|
||||
return botMessageTypeDirect, nil
|
||||
case "", storepkg.BotMessageTypeChannel:
|
||||
return storepkg.BotMessageTypeChannel, nil
|
||||
case storepkg.BotMessageTypeDirect:
|
||||
return storepkg.BotMessageTypeDirect, nil
|
||||
default:
|
||||
return "", fmt.Errorf("message type must be channel or direct")
|
||||
}
|
||||
}
|
||||
|
||||
func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) {
|
||||
if messageType == botMessageTypeChannel {
|
||||
func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) {
|
||||
if messageType == storepkg.BotMessageTypeChannel {
|
||||
return int64(mqtpp.NodeNumBroadcast), nil, nil
|
||||
}
|
||||
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
|
||||
if err := validateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||
if err := storepkg.ValidateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
|
||||
@@ -640,7 +641,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := validateBotNodeNum(int64(nodeNum)); err != nil {
|
||||
if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
normalized := mqtpp.NodeNumToID(nodeNum)
|
||||
@@ -650,7 +651,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
||||
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
|
||||
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = botDefaultTopicPrefix
|
||||
prefix = storepkg.BotDefaultTopicPrefix
|
||||
}
|
||||
if strings.HasSuffix(prefix, "/2/e") {
|
||||
return prefix + "/" + channelID + "/" + nodeID
|
||||
@@ -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
|
||||
@@ -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
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user