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 }