重构:拆出 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:
@@ -0,0 +1,353 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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 {
|
||||
NodeNum *int64 `json:"node_num"`
|
||||
LongName string `json:"long_name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultChannelID string `json:"default_channel_id"`
|
||||
TopicPrefix string `json:"topic_prefix"`
|
||||
PSK string `json:"psk"`
|
||||
NodeInfoBroadcastEnabled bool `json:"nodeinfo_broadcast_enabled"`
|
||||
NodeInfoBroadcastIntervalSeconds int64 `json:"nodeinfo_broadcast_interval_seconds"`
|
||||
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
||||
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
||||
}
|
||||
|
||||
type botSendMessageRequest struct {
|
||||
BotID uint64 `json:"bot_id"`
|
||||
MessageType string `json:"message_type"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
ToNodeID string `json:"to_node_id"`
|
||||
ToNodeNum *int64 `json:"to_node_num"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, sender TextSender) {
|
||||
r.GET("/bot/nodes", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListBotNodes(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, botNodeDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountBotNodes(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
|
||||
})
|
||||
r.POST("/bot/nodes", func(c *gin.Context) {
|
||||
var req botNodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot node request"})
|
||||
return
|
||||
}
|
||||
row, err := store.CreateBotNode(botNodeInputFromRequest(req))
|
||||
writeBotNodeMutationResponse(c, http.StatusCreated, row, err)
|
||||
})
|
||||
r.PUT("/bot/nodes/:id", func(c *gin.Context) {
|
||||
id, ok := parseBotID(c, "invalid bot node id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req botNodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot node request"})
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateBotNode(id, botNodeInputFromRequest(req))
|
||||
writeBotNodeMutationResponse(c, http.StatusOK, row, err)
|
||||
})
|
||||
r.DELETE("/bot/nodes/:id", func(c *gin.Context) {
|
||||
id, ok := parseBotID(c, "invalid bot node id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := store.DeleteBotNode(id); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
r.POST("/bot/nodes/:id/keys/regenerate", func(c *gin.Context) {
|
||||
id, ok := parseBotID(c, "invalid bot node id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := store.RegenerateBotNodeKeys(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": botNodeDTO(*row)})
|
||||
})
|
||||
r.POST("/bot/nodes/:id/nodeinfo", func(c *gin.Context) {
|
||||
if sender == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"})
|
||||
return
|
||||
}
|
||||
id, ok := parseBotID(c, "invalid bot node id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := sender.PublishNodeInfoByID(c.Request.Context(), id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": botNodeDTO(*row)})
|
||||
})
|
||||
r.GET("/bot/messages", func(c *gin.Context) {
|
||||
opts, ok := parseBotMessageListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListBotMessages(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountBotMessages(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO)
|
||||
})
|
||||
r.GET("/bot/direct-messages", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
botID, err := strconv.ParseUint(c.Query("bot_id"), 10, 64)
|
||||
if err != nil || botID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||
return
|
||||
}
|
||||
if _, err := store.GetBotNode(botID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target, err := strconv.ParseInt(c.Query("target_node_num"), 10, 64)
|
||||
if err != nil || target <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
|
||||
return
|
||||
}
|
||||
dmOpts := storepkg.BotDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
|
||||
rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
||||
})
|
||||
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
|
||||
// 给前端侧边栏渲染会话列表使用。
|
||||
r.GET("/bot/conversations", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
botID, err := strconv.ParseUint(c.Query("bot_id"), 10, 64)
|
||||
if err != nil || botID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||
return
|
||||
}
|
||||
if _, err := store.GetBotNode(botID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rows, err := store.ListBotDirectConversations(botID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
unread, err := store.CountBotDirectUnread(botID)
|
||||
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, botDirectConversationDTO(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "unread_total": unread})
|
||||
})
|
||||
// /bot/direct-messages/read 把指定 (bot, peer) 下所有未读 inbound 消息标记为已读。
|
||||
r.POST("/bot/direct-messages/read", func(c *gin.Context) {
|
||||
var req struct {
|
||||
BotID uint64 `json:"bot_id"`
|
||||
PeerNodeNum int64 `json:"peer_node_num"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mark-read request"})
|
||||
return
|
||||
}
|
||||
if req.BotID == 0 || req.PeerNodeNum == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_id and peer_node_num are required"})
|
||||
return
|
||||
}
|
||||
if _, err := store.GetBotNode(req.BotID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := store.MarkBotDirectMessagesRead(req.BotID, req.PeerNodeNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
})
|
||||
r.POST("/bot/messages", func(c *gin.Context) {
|
||||
if sender == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"})
|
||||
return
|
||||
}
|
||||
var req botSendMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if row != nil && row.ID != 0 {
|
||||
c.JSON(http.StatusAccepted, gin.H{"item": botMessageDTO(*row), "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"item": botMessageDTO(*row)})
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": message})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) {
|
||||
listOpts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return storepkg.BotMessageListOptions{}, false
|
||||
}
|
||||
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 storepkg.BotMessageListOptions{}, false
|
||||
}
|
||||
opts.BotID = id
|
||||
}
|
||||
return opts, true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"item": botNodeDTO(*row)})
|
||||
}
|
||||
|
||||
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 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 storepkg.BotDirectMessageRecord) gin.H {
|
||||
return gin.H{
|
||||
"id": row.ID,
|
||||
"bot_id": row.BotID,
|
||||
"bot_node_id": row.BotNodeID,
|
||||
"bot_node_num": row.BotNodeNum,
|
||||
"peer_node_id": row.PeerNodeID,
|
||||
"peer_node_num": row.PeerNodeNum,
|
||||
"direction": row.Direction,
|
||||
"topic": row.Topic,
|
||||
"packet_id": row.PacketID,
|
||||
"text": row.Text,
|
||||
"payload_len": row.PayloadLen,
|
||||
"pki_encrypted": row.PKIEncrypted,
|
||||
"want_ack": row.WantAck,
|
||||
"gateway_id": row.GatewayID,
|
||||
"status": row.Status,
|
||||
"error": row.Error,
|
||||
"bot_message_id": row.BotMessageID,
|
||||
"created_by": row.CreatedBy,
|
||||
"published_at": row.PublishedAt,
|
||||
"received_at": row.ReceivedAt,
|
||||
"read_at": row.ReadAt,
|
||||
"created_at": row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func botDirectConversationDTO(row storepkg.BotDirectConversation) gin.H {
|
||||
return gin.H{
|
||||
"bot_id": row.BotID,
|
||||
"peer_node_id": row.PeerNodeID,
|
||||
"peer_node_num": row.PeerNodeNum,
|
||||
"last_message_at": row.LastMessageAt,
|
||||
"last_text": row.LastText,
|
||||
"last_direction": row.LastDirection,
|
||||
"unread_count": row.UnreadCount,
|
||||
"total_count": row.TotalCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者
|
||||
// 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。
|
||||
// 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。
|
||||
func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||
bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum))
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||
if privateKeyB64 == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||
if err != nil || len(privateKey) != 32 {
|
||||
return nil, nil, false
|
||||
}
|
||||
fromPublic, ok := s.LookupNodeInfoPublicKey(fromNodeNum)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return privateKey, fromPublic, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/mqtpp"
|
||||
)
|
||||
|
||||
const botMaxTextBytes = 200
|
||||
|
||||
type SendTextRequest struct {
|
||||
BotID uint64
|
||||
MessageType string
|
||||
ChannelID string
|
||||
ToNodeID string
|
||||
ToNodeNum *int64
|
||||
Text string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type TextSender interface {
|
||||
SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
|
||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *storepkg.Store
|
||||
server *mqtt.Server
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
|
||||
return &Service{store: store, server: server, key: key}
|
||||
}
|
||||
|
||||
func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
if s == nil || s.store == nil || s.server == nil {
|
||||
return
|
||||
}
|
||||
go s.runNodeInfoBroadcaster(ctx)
|
||||
}
|
||||
|
||||
// MaybeAutoAck 在收到一个发往本地 bot 的 want_ack 包时,回送一个 Routing-NONE ACK。
|
||||
//
|
||||
// 与固件 ReliableRouter::sniffReceived 行为对齐:
|
||||
// - PKI 加密包(pki_encrypted=true)用 X25519+AES-CCM 加密 ACK,channel_id="PKI"
|
||||
// - 其它情况用原 channel + bot PSK 加密
|
||||
//
|
||||
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
|
||||
func (s *Service) MaybeAutoAck(record map[string]any) {
|
||||
if s == nil || s.store == nil || s.server == nil || record == nil {
|
||||
return
|
||||
}
|
||||
wantAck, _ := record["want_ack"].(bool)
|
||||
if !wantAck {
|
||||
return
|
||||
}
|
||||
toNum, ok := uint32FromRecord(record["packet_to_num"])
|
||||
if !ok || toNum == 0 || toNum == mqtpp.NodeNumBroadcast {
|
||||
return
|
||||
}
|
||||
fromNum, ok := uint32FromRecord(record["packet_from_num"])
|
||||
if !ok || fromNum == 0 {
|
||||
return
|
||||
}
|
||||
requestID, ok := uint32FromRecord(record["packet_id"])
|
||||
if !ok || requestID == 0 {
|
||||
return
|
||||
}
|
||||
bot, err := s.store.GetBotNodeByNodeNum(int64(toNum))
|
||||
if err != nil || bot == nil || !bot.Enabled {
|
||||
return
|
||||
}
|
||||
pkiEncrypted, _ := record["pki_encrypted"].(bool)
|
||||
channelID, _ := record["channel_id"].(string)
|
||||
|
||||
ackPacketID, err := randomPacketID()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, fallbackChannelID(channelID, pkiEncrypted, bot.DefaultChannelID), bot.NodeID)
|
||||
|
||||
var raw []byte
|
||||
if pkiEncrypted {
|
||||
raw, err = s.buildPKIAck(bot, fromNum, ackPacketID, requestID)
|
||||
if err == nil {
|
||||
topic = botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
|
||||
}
|
||||
} else {
|
||||
raw, err = s.buildPSKAck(bot, fromNum, ackPacketID, requestID, channelID)
|
||||
}
|
||||
if err != nil || raw == nil {
|
||||
printJSON(map[string]any{"event": "bot_auto_ack_skipped", "bot_node_id": bot.NodeID, "to": fromNum, "request_id": requestID, "error": errString(err)})
|
||||
return
|
||||
}
|
||||
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||
printJSON(map[string]any{"event": "bot_auto_ack_publish_failed", "bot_node_id": bot.NodeID, "topic": topic, "error": err.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")
|
||||
}
|
||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipientPublic, ok := s.store.LookupNodeInfoPublicKey(toNum)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipient %s has no public key on file", mqtpp.NodeNumToID(toNum))
|
||||
}
|
||||
return mqtpp.BuildPKIAckServiceEnvelope(mqtpp.PKIAckBuildOptions{
|
||||
FromNodeNum: uint32(bot.NodeNum),
|
||||
ToNodeNum: toNum,
|
||||
PacketID: ackPacketID,
|
||||
RequestID: requestID,
|
||||
GatewayID: bot.NodeID,
|
||||
ViaMQTT: true,
|
||||
SenderPrivate: privateKey,
|
||||
RecipientPub: recipientPublic,
|
||||
SenderPublic: senderPublic,
|
||||
})
|
||||
}
|
||||
|
||||
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 = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mqtpp.BuildAckServiceEnvelope(mqtpp.AckBuildOptions{
|
||||
PacketBuildOptions: mqtpp.PacketBuildOptions{
|
||||
FromNodeNum: uint32(bot.NodeNum),
|
||||
ToNodeNum: toNum,
|
||||
PacketID: ackPacketID,
|
||||
ChannelID: channel,
|
||||
GatewayID: bot.NodeID,
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
func fallbackChannelID(channelID string, pkiEncrypted bool, defaultChannelID string) string {
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
if pkiEncrypted {
|
||||
return mqtpp.PKIChannelID
|
||||
}
|
||||
if channelID != "" && channelID != mqtpp.PKIChannelID {
|
||||
return channelID
|
||||
}
|
||||
return defaultChannelID
|
||||
}
|
||||
|
||||
func uint32FromRecord(value any) (uint32, bool) {
|
||||
switch v := value.(type) {
|
||||
case uint32:
|
||||
return v, true
|
||||
case int:
|
||||
if v >= 0 {
|
||||
return uint32(v), true
|
||||
}
|
||||
case int64:
|
||||
if v >= 0 {
|
||||
return uint32(v), true
|
||||
}
|
||||
case uint64:
|
||||
return uint32(v), true
|
||||
case float64:
|
||||
if v >= 0 {
|
||||
return uint32(v), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.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")
|
||||
}
|
||||
bot, err := s.store.GetBotNode(req.BotID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bot.Enabled {
|
||||
return nil, fmt.Errorf("bot node is disabled")
|
||||
}
|
||||
messageType, err := normalizeBotMessageType(req.MessageType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text := strings.TrimSpace(req.Text)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("text is required")
|
||||
}
|
||||
if !utf8.ValidString(text) {
|
||||
return nil, fmt.Errorf("text must be valid utf-8")
|
||||
}
|
||||
if len([]byte(text)) > botMaxTextBytes {
|
||||
return nil, fmt.Errorf("text is too long, max %d bytes", botMaxTextBytes)
|
||||
}
|
||||
toNodeNum, toNodeID, err := botMessageTarget(messageType, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packetID, err := randomPacketID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromNodeNum := uint32(bot.NodeNum)
|
||||
|
||||
// direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径
|
||||
if messageType == storepkg.BotMessageTypeDirect {
|
||||
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
|
||||
}
|
||||
|
||||
channelID := strings.TrimSpace(req.ChannelID)
|
||||
if channelID == "" {
|
||||
channelID = bot.DefaultChannelID
|
||||
}
|
||||
if channelID == "" {
|
||||
return nil, fmt.Errorf("channel id is required")
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := mqtpp.BuildTextMessageServiceEnvelope(mqtpp.TextMessageBuildOptions{
|
||||
PacketBuildOptions: mqtpp.PacketBuildOptions{
|
||||
FromNodeNum: fromNodeNum,
|
||||
ToNodeNum: uint32(toNodeNum),
|
||||
PacketID: packetID,
|
||||
ChannelID: channelID,
|
||||
GatewayID: bot.NodeID,
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
|
||||
row := &storepkg.BotMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
MessageType: messageType,
|
||||
ChannelID: channelID,
|
||||
ToNodeID: toNodeID,
|
||||
ToNodeNum: int64PtrOrNil(toNodeNum, false),
|
||||
Topic: topic,
|
||||
PacketID: int64(packetID),
|
||||
Text: text,
|
||||
PayloadLen: int64(len(raw)),
|
||||
Encrypted: true,
|
||||
Status: storepkg.BotMessageStatusPending,
|
||||
CreatedBy: strings.TrimSpace(req.CreatedBy),
|
||||
}
|
||||
return s.persistAndPublish(row, topic, raw)
|
||||
}
|
||||
|
||||
// sendPKIDirect 按固件 PKI 流程发送私聊:
|
||||
// - 从 nodeinfo 中查目标节点的 X25519 公钥
|
||||
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
|
||||
// - ServiceEnvelope.channel_id = "PKI",topic 也用 "PKI"
|
||||
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")
|
||||
}
|
||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||
if privateKeyB64 == "" {
|
||||
return nil, fmt.Errorf("bot has no private key, regenerate keys first")
|
||||
}
|
||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
||||
}
|
||||
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipientPublic, err := s.lookupRecipientPublicKey(*toNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := mqtpp.BuildPKITextMessageServiceEnvelope(mqtpp.PKITextMessageBuildOptions{
|
||||
FromNodeNum: fromNodeNum,
|
||||
ToNodeNum: toNodeNum,
|
||||
PacketID: packetID,
|
||||
GatewayID: bot.NodeID,
|
||||
ViaMQTT: true,
|
||||
SenderPrivate: privateKey,
|
||||
RecipientPub: recipientPublic,
|
||||
SenderPublic: senderPublic,
|
||||
Text: text,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
|
||||
row := &storepkg.BotMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
MessageType: storepkg.BotMessageTypeDirect,
|
||||
ChannelID: mqtpp.PKIChannelID,
|
||||
ToNodeID: toNodeID,
|
||||
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
|
||||
Topic: topic,
|
||||
PacketID: int64(packetID),
|
||||
Text: text,
|
||||
PayloadLen: int64(len(raw)),
|
||||
Encrypted: true,
|
||||
Status: storepkg.BotMessageStatusPending,
|
||||
CreatedBy: strings.TrimSpace(createdBy),
|
||||
}
|
||||
result, err := s.persistAndPublish(row, topic, raw)
|
||||
// 不论发送结果如何,都把 DM 镜像写入 bot_direct_messages 以驱动 /admin/bot/direct 渲染。
|
||||
// 这里把发送结果(status/error/published_at)同步过去——成功时 status=published,
|
||||
// 失败时 status=failed,前端就能看到本地视图与发送日志一致。
|
||||
s.recordOutboundDirectMessage(bot, row, *toNodeID, toNodeNum, text, len(raw), err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
|
||||
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 = storepkg.BotMessageStatusFailed
|
||||
} else {
|
||||
status = storepkg.BotMessageStatusPublished
|
||||
}
|
||||
}
|
||||
errText := msg.Error
|
||||
if errText == "" && sendErr != nil {
|
||||
errText = sendErr.Error()
|
||||
}
|
||||
createdBy := strings.TrimSpace(msg.CreatedBy)
|
||||
var createdByPtr *string
|
||||
if createdBy != "" {
|
||||
createdByPtr = &createdBy
|
||||
}
|
||||
gateway := strings.TrimSpace(bot.NodeID)
|
||||
var gatewayPtr *string
|
||||
if gateway != "" {
|
||||
gatewayPtr = &gateway
|
||||
}
|
||||
var botMessageID *uint64
|
||||
if msg.ID != 0 {
|
||||
id := msg.ID
|
||||
botMessageID = &id
|
||||
}
|
||||
now := time.Now()
|
||||
dm := &storepkg.BotDirectMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
PeerNodeID: peerNodeID,
|
||||
PeerNodeNum: int64(peerNodeNum),
|
||||
Direction: storepkg.BotDirectMessageDirectionOutbound,
|
||||
Topic: msg.Topic,
|
||||
PacketID: msg.PacketID,
|
||||
Text: text,
|
||||
PayloadLen: int64(payloadLen),
|
||||
PKIEncrypted: true,
|
||||
WantAck: false, // 我们当前发送的 DM 默认不显式请求 ack
|
||||
GatewayID: gatewayPtr,
|
||||
Status: status,
|
||||
Error: strings.TrimSpace(errText),
|
||||
BotMessageID: botMessageID,
|
||||
CreatedBy: createdByPtr,
|
||||
PublishedAt: msg.PublishedAt,
|
||||
// 出向消息从产生那一刻起就视为“已读”,未读计数只关心 inbound。
|
||||
ReadAt: &now,
|
||||
}
|
||||
if err := s.store.InsertBotDirectMessage(dm); err != nil {
|
||||
printJSON(map[string]any{
|
||||
"event": "bot_direct_message_outbound_persist_failed",
|
||||
"bot_node_id": bot.NodeID,
|
||||
"peer_node_id": peerNodeID,
|
||||
"bot_message_id": msg.ID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
|
||||
func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
node, err := s.store.GetNodeInfo(nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("recipient node %s not found in nodeinfo, cannot send PKI message", nodeID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if node.PublicKey == nil || strings.TrimSpace(*node.PublicKey) == "" {
|
||||
return nil, fmt.Errorf("recipient node %s has no public key on file", nodeID)
|
||||
}
|
||||
keyHex := strings.TrimSpace(*node.PublicKey)
|
||||
keyBytes, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
// 兼容历史上可能存储为 base64 的情况
|
||||
if alt, altErr := base64.StdEncoding.DecodeString(keyHex); altErr == nil {
|
||||
keyBytes = alt
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid recipient public key for %s: %w", nodeID, err)
|
||||
}
|
||||
}
|
||||
if len(keyBytes) != 32 {
|
||||
return nil, fmt.Errorf("recipient public key for %s has unexpected length %d", nodeID, len(keyBytes))
|
||||
}
|
||||
return keyBytes, nil
|
||||
}
|
||||
|
||||
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
|
||||
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, 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, 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, storepkg.BotMessageStatusPublished, "", &now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Status = storepkg.BotMessageStatusPublished
|
||||
row.Error = ""
|
||||
row.PublishedAt = &now
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *Service) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
s.broadcastDueNodeInfo(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.broadcastDueNodeInfo(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
now := time.Now()
|
||||
for _, bot := range rows {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !bot.Enabled || !bot.NodeInfoBroadcastEnabled {
|
||||
continue
|
||||
}
|
||||
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Duration(storepkg.BotDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||
}
|
||||
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
|
||||
continue
|
||||
}
|
||||
if err := s.PublishNodeInfo(ctx, bot); err != nil {
|
||||
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "bot_id": bot.ID, "node_id": bot.NodeID, "error": err.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")
|
||||
}
|
||||
bot, err := s.store.GetBotNode(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bot.Enabled {
|
||||
return nil, fmt.Errorf("bot node is disabled")
|
||||
}
|
||||
if err := s.PublishNodeInfo(ctx, *bot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.store.GetBotNode(id)
|
||||
if err != nil {
|
||||
return bot, nil
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishNodeInfo(_ context.Context, bot storepkg.BotNodeRecord) error {
|
||||
if s == nil || s.server == nil {
|
||||
return fmt.Errorf("mqtt server is not configured")
|
||||
}
|
||||
if strings.TrimSpace(bot.PublicKey) == "" && s.store != nil {
|
||||
updated, err := s.store.RegenerateBotNodeKeys(bot.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot = *updated
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packetID, err := randomPacketID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey, err := storepkg.DecodeBotPublicKey(bot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := mqtpp.BuildNodeInfoServiceEnvelope(mqtpp.NodeInfoBuildOptions{
|
||||
PacketBuildOptions: mqtpp.PacketBuildOptions{
|
||||
FromNodeNum: uint32(bot.NodeNum),
|
||||
ToNodeNum: mqtpp.NodeNumBroadcast,
|
||||
PacketID: packetID,
|
||||
ChannelID: bot.DefaultChannelID,
|
||||
GatewayID: bot.NodeID,
|
||||
PSK: key,
|
||||
Encrypt: true,
|
||||
ViaMQTT: true,
|
||||
},
|
||||
NodeID: bot.NodeID,
|
||||
LongName: bot.LongName,
|
||||
ShortName: bot.ShortName,
|
||||
HWModel: 255,
|
||||
Role: 0,
|
||||
IsLicensed: false,
|
||||
PublicKey: publicKey,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, bot.DefaultChannelID, bot.NodeID)
|
||||
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.store != nil {
|
||||
valid, _, record := mqtpp.MQTTPP(topic, raw, key, mqtpp.Options{AllowEncryptedForwarding: true})
|
||||
if valid && record["type"] == "nodeinfo" {
|
||||
if err := s.store.UpsertNodeInfo(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.store.UpdateBotNodeInfoBroadcastAt(bot.ID, time.Now())
|
||||
}
|
||||
|
||||
func normalizeBotMessageType(value string) (string, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
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 SendTextRequest) (int64, *string, error) {
|
||||
if messageType == storepkg.BotMessageTypeChannel {
|
||||
return int64(mqtpp.NodeNumBroadcast), nil, nil
|
||||
}
|
||||
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
|
||||
if err := storepkg.ValidateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
|
||||
return *req.ToNodeNum, &nodeID, nil
|
||||
}
|
||||
toNodeID := strings.TrimSpace(req.ToNodeID)
|
||||
if toNodeID == "" {
|
||||
return 0, nil, fmt.Errorf("target node is required for direct message")
|
||||
}
|
||||
nodeNum, err := mqtpp.ParseNodeID(toNodeID)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
normalized := mqtpp.NodeNumToID(nodeNum)
|
||||
return int64(nodeNum), &normalized, nil
|
||||
}
|
||||
|
||||
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
|
||||
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = storepkg.BotDefaultTopicPrefix
|
||||
}
|
||||
if strings.HasSuffix(prefix, "/2/e") {
|
||||
return prefix + "/" + channelID + "/" + nodeID
|
||||
}
|
||||
return prefix + "/2/e/" + channelID + "/" + nodeID
|
||||
}
|
||||
|
||||
func randomPacketID() (uint32, error) {
|
||||
for i := 0; i < 8; i++ {
|
||||
var buf [4]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id := binary.LittleEndian.Uint32(buf[:])
|
||||
if id != 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("generate packet id failed")
|
||||
}
|
||||
|
||||
func int64PtrOrNil(value int64, ok bool) *int64 {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package bot
|
||||
|
||||
// printJSON 是 bot service 的内部诊断输出钩子;当前为 noop,与重构前
|
||||
// main.go 中的 printJSON 行为一致(注释掉了实际写出)。
|
||||
// 如需调试可直接替换实现。
|
||||
func printJSON(record map[string]any) {
|
||||
_ = record
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package llmadmin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
||||
group := r.Group("/llm")
|
||||
{
|
||||
// LLM Message Queue
|
||||
group.GET("/messages", handleListLLMMessages(store))
|
||||
group.GET("/messages/:id", handleGetLLMMessage(store))
|
||||
group.PUT("/messages/:id/status", handleUpdateLLMMessageStatus(store))
|
||||
group.DELETE("/messages/:id", handleDeleteLLMMessage(store))
|
||||
group.DELETE("/bots/:bot_id/messages", handleDeleteLLMMessagesByBot(store))
|
||||
group.POST("/messages/cleanup", handleCleanupDeletedLLMMessages(store))
|
||||
|
||||
// LLM Providers
|
||||
group.GET("/providers", handleListLLMProviders(store))
|
||||
group.GET("/providers/:name", handleGetLLMProvider(store))
|
||||
group.POST("/providers", handleCreateLLMProvider(store))
|
||||
group.PUT("/providers/:name", handleUpdateLLMProvider(store))
|
||||
group.DELETE("/providers/:name", handleDeleteLLMProvider(store))
|
||||
|
||||
// LLM Tool Router
|
||||
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
||||
group.PUT("/tool-router", handleUpdateLLMToolRouter(store))
|
||||
|
||||
// LLM Primary Config - 主 AI 回复配置
|
||||
group.GET("/primary-config", handleGetLLMPrimaryConfig(store))
|
||||
group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store))
|
||||
}
|
||||
}
|
||||
|
||||
func handleListLLMMessages(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var botID uint64
|
||||
if botIDStr := c.Query("bot_id"); botIDStr != "" {
|
||||
id, err := strconv.ParseUint(botIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot_id"})
|
||||
return
|
||||
}
|
||||
botID = id
|
||||
}
|
||||
|
||||
includeDeleted := c.Query("include_deleted") == "true"
|
||||
|
||||
rows, total, err := store.ListLLMMessages(opts, botID, includeDeleted)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, storepkg.LLMMessageDTO(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"limit": opts.Limit,
|
||||
"offset": opts.Offset,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
|
||||
record, err := store.GetLLMMessage(id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": storepkg.LLMMessageDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证状态值
|
||||
validStatus := map[string]bool{
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.UpdateLLMMessageStatus(id, req.Status, req.Error); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SoftDeleteLLMMessage(id); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SoftDeleteLLMMessagesByBot(botID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCleanupDeletedLLMMessages(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.Days = 7 // 默认清理 7 天前删除的消息
|
||||
}
|
||||
if req.Days <= 0 {
|
||||
req.Days = 7
|
||||
}
|
||||
|
||||
before := time.Now().AddDate(0, 0, -req.Days)
|
||||
count, err := store.CleanupDeletedLLMMessages(before)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted_count": count})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Provider Handlers
|
||||
// ============================================
|
||||
|
||||
func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
includeInactive := c.Query("include_inactive") == "true"
|
||||
|
||||
rows, err := store.ListLLMProviders(includeInactive)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, llmProviderDTO(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
record, err := store.GetLLMProvider(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Timeout int `json:"timeout"`
|
||||
ContextWindowTokens int `json:"context_window_tokens"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
record := &storepkg.LLMProviderRecord{
|
||||
Name: req.Name,
|
||||
Active: req.Active,
|
||||
APIKey: req.APIKey,
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
Timeout: req.Timeout,
|
||||
ContextWindowTokens: req.ContextWindowTokens,
|
||||
}
|
||||
|
||||
if err := store.CreateLLMProvider(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Active *bool `json:"active"`
|
||||
APIKey *string `json:"api_key"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Timeout *int `json:"timeout"`
|
||||
ContextWindowTokens *int `json:"context_window_tokens"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Active != nil {
|
||||
updates["active"] = *req.Active
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
updates["api_key"] = *req.APIKey
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
updates["base_url"] = *req.BaseURL
|
||||
}
|
||||
if req.Model != nil {
|
||||
updates["model"] = *req.Model
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.ContextWindowTokens != nil {
|
||||
updates["context_window_tokens"] = *req.ContextWindowTokens
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.UpdateLLMProvider(name, updates); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
record, err := store.GetLLMProvider(name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.DeleteLLMProvider(name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func llmProviderDTO(row storepkg.LLMProviderRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"name": row.Name,
|
||||
"active": row.Active,
|
||||
"base_url": row.BaseURL,
|
||||
"model": row.Model,
|
||||
"timeout": row.Timeout,
|
||||
"context_window_tokens": row.ContextWindowTokens,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Tool Router Handlers
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMToolRouter()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tool router config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmToolRouterDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMToolRouter()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
OpenAIName *string `json:"openai_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
updates["openai_name"] = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &storepkg.LLMToolRouterRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "",
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
newRecord.OpenAIName = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if err := store.CreateLLMToolRouter(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMToolRouter(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMToolRouter()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmToolRouterDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"openai_name": row.OpenAIName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config Handlers
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "primary config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmPrimaryConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMPrimaryConfig()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
ProviderName *string `json:"provider_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
EnableTool *bool `json:"enable_tool"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.ProviderName != nil {
|
||||
updates["provider_name"] = *req.ProviderName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
if req.EnableTool != nil {
|
||||
updates["enable_tool"] = *req.EnableTool
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &storepkg.LLMPrimaryConfigRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
ProviderName: "",
|
||||
Timeout: 120,
|
||||
MaxTokens: 1024,
|
||||
SystemPrompt: "",
|
||||
EnableTool: false,
|
||||
}
|
||||
if req.ProviderName != nil {
|
||||
newRecord.ProviderName = *req.ProviderName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if req.EnableTool != nil {
|
||||
newRecord.EnableTool = *req.EnableTool
|
||||
}
|
||||
if err := store.CreateLLMPrimaryConfig(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMPrimaryConfig(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmPrimaryConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmPrimaryConfigDTO(row storepkg.LLMPrimaryConfigRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"provider_name": row.ProviderName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"enable_tool": row.EnableTool,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package mapsource 提供地图瓦片源的 admin 与公开路由。
|
||||
package mapsource
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type mapTileSourceRequest struct {
|
||||
Name string `json:"name"`
|
||||
URLTemplate string `json:"url_template"`
|
||||
Attribution string `json:"attribution"`
|
||||
MaxZoom int `json:"max_zoom"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ProxyEnabled bool `json:"proxy_enabled"`
|
||||
}
|
||||
|
||||
// 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": PublicDTO(*row)})
|
||||
})
|
||||
r.GET("/map-source/enabled", func(c *gin.Context) {
|
||||
rows, err := store.ListEnabledMapTileSources()
|
||||
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, PublicDTO(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes 注册管理员侧 CRUD 与设默认。
|
||||
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/map-source", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMapTileSources(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, AdminDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMapTileSources(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, AdminDTO)
|
||||
})
|
||||
r.POST("/map-source", func(c *gin.Context) {
|
||||
var req mapTileSourceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
|
||||
return
|
||||
}
|
||||
row, err := store.CreateMapTileSource(mapTileSourceInputFromRequest(req))
|
||||
writeMapTileSourceMutationResponse(c, http.StatusCreated, row, err)
|
||||
})
|
||||
r.PUT("/map-source/:id", func(c *gin.Context) {
|
||||
id, ok := parseMapTileSourceID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req mapTileSourceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateMapTileSource(id, mapTileSourceInputFromRequest(req))
|
||||
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
|
||||
})
|
||||
r.DELETE("/map-source/:id", func(c *gin.Context) {
|
||||
id, ok := parseMapTileSourceID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeMapTileSourceDeleteResponse(c, store.DeleteMapTileSource(id))
|
||||
})
|
||||
r.POST("/map-source/:id/default", func(c *gin.Context) {
|
||||
id, ok := parseMapTileSourceID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := store.SetDefaultMapTileSource(id)
|
||||
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
|
||||
})
|
||||
}
|
||||
|
||||
func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput {
|
||||
return storepkg.MapTileSourceInput{
|
||||
Name: req.Name,
|
||||
URLTemplate: req.URLTemplate,
|
||||
Attribution: req.Attribution,
|
||||
MaxZoom: req.MaxZoom,
|
||||
Enabled: req.Enabled,
|
||||
IsDefault: req.IsDefault,
|
||||
ProxyEnabled: req.ProxyEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source id"})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"item": AdminDTO(*row)})
|
||||
}
|
||||
|
||||
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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 = storepkg.MapTileSourceHash(row.URLTemplate)
|
||||
}
|
||||
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
|
||||
}
|
||||
return gin.H{"id": row.ID, "name": row.Name, "url_template": urlTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package sign 提供签到记录的 admin 路由与对应的 DTO/列表查询。
|
||||
//
|
||||
// 拆离自原来 main 包的 admin_sign_routes.go 与 web.go 中的 signDTO /
|
||||
// signDayCountDTO;其它 admin 路由也通过 SignDTO / SignDayCountDTO 复用。
|
||||
package sign
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type signRequest struct {
|
||||
NodeID string `json:"node_id"`
|
||||
LongName string `json:"long_name"`
|
||||
ShortName string `json:"short_name"`
|
||||
SignText string `json:"sign_text"`
|
||||
SignTime string `json:"sign_time"`
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes 在 admin 路由组下挂 sign CRUD 端点。
|
||||
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/signs", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListSigns(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, SignDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountSigns(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, SignDTO)
|
||||
})
|
||||
r.POST("/signs", func(c *gin.Context) {
|
||||
var req signRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"})
|
||||
return
|
||||
}
|
||||
signTime, ok := parseSignRequestTime(c, req.SignTime)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
id, ok := parseSignID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req signRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"})
|
||||
return
|
||||
}
|
||||
signTime, ok := parseSignRequestTime(c, req.SignTime)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
id, ok := parseSignID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := store.DeleteSign(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func parseSignID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign id"})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) {
|
||||
if value == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign_time: use RFC3339"})
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
mapTileCacheControl = "public, max-age=86400"
|
||||
maxMapTileBytes = 10 << 20
|
||||
)
|
||||
|
||||
type mapTileProxy struct {
|
||||
store *storepkg.Store
|
||||
cacheDir string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
|
||||
proxy := &mapTileProxy{
|
||||
store: store,
|
||||
cacheDir: cacheDir,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
r.GET("/map/:sourceHash", proxy.handle)
|
||||
}
|
||||
|
||||
func (p *mapTileProxy) handle(c *gin.Context) {
|
||||
sourceHash := strings.ToLower(c.Param("sourceHash"))
|
||||
if !isMapTileSourceHash(sourceHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source hash"})
|
||||
return
|
||||
}
|
||||
|
||||
row, err := p.store.GetEnabledMapTileSourceByHash(sourceHash)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tile, ok := parseMapTileCoordinates(c, row.MaxZoom)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := mapTileCachePath(p.cacheDir, sourceHash, tile)
|
||||
if data, err := os.ReadFile(cachePath); err == nil {
|
||||
writeMapTile(c, data)
|
||||
return
|
||||
} else if !os.IsNotExist(err) {
|
||||
// Fall through to upstream fetch. A broken cache file should not prevent map rendering.
|
||||
}
|
||||
|
||||
data, status, err := p.fetchRemoteTile(c.Request, row.URLTemplate, tile)
|
||||
if err != nil {
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = writeMapTileCacheFile(cachePath, data)
|
||||
writeMapTile(c, data)
|
||||
}
|
||||
|
||||
func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) {
|
||||
remoteURL := expandMapTileURLTemplate(template, tile)
|
||||
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
|
||||
if err != nil {
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("build upstream map tile request: %w", err)
|
||||
}
|
||||
upstreamReq.Header.Set("User-Agent", "mesh_mqtt_go map tile cache")
|
||||
|
||||
resp, err := p.client.Do(upstreamReq)
|
||||
if err != nil {
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("fetch upstream map tile: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, http.StatusNotFound, fmt.Errorf("upstream map tile not found")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxMapTileBytes+1))
|
||||
if err != nil {
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("read upstream map tile: %w", err)
|
||||
}
|
||||
if len(data) > maxMapTileBytes {
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile is too large")
|
||||
}
|
||||
return data, http.StatusOK, nil
|
||||
}
|
||||
|
||||
type mapTileCoordinates struct {
|
||||
x int64
|
||||
y int64
|
||||
z int64
|
||||
}
|
||||
|
||||
func parseMapTileCoordinates(c *gin.Context, maxZoom int) (mapTileCoordinates, bool) {
|
||||
x, ok := parseMapTileCoordinate(c, "x")
|
||||
if !ok {
|
||||
return mapTileCoordinates{}, false
|
||||
}
|
||||
y, ok := parseMapTileCoordinate(c, "y")
|
||||
if !ok {
|
||||
return mapTileCoordinates{}, false
|
||||
}
|
||||
z, ok := parseMapTileCoordinate(c, "z")
|
||||
if !ok {
|
||||
return mapTileCoordinates{}, false
|
||||
}
|
||||
if z > int64(maxZoom) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile z exceeds max zoom"})
|
||||
return mapTileCoordinates{}, false
|
||||
}
|
||||
limit := int64(1) << z
|
||||
if x >= limit || y >= limit {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile coordinates out of range"})
|
||||
return mapTileCoordinates{}, false
|
||||
}
|
||||
return mapTileCoordinates{x: x, y: y, z: z}, true
|
||||
}
|
||||
|
||||
func parseMapTileCoordinate(c *gin.Context, name string) (int64, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing map tile " + name})
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 || parsed > 30_000_000_000 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map tile " + name})
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func isMapTileSourceHash(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func expandMapTileURLTemplate(template string, tile mapTileCoordinates) string {
|
||||
result := strings.ReplaceAll(template, "{x}", strconv.FormatInt(tile.x, 10))
|
||||
result = strings.ReplaceAll(result, "{y}", strconv.FormatInt(tile.y, 10))
|
||||
result = strings.ReplaceAll(result, "{z}", strconv.FormatInt(tile.z, 10))
|
||||
return result
|
||||
}
|
||||
|
||||
func mapTileCachePath(cacheDir, sourceHash string, tile mapTileCoordinates) string {
|
||||
return filepath.Join(cacheDir, sourceHash, strconv.FormatInt(tile.z, 10), strconv.FormatInt(tile.x, 10), strconv.FormatInt(tile.y, 10)+".tile")
|
||||
}
|
||||
|
||||
func writeMapTileCacheFile(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func writeMapTile(c *gin.Context, data []byte) {
|
||||
contentType := http.DetectContentType(data)
|
||||
c.Header("Cache-Control", mapTileCacheControl)
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"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()
|
||||
|
||||
requests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if r.URL.Path != "/3/1/2.png" {
|
||||
t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("tile-data"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
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(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++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if recorder.Body.String() != "tile-data" {
|
||||
t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("upstream requests = %d, want 1", requests)
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile")
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cache file %s: %v", cachePath, err)
|
||||
}
|
||||
if string(data) != "tile-data" {
|
||||
t.Fatalf("cache file = %q, want tile-data", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
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(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",
|
||||
"/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0",
|
||||
"/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4",
|
||||
"/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1",
|
||||
}
|
||||
for _, url := range cases {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
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(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(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",
|
||||
"/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0",
|
||||
"/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0",
|
||||
"/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0",
|
||||
}
|
||||
wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound}
|
||||
for i, url := range cases {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != wantStatus[i] {
|
||||
t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileProxyUpstreamStatus(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/404/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "upstream error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
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(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(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []struct {
|
||||
url string
|
||||
want int
|
||||
}{
|
||||
{url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound},
|
||||
{url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != tc.want {
|
||||
t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.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, ""
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 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),
|
||||
}
|
||||
}
|
||||
|
||||
func ServeUnixSocket(server *http.Server, socketPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(socketPath); err == nil {
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return errors.New("web socket path exists and is not a socket")
|
||||
}
|
||||
if err := os.Remove(socketPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(socketPath)
|
||||
if err := os.Chmod(socketPath, 0660); err != nil {
|
||||
listener.Close()
|
||||
return err
|
||||
}
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
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")
|
||||
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
|
||||
registerStaticRoutes(r, cfg.StaticDir)
|
||||
return r
|
||||
}
|
||||
|
||||
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 {
|
||||
status["status"] = "error"
|
||||
status["database"] = err.Error()
|
||||
c.JSON(http.StatusServiceUnavailable, status)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, status)
|
||||
})
|
||||
|
||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||
registerNodeInfoRoutes(r, store, "/nodes")
|
||||
registerMapReportRoutes(r, store)
|
||||
mappkg.RegisterPublicRoutes(r, store)
|
||||
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
||||
helppkg.RegisterPublicRoutes(r, store)
|
||||
r.GET("/signs", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListSigns(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, signpkg.SignDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountSigns(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
|
||||
})
|
||||
r.GET("/signs/daily", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.CountSignsByDay(opts)
|
||||
writeListResponse(c, rows, opts, err, signpkg.SignDayCountDTO)
|
||||
})
|
||||
r.GET("/text-messages", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListTextMessages(opts)
|
||||
writeListResponse(c, rows, opts, err, textMessageDTO)
|
||||
})
|
||||
r.GET("/discard-details", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListDiscardDetails(opts)
|
||||
writeListResponse(c, rows, opts, err, discardDetailsDTO)
|
||||
})
|
||||
r.GET("/positions", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListPositions(opts)
|
||||
writeListResponse(c, rows, opts, err, positionDTO)
|
||||
})
|
||||
r.GET("/telemetry", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListTelemetry(opts)
|
||||
writeListResponse(c, rows, opts, err, telemetryDTO)
|
||||
})
|
||||
r.GET("/routing", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListRouting(opts)
|
||||
writeListResponse(c, rows, opts, err, routingDTO)
|
||||
})
|
||||
r.GET("/traceroute", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListTraceroute(opts)
|
||||
writeListResponse(c, rows, opts, err, tracerouteDTO)
|
||||
})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type updatePasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
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 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) {
|
||||
remoteAddr := c.Request.RemoteAddr
|
||||
remoteHost, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil || remoteHost == "" {
|
||||
remoteHost = remoteAddr
|
||||
}
|
||||
return remoteAddr, remoteHost
|
||||
}
|
||||
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
|
||||
remoteAddr, remoteHost := remoteInfo(c)
|
||||
_ = 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) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
recordLogin(c, "", nil, false, "invalid request")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
|
||||
return
|
||||
}
|
||||
user, err := store.GetUserByUsername(req.Username)
|
||||
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
|
||||
}
|
||||
cookie, err := sessions.NewCookie(*user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
recordLogin(c, req.Username, &user.ID, true, "success")
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserResponse(*user)})
|
||||
})
|
||||
r.POST("/logout", func(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, sessions.ClearCookie())
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
protected := r.Group("")
|
||||
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").(*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})
|
||||
return
|
||||
}
|
||||
status := mqttStatus.Status()
|
||||
discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
status.MessagesDropped = discardCount
|
||||
c.JSON(http.StatusOK, status)
|
||||
})
|
||||
protected.GET("/users", func(c *gin.Context) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, userDTO(user))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
})
|
||||
protected.POST("/users", func(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid create user request"})
|
||||
return
|
||||
}
|
||||
user, err := store.CreateAdminUser(req.Username, req.Password)
|
||||
if errors.Is(err, storepkg.ErrUserAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": userDTO(*user)})
|
||||
})
|
||||
protected.PUT("/users/:id/password", func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
var req updatePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"})
|
||||
return
|
||||
}
|
||||
user, err := store.UpdateUserPassword(id, req.Password)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)})
|
||||
})
|
||||
protected.GET("/log/login", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListLoginLogs(opts)
|
||||
writeListResponse(c, rows, opts, err, loginLogDTO)
|
||||
})
|
||||
protected.DELETE("/text-messages/:id", func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
if err := store.DeleteTextMessage(id); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
protected.DELETE("/nodes/:id", func(c *gin.Context) {
|
||||
nodeID := c.Param("id")
|
||||
if nodeID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
if err := store.DeleteNode(nodeID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "node not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
|
||||
r.GET(path, func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListNodeInfo(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, nodeInfoDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountNodeInfo(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, nodeInfoDTO)
|
||||
})
|
||||
r.GET(path+"/:id", func(c *gin.Context) {
|
||||
row, err := store.GetNodeInfo(c.Param("id"))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "nodeinfo not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, nodeInfoDTO(*row))
|
||||
})
|
||||
}
|
||||
|
||||
func registerMapReportRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/map-reports/viewport", func(c *gin.Context) {
|
||||
opts, ok := parseMapReportViewportOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := store.ListMapReportViewport(opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(result.Points)+len(result.Clusters))
|
||||
if result.Mode == "points" {
|
||||
for _, row := range result.Points {
|
||||
items = append(items, mapReportViewportPointDTO(row))
|
||||
}
|
||||
} else {
|
||||
for _, row := range result.Clusters {
|
||||
items = append(items, mapReportClusterDTO(row))
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"mode": result.Mode, "items": items, "total": result.Total, "limit": result.Limit, "zoom": result.Zoom})
|
||||
})
|
||||
r.GET("/map-reports", func(c *gin.Context) {
|
||||
opts, ok := parseMapReportListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMapReports(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, mapReportDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMapReports(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, mapReportDTO)
|
||||
})
|
||||
r.GET("/map-reports/:id", func(c *gin.Context) {
|
||||
row, err := store.GetMapReport(c.Param("id"))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "map report not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mapReportDTO(*row))
|
||||
})
|
||||
}
|
||||
|
||||
func registerStaticRoutes(r *gin.Engine, staticDir string) {
|
||||
assetsDir := filepath.Join(staticDir, "assets")
|
||||
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
|
||||
r.Static("/assets", assetsDir)
|
||||
}
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
serveIndex(c, staticDir)
|
||||
})
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if filepath.Ext(c.Request.URL.Path) != "" {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
serveIndex(c, staticDir)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(c *gin.Context, staticDir string) {
|
||||
indexPath := filepath.Join(staticDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err != nil {
|
||||
c.String(http.StatusNotFound, "frontend dist not found: run npm run build in meshmap_frontend")
|
||||
return
|
||||
}
|
||||
c.File(indexPath)
|
||||
}
|
||||
|
||||
func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
|
||||
return webutil.ParseListOptions(c)
|
||||
}
|
||||
|
||||
func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
|
||||
return webutil.ParseMapReportListOptions(c)
|
||||
}
|
||||
|
||||
func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) {
|
||||
return webutil.ParseMapReportViewportOptions(c)
|
||||
}
|
||||
|
||||
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||
return webutil.ParseIntQuery(c, name, defaultValue)
|
||||
}
|
||||
|
||||
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 storepkg.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert)
|
||||
}
|
||||
|
||||
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 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 storepkg.MapReportRecord) gin.H {
|
||||
item := mapReportDTO(row)
|
||||
item["type"] = "point"
|
||||
return item
|
||||
}
|
||||
|
||||
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 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 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 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 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 storepkg.RoutingRecord) gin.H {
|
||||
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
||||
}
|
||||
|
||||
func tracerouteDTO(row storepkg.TracerouteRecord) gin.H {
|
||||
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
||||
}
|
||||
|
||||
func appendPacketDTO(id uint64, fromID string, fromNum int64, packetID *int64, portnum *string, createdAt time.Time, contentJSON string) gin.H {
|
||||
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 { 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) }
|
||||
Reference in New Issue
Block a user