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

453
backend/service/sidebar.go Normal file
View File

@@ -0,0 +1,453 @@
package service
import (
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// SettingKeySidebarConfig 侧栏布局配置在 site_settings 中的键(沿 ads_config JSON 先例)
const SettingKeySidebarConfig = "sidebar_config"
const (
// SidebarConfigVersion 配置结构版本;升级结构时递增并做迁移
SidebarConfigVersion = 1
// SidebarMaxBlocksPerSide 每侧最多放置的块数
SidebarMaxBlocksPerSide = 24
// SidebarWidgetMaxNameRunes 自定义工具备注名上限(字符数)
SidebarWidgetMaxNameRunes = 40
// SidebarWidgetMaxCodeBytes 单段代码(HTML/CSS/JS)字节上限
SidebarWidgetMaxCodeBytes = 64 << 10
)
var (
// ErrSidebarInvalid 侧栏配置/工具参数校验失败
ErrSidebarInvalid = errors.New("侧栏配置参数无效")
// ErrSidebarWidgetNotFound 自定义工具不存在
ErrSidebarWidgetNotFound = errors.New("侧栏工具不存在")
)
// 内置块 key 白名单(与前端 lib/sidebarMeta.ts 保持一致)
var sidebarBuiltinKeys = map[string]map[string]struct{}{
"home": setOf("board_nav", "pulse", "announcements", "ads", "sponsors",
"hot", "new_users", "active_users", "friend_links", "about_site"),
// board scope 仅右栏可配;board_* 不允许放进 home/post
"board": setOf("board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"),
"post": setOf("post_board", "post_author", "post_author_posts", "post_toc"),
}
// sidebarScopes 合法页面范围(board 布局只存 right;board_* 块仅限 board scope)
var sidebarScopes = setOf("home", "board", "post")
func setOf(keys ...string) map[string]struct{} {
m := make(map[string]struct{}, len(keys))
for _, k := range keys {
m[k] = struct{}{}
}
return m
}
// SidebarBlockRef 侧栏块引用:内置块用 Key,自定义工具用 WidgetID(二选一)
type SidebarBlockRef struct {
Key string `json:"key,omitempty"`
WidgetID uint `json:"widget,omitempty"`
}
// SidebarScopeConfig 单个页面范围的侧栏布局;指针切片区分「未配置(回落默认)」与「显式清空」
type SidebarScopeConfig struct {
Left *[]SidebarBlockRef `json:"left,omitempty"`
Right *[]SidebarBlockRef `json:"right,omitempty"`
}
// SidebarConfig 全站侧栏布局配置
type SidebarConfig struct {
Version int `json:"version"`
Scopes map[string]SidebarScopeConfig `json:"scopes"`
}
// SidebarPublicWidget 前台可见的自定义工具内容(仅启用项)
type SidebarPublicWidget struct {
ID uint `json:"id"`
Name string `json:"name"`
Html string `json:"html"`
Css string `json:"css"`
Js string `json:"js"`
}
// SidebarPublicPayload 公开侧栏载荷:生效布局 + 启用中的自定义工具
type SidebarPublicPayload struct {
Config SidebarConfig `json:"config"`
Widgets []SidebarPublicWidget `json:"widgets"`
}
func refKey(r SidebarBlockRef) string {
if r.WidgetID > 0 {
return "widget:" + itoa(int(r.WidgetID))
}
return "key:" + r.Key
}
func refsEqualRefs(a, b []SidebarBlockRef) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func defaultScopeLeft(scope string) *[]SidebarBlockRef {
var refs []SidebarBlockRef
switch scope {
case "home":
refs = []SidebarBlockRef{{Key: "board_nav"}}
case "post":
refs = []SidebarBlockRef{{Key: "post_board"}, {Key: "post_author"}, {Key: "post_author_posts"}}
default: // board:左栏不可配,恒为空
refs = []SidebarBlockRef{}
}
return &refs
}
func defaultScopeRight(scope string) *[]SidebarBlockRef {
var refs []SidebarBlockRef
switch scope {
case "home":
refs = []SidebarBlockRef{
{Key: "pulse"}, {Key: "announcements"}, {Key: "ads"}, {Key: "sponsors"},
{Key: "hot"}, {Key: "new_users"}, {Key: "active_users"},
{Key: "friend_links"}, {Key: "about_site"},
}
case "board":
refs = []SidebarBlockRef{
{Key: "board_moderators"}, {Key: "board_intro"}, {Key: "board_pulse"},
{Key: "board_hot"}, {Key: "board_active"},
}
default: // post
refs = []SidebarBlockRef{{Key: "post_toc"}}
}
return &refs
}
// DefaultSidebarConfig 当前站点的默认侧栏布局(与历史硬编码渲染一致,零迁移)
func DefaultSidebarConfig() SidebarConfig {
return SidebarConfig{
Version: SidebarConfigVersion,
Scopes: map[string]SidebarScopeConfig{
"home": {Left: defaultScopeLeft("home"), Right: defaultScopeRight("home")},
"board": {Left: defaultScopeLeft("board"), Right: defaultScopeRight("board")},
"post": {Left: defaultScopeLeft("post"), Right: defaultScopeRight("post")},
},
}
}
// SidebarService 侧栏布局配置 + 自定义工具
type SidebarService struct {
db *gorm.DB
}
func NewSidebarService(db *gorm.DB) *SidebarService {
return &SidebarService{db: db}
}
// fillScopeDefaults 补齐缺失的 scope 与侧栏(nil 指针 = 未配置 → 默认布局)
func fillScopeDefaults(cfg SidebarConfig) SidebarConfig {
out := DefaultSidebarConfig()
for scope, sc := range cfg.Scopes {
dst, ok := out.Scopes[scope]
if !ok {
continue
}
if sc.Left != nil {
dst.Left = sc.Left
}
if sc.Right != nil {
dst.Right = sc.Right
}
out.Scopes[scope] = dst
}
return out
}
// GetConfig 读取生效配置(缺键/坏 JSON 回落默认布局)
func (s *SidebarService) GetConfig() (SidebarConfig, error) {
var row model.SiteSetting
err := s.db.Where("key = ?", SettingKeySidebarConfig).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return DefaultSidebarConfig(), nil
}
if err != nil {
return DefaultSidebarConfig(), err
}
if strings.TrimSpace(row.Value) == "" {
return DefaultSidebarConfig(), nil
}
var cfg SidebarConfig
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
return DefaultSidebarConfig(), nil
}
return fillScopeDefaults(cfg), nil
}
// validateConfig 校验并归一化;widgetExists 用于校验工具引用
func (s *SidebarService) validateConfig(in SidebarConfig, widgetExists func(uint) (bool, error)) (SidebarConfig, error) {
if len(in.Scopes) == 0 {
return SidebarConfig{}, ErrSidebarInvalid
}
out := SidebarConfig{Version: SidebarConfigVersion, Scopes: map[string]SidebarScopeConfig{}}
for scope, sc := range in.Scopes {
if _, ok := sidebarScopes[scope]; !ok {
return SidebarConfig{}, ErrSidebarInvalid
}
keys := sidebarBuiltinKeys[scope]
seen := map[string]struct{}{}
left, err := s.validateSide(scope, "left", sc.Left, keys, seen, widgetExists)
if err != nil {
return SidebarConfig{}, err
}
right, err := s.validateSide(scope, "right", sc.Right, keys, seen, widgetExists)
if err != nil {
return SidebarConfig{}, err
}
// board 布局只存 right(左栏沿用 home 配置)
if scope == "board" {
left = &[]SidebarBlockRef{}
}
out.Scopes[scope] = SidebarScopeConfig{Left: left, Right: right}
}
return out, nil
}
func (s *SidebarService) validateSide(scope, side string, list *[]SidebarBlockRef, builtinKeys map[string]struct{}, seen map[string]struct{}, widgetExists func(uint) (bool, error)) (*[]SidebarBlockRef, error) {
if list == nil {
return defaultScopeSide(scope, side), nil
}
if len(*list) > SidebarMaxBlocksPerSide {
return nil, ErrSidebarInvalid
}
out := make([]SidebarBlockRef, 0, len(*list))
for _, r := range *list {
hasKey := r.Key != ""
hasWidget := r.WidgetID > 0
if hasKey == hasWidget { // 二选一,且必填其一
return nil, ErrSidebarInvalid
}
if hasKey {
if _, ok := builtinKeys[r.Key]; !ok {
return nil, ErrSidebarInvalid
}
} else {
ok, err := widgetExists(r.WidgetID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrSidebarInvalid
}
}
k := refKey(r)
if _, dup := seen[k]; dup {
return nil, ErrSidebarInvalid
}
seen[k] = struct{}{}
out = append(out, r)
}
return &out, nil
}
func defaultScopeSide(scope, side string) *[]SidebarBlockRef {
if side == "left" {
return defaultScopeLeft(scope)
}
return defaultScopeRight(scope)
}
// SaveConfig 保存布局配置(board.left 丢弃)
func (s *SidebarService) SaveConfig(in SidebarConfig) (SidebarConfig, error) {
out, err := s.validateConfig(in, s.widgetExists)
if err != nil {
return SidebarConfig{}, err
}
b, err := json.Marshal(out)
if err != nil {
return SidebarConfig{}, err
}
row := model.SiteSetting{Key: SettingKeySidebarConfig, Value: string(b), UpdatedAt: time.Now()}
if err := s.db.Save(&row).Error; err != nil {
return SidebarConfig{}, err
}
return out, nil
}
// widgetExists 批量存在性检查走缓存式的单查即可(引用量 ≤48)
func (s *SidebarService) widgetExists(id uint) (bool, error) {
var n int64
if err := s.db.Model(&model.SidebarWidget{}).Where("id = ?", id).Count(&n).Error; err != nil {
return false, err
}
return n > 0, nil
}
// ListWidgets 管理端工具全量列表
func (s *SidebarService) ListWidgets() ([]model.SidebarWidget, error) {
var list []model.SidebarWidget
err := s.db.Order("id ASC").Find(&list).Error
return list, err
}
func validateWidgetInput(name, html, css, js string) (string, error) {
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > SidebarWidgetMaxNameRunes {
return "", ErrSidebarInvalid
}
for _, code := range []string{html, css, js} {
if len(code) > SidebarWidgetMaxCodeBytes {
return "", ErrSidebarInvalid
}
}
return name, nil
}
// CreateWidget 新建自定义工具
func (s *SidebarService) CreateWidget(w *model.SidebarWidget) error {
name, err := validateWidgetInput(w.Name, w.Html, w.Css, w.Js)
if err != nil {
return err
}
w.Name = name
w.Enabled = true
now := time.Now()
w.CreatedAt = now
w.UpdatedAt = now
return s.db.Create(w).Error
}
// UpdateWidget 全量更新工具内容
func (s *SidebarService) UpdateWidget(id uint, in *model.SidebarWidget) error {
name, err := validateWidgetInput(in.Name, in.Html, in.Css, in.Js)
if err != nil {
return err
}
var w model.SidebarWidget
if err := s.db.First(&w, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSidebarWidgetNotFound
}
return err
}
// 用 Select 强制写全部列,规避布尔零值省略
return s.db.Model(&w).Select("name", "html", "css", "js", "enabled", "updated_at").
Updates(model.SidebarWidget{Name: name, Html: in.Html, Css: in.Css, Js: in.Js,
Enabled: in.Enabled, UpdatedAt: time.Now()}).Error
}
// SetWidgetEnabled 仅切换启用状态
func (s *SidebarService) SetWidgetEnabled(id uint, enabled bool) error {
var w model.SidebarWidget
if err := s.db.First(&w, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSidebarWidgetNotFound
}
return err
}
return s.db.Model(&w).Select("enabled", "updated_at").
Updates(model.SidebarWidget{Enabled: enabled, UpdatedAt: time.Now()}).Error
}
// DeleteWidget 删除工具并同事务清理配置中的引用
func (s *SidebarService) DeleteWidget(id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Delete(&model.SidebarWidget{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrSidebarWidgetNotFound
}
cfg, err := loadConfigTx(tx)
if err != nil {
return err
}
changed := false
for scope, sc := range cfg.Scopes {
if sc.Left != nil {
filtered := filterOutWidget(*sc.Left, id)
if !refsEqualRefs(filtered, *sc.Left) {
sc.Left = &filtered
changed = true
}
}
if sc.Right != nil {
filtered := filterOutWidget(*sc.Right, id)
if !refsEqualRefs(filtered, *sc.Right) {
sc.Right = &filtered
changed = true
}
}
cfg.Scopes[scope] = sc
}
if !changed {
return nil
}
b, err := json.Marshal(cfg)
if err != nil {
return err
}
return tx.Model(&model.SiteSetting{}).Where("key = ?", SettingKeySidebarConfig).
Update("value", string(b)).Error
})
}
func filterOutWidget(refs []SidebarBlockRef, id uint) []SidebarBlockRef {
out := make([]SidebarBlockRef, 0, len(refs))
for _, r := range refs {
if r.WidgetID == id {
continue
}
out = append(out, r)
}
return out
}
func loadConfigTx(tx *gorm.DB) (SidebarConfig, error) {
var row model.SiteSetting
err := tx.Where("key = ?", SettingKeySidebarConfig).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return DefaultSidebarConfig(), nil
}
if err != nil {
return SidebarConfig{}, err
}
var cfg SidebarConfig
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
return DefaultSidebarConfig(), nil
}
return cfg, nil
}
// PublicPayload 前台载荷:生效布局 + 启用中的工具(按 id 升序)
func (s *SidebarService) PublicPayload() (*SidebarPublicPayload, error) {
cfg, err := s.GetConfig()
if err != nil {
return nil, err
}
var rows []model.SidebarWidget
if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
widgets := make([]SidebarPublicWidget, 0, len(rows))
for _, w := range rows {
widgets = append(widgets, SidebarPublicWidget{
ID: w.ID, Name: w.Name, Html: w.Html, Css: w.Css, Js: w.Js,
})
}
return &SidebarPublicPayload{Config: cfg, Widgets: widgets}, nil
}