feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
453
backend/service/sidebar.go
Normal file
453
backend/service/sidebar.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user