Files
jiang13-bbs/backend/service/setting.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

1432 lines
44 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 (
"encoding/json"
"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"
// SettingKeyPublishWithoutReview 全站免审:所有人发帖/评论直接公开,无需过审记录;缺行视为关闭(默认关)
SettingKeyPublishWithoutReview = "publish_without_review"
SettingKeySiteName = "site_name"
SettingKeySiteDescription = "site_description"
SettingKeyAllowRegister = "allow_register"
// SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开)
SettingKeyAllowComments = "allow_comments"
// SettingKeyCommentsRequireLogin 评论仅登录可见;缺行视为关闭(游客可读)
SettingKeyCommentsRequireLogin = "comments_require_login"
// SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开)
SettingKeyAllowMessages = "allow_messages"
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"
// SettingKeyAttachmentExtLimit 是否限制附件扩展名;缺行视为开启
SettingKeyAttachmentExtLimit = "attachment_ext_limit"
// SettingKeyAttachmentExts 允许的扩展名(逗号分隔,无点);缺行=默认列表
SettingKeyAttachmentExts = "attachment_exts"
// SettingKeyAttachmentMaxMB 附件单文件上限(MB);缺行=20
SettingKeyAttachmentMaxMB = "attachment_max_mb"
// SettingKeyAttachmentMaxCount 每帖附件个数上限;缺行=10
SettingKeyAttachmentMaxCount = "attachment_max_count"
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
SettingKeyImageMaxMB = "image_max_mb"
// SettingKeyAnalyticsEnabled 访问统计采集开关;缺行视为开启(默认开)
SettingKeyAnalyticsEnabled = "analytics_enabled"
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
// 内容锁定与旧帖回复(全部缺行=0 关闭)
SettingKeyPostEditLockHours = "post_edit_lock_hours" // 帖子可编辑时长;0=不锁定
SettingKeyCommentEditLockHours = "comment_edit_lock_hours" // 评论可编辑时长;0=不锁定
SettingKeyNecroReplyAfterHours = "necro_reply_after_hours" // 超过该时长回复视为旧帖回复;0=关闭
SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0
SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0
SettingKeyPointsPostDailyCap = "points_post_daily_cap" // 发帖奖励每日上限笔数;0=不限;缺行=0
SettingKeyPointsReplyReward = "points_reply_reward" // 回复奖励;0=关闭;缺行=0
SettingKeyPointsReplyDailyCap = "points_reply_daily_cap" // 回复奖励每日上限笔数;0=不限;缺行=0
SettingKeyPointsRecommendReward = "points_recommend_reward" // 帖子被推荐奖励(每帖仅一次);0=关闭;缺行=0
// SettingKeyLevelsConfig 用户等级体系配置(JSON []model.LevelDef:阈值+色板 key);缺行=默认 7 级
SettingKeyLevelsConfig = "levels_config"
// SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开)
SettingKeyLevelsFx = "levels_fx"
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
SettingKeyUrlStyle = "url_style"
// SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys);
// 缺行/非法/为空视为全量默认
SettingKeyMobileTabs = "mobile_tabs"
)
const (
DefaultSiteName = "姜十三论坛"
DefaultSiteDescription = "姜十三论坛 - 技术分享与讨论社区"
DefaultCooldownHours = 24
DefaultCodeFoldLines = 5
MaxSiteNameRunes = 32
MaxSiteDescRunes = 160
MaxCooldownHours = 168
MinCodeFoldLines = 0
MaxCodeFoldLines = 100
DefaultAttachmentMaxMB = 20
DefaultAttachmentMaxCount = 10
DefaultImageMaxMB = 5
MinAttachmentMaxMB = 1
MinImageMaxMB = 1
MinAttachmentMaxCount = 1
MaxAttachmentMaxCount = 20
MaxAttachmentExtCount = 80
DefaultAnalyticsRetentionDays = 90
MinAnalyticsRetentionDays = 7
MaxAnalyticsRetentionDays = 365
// MaxContentLockHours 内容锁定/旧帖阈值小时数上限(一年)
MaxContentLockHours = 8760
DefaultPointsCheckinBase = 5
DefaultPointsStreakEveryDays = 7
MaxPointsRule = 100 // 各积分规则值/上限的统一上界
// DefaultUrlStyle 标准动态路由风格(/post/123)
DefaultUrlStyle = "default"
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
MaxMobileTabs = 5
)
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认全量配置
func DefaultMobileTabs() []string {
return append([]string(nil), MobileTabKeys...)
}
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
var DefaultAttachmentExts = []string{
"pdf", "txt", "md", "csv", "json",
"doc", "docx", "xls", "xlsx", "ppt", "pptx",
"zip", "7z", "rar", "tar", "gz", "tgz",
"jpg", "jpeg", "png", "webp", "gif",
"mp3", "wav", "ogg", "mp4", "webm",
"exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll",
"html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh",
}
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
// UrlStyles 伪静态风格白名单,与前端 lib/urlStyle.ts 保持一致
var UrlStyles = []string{"default", "html", "discuz"}
// 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"`
PublishWithoutReview bool `json:"publish_without_review"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
CommentsRequireLogin bool `json:"comments_require_login"`
AllowMessages bool `json:"allow_messages"`
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"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
// 内容锁定与旧帖回复(0=关闭;公开保持规则透明)
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
BgAdminMode string `json:"bg_admin_mode"`
SiteWordmark string `json:"site_wordmark"`
SiteSlogan string `json:"site_slogan"`
SiteKeywords []string `json:"site_keywords"`
LogoLightURL string `json:"logo_light_url"`
LogoDarkURL string `json:"logo_dark_url"`
FaviconURL string `json:"favicon_url"`
BrandMark string `json:"brand_mark"`
BrandLogoSize string `json:"brand_logo_size"`
BrandLogoFit string `json:"brand_logo_fit"`
FooterLinks []FooterLink `json:"footer_links"`
// 积分增长规则(经济规则公开透明,签到卡等可直出)
PointsCheckinBase int `json:"points_checkin_base"`
PointsStreakEveryDays int `json:"points_streak_every_days"`
PointsStreakBonus int `json:"points_streak_bonus"`
PointsPostReward int `json:"points_post_reward"`
PointsPostDailyCap int `json:"points_post_daily_cap"`
PointsReplyReward int `json:"points_reply_reward"`
PointsReplyDailyCap int `json:"points_reply_daily_cap"`
PointsRecommendReward int `json:"points_recommend_reward"`
// 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端按累计获得积分计算
Levels []model.LevelDef `json:"levels"`
// LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色)
LevelsFx bool `json:"levels_fx"`
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
UrlStyle string `json:"url_style"`
// MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认
MobileTabs []string `json:"mobile_tabs"`
// HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项
HeaderNav []HeaderNavItem `json:"header_nav"`
}
// 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")
}
// 缺行视为关;仅显式 true/1/on/yes 为开。
func parseBoolDefaultFalse(v string, found bool) bool {
if !found {
return false
}
switch strings.TrimSpace(strings.ToLower(v)) {
case "true", "1", "on", "yes":
return true
default:
return false
}
}
func (s *SettingService) setBoolDefaultFalse(key string, on bool) error {
if !on {
return s.deleteKey(key)
}
return s.putValue(key, "true")
}
// Public 返回解析后的公开站点设置(缺省已填默认值)
func (s *SettingService) Public() (PublicSiteSettings, error) {
out := PublicSiteSettings{
SiteName: DefaultSiteName,
SiteDescription: DefaultSiteDescription,
TrustReviewedPublish: true,
PublishWithoutReview: false,
AllowRegister: true,
AllowComments: true,
CommentsRequireLogin: false,
AllowMessages: true,
PostCooldownHours: DefaultCooldownHours,
CodeBlockAutoFold: true,
CodeBlockFoldLines: DefaultCodeFoldLines,
UIAnimations: true,
AnimCodeFold: true,
AnimSmoothScroll: true,
AnimChrome: true,
PostLinkNewTab: true,
AttachmentExtLimit: true,
AttachmentExts: append([]string(nil), DefaultAttachmentExts...),
AttachmentMaxMB: DefaultAttachmentMaxMB,
AttachmentMaxCount: DefaultAttachmentMaxCount,
ImageMaxMB: DefaultImageMaxMB,
BgSiteMode: DefaultBgMode,
BgAdminMode: DefaultBgMode,
SiteKeywords: []string{},
BrandMark: BrandMarkImageText,
BrandLogoSize: BrandLogoSizeSQ,
BrandLogoFit: BrandLogoFitContain,
FooterLinks: []FooterLink{},
UrlStyle: DefaultUrlStyle,
HeaderNav: DefaultHeaderNav(),
}
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
withoutReview, err := s.PublishWithoutReview()
if err != nil {
return out, err
}
out.PublishWithoutReview = withoutReview
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
requireLogin, err := s.CommentsRequireLogin()
if err != nil {
return out, err
}
out.CommentsRequireLogin = requireLogin
messages, err := s.AllowMessages()
if err != nil {
return out, err
}
out.AllowMessages = messages
rules, err := s.PointsRules()
if err != nil {
return out, err
}
out.PointsCheckinBase = rules.CheckinBase
out.PointsStreakEveryDays = rules.StreakEveryDays
out.PointsStreakBonus = rules.StreakBonus
out.PointsPostReward = rules.PostReward
out.PointsPostDailyCap = rules.PostDailyCap
out.PointsReplyReward = rules.ReplyReward
out.PointsReplyDailyCap = rules.ReplyDailyCap
out.PointsRecommendReward = rules.RecommendReward
out.Levels = s.Levels()
out.LevelsFx = s.LevelsFx()
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
extLimit, err := s.AttachmentExtLimit()
if err != nil {
return out, err
}
out.AttachmentExtLimit = extLimit
exts, err := s.AttachmentExts()
if err != nil {
return out, err
}
out.AttachmentExts = exts
attMB, err := s.AttachmentMaxMB()
if err != nil {
return out, err
}
out.AttachmentMaxMB = attMB
attCount, err := s.AttachmentMaxCount()
if err != nil {
return out, err
}
out.AttachmentMaxCount = attCount
imgMB, err := s.ImageMaxMB()
if err != nil {
return out, err
}
out.ImageMaxMB = imgMB
postLock, err := s.PostEditLockHours()
if err != nil {
return out, err
}
out.PostEditLockHours = postLock
commentLock, err := s.CommentEditLockHours()
if err != nil {
return out, err
}
out.CommentEditLockHours = commentLock
necroAfter, err := s.NecroReplyAfterHours()
if err != nil {
return out, err
}
out.NecroReplyAfterHours = necroAfter
necroPenalty, err := s.NecroReplyPenalty()
if err != nil {
return out, err
}
out.NecroReplyPenalty = necroPenalty
siteURL, err := s.BgSiteURL()
if err != nil {
return out, err
}
out.BgSiteURL = siteURL
siteMode, err := s.BgSiteMode()
if err != nil {
return out, err
}
out.BgSiteMode = siteMode
adminURL, err := s.BgAdminURL()
if err != nil {
return out, err
}
out.BgAdminURL = adminURL
adminMode, err := s.BgAdminMode()
if err != nil {
return out, err
}
out.BgAdminMode = adminMode
urlStyle, err := s.UrlStyle()
if err != nil {
return out, err
}
out.UrlStyle = urlStyle
out.MobileTabs = s.MobileTabs()
out.HeaderNav = s.HeaderNav()
if err := s.fillBrand(&out); err != nil {
return out, err
}
return out, nil
}
// UrlStyle 返回详情页伪静态风格;缺行或值非法时回落 default
func (s *SettingService) UrlStyle() (string, error) {
v, found, err := s.getValue(SettingKeyUrlStyle)
if err != nil {
return DefaultUrlStyle, err
}
if !found {
return DefaultUrlStyle, nil
}
style := strings.TrimSpace(strings.ToLower(v))
for _, valid := range UrlStyles {
if style == valid {
return style, nil
}
}
return DefaultUrlStyle, nil
}
// SetUrlStyle 校验并保存伪静态风格;传 default 表示恢复标准风格(删除该键)
func (s *SettingService) SetUrlStyle(style string) error {
style = strings.TrimSpace(strings.ToLower(style))
if style == DefaultUrlStyle {
return s.deleteKey(SettingKeyUrlStyle)
}
for _, valid := range UrlStyles {
if style == valid {
return s.putValue(SettingKeyUrlStyle, style)
}
}
return ErrInvalidSiteSetting
}
// normalizeMobileTabs 校验底栏 tab 列表:去空白、去重、只留白名单内元素
func normalizeMobileTabs(tabs []string) ([]string, bool) {
allowed := make(map[string]struct{}, len(MobileTabKeys))
for _, k := range MobileTabKeys {
allowed[k] = struct{}{}
}
seen := make(map[string]struct{}, len(tabs))
out := make([]string, 0, len(tabs))
for _, t := range tabs {
k := strings.TrimSpace(strings.ToLower(t))
if k == "" {
continue
}
if _, ok := allowed[k]; !ok {
return nil, false
}
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
out = append(out, k)
if len(out) > MaxMobileTabs {
return nil, false
}
}
return out, true
}
// mobileTabsEqual 判断两个列表是否元素一致(忽略顺序)
func mobileTabsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
set := make(map[string]struct{}, len(b))
for _, k := range b {
set[k] = struct{}{}
}
for _, k := range a {
if _, ok := set[k]; !ok {
return false
}
}
return true
}
// MobileTabs 移动端底栏配置;缺行/为空/非法/解析失败回落默认全量
func (s *SettingService) MobileTabs() []string {
v, found, err := s.getValue(SettingKeyMobileTabs)
if err != nil || !found || strings.TrimSpace(v) == "" {
return DefaultMobileTabs()
}
var tabs []string
if err := json.Unmarshal([]byte(v), &tabs); err != nil {
return DefaultMobileTabs()
}
out, ok := normalizeMobileTabs(tabs)
if !ok || len(out) == 0 {
return DefaultMobileTabs()
}
return out
}
// SetMobileTabs 校验并保存底栏配置;与默认全量一致时删键保持「缺行=默认」语义
func (s *SettingService) SetMobileTabs(tabs []string) error {
out, ok := normalizeMobileTabs(tabs)
if !ok || len(out) == 0 {
return ErrInvalidSiteSetting
}
if mobileTabsEqual(out, MobileTabKeys) {
return s.deleteKey(SettingKeyMobileTabs)
}
b, err := json.Marshal(out)
if err != nil {
return err
}
return s.putValue(SettingKeyMobileTabs, string(b))
}
// 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)
}
// PublishWithoutReview 全站免审:所有人发布内容直接公开(优先于过审免审)。缺行视为关闭。
func (s *SettingService) PublishWithoutReview() (bool, error) {
v, found, err := s.getValue(SettingKeyPublishWithoutReview)
if err != nil {
return false, err
}
return parseBoolDefaultFalse(v, found), nil
}
// SetPublishWithoutReview 持久化全站免审开关;关闭时删键保持「缺行=默认关」。
func (s *SettingService) SetPublishWithoutReview(on bool) error {
return s.setBoolDefaultFalse(SettingKeyPublishWithoutReview, 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)
}
// CommentsRequireLogin 评论是否仅登录可见。缺行视为关闭。
func (s *SettingService) CommentsRequireLogin() (bool, error) {
v, found, err := s.getValue(SettingKeyCommentsRequireLogin)
if err != nil {
return false, err
}
return parseBoolDefaultFalse(v, found), nil
}
// SetCommentsRequireLogin 持久化「登录可见评论」;关闭时删键保持「缺行=默认关」。
func (s *SettingService) SetCommentsRequireLogin(on bool) error {
return s.setBoolDefaultFalse(SettingKeyCommentsRequireLogin, on)
}
// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。
func (s *SettingService) AllowMessages() (bool, error) {
v, found, err := s.getValue(SettingKeyAllowMessages)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
// SetAllowMessages 持久化消息开关;开启时删键保持「缺行=默认开」。
func (s *SettingService) SetAllowMessages(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAllowMessages, 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)
}
// AttachmentExtLimit 是否限制附件扩展名。缺行视为开启。
func (s *SettingService) AttachmentExtLimit() (bool, error) {
v, found, err := s.getValue(SettingKeyAttachmentExtLimit)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetAttachmentExtLimit(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAttachmentExtLimit, on)
}
// NormalizeAttachmentExts 规范化扩展名列表:去点、小写、去重、校验格式。
func NormalizeAttachmentExts(raw []string) ([]string, error) {
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
e := strings.TrimSpace(strings.ToLower(item))
e = strings.TrimPrefix(e, ".")
if e == "" {
continue
}
if !attachmentExtRe.MatchString(e) {
return nil, ErrInvalidSiteSetting
}
if _, ok := seen[e]; ok {
continue
}
seen[e] = struct{}{}
out = append(out, e)
if len(out) > MaxAttachmentExtCount {
return nil, ErrInvalidSiteSetting
}
}
return out, nil
}
func parseExtList(v string) []string {
parts := strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ' ' || r == ';' || r == '\n' || r == '\t'
})
out, err := NormalizeAttachmentExts(parts)
if err != nil || len(out) == 0 {
return append([]string(nil), DefaultAttachmentExts...)
}
return out
}
func extsEqualDefault(exts []string) bool {
if len(exts) != len(DefaultAttachmentExts) {
return false
}
for i := range exts {
if exts[i] != DefaultAttachmentExts[i] {
return false
}
}
return true
}
// AttachmentExts 允许的扩展名(无点)。缺行=默认列表;显式空串=空列表。
func (s *SettingService) AttachmentExts() ([]string, error) {
v, found, err := s.getValue(SettingKeyAttachmentExts)
if err != nil {
return append([]string(nil), DefaultAttachmentExts...), err
}
if !found {
return append([]string(nil), DefaultAttachmentExts...), nil
}
if strings.TrimSpace(v) == "" {
return []string{}, nil
}
return parseExtList(v), nil
}
// SetAttachmentExts 保存扩展名列表;与默认完全一致时删键。空列表写入空串(限制开启时表示禁止上传)。
func (s *SettingService) SetAttachmentExts(exts []string) error {
norm, err := NormalizeAttachmentExts(exts)
if err != nil {
return err
}
if len(norm) == 0 {
return s.putValue(SettingKeyAttachmentExts, "")
}
if extsEqualDefault(norm) {
return s.deleteKey(SettingKeyAttachmentExts)
}
return s.putValue(SettingKeyAttachmentExts, strings.Join(norm, ","))
}
// AttachmentExtAllowed 当前设置下扩展名(含点或不含点)是否允许上传。
func (s *SettingService) AttachmentExtAllowed(ext string) (bool, error) {
limit, err := s.AttachmentExtLimit()
if err != nil {
return false, err
}
if !limit {
return true, nil
}
e := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
if e == "" {
return false, nil
}
list, err := s.AttachmentExts()
if err != nil {
return false, err
}
for _, x := range list {
if x == e {
return true, nil
}
}
return false, nil
}
// AttachmentMaxMB 附件单文件上限(MB)。缺行=20;最小 1,无硬顶。
func (s *SettingService) AttachmentMaxMB() (int, error) {
v, found, err := s.getValue(SettingKeyAttachmentMaxMB)
if err != nil {
return DefaultAttachmentMaxMB, err
}
if !found {
return DefaultAttachmentMaxMB, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinAttachmentMaxMB {
return DefaultAttachmentMaxMB, nil
}
return n, nil
}
func (s *SettingService) SetAttachmentMaxMB(mb int) error {
if mb < MinAttachmentMaxMB {
return ErrInvalidSiteSetting
}
if mb == DefaultAttachmentMaxMB {
return s.deleteKey(SettingKeyAttachmentMaxMB)
}
return s.putValue(SettingKeyAttachmentMaxMB, strconv.Itoa(mb))
}
// AttachmentMaxBytes 附件字节上限(按当前 MB 设置)。
func (s *SettingService) AttachmentMaxBytes() (int64, error) {
mb, err := s.AttachmentMaxMB()
if err != nil {
return int64(DefaultAttachmentMaxMB) << 20, err
}
return int64(mb) << 20, nil
}
// AttachmentMaxCount 每帖/草稿附件个数上限。缺行=10;范围 1–20。
func (s *SettingService) AttachmentMaxCount() (int, error) {
v, found, err := s.getValue(SettingKeyAttachmentMaxCount)
if err != nil {
return DefaultAttachmentMaxCount, err
}
if !found {
return DefaultAttachmentMaxCount, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinAttachmentMaxCount {
return DefaultAttachmentMaxCount, nil
}
if n > MaxAttachmentMaxCount {
return MaxAttachmentMaxCount, nil
}
return n, nil
}
func (s *SettingService) SetAttachmentMaxCount(n int) error {
if n < MinAttachmentMaxCount || n > MaxAttachmentMaxCount {
return ErrInvalidSiteSetting
}
if n == DefaultAttachmentMaxCount {
return s.deleteKey(SettingKeyAttachmentMaxCount)
}
return s.putValue(SettingKeyAttachmentMaxCount, strconv.Itoa(n))
}
// ImageMaxMB 正文插图上限(MB)。缺行=5;最小 1,无硬顶。
func (s *SettingService) ImageMaxMB() (int, error) {
v, found, err := s.getValue(SettingKeyImageMaxMB)
if err != nil {
return DefaultImageMaxMB, err
}
if !found {
return DefaultImageMaxMB, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinImageMaxMB {
return DefaultImageMaxMB, nil
}
return n, nil
}
func (s *SettingService) SetImageMaxMB(mb int) error {
if mb < MinImageMaxMB {
return ErrInvalidSiteSetting
}
if mb == DefaultImageMaxMB {
return s.deleteKey(SettingKeyImageMaxMB)
}
return s.putValue(SettingKeyImageMaxMB, strconv.Itoa(mb))
}
// ImageMaxBytes 插图像素字节上限。
func (s *SettingService) ImageMaxBytes() (int64, error) {
mb, err := s.ImageMaxMB()
if err != nil {
return int64(DefaultImageMaxMB) << 20, err
}
return int64(mb) << 20, nil
}
// getLockHours 读取小时数配置(缺行/非法=0 关闭,超上限钳制)
func (s *SettingService) getLockHours(key string) (int, error) {
v, found, err := s.getValue(key)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return 0, nil
}
if n > MaxContentLockHours {
return MaxContentLockHours, nil
}
return n, nil
}
func (s *SettingService) setLockHours(key string, hours int) error {
if hours < 0 || hours > MaxContentLockHours {
return ErrInvalidSiteSetting
}
if hours == 0 {
return s.deleteKey(key)
}
return s.putValue(key, strconv.Itoa(hours))
}
// PostEditLockHours 帖子可编辑时长(小时)。缺行=0(不锁定)。
func (s *SettingService) PostEditLockHours() (int, error) {
return s.getLockHours(SettingKeyPostEditLockHours)
}
func (s *SettingService) SetPostEditLockHours(hours int) error {
return s.setLockHours(SettingKeyPostEditLockHours, hours)
}
// CommentEditLockHours 评论可编辑时长(小时)。缺行=0(不锁定)。
func (s *SettingService) CommentEditLockHours() (int, error) {
return s.getLockHours(SettingKeyCommentEditLockHours)
}
func (s *SettingService) SetCommentEditLockHours(hours int) error {
return s.setLockHours(SettingKeyCommentEditLockHours, hours)
}
// NecroReplyAfterHours 旧帖回复阈值(小时)。缺行=0(关闭)。
func (s *SettingService) NecroReplyAfterHours() (int, error) {
return s.getLockHours(SettingKeyNecroReplyAfterHours)
}
func (s *SettingService) SetNecroReplyAfterHours(hours int) error {
return s.setLockHours(SettingKeyNecroReplyAfterHours, hours)
}
// NecroReplyPenalty 旧帖回复扣分。缺行=0(仅提醒不扣分)。
func (s *SettingService) NecroReplyPenalty() (int, error) {
v, found, err := s.getValue(SettingKeyNecroReplyPenalty)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return 0, nil
}
if n > MaxPointsRule {
return MaxPointsRule, nil
}
return n, nil
}
func (s *SettingService) SetNecroReplyPenalty(n int) error {
if n < 0 || n > MaxPointsRule {
return ErrInvalidSiteSetting
}
if n == 0 {
return s.deleteKey(SettingKeyNecroReplyPenalty)
}
return s.putValue(SettingKeyNecroReplyPenalty, strconv.Itoa(n))
}
// AnalyticsEnabled 访问统计采集开关。缺行视为开启。
func (s *SettingService) AnalyticsEnabled() (bool, error) {
v, found, err := s.getValue(SettingKeyAnalyticsEnabled)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
// SetAnalyticsEnabled 持久化采集开关;开启时删键保持「缺行=默认开」。
func (s *SettingService) SetAnalyticsEnabled(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAnalyticsEnabled, on)
}
// AnalyticsRetentionDays 访问明细保留天数。缺行=90;钳制 7–365。
func (s *SettingService) AnalyticsRetentionDays() (int, error) {
v, found, err := s.getValue(SettingKeyAnalyticsRetentionDays)
if err != nil {
return DefaultAnalyticsRetentionDays, err
}
if !found {
return DefaultAnalyticsRetentionDays, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinAnalyticsRetentionDays {
return DefaultAnalyticsRetentionDays, nil
}
if n > MaxAnalyticsRetentionDays {
return MaxAnalyticsRetentionDays, nil
}
return n, nil
}
// SetAnalyticsRetentionDays 持久化保留天数;等于默认值时删键。
func (s *SettingService) SetAnalyticsRetentionDays(days int) error {
if days < MinAnalyticsRetentionDays || days > MaxAnalyticsRetentionDays {
return ErrInvalidSiteSetting
}
if days == DefaultAnalyticsRetentionDays {
return s.deleteKey(SettingKeyAnalyticsRetentionDays)
}
return s.putValue(SettingKeyAnalyticsRetentionDays, strconv.Itoa(days))
}
// PointsRules 积分增长规则聚合(全部 0-100 裁剪,非法/缺行取默认)
type PointsRules struct {
CheckinBase int
StreakEveryDays int
StreakBonus int
PostReward int
PostDailyCap int
ReplyReward int
ReplyDailyCap int
RecommendReward int
}
// getPointsValue 读取单个积分规则键(0-100 裁剪,非法/缺行取 def)
func (s *SettingService) getPointsValue(key string, def int) (int, error) {
v, found, err := s.getValue(key)
if err != nil {
return def, err
}
if !found {
return def, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return def, nil
}
if n > MaxPointsRule {
return MaxPointsRule, nil
}
return n, nil
}
// setPointsValue 持久化积分规则;等于默认值时删键保持「缺行=默认」
func (s *SettingService) setPointsValue(key string, n, def int) error {
if n < 0 || n > MaxPointsRule {
return ErrInvalidSiteSetting
}
if n == def {
return s.deleteKey(key)
}
return s.putValue(key, strconv.Itoa(n))
}
// PointsRules 一次读取全部积分增长规则
func (s *SettingService) PointsRules() (PointsRules, error) {
rules := PointsRules{
CheckinBase: DefaultPointsCheckinBase,
StreakEveryDays: DefaultPointsStreakEveryDays,
}
keys := []struct {
key string
dst *int
}{
{SettingKeyPointsCheckinBase, &rules.CheckinBase},
{SettingKeyPointsStreakEveryDays, &rules.StreakEveryDays},
{SettingKeyPointsStreakBonus, &rules.StreakBonus},
{SettingKeyPointsPostReward, &rules.PostReward},
{SettingKeyPointsPostDailyCap, &rules.PostDailyCap},
{SettingKeyPointsReplyReward, &rules.ReplyReward},
{SettingKeyPointsReplyDailyCap, &rules.ReplyDailyCap},
{SettingKeyPointsRecommendReward, &rules.RecommendReward},
}
for _, it := range keys {
n, err := s.getPointsValue(it.key, *it.dst)
if err != nil {
return rules, err
}
*it.dst = n
}
return rules, nil
}
func (s *SettingService) SetPointsCheckinBase(n int) error {
return s.setPointsValue(SettingKeyPointsCheckinBase, n, DefaultPointsCheckinBase)
}
func (s *SettingService) SetPointsStreakEveryDays(n int) error {
return s.setPointsValue(SettingKeyPointsStreakEveryDays, n, DefaultPointsStreakEveryDays)
}
func (s *SettingService) SetPointsStreakBonus(n int) error {
return s.setPointsValue(SettingKeyPointsStreakBonus, n, 0)
}
func (s *SettingService) SetPointsPostReward(n int) error {
return s.setPointsValue(SettingKeyPointsPostReward, n, 0)
}
func (s *SettingService) SetPointsPostDailyCap(n int) error {
return s.setPointsValue(SettingKeyPointsPostDailyCap, n, 0)
}
func (s *SettingService) SetPointsReplyReward(n int) error {
return s.setPointsValue(SettingKeyPointsReplyReward, n, 0)
}
func (s *SettingService) SetPointsReplyDailyCap(n int) error {
return s.setPointsValue(SettingKeyPointsReplyDailyCap, n, 0)
}
func (s *SettingService) SetPointsRecommendReward(n int) error {
return s.setPointsValue(SettingKeyPointsRecommendReward, n, 0)
}
// levelColorPalette 等级可用色板 key(与徽章色板一致 + neutral 中性色)
var levelColorPalette = map[string]bool{
"neutral": true,
"gold": true,
"accent": true,
"ok": true,
"danger": true,
"info": true,
"purple": true,
"teal": true,
}
// Levels 等级体系配置;缺行/解析失败/校验不过回落默认表
func (s *SettingService) Levels() []model.LevelDef {
v, found, err := s.getValue(SettingKeyLevelsConfig)
if err != nil || !found || strings.TrimSpace(v) == "" {
return model.DefaultLevelDefs()
}
var defs []model.LevelDef
if err := json.Unmarshal([]byte(v), &defs); err != nil {
return model.DefaultLevelDefs()
}
if !model.ValidLevelDefs(defs) || !levelColorsValid(defs) {
return model.DefaultLevelDefs()
}
return defs
}
// SetLevels 校验并保存等级配置,同时刷新进程内快照(LevelOf 立即生效)
func (s *SettingService) SetLevels(defs []model.LevelDef) error {
if !model.ValidLevelDefs(defs) || !levelColorsValid(defs) {
return ErrInvalidSiteSetting
}
b, err := json.Marshal(defs)
if err != nil {
return err
}
if err := s.putValue(SettingKeyLevelsConfig, string(b)); err != nil {
return err
}
model.ApplyLevelDefs(defs)
return nil
}
// LevelsFx 等级特效动画总开关;缺行视为开启
func (s *SettingService) LevelsFx() bool {
v, found, err := s.getValue(SettingKeyLevelsFx)
if err != nil {
return true
}
return parseBoolDefaultTrue(v, found)
}
// SetLevelsFx 保存等级特效动画总开关(开=删行回落默认,关=显式存 false)
func (s *SettingService) SetLevelsFx(on bool) error {
return s.setBoolDefaultTrue(SettingKeyLevelsFx, on)
}
func levelColorsValid(defs []model.LevelDef) bool {
for _, d := range defs {
if !levelColorPalette[d.Color] {
return false
}
}
return true
}