Files
jiang13-bbs/backend/service/sidebar.go
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

454 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}