新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
1433 lines
44 KiB
Go
1433 lines
44 KiB
Go
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"`
|
||
BrandSloganVisible bool `json:"brand_slogan_visible"`
|
||
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
|
||
}
|