package service import ( "errors" "regexp" "strconv" "strings" "unicode/utf8" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 站点设置键 const ( // SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色 SettingKeyAccent = "theme_accent" // SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开) SettingKeyTrustReviewedPublish = "trust_reviewed_publish" SettingKeySiteName = "site_name" SettingKeySiteDescription = "site_description" SettingKeyAllowRegister = "allow_register" // SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开) SettingKeyAllowComments = "allow_comments" SettingKeyPostCooldownHours = "post_cooldown_hours" // SettingKeyCodeBlockAutoFold 代码块自动折叠;缺行视为开启 SettingKeyCodeBlockAutoFold = "code_block_auto_fold" // SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5 SettingKeyCodeBlockFoldLines = "code_block_fold_lines" // SettingKeyUIAnimations 界面动画总开关;缺行视为开启 SettingKeyUIAnimations = "ui_animations" // SettingKeyAnimCodeFold 代码折叠高度/跟滚动画;缺行视为开启 SettingKeyAnimCodeFold = "anim_code_fold" // SettingKeyAnimSmoothScroll 平滑滚动;缺行视为开启 SettingKeyAnimSmoothScroll = "anim_smooth_scroll" // SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启 SettingKeyAnimChrome = "anim_chrome" // SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制) SettingKeyPostLinkNewTab = "post_link_new_tab" ) const ( DefaultSiteName = "姜十三论坛" DefaultSiteDescription = "姜十三论坛 - 技术分享与讨论社区" DefaultCooldownHours = 24 DefaultCodeFoldLines = 5 MaxSiteNameRunes = 32 MaxSiteDescRunes = 160 MaxCooldownHours = 168 MinCodeFoldLines = 0 MaxCodeFoldLines = 100 ) // ErrInvalidAccent 主题色格式非法 var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB") // ErrInvalidSiteSetting 站点设置字段校验失败 var ErrInvalidSiteSetting = errors.New("站点设置参数无效") var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示) type PublicSiteSettings struct { Accent string `json:"accent"` TrustReviewedPublish bool `json:"trust_reviewed_publish"` SiteName string `json:"site_name"` SiteDescription string `json:"site_description"` AllowRegister bool `json:"allow_register"` AllowComments bool `json:"allow_comments"` PostCooldownHours int `json:"post_cooldown_hours"` CodeBlockAutoFold bool `json:"code_block_auto_fold"` CodeBlockFoldLines int `json:"code_block_fold_lines"` UIAnimations bool `json:"ui_animations"` AnimCodeFold bool `json:"anim_code_fold"` AnimSmoothScroll bool `json:"anim_smooth_scroll"` AnimChrome bool `json:"anim_chrome"` PostLinkNewTab bool `json:"post_link_new_tab"` } // SettingService 站点级键值设置 type SettingService struct { db *gorm.DB } func NewSettingService(db *gorm.DB) *SettingService { return &SettingService{db: db} } func (s *SettingService) getValue(key string) (string, bool, error) { var st model.SiteSetting err := s.db.Where("key = ?", key).First(&st).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", false, nil } if err != nil { return "", false, err } return st.Value, true, nil } func (s *SettingService) putValue(key, value string) error { st := model.SiteSetting{Key: key, Value: value} return s.db.Save(&st).Error } func (s *SettingService) deleteKey(key string) error { return s.db.Where("key = ?", key).Delete(&model.SiteSetting{}).Error } // 缺行或空值视为 on;仅显式 false/0/off/no 为关。不用 GORM bool default:true,避免零值省略。 func parseBoolDefaultTrue(v string, found bool) bool { if !found { return true } switch strings.TrimSpace(strings.ToLower(v)) { case "false", "0", "off", "no": return false default: return true } } func (s *SettingService) setBoolDefaultTrue(key string, on bool) error { if on { return s.deleteKey(key) } return s.putValue(key, "false") } // Public 返回解析后的公开站点设置(缺省已填默认值) func (s *SettingService) Public() (PublicSiteSettings, error) { out := PublicSiteSettings{ SiteName: DefaultSiteName, SiteDescription: DefaultSiteDescription, TrustReviewedPublish: true, AllowRegister: true, AllowComments: true, PostCooldownHours: DefaultCooldownHours, CodeBlockAutoFold: true, CodeBlockFoldLines: DefaultCodeFoldLines, UIAnimations: true, AnimCodeFold: true, AnimSmoothScroll: true, AnimChrome: true, PostLinkNewTab: true, } accent, err := s.AccentColor() if err != nil { return out, err } out.Accent = accent trust, err := s.TrustReviewedPublish() if err != nil { return out, err } out.TrustReviewedPublish = trust name, found, err := s.getValue(SettingKeySiteName) if err != nil { return out, err } if found { name = strings.TrimSpace(name) if name != "" { out.SiteName = name } } desc, found, err := s.getValue(SettingKeySiteDescription) if err != nil { return out, err } if found { out.SiteDescription = strings.TrimSpace(desc) } reg, found, err := s.getValue(SettingKeyAllowRegister) if err != nil { return out, err } out.AllowRegister = parseBoolDefaultTrue(reg, found) comments, err := s.AllowComments() if err != nil { return out, err } out.AllowComments = comments hours, err := s.PostCooldownHours() if err != nil { return out, err } out.PostCooldownHours = hours fold, err := s.CodeBlockAutoFold() if err != nil { return out, err } out.CodeBlockAutoFold = fold foldLines, err := s.CodeBlockFoldLines() if err != nil { return out, err } out.CodeBlockFoldLines = foldLines uiAnim, err := s.UIAnimations() if err != nil { return out, err } out.UIAnimations = uiAnim animFold, err := s.AnimCodeFold() if err != nil { return out, err } out.AnimCodeFold = animFold animScroll, err := s.AnimSmoothScroll() if err != nil { return out, err } out.AnimSmoothScroll = animScroll animChrome, err := s.AnimChrome() if err != nil { return out, err } out.AnimChrome = animChrome linkNewTab, err := s.PostLinkNewTab() if err != nil { return out, err } out.PostLinkNewTab = linkNewTab return out, nil } // AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色 func (s *SettingService) AccentColor() (string, error) { v, found, err := s.getValue(SettingKeyAccent) if err != nil || !found { return "", err } return strings.ToLower(v), nil } // SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键) func (s *SettingService) SetAccent(hex string) error { hex = strings.TrimSpace(strings.ToLower(hex)) if hex == "" { return s.deleteKey(SettingKeyAccent) } if !accentHexRe.MatchString(hex) { return ErrInvalidAccent } return s.putValue(SettingKeyAccent, hex) } // TrustReviewedPublish 是否对「已有过审内容」的用户免审。 func (s *SettingService) TrustReviewedPublish() (bool, error) { v, found, err := s.getValue(SettingKeyTrustReviewedPublish) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } // SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义 func (s *SettingService) SetTrustReviewedPublish(on bool) error { return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on) } func (s *SettingService) SetSiteName(name string) error { name = strings.TrimSpace(name) if name == "" || name == DefaultSiteName { return s.deleteKey(SettingKeySiteName) } if utf8.RuneCountInString(name) > MaxSiteNameRunes { return ErrInvalidSiteSetting } return s.putValue(SettingKeySiteName, name) } func (s *SettingService) SetSiteDescription(desc string) error { desc = strings.TrimSpace(desc) if utf8.RuneCountInString(desc) > MaxSiteDescRunes { return ErrInvalidSiteSetting } if desc == "" || desc == DefaultSiteDescription { return s.deleteKey(SettingKeySiteDescription) } return s.putValue(SettingKeySiteDescription, desc) } func (s *SettingService) AllowRegister() (bool, error) { v, found, err := s.getValue(SettingKeyAllowRegister) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } func (s *SettingService) SetAllowRegister(on bool) error { return s.setBoolDefaultTrue(SettingKeyAllowRegister, on) } // AllowComments 是否允许全站发表评论/回复。缺行视为开启。 func (s *SettingService) AllowComments() (bool, error) { v, found, err := s.getValue(SettingKeyAllowComments) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } // SetAllowComments 持久化评论开关;开启时删键保持「缺行=默认开」。 func (s *SettingService) SetAllowComments(on bool) error { return s.setBoolDefaultTrue(SettingKeyAllowComments, on) } // PostCooldownHours 新用户发帖冷静期(小时)。缺行=24;0 表示关闭。 func (s *SettingService) PostCooldownHours() (int, error) { v, found, err := s.getValue(SettingKeyPostCooldownHours) if err != nil { return DefaultCooldownHours, err } if !found { return DefaultCooldownHours, nil } n, convErr := strconv.Atoi(strings.TrimSpace(v)) if convErr != nil || n < 0 { return DefaultCooldownHours, nil } if n > MaxCooldownHours { return MaxCooldownHours, nil } return n, nil } func (s *SettingService) SetPostCooldownHours(hours int) error { if hours < 0 || hours > MaxCooldownHours { return ErrInvalidSiteSetting } if hours == DefaultCooldownHours { return s.deleteKey(SettingKeyPostCooldownHours) } return s.putValue(SettingKeyPostCooldownHours, strconv.Itoa(hours)) } // CodeBlockAutoFold 是否对超过阈值行数的 Markdown 代码块默认折叠。缺行视为开启。 func (s *SettingService) CodeBlockAutoFold() (bool, error) { v, found, err := s.getValue(SettingKeyCodeBlockAutoFold) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } // SetCodeBlockAutoFold 持久化代码折叠开关;开启时删键保持「缺行=默认开」。 func (s *SettingService) SetCodeBlockAutoFold(on bool) error { return s.setBoolDefaultTrue(SettingKeyCodeBlockAutoFold, on) } // CodeBlockFoldLines 超过该行数则折叠(折叠预览约同高;0=无预览)。缺行=5。 func (s *SettingService) CodeBlockFoldLines() (int, error) { v, found, err := s.getValue(SettingKeyCodeBlockFoldLines) if err != nil { return DefaultCodeFoldLines, err } if !found { return DefaultCodeFoldLines, nil } n, convErr := strconv.Atoi(strings.TrimSpace(v)) if convErr != nil || n < MinCodeFoldLines { return DefaultCodeFoldLines, nil } if n > MaxCodeFoldLines { return MaxCodeFoldLines, nil } return n, nil } func (s *SettingService) SetCodeBlockFoldLines(lines int) error { if lines < MinCodeFoldLines || lines > MaxCodeFoldLines { return ErrInvalidSiteSetting } if lines == DefaultCodeFoldLines { return s.deleteKey(SettingKeyCodeBlockFoldLines) } return s.putValue(SettingKeyCodeBlockFoldLines, strconv.Itoa(lines)) } // UIAnimations 界面动画总开关。缺行视为开启;关闭后全站减少动态效果。 func (s *SettingService) UIAnimations() (bool, error) { v, found, err := s.getValue(SettingKeyUIAnimations) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } func (s *SettingService) SetUIAnimations(on bool) error { return s.setBoolDefaultTrue(SettingKeyUIAnimations, on) } // AnimCodeFold 代码折叠动画子开关(总开关开启时生效)。 func (s *SettingService) AnimCodeFold() (bool, error) { v, found, err := s.getValue(SettingKeyAnimCodeFold) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } func (s *SettingService) SetAnimCodeFold(on bool) error { return s.setBoolDefaultTrue(SettingKeyAnimCodeFold, on) } // AnimSmoothScroll 平滑滚动子开关(总开关开启时生效)。 func (s *SettingService) AnimSmoothScroll() (bool, error) { v, found, err := s.getValue(SettingKeyAnimSmoothScroll) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } func (s *SettingService) SetAnimSmoothScroll(on bool) error { return s.setBoolDefaultTrue(SettingKeyAnimSmoothScroll, on) } // AnimChrome 装饰入场子开关(列表/Toast/点赞等;总开关开启时生效)。 func (s *SettingService) AnimChrome() (bool, error) { v, found, err := s.getValue(SettingKeyAnimChrome) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } func (s *SettingService) SetAnimChrome(on bool) error { return s.setBoolDefaultTrue(SettingKeyAnimChrome, on) } // PostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开。缺行视为开启。 func (s *SettingService) PostLinkNewTab() (bool, error) { v, found, err := s.getValue(SettingKeyPostLinkNewTab) if err != nil { return true, err } return parseBoolDefaultTrue(v, found), nil } func (s *SettingService) SetPostLinkNewTab(on bool) error { return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on) }