屏蔽表已创建

This commit is contained in:
2026-06-04 12:08:25 +08:00
parent e6fa998854
commit acd03a614a
10 changed files with 1492 additions and 1 deletions
+211
View File
@@ -0,0 +1,211 @@
package main
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type nodeBlockingRequest struct {
NodeID string `json:"node_id"`
NodeNum *int64 `json:"node_num"`
Reason string `json:"reason"`
Enabled bool `json:"enabled"`
}
type ipBlockingRequest struct {
IPValue string `json:"ip_value"`
Reason string `json:"reason"`
Enabled bool `json:"enabled"`
}
type forbiddenWordBlockingRequest struct {
Word string `json:"word"`
MatchType string `json:"match_type"`
CaseSensitive bool `json:"case_sensitive"`
Reason string `json:"reason"`
Enabled bool `json:"enabled"`
}
func registerAdminBlockingRoutes(r gin.IRouter, store *store) {
r.GET("/blocking/nodes", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListNodeBlocking(opts)
if err != nil {
writeListResponse(c, rows, opts, err, nodeBlockingDTO)
return
}
total, err := store.CountNodeBlocking(opts)
writeListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
})
r.POST("/blocking/nodes", func(c *gin.Context) {
var req nodeBlockingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node blocking request"})
return
}
row, err := store.CreateNodeBlocking(req.NodeID, req.NodeNum, req.Reason, req.Enabled)
writeBlockingMutationResponse(c, http.StatusCreated, row, err, nodeBlockingDTO)
})
r.PUT("/blocking/nodes/:id", func(c *gin.Context) {
id, ok := parseBlockingID(c)
if !ok {
return
}
var req nodeBlockingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node blocking request"})
return
}
row, err := store.UpdateNodeBlocking(id, req.NodeID, req.NodeNum, req.Reason, req.Enabled)
writeBlockingMutationResponse(c, http.StatusOK, row, err, nodeBlockingDTO)
})
r.DELETE("/blocking/nodes/:id", func(c *gin.Context) {
id, ok := parseBlockingID(c)
if !ok {
return
}
writeBlockingDeleteResponse(c, store.DeleteNodeBlocking(id))
})
r.GET("/blocking/ips", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListIPBlocking(opts)
if err != nil {
writeListResponse(c, rows, opts, err, ipBlockingDTO)
return
}
total, err := store.CountIPBlocking(opts)
writeListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
})
r.POST("/blocking/ips", func(c *gin.Context) {
var req ipBlockingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip blocking request"})
return
}
row, err := store.CreateIPBlocking(req.IPValue, req.Reason, req.Enabled)
writeBlockingMutationResponse(c, http.StatusCreated, row, err, ipBlockingDTO)
})
r.PUT("/blocking/ips/:id", func(c *gin.Context) {
id, ok := parseBlockingID(c)
if !ok {
return
}
var req ipBlockingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip blocking request"})
return
}
row, err := store.UpdateIPBlocking(id, req.IPValue, req.Reason, req.Enabled)
writeBlockingMutationResponse(c, http.StatusOK, row, err, ipBlockingDTO)
})
r.DELETE("/blocking/ips/:id", func(c *gin.Context) {
id, ok := parseBlockingID(c)
if !ok {
return
}
writeBlockingDeleteResponse(c, store.DeleteIPBlocking(id))
})
r.GET("/blocking/words", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListForbiddenWordBlocking(opts)
if err != nil {
writeListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
return
}
total, err := store.CountForbiddenWordBlocking(opts)
writeListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
})
r.POST("/blocking/words", func(c *gin.Context) {
var req forbiddenWordBlockingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid forbidden word blocking request"})
return
}
row, err := store.CreateForbiddenWordBlocking(req.Word, req.MatchType, req.CaseSensitive, req.Reason, req.Enabled)
writeBlockingMutationResponse(c, http.StatusCreated, row, err, forbiddenWordBlockingDTO)
})
r.PUT("/blocking/words/:id", func(c *gin.Context) {
id, ok := parseBlockingID(c)
if !ok {
return
}
var req forbiddenWordBlockingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid forbidden word blocking request"})
return
}
row, err := store.UpdateForbiddenWordBlocking(id, req.Word, req.MatchType, req.CaseSensitive, req.Reason, req.Enabled)
writeBlockingMutationResponse(c, http.StatusOK, row, err, forbiddenWordBlockingDTO)
})
r.DELETE("/blocking/words/:id", func(c *gin.Context) {
id, ok := parseBlockingID(c)
if !ok {
return
}
writeBlockingDeleteResponse(c, store.DeleteForbiddenWordBlocking(id))
})
}
func parseBlockingID(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 blocking rule id"})
return 0, false
}
return id, true
}
func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H) {
if errors.Is(err, errBlockingAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "blocking rule not found"})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(status, gin.H{"item": convert(*row)})
}
func writeBlockingDeleteResponse(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "blocking rule 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 nodeBlockingDTO(row nodeBlockingRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": ptrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func ipBlockingDTO(row ipBlockingRecord) gin.H {
return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func forbiddenWordBlockingDTO(row forbiddenWordBlockingRecord) gin.H {
return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"errors"
"fmt"
"net"
"strings"
"time"
"gorm.io/gorm"
)
const forbiddenWordMatchContains = "contains"
var errBlockingAlreadyExists = errors.New("blocking rule already exists")
func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error) {
opts = normalizeListOptions(opts)
var rows []nodeBlockingRecord
q := s.db.Model(&nodeBlockingRecord{}).
Order("updated_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
}
func (s *store) CountNodeBlocking(opts listOptions) (int64, error) {
var total int64
return total, s.db.Model(&nodeBlockingRecord{}).Count(&total).Error
}
func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) {
nodeID = strings.TrimSpace(nodeID)
if nodeID == "" {
return nil, fmt.Errorf("node id is required")
}
if err := s.ensureNodeBlockingUnique(0, nodeID); err != nil {
return nil, err
}
row := nodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled}
if err := s.db.Create(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) {
if id == 0 {
return nil, fmt.Errorf("blocking rule id is required")
}
nodeID = strings.TrimSpace(nodeID)
if nodeID == "" {
return nil, fmt.Errorf("node id is required")
}
if _, err := s.getNodeBlockingByID(id); err != nil {
return nil, err
}
if err := s.ensureNodeBlockingUnique(id, nodeID); err != nil {
return nil, err
}
updates := map[string]any{"node_id": nodeID, "node_num": nodeNum, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
if err := s.db.Model(&nodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
return s.getNodeBlockingByID(id)
}
func (s *store) DeleteNodeBlocking(id uint64) error {
result := s.db.Where("id = ?", id).Delete(&nodeBlockingRecord{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) {
opts = normalizeListOptions(opts)
var rows []ipBlockingRecord
q := s.db.Model(&ipBlockingRecord{}).
Order("updated_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
}
func (s *store) CountIPBlocking(opts listOptions) (int64, error) {
var total int64
return total, s.db.Model(&ipBlockingRecord{}).Count(&total).Error
}
func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) {
value, err := normalizeIPBlockingValue(ipValue)
if err != nil {
return nil, err
}
if err := s.ensureIPBlockingUnique(0, value); err != nil {
return nil, err
}
row := ipBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled}
if err := s.db.Create(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) {
if id == 0 {
return nil, fmt.Errorf("blocking rule id is required")
}
value, err := normalizeIPBlockingValue(ipValue)
if err != nil {
return nil, err
}
if _, err := s.getIPBlockingByID(id); err != nil {
return nil, err
}
if err := s.ensureIPBlockingUnique(id, value); err != nil {
return nil, err
}
updates := map[string]any{"ip_value": value, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
if err := s.db.Model(&ipBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
return s.getIPBlockingByID(id)
}
func (s *store) DeleteIPBlocking(id uint64) error {
result := s.db.Where("id = ?", id).Delete(&ipBlockingRecord{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBlockingRecord, error) {
opts = normalizeListOptions(opts)
var rows []forbiddenWordBlockingRecord
q := s.db.Model(&forbiddenWordBlockingRecord{}).
Order("updated_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
}
func (s *store) CountForbiddenWordBlocking(opts listOptions) (int64, error) {
var total int64
return total, s.db.Model(&forbiddenWordBlockingRecord{}).Count(&total).Error
}
func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) {
word = strings.TrimSpace(word)
if word == "" {
return nil, fmt.Errorf("forbidden word is required")
}
matchType, err := normalizeForbiddenWordMatchType(matchType)
if err != nil {
return nil, err
}
if err := s.ensureForbiddenWordBlockingUnique(0, word); err != nil {
return nil, err
}
row := forbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled}
if err := s.db.Create(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) {
if id == 0 {
return nil, fmt.Errorf("blocking rule id is required")
}
word = strings.TrimSpace(word)
if word == "" {
return nil, fmt.Errorf("forbidden word is required")
}
matchType, err := normalizeForbiddenWordMatchType(matchType)
if err != nil {
return nil, err
}
if _, err := s.getForbiddenWordBlockingByID(id); err != nil {
return nil, err
}
if err := s.ensureForbiddenWordBlockingUnique(id, word); err != nil {
return nil, err
}
updates := map[string]any{"word": word, "match_type": matchType, "case_sensitive": caseSensitive, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
if err := s.db.Model(&forbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
return s.getForbiddenWordBlockingByID(id)
}
func (s *store) DeleteForbiddenWordBlocking(id uint64) error {
result := s.db.Where("id = ?", id).Delete(&forbiddenWordBlockingRecord{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *store) getNodeBlockingByID(id uint64) (*nodeBlockingRecord, error) {
var row nodeBlockingRecord
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) getIPBlockingByID(id uint64) (*ipBlockingRecord, error) {
var row ipBlockingRecord
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) getForbiddenWordBlockingByID(id uint64) (*forbiddenWordBlockingRecord, error) {
var row forbiddenWordBlockingRecord
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
var existing nodeBlockingRecord
q := s.db.Where("node_id = ?", nodeID)
if id != 0 {
q = q.Where("id <> ?", id)
}
err := q.Take(&existing).Error
if err == nil {
return errBlockingAlreadyExists
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error {
var existing ipBlockingRecord
q := s.db.Where("ip_value = ?", ipValue)
if id != 0 {
q = q.Where("id <> ?", id)
}
err := q.Take(&existing).Error
if err == nil {
return errBlockingAlreadyExists
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
func (s *store) ensureForbiddenWordBlockingUnique(id uint64, word string) error {
var existing forbiddenWordBlockingRecord
q := s.db.Where("word = ?", word)
if id != 0 {
q = q.Where("id <> ?", id)
}
err := q.Take(&existing).Error
if err == nil {
return errBlockingAlreadyExists
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
func normalizeIPBlockingValue(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("ip value is required")
}
if ip := net.ParseIP(value); ip != nil {
return ip.String(), nil
}
_, ipNet, err := net.ParseCIDR(value)
if err == nil {
return ipNet.String(), nil
}
return "", fmt.Errorf("ip value must be a valid IP or CIDR")
}
func normalizeForbiddenWordMatchType(matchType string) (string, error) {
matchType = strings.TrimSpace(matchType)
if matchType == "" {
return forbiddenWordMatchContains, nil
}
if matchType != forbiddenWordMatchContains {
return "", fmt.Errorf("unsupported forbidden word match type")
}
return matchType, nil
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"errors"
"testing"
"gorm.io/gorm"
)
func TestNodeBlockingCRUD(t *testing.T) {
st := openTestStore(t)
defer st.Close()
nodeNum := int64(305419896)
rule, err := st.CreateNodeBlocking(" !12345678 ", &nodeNum, " noisy node ", true)
if err != nil {
t.Fatalf("CreateNodeBlocking() error = %v", err)
}
if rule.NodeID != "!12345678" || rule.NodeNum == nil || *rule.NodeNum != nodeNum || rule.Reason != "noisy node" || !rule.Enabled {
t.Fatalf("created node rule = %+v, want normalized fields", rule)
}
if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
t.Fatalf("duplicate CreateNodeBlocking() error = %v, want errBlockingAlreadyExists", err)
}
updatedNum := int64(7)
updated, err := st.UpdateNodeBlocking(rule.ID, "!00000007", &updatedNum, "updated", false)
if err != nil {
t.Fatalf("UpdateNodeBlocking() error = %v", err)
}
if updated.NodeID != "!00000007" || updated.NodeNum == nil || *updated.NodeNum != updatedNum || updated.Reason != "updated" || updated.Enabled {
t.Fatalf("updated node rule = %+v, want updated fields", updated)
}
rows, err := st.ListNodeBlocking(listOptions{})
if err != nil {
t.Fatalf("ListNodeBlocking() error = %v", err)
}
if len(rows) != 1 || rows[0].ID != rule.ID {
t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows)
}
total, err := st.CountNodeBlocking(listOptions{})
if err != nil || total != 1 {
t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err)
}
if err := st.DeleteNodeBlocking(rule.ID); err != nil {
t.Fatalf("DeleteNodeBlocking() error = %v", err)
}
if err := st.DeleteNodeBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("DeleteNodeBlocking(missing) error = %v, want record not found", err)
}
}
func TestNodeBlockingValidation(t *testing.T) {
st := openTestStore(t)
defer st.Close()
if _, err := st.CreateNodeBlocking(" ", nil, "", true); err == nil {
t.Fatal("CreateNodeBlocking(empty) error = nil, want error")
}
if _, err := st.UpdateNodeBlocking(1, "!missing", nil, "", true); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("UpdateNodeBlocking(missing) error = %v, want record not found", err)
}
}
func TestIPBlockingCRUDAndValidation(t *testing.T) {
st := openTestStore(t)
defer st.Close()
rule, err := st.CreateIPBlocking(" 127.0.0.1 ", "local", true)
if err != nil {
t.Fatalf("CreateIPBlocking(ip) error = %v", err)
}
if rule.IPValue != "127.0.0.1" || rule.Reason != "local" || !rule.Enabled {
t.Fatalf("created ip rule = %+v, want normalized IP", rule)
}
cidr, err := st.CreateIPBlocking("192.168.1.99/24", "cidr", true)
if err != nil {
t.Fatalf("CreateIPBlocking(cidr) error = %v", err)
}
if cidr.IPValue != "192.168.1.0/24" {
t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue)
}
if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
t.Fatalf("duplicate CreateIPBlocking() error = %v, want errBlockingAlreadyExists", err)
}
if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil {
t.Fatal("CreateIPBlocking(invalid) error = nil, want error")
}
updated, err := st.UpdateIPBlocking(rule.ID, "10.0.0.0/8", "updated", false)
if err != nil {
t.Fatalf("UpdateIPBlocking() error = %v", err)
}
if updated.IPValue != "10.0.0.0/8" || updated.Reason != "updated" || updated.Enabled {
t.Fatalf("updated ip rule = %+v, want updated fields", updated)
}
rows, err := st.ListIPBlocking(listOptions{})
if err != nil {
t.Fatalf("ListIPBlocking() error = %v", err)
}
if len(rows) != 2 {
t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows))
}
total, err := st.CountIPBlocking(listOptions{})
if err != nil || total != 2 {
t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err)
}
if err := st.DeleteIPBlocking(rule.ID); err != nil {
t.Fatalf("DeleteIPBlocking() error = %v", err)
}
if err := st.DeleteIPBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("DeleteIPBlocking(missing) error = %v, want record not found", err)
}
}
func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) {
st := openTestStore(t)
defer st.Close()
rule, err := st.CreateForbiddenWordBlocking(" spam ", "", false, "junk", true)
if err != nil {
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
}
if rule.Word != "spam" || rule.MatchType != forbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled {
t.Fatalf("created word rule = %+v, want normalized fields", rule)
}
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want errBlockingAlreadyExists", err)
}
if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil {
t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error")
}
if _, err := st.CreateForbiddenWordBlocking("regex", "regex", false, "unsupported", true); err == nil {
t.Fatal("CreateForbiddenWordBlocking(unsupported match type) error = nil, want error")
}
updated, err := st.UpdateForbiddenWordBlocking(rule.ID, "Spam", "contains", true, "updated", false)
if err != nil {
t.Fatalf("UpdateForbiddenWordBlocking() error = %v", err)
}
if updated.Word != "Spam" || updated.MatchType != "contains" || !updated.CaseSensitive || updated.Reason != "updated" || updated.Enabled {
t.Fatalf("updated word rule = %+v, want updated fields", updated)
}
rows, err := st.ListForbiddenWordBlocking(listOptions{})
if err != nil {
t.Fatalf("ListForbiddenWordBlocking() error = %v", err)
}
if len(rows) != 1 || rows[0].ID != rule.ID {
t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows)
}
total, err := st.CountForbiddenWordBlocking(listOptions{})
if err != nil || total != 1 {
t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err)
}
if err := st.DeleteForbiddenWordBlocking(rule.ID); err != nil {
t.Fatalf("DeleteForbiddenWordBlocking() error = %v", err)
}
if err := st.DeleteForbiddenWordBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("DeleteForbiddenWordBlocking(missing) error = %v, want record not found", err)
}
}
+45
View File
@@ -111,6 +111,48 @@ func (discardDetailsRecord) TableName() string {
return "discard_details"
}
type nodeBlockingRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
NodeID string `gorm:"column:node_id;not null;uniqueIndex"`
NodeNum *int64 `gorm:"column:node_num;index"`
Reason string `gorm:"column:reason"`
Enabled bool `gorm:"column:enabled;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (nodeBlockingRecord) TableName() string {
return "node_blocking"
}
type ipBlockingRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
IPValue string `gorm:"column:ip_value;not null;uniqueIndex"`
Reason string `gorm:"column:reason"`
Enabled bool `gorm:"column:enabled;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (ipBlockingRecord) TableName() string {
return "ip_blocking"
}
type forbiddenWordBlockingRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Word string `gorm:"column:word;not null;uniqueIndex"`
MatchType string `gorm:"column:match_type;not null;index"`
CaseSensitive bool `gorm:"column:case_sensitive;not null"`
Reason string `gorm:"column:reason"`
Enabled bool `gorm:"column:enabled;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (forbiddenWordBlockingRecord) TableName() string {
return "forbidden_word_blocking"
}
type nodeInfoRecord struct {
NodeID string `gorm:"column:node_id;primaryKey;not null"`
NodeNum int64 `gorm:"column:node_num;not null;index"`
@@ -306,6 +348,9 @@ func (s *store) migrate() error {
{label: "users", model: &userRecord{}},
{label: "login_log", model: &loginLogRecord{}},
{label: "discard_details", model: &discardDetailsRecord{}},
{label: "node_blocking", model: &nodeBlockingRecord{}},
{label: "ip_blocking", model: &ipBlockingRecord{}},
{label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}},
{label: "nodeinfo", model: &nodeInfoRecord{}},
{label: "map_report", model: &mapReportRecord{}},
{label: "text_message", model: &textMessageRecord{}},
+1 -1
View File
@@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
st := openTestStore(t)
defer st.Close()
for _, table := range []string{"users", "login_log", "discard_details", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
for _, table := range []string{"users", "login_log", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
var name string
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
t.Fatalf("%s table missing: %v", table, err)
+3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
import AdminDashboard from './components/AdminDashboard.vue'
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
import AdminLogin from './components/AdminLogin.vue'
@@ -248,6 +249,7 @@ onBeforeUnmount(() => {
<nav v-if="adminUser" class="admin-nav">
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
</nav>
@@ -282,6 +284,7 @@ onBeforeUnmount(() => {
<button class="admin-button" @click="logoutAdmin">退出登录</button>
</div>
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
<AdminDashboard v-else />
+55
View File
@@ -4,10 +4,17 @@ import type {
AdminManagedUserResponse,
AdminMqttStatus,
AdminUsersResponse,
BlockingRuleResponse,
DiscardDetails,
ForbiddenWordBlockingRule,
ForbiddenWordBlockingRulePayload,
HealthStatus,
IPBlockingRule,
IPBlockingRulePayload,
ListResponse,
MapReport,
NodeBlockingRule,
NodeBlockingRulePayload,
NodeInfo,
PositionRecord,
TelemetryRecord,
@@ -138,3 +145,51 @@ export function updateAdminUserPassword(id: number, password: string): Promise<A
export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLogsResponse> {
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
}
export function getNodeBlockingRules(limit = 100, offset = 0): Promise<ListResponse<NodeBlockingRule>> {
return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset))
}
export function createNodeBlockingRule(payload: NodeBlockingRulePayload): Promise<BlockingRuleResponse<NodeBlockingRule>> {
return postJSON<BlockingRuleResponse<NodeBlockingRule>>('/api/admin/blocking/nodes', payload)
}
export function updateNodeBlockingRule(id: number, payload: NodeBlockingRulePayload): Promise<BlockingRuleResponse<NodeBlockingRule>> {
return putJSON<BlockingRuleResponse<NodeBlockingRule>>(`/api/admin/blocking/nodes/${id}`, payload)
}
export function deleteNodeBlockingRule(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/blocking/nodes/${id}`)
}
export function getIPBlockingRules(limit = 100, offset = 0): Promise<ListResponse<IPBlockingRule>> {
return getJSON<ListResponse<IPBlockingRule>>(listPath('/api/admin/blocking/ips', limit, offset))
}
export function createIPBlockingRule(payload: IPBlockingRulePayload): Promise<BlockingRuleResponse<IPBlockingRule>> {
return postJSON<BlockingRuleResponse<IPBlockingRule>>('/api/admin/blocking/ips', payload)
}
export function updateIPBlockingRule(id: number, payload: IPBlockingRulePayload): Promise<BlockingRuleResponse<IPBlockingRule>> {
return putJSON<BlockingRuleResponse<IPBlockingRule>>(`/api/admin/blocking/ips/${id}`, payload)
}
export function deleteIPBlockingRule(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/blocking/ips/${id}`)
}
export function getForbiddenWordBlockingRules(limit = 100, offset = 0): Promise<ListResponse<ForbiddenWordBlockingRule>> {
return getJSON<ListResponse<ForbiddenWordBlockingRule>>(listPath('/api/admin/blocking/words', limit, offset))
}
export function createForbiddenWordBlockingRule(payload: ForbiddenWordBlockingRulePayload): Promise<BlockingRuleResponse<ForbiddenWordBlockingRule>> {
return postJSON<BlockingRuleResponse<ForbiddenWordBlockingRule>>('/api/admin/blocking/words', payload)
}
export function updateForbiddenWordBlockingRule(id: number, payload: ForbiddenWordBlockingRulePayload): Promise<BlockingRuleResponse<ForbiddenWordBlockingRule>> {
return putJSON<BlockingRuleResponse<ForbiddenWordBlockingRule>>(`/api/admin/blocking/words/${id}`, payload)
}
export function deleteForbiddenWordBlockingRule(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/blocking/words/${id}`)
}
@@ -0,0 +1,640 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
createForbiddenWordBlockingRule,
createIPBlockingRule,
createNodeBlockingRule,
deleteForbiddenWordBlockingRule,
deleteIPBlockingRule,
deleteNodeBlockingRule,
getForbiddenWordBlockingRules,
getIPBlockingRules,
getNodeBlockingRules,
updateForbiddenWordBlockingRule,
updateIPBlockingRule,
updateNodeBlockingRule,
} from '../api'
import type {
ForbiddenWordBlockingRule,
ForbiddenWordBlockingRulePayload,
IPBlockingRule,
IPBlockingRulePayload,
NodeBlockingRule,
NodeBlockingRulePayload,
} from '../types'
const pageSize = 25
const nodeRules = ref<NodeBlockingRule[]>([])
const nodeLoading = ref(false)
const nodeError = ref('')
const nodeMessage = ref('')
const nodePage = ref(1)
const nodeTotal = ref(0)
const newNodeId = ref('')
const newNodeNum = ref('')
const newNodeReason = ref('')
const newNodeEnabled = ref(true)
const nodeEdits = ref<Record<number, { node_id: string; node_num: string; reason: string; enabled: boolean }>>({})
const ipRules = ref<IPBlockingRule[]>([])
const ipLoading = ref(false)
const ipError = ref('')
const ipMessage = ref('')
const ipPage = ref(1)
const ipTotal = ref(0)
const newIPValue = ref('')
const newIPReason = ref('')
const newIPEnabled = ref(true)
const ipEdits = ref<Record<number, IPBlockingRulePayload>>({})
const wordRules = ref<ForbiddenWordBlockingRule[]>([])
const wordLoading = ref(false)
const wordError = ref('')
const wordMessage = ref('')
const wordPage = ref(1)
const wordTotal = ref(0)
const newWord = ref('')
const newWordMatchType = ref('contains')
const newWordCaseSensitive = ref(false)
const newWordReason = ref('')
const newWordEnabled = ref(true)
const wordEdits = ref<Record<number, ForbiddenWordBlockingRulePayload>>({})
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function canPrev(page: number): boolean {
return page > 1
}
function canNext(page: number, total: number, count: number): boolean {
return page * pageSize < total || count === pageSize
}
function parseOptionalInt(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) {
return null
}
const parsed = Number.parseInt(trimmed, 10)
if (!Number.isFinite(parsed) || String(parsed) !== trimmed) {
throw new Error('节点数字 ID 必须是整数')
}
return parsed
}
function nodePayload(nodeId: string, nodeNum: string, reason: string, enabled: boolean): NodeBlockingRulePayload {
if (!nodeId.trim()) {
throw new Error('节点 ID 不能为空')
}
return {
node_id: nodeId.trim(),
node_num: parseOptionalInt(nodeNum),
reason: reason.trim(),
enabled,
}
}
function ipPayload(ipValue: string, reason: string, enabled: boolean): IPBlockingRulePayload {
if (!ipValue.trim()) {
throw new Error('IP 或 CIDR 不能为空')
}
return { ip_value: ipValue.trim(), reason: reason.trim(), enabled }
}
function wordPayload(word: string, matchType: string, caseSensitive: boolean, reason: string, enabled: boolean): ForbiddenWordBlockingRulePayload {
if (!word.trim()) {
throw new Error('违禁词不能为空')
}
return {
word: word.trim(),
match_type: matchType || 'contains',
case_sensitive: caseSensitive,
reason: reason.trim(),
enabled,
}
}
function resetNodeEdits(items: NodeBlockingRule[]) {
nodeEdits.value = Object.fromEntries(
items.map((item) => [
item.id,
{
node_id: item.node_id,
node_num: item.node_num == null ? '' : String(item.node_num),
reason: item.reason,
enabled: item.enabled,
},
]),
)
}
function resetIPEdits(items: IPBlockingRule[]) {
ipEdits.value = Object.fromEntries(items.map((item) => [item.id, { ip_value: item.ip_value, reason: item.reason, enabled: item.enabled }]))
}
function resetWordEdits(items: ForbiddenWordBlockingRule[]) {
wordEdits.value = Object.fromEntries(
items.map((item) => [
item.id,
{
word: item.word,
match_type: item.match_type,
case_sensitive: item.case_sensitive,
reason: item.reason,
enabled: item.enabled,
},
]),
)
}
async function refreshNodeRules(page = nodePage.value) {
nodeLoading.value = true
nodeError.value = ''
try {
const safePage = Math.max(1, page)
const response = await getNodeBlockingRules(pageSize, (safePage - 1) * pageSize)
nodeRules.value = response.items
nodeTotal.value = response.total ?? response.offset + response.items.length
nodePage.value = safePage
resetNodeEdits(response.items)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function createNodeRule() {
nodeError.value = ''
nodeMessage.value = ''
let payload: NodeBlockingRulePayload
try {
payload = nodePayload(newNodeId.value, newNodeNum.value, newNodeReason.value, newNodeEnabled.value)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
return
}
nodeLoading.value = true
try {
await createNodeBlockingRule(payload)
newNodeId.value = ''
newNodeNum.value = ''
newNodeReason.value = ''
newNodeEnabled.value = true
nodeMessage.value = '节点屏蔽规则已新增'
await refreshNodeRules(1)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function saveNodeRule(rule: NodeBlockingRule) {
nodeError.value = ''
nodeMessage.value = ''
const edit = nodeEdits.value[rule.id]
if (!edit) {
return
}
let payload: NodeBlockingRulePayload
try {
payload = nodePayload(edit.node_id, edit.node_num, edit.reason, edit.enabled)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
return
}
nodeLoading.value = true
try {
await updateNodeBlockingRule(rule.id, payload)
nodeMessage.value = '节点屏蔽规则已保存'
await refreshNodeRules()
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function removeNodeRule(rule: NodeBlockingRule) {
nodeError.value = ''
nodeMessage.value = ''
nodeLoading.value = true
try {
await deleteNodeBlockingRule(rule.id)
nodeMessage.value = '节点屏蔽规则已删除'
await refreshNodeRules(nodeRules.value.length === 1 ? nodePage.value - 1 : nodePage.value)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function refreshIPRules(page = ipPage.value) {
ipLoading.value = true
ipError.value = ''
try {
const safePage = Math.max(1, page)
const response = await getIPBlockingRules(pageSize, (safePage - 1) * pageSize)
ipRules.value = response.items
ipTotal.value = response.total ?? response.offset + response.items.length
ipPage.value = safePage
resetIPEdits(response.items)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function createIPRule() {
ipError.value = ''
ipMessage.value = ''
let payload: IPBlockingRulePayload
try {
payload = ipPayload(newIPValue.value, newIPReason.value, newIPEnabled.value)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
return
}
ipLoading.value = true
try {
await createIPBlockingRule(payload)
newIPValue.value = ''
newIPReason.value = ''
newIPEnabled.value = true
ipMessage.value = 'IP 屏蔽规则已新增'
await refreshIPRules(1)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function saveIPRule(rule: IPBlockingRule) {
ipError.value = ''
ipMessage.value = ''
const edit = ipEdits.value[rule.id]
if (!edit) {
return
}
let payload: IPBlockingRulePayload
try {
payload = ipPayload(edit.ip_value, edit.reason, edit.enabled)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
return
}
ipLoading.value = true
try {
await updateIPBlockingRule(rule.id, payload)
ipMessage.value = 'IP 屏蔽规则已保存'
await refreshIPRules()
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function removeIPRule(rule: IPBlockingRule) {
ipError.value = ''
ipMessage.value = ''
ipLoading.value = true
try {
await deleteIPBlockingRule(rule.id)
ipMessage.value = 'IP 屏蔽规则已删除'
await refreshIPRules(ipRules.value.length === 1 ? ipPage.value - 1 : ipPage.value)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function refreshWordRules(page = wordPage.value) {
wordLoading.value = true
wordError.value = ''
try {
const safePage = Math.max(1, page)
const response = await getForbiddenWordBlockingRules(pageSize, (safePage - 1) * pageSize)
wordRules.value = response.items
wordTotal.value = response.total ?? response.offset + response.items.length
wordPage.value = safePage
resetWordEdits(response.items)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
async function createWordRule() {
wordError.value = ''
wordMessage.value = ''
let payload: ForbiddenWordBlockingRulePayload
try {
payload = wordPayload(newWord.value, newWordMatchType.value, newWordCaseSensitive.value, newWordReason.value, newWordEnabled.value)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
return
}
wordLoading.value = true
try {
await createForbiddenWordBlockingRule(payload)
newWord.value = ''
newWordMatchType.value = 'contains'
newWordCaseSensitive.value = false
newWordReason.value = ''
newWordEnabled.value = true
wordMessage.value = '违禁词屏蔽规则已新增'
await refreshWordRules(1)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
async function saveWordRule(rule: ForbiddenWordBlockingRule) {
wordError.value = ''
wordMessage.value = ''
const edit = wordEdits.value[rule.id]
if (!edit) {
return
}
let payload: ForbiddenWordBlockingRulePayload
try {
payload = wordPayload(edit.word, edit.match_type, edit.case_sensitive, edit.reason, edit.enabled)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
return
}
wordLoading.value = true
try {
await updateForbiddenWordBlockingRule(rule.id, payload)
wordMessage.value = '违禁词屏蔽规则已保存'
await refreshWordRules()
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
async function removeWordRule(rule: ForbiddenWordBlockingRule) {
wordError.value = ''
wordMessage.value = ''
wordLoading.value = true
try {
await deleteForbiddenWordBlockingRule(rule.id)
wordMessage.value = '违禁词屏蔽规则已删除'
await refreshWordRules(wordRules.value.length === 1 ? wordPage.value - 1 : wordPage.value)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
onMounted(() => {
refreshNodeRules()
refreshIPRules()
refreshWordRules()
})
</script>
<template>
<section class="admin-dashboard">
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Blocking</p>
<h2>屏蔽管理</h2>
</div>
</div>
<p class="empty">管理节点IP/CIDR违禁词三类屏蔽规则当前页面只维护规则不改变 MQTT 转发行为</p>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Forbidden Words</p>
<h2>违禁词屏蔽</h2>
</div>
<button class="admin-button" @click="refreshWordRules()" :disabled="wordLoading">{{ wordLoading ? '刷新中...' : '刷新' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createWordRule">
<label>
<span>违禁词</span>
<input v-model="newWord" autocomplete="off" placeholder="spam" />
</label>
<label>
<span>匹配类型</span>
<select v-model="newWordMatchType" class="admin-table-input">
<option value="contains">包含</option>
</select>
</label>
<label>
<span>区分大小写</span>
<input v-model="newWordCaseSensitive" type="checkbox" />
</label>
<label>
<span>原因</span>
<input v-model="newWordReason" autocomplete="off" placeholder="policy" />
</label>
<label>
<span>启用</span>
<input v-model="newWordEnabled" type="checkbox" />
</label>
<button class="admin-button" :disabled="wordLoading" type="submit">新增违禁词规则</button>
</form>
<p v-if="wordError" class="error">{{ wordError }}</p>
<p v-if="wordMessage" class="success">{{ wordMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>违禁词</th>
<th>匹配类型</th>
<th>区分大小写</th>
<th>原因</th>
<th>启用</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in wordRules" :key="rule.id">
<td>{{ rule.id }}</td>
<td><input v-model="wordEdits[rule.id].word" class="admin-table-input" /></td>
<td>
<select v-model="wordEdits[rule.id].match_type" class="admin-table-input">
<option value="contains">包含</option>
</select>
</td>
<td><input v-model="wordEdits[rule.id].case_sensitive" type="checkbox" /></td>
<td><input v-model="wordEdits[rule.id].reason" class="admin-table-input" /></td>
<td><input v-model="wordEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td>
<td>
<button class="admin-button" :disabled="wordLoading" @click="saveWordRule(rule)">保存</button>
<button class="admin-button" :disabled="wordLoading" @click="removeWordRule(rule)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="wordRules.length === 0" class="empty">暂无违禁词屏蔽规则</div>
</div>
<div class="pagination">
<button class="admin-button" :disabled="wordLoading || !canPrev(wordPage)" @click="refreshWordRules(wordPage - 1)">上一页</button>
<span> {{ wordPage }} · {{ wordTotal }} </span>
<button class="admin-button" :disabled="wordLoading || !canNext(wordPage, wordTotal, wordRules.length)" @click="refreshWordRules(wordPage + 1)">下一页</button>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Nodes</p>
<h2>节点屏蔽</h2>
</div>
<button class="admin-button" @click="refreshNodeRules()" :disabled="nodeLoading">{{ nodeLoading ? '刷新中...' : '刷新' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createNodeRule">
<label>
<span>节点 ID</span>
<input v-model="newNodeId" autocomplete="off" placeholder="!12345678" />
</label>
<label>
<span>节点数字 ID</span>
<input v-model="newNodeNum" autocomplete="off" placeholder="可选" />
</label>
<label>
<span>原因</span>
<input v-model="newNodeReason" autocomplete="off" placeholder="spam / abuse" />
</label>
<label>
<span>启用</span>
<input v-model="newNodeEnabled" type="checkbox" />
</label>
<button class="admin-button" :disabled="nodeLoading" type="submit">新增节点规则</button>
</form>
<p v-if="nodeError" class="error">{{ nodeError }}</p>
<p v-if="nodeMessage" class="success">{{ nodeMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>节点 ID</th>
<th>数字 ID</th>
<th>原因</th>
<th>启用</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in nodeRules" :key="rule.id">
<td>{{ rule.id }}</td>
<td><input v-model="nodeEdits[rule.id].node_id" class="admin-table-input" /></td>
<td><input v-model="nodeEdits[rule.id].node_num" class="admin-table-input" placeholder="可选" /></td>
<td><input v-model="nodeEdits[rule.id].reason" class="admin-table-input" /></td>
<td><input v-model="nodeEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td>
<td>
<button class="admin-button" :disabled="nodeLoading" @click="saveNodeRule(rule)">保存</button>
<button class="admin-button" :disabled="nodeLoading" @click="removeNodeRule(rule)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="nodeRules.length === 0" class="empty">暂无节点屏蔽规则</div>
</div>
<div class="pagination">
<button class="admin-button" :disabled="nodeLoading || !canPrev(nodePage)" @click="refreshNodeRules(nodePage - 1)">上一页</button>
<span> {{ nodePage }} · {{ nodeTotal }} </span>
<button class="admin-button" :disabled="nodeLoading || !canNext(nodePage, nodeTotal, nodeRules.length)" @click="refreshNodeRules(nodePage + 1)">下一页</button>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">IP / CIDR</p>
<h2>IP 屏蔽</h2>
</div>
<button class="admin-button" @click="refreshIPRules()" :disabled="ipLoading">{{ ipLoading ? '刷新中...' : '刷新' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createIPRule">
<label>
<span>IP CIDR</span>
<input v-model="newIPValue" autocomplete="off" placeholder="127.0.0.1 或 192.168.1.0/24" />
</label>
<label>
<span>原因</span>
<input v-model="newIPReason" autocomplete="off" placeholder="abuse" />
</label>
<label>
<span>启用</span>
<input v-model="newIPEnabled" type="checkbox" />
</label>
<button class="admin-button" :disabled="ipLoading" type="submit">新增 IP 规则</button>
</form>
<p v-if="ipError" class="error">{{ ipError }}</p>
<p v-if="ipMessage" class="success">{{ ipMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>IP/CIDR</th>
<th>原因</th>
<th>启用</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in ipRules" :key="rule.id">
<td>{{ rule.id }}</td>
<td><input v-model="ipEdits[rule.id].ip_value" class="admin-table-input" /></td>
<td><input v-model="ipEdits[rule.id].reason" class="admin-table-input" /></td>
<td><input v-model="ipEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td>
<td>
<button class="admin-button" :disabled="ipLoading" @click="saveIPRule(rule)">保存</button>
<button class="admin-button" :disabled="ipLoading" @click="removeIPRule(rule)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="ipRules.length === 0" class="empty">暂无 IP 屏蔽规则</div>
</div>
<div class="pagination">
<button class="admin-button" :disabled="ipLoading || !canPrev(ipPage)" @click="refreshIPRules(ipPage - 1)">上一页</button>
<span> {{ ipPage }} · {{ ipTotal }} </span>
<button class="admin-button" :disabled="ipLoading || !canNext(ipPage, ipTotal, ipRules.length)" @click="refreshIPRules(ipPage + 1)">下一页</button>
</div>
</div>
</section>
</template>
+55
View File
@@ -183,3 +183,58 @@ export interface AdminMqttStatus {
packets_sent: number
clients: AdminMqttClient[]
}
export interface NodeBlockingRule {
id: number
node_id: string
node_num: number | null
reason: string
enabled: boolean
created_at: string
updated_at: string
}
export interface NodeBlockingRulePayload {
node_id: string
node_num: number | null
reason: string
enabled: boolean
}
export interface IPBlockingRule {
id: number
ip_value: string
reason: string
enabled: boolean
created_at: string
updated_at: string
}
export interface IPBlockingRulePayload {
ip_value: string
reason: string
enabled: boolean
}
export interface ForbiddenWordBlockingRule {
id: number
word: string
match_type: string
case_sensitive: boolean
reason: string
enabled: boolean
created_at: string
updated_at: string
}
export interface ForbiddenWordBlockingRulePayload {
word: string
match_type: string
case_sensitive: boolean
reason: string
enabled: boolean
}
export interface BlockingRuleResponse<T> {
item: T
}
+1
View File
@@ -156,6 +156,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
protected := r.Group("")
protected.Use(requireAdmin(sessions))
registerAdminBlockingRoutes(protected, store)
protected.GET("/me", func(c *gin.Context) {
claims := c.MustGet("admin_claims").(*sessionClaims)
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})