feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View 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")
}
}