Files
jiang13-bbs/backend/service/setting.go
freefire 809d8f1df7 feat(settings): 后台可配置 Markdown 代码块自动折叠与阈值行数
支持开关与 0–100 行阈值(0 为无预览),经站点设置与 WS 热更新;短代码在视口内收起时不再回滚浏览位置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 06:43:37 +08:00

321 lines
9.0 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 (
"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"
SettingKeyPostCooldownHours = "post_cooldown_hours"
// SettingKeyCodeBlockAutoFold 代码块自动折叠;缺行视为开启
SettingKeyCodeBlockAutoFold = "code_block_auto_fold"
// SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5
SettingKeyCodeBlockFoldLines = "code_block_fold_lines"
)
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"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
}
// 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,
PostCooldownHours: DefaultCooldownHours,
CodeBlockAutoFold: true,
CodeBlockFoldLines: DefaultCodeFoldLines,
}
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)
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
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)
}
// 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))
}