- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
454 lines
13 KiB
Go
454 lines
13 KiB
Go
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
|
||
}
|