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=关闭/不限,缺行=默认值) 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" ) 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 DefaultPointsCheckinBase = 5 DefaultPointsStreakEveryDays = 7 MaxPointsRule = 100 // 各积分规则值/上限的统一上界 ) // 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}$`) // 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"` 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"` } // 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{}, } 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 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 if err := s.fillBrand(&out); err != nil { return out, err } 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) } // 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 } // 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 }