feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
224
backend/service/sidebar_test.go
Normal file
224
backend/service/sidebar_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newSidebarTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SiteSetting{}, &model.SidebarWidget{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSidebarGetConfigDefaults(t *testing.T) {
|
||||
db := newSidebarTestDB(t)
|
||||
s := NewSidebarService(db)
|
||||
|
||||
// 无键 → 默认布局
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig: %v", err)
|
||||
}
|
||||
def := DefaultSidebarConfig()
|
||||
home := cfg.Scopes["home"]
|
||||
if home.Left == nil || (*home.Left)[0].Key != "board_nav" {
|
||||
t.Fatal("home.left 默认应为 board_nav")
|
||||
}
|
||||
if len(*home.Right) != len(*def.Scopes["home"].Right) {
|
||||
t.Fatalf("home.right 默认长度不符: %d", len(*home.Right))
|
||||
}
|
||||
// board.left 恒为空
|
||||
if bl := cfg.Scopes["board"].Left; bl == nil || len(*bl) != 0 {
|
||||
t.Fatal("board.left 默认应为空")
|
||||
}
|
||||
|
||||
// 坏 JSON → 回落默认
|
||||
if err := db.Create(&model.SiteSetting{Key: SettingKeySidebarConfig, Value: "{oops"}).Error; err != nil {
|
||||
t.Fatalf("seed bad json: %v", err)
|
||||
}
|
||||
cfg2, err := s.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig bad json: %v", err)
|
||||
}
|
||||
if (*cfg2.Scopes["home"].Left)[0].Key != "board_nav" {
|
||||
t.Fatal("坏 JSON 应回落默认布局")
|
||||
}
|
||||
|
||||
// 显式清空的侧栏要保持为空(非默认回填)
|
||||
empty := []SidebarBlockRef{}
|
||||
saved, err := s.SaveConfig(SidebarConfig{
|
||||
Version: SidebarConfigVersion,
|
||||
Scopes: map[string]SidebarScopeConfig{
|
||||
"home": {Right: &empty},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
if saved.Scopes["home"].Right == nil || len(*saved.Scopes["home"].Right) != 0 {
|
||||
t.Fatal("显式清空的 home.right 应保持为空")
|
||||
}
|
||||
cfg3, _ := s.GetConfig()
|
||||
if len(*cfg3.Scopes["home"].Right) != 0 {
|
||||
t.Fatal("读取时显式清空不应被默认回填")
|
||||
}
|
||||
if len(*cfg3.Scopes["post"].Left) == 0 {
|
||||
t.Fatal("未配置的 post.left 应回落默认")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarSaveConfigValidation(t *testing.T) {
|
||||
db := newSidebarTestDB(t)
|
||||
s := NewSidebarService(db)
|
||||
|
||||
mk := func(scopes map[string]SidebarScopeConfig) SidebarConfig {
|
||||
return SidebarConfig{Version: SidebarConfigVersion, Scopes: scopes}
|
||||
}
|
||||
side := func(refs []SidebarBlockRef) *[]SidebarBlockRef { return &refs }
|
||||
|
||||
// 非法 scope
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"unknown": {Right: side([]SidebarBlockRef{{Key: "pulse"}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("非法 scope 应被拒绝")
|
||||
}
|
||||
// 非法 key(board_* 不允许放进 home)
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{Key: "board_hot"}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("跨 scope 的内置 key 应被拒绝")
|
||||
}
|
||||
// 引用不存在的自定义工具
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{WidgetID: 999}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("不存在的 widget 引用应被拒绝")
|
||||
}
|
||||
// 同 scope 重复引用
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{Key: "pulse"}, {Key: "pulse"}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("同 scope 重复引用应被拒绝")
|
||||
}
|
||||
// 超 24 块
|
||||
many := make([]SidebarBlockRef, SidebarMaxBlocksPerSide+1)
|
||||
for i := range many {
|
||||
many[i] = SidebarBlockRef{Key: "pulse"}
|
||||
}
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: &many},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("超上限应被拒绝")
|
||||
}
|
||||
// key 与 widget 同时出现
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{Key: "pulse", WidgetID: 1}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("key 与 widget 二选一应被强制")
|
||||
}
|
||||
// 空 scopes
|
||||
if _, err := s.SaveConfig(SidebarConfig{Version: 1}); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("空 scopes 应被拒绝")
|
||||
}
|
||||
// board.left 保存后应被丢弃(即使引用合法的 board 块)
|
||||
saved, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"board": {Left: side([]SidebarBlockRef{{Key: "board_intro"}}), Right: side([]SidebarBlockRef{{Key: "board_hot"}})},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("board 保存失败: %v", err)
|
||||
}
|
||||
if len(*saved.Scopes["board"].Left) != 0 {
|
||||
t.Fatal("board.left 应被丢弃为空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarWidgetCRUDAndRefCleanup(t *testing.T) {
|
||||
db := newSidebarTestDB(t)
|
||||
s := NewSidebarService(db)
|
||||
|
||||
// 名称校验
|
||||
w := &model.SidebarWidget{Name: " ", Html: "<b>x</b>"}
|
||||
if err := s.CreateWidget(w); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("空名称应被拒绝")
|
||||
}
|
||||
w = &model.SidebarWidget{Name: strings.Repeat("字", SidebarWidgetMaxNameRunes+1)}
|
||||
if err := s.CreateWidget(w); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("超长名称应被拒绝")
|
||||
}
|
||||
big := strings.Repeat("a", SidebarWidgetMaxCodeBytes+1)
|
||||
if err := s.CreateWidget(&model.SidebarWidget{Name: "t", Html: big}); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("超 64KB 代码应被拒绝")
|
||||
}
|
||||
|
||||
// 正常创建 + 放入配置
|
||||
w = &model.SidebarWidget{Name: "跑马灯", Html: "<div>hi</div>", Enabled: false}
|
||||
if err := s.CreateWidget(w); err != nil {
|
||||
t.Fatalf("CreateWidget: %v", err)
|
||||
}
|
||||
if w.ID == 0 || !w.Enabled {
|
||||
t.Fatalf("创建后应启用且有 ID: %+v", w)
|
||||
}
|
||||
if _, err := s.SaveConfig(SidebarConfig{Version: SidebarConfigVersion, Scopes: map[string]SidebarScopeConfig{
|
||||
"home": {Right: func() *[]SidebarBlockRef {
|
||||
r := []SidebarBlockRef{{Key: "pulse"}, {WidgetID: w.ID}}
|
||||
return &r
|
||||
}()},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveConfig with widget: %v", err)
|
||||
}
|
||||
|
||||
// 更新:显式禁用(布尔零值不能被省略)
|
||||
if err := s.UpdateWidget(w.ID, &model.SidebarWidget{Name: "跑马灯2", Html: "<div>hi2</div>", Js: "console.log(1)", Enabled: false}); err != nil {
|
||||
t.Fatalf("UpdateWidget: %v", err)
|
||||
}
|
||||
var after model.SidebarWidget
|
||||
if err := db.First(&after, w.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if after.Enabled || after.Name != "跑马灯2" || after.Js == "" {
|
||||
t.Fatalf("禁用/字段更新未生效: %+v", after)
|
||||
}
|
||||
|
||||
// 禁用后不出现在公开载荷
|
||||
payload, err := s.PublicPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("PublicPayload: %v", err)
|
||||
}
|
||||
if len(payload.Widgets) != 0 {
|
||||
t.Fatal("禁用的工具不应出现在公开载荷")
|
||||
}
|
||||
if err := s.SetWidgetEnabled(w.ID, true); err != nil {
|
||||
t.Fatalf("SetWidgetEnabled: %v", err)
|
||||
}
|
||||
payload, _ = s.PublicPayload()
|
||||
if len(payload.Widgets) != 1 || payload.Widgets[0].Html != "<div>hi2</div>" {
|
||||
t.Fatalf("启用后应出现在公开载荷: %+v", payload.Widgets)
|
||||
}
|
||||
|
||||
// 删除工具 → 配置中的引用被清理
|
||||
if err := s.DeleteWidget(w.ID); err != nil {
|
||||
t.Fatalf("DeleteWidget: %v", err)
|
||||
}
|
||||
cfg, _ := s.GetConfig()
|
||||
for _, r := range *cfg.Scopes["home"].Right {
|
||||
if r.WidgetID == w.ID {
|
||||
t.Fatal("删除后配置中不应残留引用")
|
||||
}
|
||||
}
|
||||
// 重复删除 → NotFound
|
||||
if err := s.DeleteWidget(w.ID); !errors.Is(err, ErrSidebarWidgetNotFound) {
|
||||
t.Fatal("重复删除应返回 NotFound")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user