支持开关与 0–100 行阈值(0 为无预览),经站点设置与 WS 热更新;短代码在视口内收起时不再回滚浏览位置。 Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
9.0 KiB
Go
321 lines
9.0 KiB
Go
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))
|
||
}
|