新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -2,6 +2,7 @@ package service
import (
"strconv"
"strings"
"sync"
"git.iioio.com/freefire/jiang13-forum/model"
@@ -27,13 +28,46 @@ const (
SettingSearchKeywordMax = "search_keyword_max"
SettingPageSizeDefault = "page_size_default"
SettingPageSizeMax = "page_size_max"
SettingFeedMaxPages = "feed_max_pages"
SettingFeedMaxItems = "feed_max_items"
SettingPasswordMinLen = "password_min_len"
SettingAvatarMaxMB = "avatar_max_mb"
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
SettingSMTPEnabled = "smtp_enabled"
SettingSMTPHost = "smtp_host"
SettingSMTPPort = "smtp_port"
SettingSMTPUsername = "smtp_username"
SettingSMTPPassword = "smtp_password"
SettingSMTPFrom = "smtp_from"
SettingSMTPFromName = "smtp_from_name"
SettingSMTPEncryption = "smtp_encryption"
SettingOIDCEnabled = "oidc_enabled"
SettingOIDCRootURL = "oidc_root_url"
SettingOIDCGroupClaim = "oidc_group_claim"
SettingOIDCAdminGroup = "oidc_admin_group"
SettingOIDCUserGroup = "oidc_user_group"
// 遗留单客户端字段(仅用于迁移到 oauth_clients
SettingOAuthClientID = "oauth_client_id"
SettingOAuthClientSecret = "oauth_client_secret"
SettingOAuthRedirectURIs = "oauth_redirect_uris"
SettingGiteaSyncEnabled = "gitea_sync_enabled"
SettingGiteaBaseURL = "gitea_base_url"
SettingGiteaToken = "gitea_token"
SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"
SettingSiteName = "site_name"
SettingSiteNameEN = "site_name_en"
SettingSiteSlogan = "site_slogan"
SettingSiteLogoMark = "site_logo_mark"
SettingSiteLogo = "site_logo"
SettingSiteFavicon = "site_favicon"
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100
)
// ForumLimits 论坛可配置限制API 传输结构)
@@ -56,28 +90,28 @@ type ForumLimits struct {
SearchKeywordMax int `json:"search_keyword_max"`
PageSizeDefault int `json:"page_size_default"`
PageSizeMax int `json:"page_size_max"`
FeedMaxPages int `json:"feed_max_pages"`
FeedMaxItems int `json:"feed_max_items"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
PostTagsMax int `json:"post_tags_max"`
PostContentMax int `json:"post_content_max"`
CommentMax int `json:"comment_max"`
PostTitleMax int `json:"post_title_max"`
PostTagsMax int `json:"post_tags_max"`
PostContentMax int `json:"post_content_max"`
CommentMax int `json:"comment_max"`
SearchKeywordMin int `json:"search_keyword_min"`
SearchKeywordMax int `json:"search_keyword_max"`
PageSizeDefault int `json:"page_size_default"`
FeedMaxPages int `json:"feed_max_pages"`
FeedMaxItems int `json:"feed_max_items"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
}
type settingDef struct {
@@ -105,14 +139,86 @@ var forumSettingDefs = []settingDef{
{SettingSearchKeywordMin, "1", 0, 100},
{SettingSearchKeywordMax, "50", 1, 200},
{SettingPageSizeDefault, "30", 1, 200},
{SettingPageSizeMax, "50", 1, 200},
{SettingFeedMaxPages, "10", 1, 100},
{SettingFeedMaxItems, "300", 1, 5000},
{SettingPageSizeDefault, "30", 1, pageSizeAPIMax},
{SettingPasswordMinLen, "6", 4, 128},
{SettingAvatarMaxMB, "2", 1, 20},
{SettingOpenPostsInNewTab, "1", 0, 1},
{SettingOpenContentLinksInNewTab, "1", 0, 1},
}
var mailSettingDefaults = map[string]string{
SettingSMTPEnabled: "0",
SettingSMTPHost: "",
SettingSMTPPort: "465",
SettingSMTPUsername: "",
SettingSMTPPassword: "",
SettingSMTPFrom: "",
SettingSMTPFromName: "姜十三论坛",
SettingSMTPEncryption: "ssl",
}
var oidcSettingDefaults = map[string]string{
SettingOIDCEnabled: "0",
SettingOIDCRootURL: "",
SettingOIDCGroupClaim: "groups",
SettingOIDCAdminGroup: "gitea-admin",
SettingOIDCUserGroup: "gitea-users",
SettingOAuthClientID: "",
SettingOAuthClientSecret: "",
SettingOAuthRedirectURIs: "",
}
var giteaSettingDefaults = map[string]string{
SettingGiteaSyncEnabled: "0",
SettingGiteaBaseURL: "",
SettingGiteaToken: "",
SettingGiteaSyncIntervalMin: "60",
}
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteNameEN: "Jiang13 Forum",
SettingSiteSlogan: "拾三一隅,自在交流",
SettingSiteLogoMark: "姜",
SettingSiteLogo: "",
SettingSiteFavicon: "",
}
// SiteBranding 站点品牌配置名称、Logo、Favicon 等)
type SiteBranding struct {
Name string `json:"name"`
NameEN string `json:"name_en"`
Slogan string `json:"slogan"`
LogoMark string `json:"logo_mark"`
Logo string `json:"logo"`
Favicon string `json:"favicon"`
}
// GiteaSyncConfig Gitea 仓库同步配置
type GiteaSyncConfig struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
Token string `json:"token,omitempty"` // 更新时传入;回显时为空
HasToken bool `json:"has_token"`
SyncIntervalMin int `json:"sync_interval_min"`
Ready bool `json:"ready"`
RepoCount int64 `json:"repo_count"`
}
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients
type OIDCConfig struct {
Enabled bool `json:"enabled"`
RootURL string `json:"root_url"`
Ready bool `json:"ready"`
DiscoveryURL string `json:"discovery_url,omitempty"`
AuthorizeURL string `json:"authorize_url,omitempty"`
LogoutURL string `json:"logout_url,omitempty"`
GroupClaim string `json:"group_claim"`
AdminGroup string `json:"admin_group"`
UserGroup string `json:"user_group"`
ClientCount int64 `json:"client_count"`
}
// ForumSettingsService 论坛全局设置
@@ -134,6 +240,48 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
for key, val := range mailSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range oidcSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range giteaSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range siteBrandingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
}
func (s *ForumSettingsService) getString(key, fallback string) string {
var setting model.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
return fallback
}
return setting.Value
}
func (s *ForumSettingsService) setString(key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
return model.DB.Save(&model.ForumSetting{Key: key, Value: value}).Error
}
func (s *ForumSettingsService) getInt(key string, fallback int) int {
@@ -186,13 +334,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
SearchKeywordMax: s.SearchKeywordMax(),
PageSizeDefault: s.PageSizeDefault(),
PageSizeMax: s.PageSizeMax(),
FeedMaxPages: s.FeedMaxPages(),
FeedMaxItems: s.FeedMaxItems(),
PasswordMinLen: s.PasswordMinLen(),
AvatarMaxMB: s.AvatarMaxMB(),
OpenPostsInNewTab: s.OpenPostsInNewTab(),
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
}
}
@@ -206,10 +353,11 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
SearchKeywordMin: limits.SearchKeywordMin,
SearchKeywordMax: limits.SearchKeywordMax,
PageSizeDefault: limits.PageSizeDefault,
FeedMaxPages: limits.FeedMaxPages,
FeedMaxItems: limits.FeedMaxItems,
PasswordMinLen: limits.PasswordMinLen,
AvatarMaxMB: limits.AvatarMaxMB,
OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
}
}
@@ -228,23 +376,30 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPageSizeMax: in.PageSizeMax,
SettingFeedMaxPages: in.FeedMaxPages,
SettingFeedMaxItems: in.FeedMaxItems,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
}
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting
}
if in.PageSizeDefault > in.PageSizeMax {
return ErrInvalidSetting
}
for key, val := range updates {
if err := s.setInt(key, val); err != nil {
return err
}
}
boolUpdates := map[string]bool{
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
}
for key, on := range boolUpdates {
v := "0"
if on {
v = "1"
}
if err := s.setString(key, v); err != nil {
return err
}
}
return nil
}
@@ -280,14 +435,370 @@ func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingS
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
func (s *ForumSettingsService) PageSizeMax() int { return s.getInt(SettingPageSizeMax, 50) }
func (s *ForumSettingsService) FeedMaxPages() int { return s.getInt(SettingFeedMaxPages, 10) }
func (s *ForumSettingsService) FeedMaxItems() int { return s.getInt(SettingFeedMaxItems, 300) }
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
func (s *ForumSettingsService) OpenPostsInNewTab() bool {
return s.getString(SettingOpenPostsInNewTab, "1") == "1"
}
func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
}
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
if port <= 0 {
port = 465
}
password := s.getString(SettingSMTPPassword, "")
return MailConfig{
Enabled: s.getString(SettingSMTPEnabled, "0") == "1",
Host: s.getString(SettingSMTPHost, ""),
Port: port,
Username: s.getString(SettingSMTPUsername, ""),
Password: password,
From: s.getString(SettingSMTPFrom, ""),
FromName: s.getString(SettingSMTPFromName, "姜十三论坛"),
Encryption: normalizeEncryption(s.getString(SettingSMTPEncryption, "ssl")),
HasPassword: password != "",
}
}
// MailConfigPublic 管理端回显(不含密码明文)
func (s *ForumSettingsService) MailConfigPublic() MailConfig {
cfg := s.MailConfig()
cfg.Password = ""
return cfg
}
// MailReady 邮件服务是否可用于发信
func (s *ForumSettingsService) MailReady() bool {
cfg := s.MailConfig()
return cfg.Enabled &&
strings.TrimSpace(cfg.Host) != "" &&
cfg.Port > 0 &&
strings.TrimSpace(cfg.From) != "" &&
strings.TrimSpace(cfg.Username) != "" &&
cfg.HasPassword
}
// UpdateMailConfig 更新 SMTP 配置;密码为空表示保持原值
func (s *ForumSettingsService) UpdateMailConfig(in MailConfig) error {
enc := normalizeEncryption(in.Encryption)
if enc != "none" && enc != "starttls" && enc != "ssl" {
return ErrInvalidSetting
}
port := in.Port
if port <= 0 {
port = 465
}
enabled := "0"
if in.Enabled {
enabled = "1"
}
updates := map[string]string{
SettingSMTPEnabled: enabled,
SettingSMTPHost: strings.TrimSpace(in.Host),
SettingSMTPPort: strconv.Itoa(port),
SettingSMTPUsername: strings.TrimSpace(in.Username),
SettingSMTPFrom: strings.TrimSpace(in.From),
SettingSMTPFromName: strings.TrimSpace(in.FromName),
SettingSMTPEncryption: enc,
}
if strings.TrimSpace(in.Password) != "" {
updates[SettingSMTPPassword] = in.Password
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// OIDCConfig 读取 OIDC 全局配置
func (s *ForumSettingsService) OIDCConfig() OIDCConfig {
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
clientCount := CountEnabledOAuthClients()
cfg := OIDCConfig{
Enabled: s.getString(SettingOIDCEnabled, "0") == "1",
RootURL: root,
GroupClaim: strings.TrimSpace(s.getString(SettingOIDCGroupClaim, "groups")),
AdminGroup: strings.TrimSpace(s.getString(SettingOIDCAdminGroup, "gitea-admin")),
UserGroup: strings.TrimSpace(s.getString(SettingOIDCUserGroup, "gitea-users")),
ClientCount: clientCount,
}
if cfg.GroupClaim == "" {
cfg.GroupClaim = "groups"
}
cfg.Ready = cfg.Enabled && cfg.RootURL != "" && clientCount > 0
if root != "" {
cfg.DiscoveryURL = root + "/.well-known/openid-configuration"
cfg.AuthorizeURL = root + "/oauth/authorize"
cfg.LogoutURL = root + "/oauth/logout"
}
return cfg
}
// OIDCConfigPublic 管理端回显
func (s *ForumSettingsService) OIDCConfigPublic() OIDCConfig {
return s.OIDCConfig()
}
// UpdateOIDCConfig 更新 OIDC 全局配置(不含 OAuth 应用凭证)
func (s *ForumSettingsService) UpdateOIDCConfig(in OIDCConfig) error {
root := normalizeRootURL(in.RootURL)
if root != "" && !strings.HasPrefix(root, "http://") && !strings.HasPrefix(root, "https://") {
return ErrInvalidSetting
}
enabled := "0"
if in.Enabled {
enabled = "1"
}
groupClaim := strings.TrimSpace(in.GroupClaim)
if groupClaim == "" {
groupClaim = "groups"
}
adminGroup := strings.TrimSpace(in.AdminGroup)
userGroup := strings.TrimSpace(in.UserGroup)
updates := map[string]string{
SettingOIDCEnabled: enabled,
SettingOIDCRootURL: root,
SettingOIDCGroupClaim: groupClaim,
SettingOIDCAdminGroup: adminGroup,
SettingOIDCUserGroup: userGroup,
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// SeedOIDCFromINI 若库中尚未配置,则用 app.ini 种子一次(便于迁移)
func (s *ForumSettingsService) SeedOIDCFromINI(rootURL, clientID, clientSecret, redirectURIsCSV string) {
rootURL = normalizeRootURL(rootURL)
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
uris := normalizeRedirectURIs(redirectURIsCSV)
if rootURL == "" && clientID == "" && clientSecret == "" && uris == "" {
return
}
if s.getString(SettingOIDCRootURL, "") == "" && rootURL != "" {
_ = s.setString(SettingOIDCRootURL, rootURL)
}
if s.getString(SettingOAuthClientID, "") == "" && clientID != "" {
_ = s.setString(SettingOAuthClientID, clientID)
}
if s.getString(SettingOAuthClientSecret, "") == "" && clientSecret != "" {
_ = s.setString(SettingOAuthClientSecret, clientSecret)
}
if s.getString(SettingOAuthRedirectURIs, "") == "" && uris != "" {
_ = s.setString(SettingOAuthRedirectURIs, uris)
}
s.MigrateLegacyOIDCClient()
if s.getString(SettingOIDCEnabled, "0") == "0" &&
s.getString(SettingOIDCRootURL, "") != "" &&
CountEnabledOAuthClients() > 0 {
_ = s.setString(SettingOIDCEnabled, "1")
}
}
// GiteaSyncConfig 读取 Gitea 同步配置(含 Token 明文,供服务内部使用)
func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
interval, _ := strconv.Atoi(s.getString(SettingGiteaSyncIntervalMin, "60"))
if interval < 5 {
interval = 5
}
if interval > 24*60 {
interval = 24 * 60
}
token := s.getString(SettingGiteaToken, "")
base := normalizeRootURL(s.getString(SettingGiteaBaseURL, ""))
cfg := GiteaSyncConfig{
Enabled: s.getString(SettingGiteaSyncEnabled, "0") == "1",
BaseURL: base,
Token: token,
HasToken: token != "",
SyncIntervalMin: interval,
}
cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
var n int64
model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false).Count(&n)
cfg.RepoCount = n
return cfg
}
// GiteaSyncConfigPublic 管理端回显(不含 Token 明文)
func (s *ForumSettingsService) GiteaSyncConfigPublic() GiteaSyncConfig {
cfg := s.GiteaSyncConfig()
cfg.Token = ""
return cfg
}
// UpdateGiteaSyncConfig 更新同步配置Token 为空表示保持原值
func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
base := normalizeRootURL(in.BaseURL)
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
return ErrInvalidSetting
}
interval := in.SyncIntervalMin
if interval <= 0 {
interval = 60
}
if interval < 5 {
interval = 5
}
if interval > 24*60 {
interval = 24 * 60
}
enabled := "0"
if in.Enabled {
enabled = "1"
}
updates := map[string]string{
SettingGiteaSyncEnabled: enabled,
SettingGiteaBaseURL: base,
SettingGiteaSyncIntervalMin: strconv.Itoa(interval),
}
if strings.TrimSpace(in.Token) != "" {
updates[SettingGiteaToken] = strings.TrimSpace(in.Token)
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// SeedGiteaFromINI 若库中尚未配置,则用 app.ini 种子一次
func (s *ForumSettingsService) SeedGiteaFromINI(baseURL, token string, enabled bool) {
baseURL = normalizeRootURL(baseURL)
token = strings.TrimSpace(token)
if baseURL == "" && token == "" && !enabled {
return
}
if s.getString(SettingGiteaBaseURL, "") == "" && baseURL != "" {
_ = s.setString(SettingGiteaBaseURL, baseURL)
}
if s.getString(SettingGiteaToken, "") == "" && token != "" {
_ = s.setString(SettingGiteaToken, token)
}
if enabled && s.getString(SettingGiteaSyncEnabled, "0") == "0" &&
s.getString(SettingGiteaBaseURL, "") != "" &&
s.getString(SettingGiteaToken, "") != "" {
_ = s.setString(SettingGiteaSyncEnabled, "1")
}
}
// SiteBranding 读取站点品牌配置
func (s *ForumSettingsService) SiteBranding() SiteBranding {
name := strings.TrimSpace(s.getString(SettingSiteName, siteBrandingDefaults[SettingSiteName]))
if name == "" {
name = siteBrandingDefaults[SettingSiteName]
}
mark := strings.TrimSpace(s.getString(SettingSiteLogoMark, siteBrandingDefaults[SettingSiteLogoMark]))
if mark == "" {
mark = siteBrandingDefaults[SettingSiteLogoMark]
}
// 字标取首个字符(支持中文)
runes := []rune(mark)
if len(runes) > 1 {
mark = string(runes[0])
}
return SiteBranding{
Name: name,
NameEN: strings.TrimSpace(s.getString(SettingSiteNameEN, siteBrandingDefaults[SettingSiteNameEN])),
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
LogoMark: mark,
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
}
}
// UpdateSiteBranding 更新品牌文案Logo/Favicon URL 由上传接口单独写入
func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
name := strings.TrimSpace(in.Name)
if name == "" {
return ErrInvalidSetting
}
if len([]rune(name)) > 64 {
return ErrInvalidSetting
}
mark := strings.TrimSpace(in.LogoMark)
if mark == "" {
mark = siteBrandingDefaults[SettingSiteLogoMark]
}
runes := []rune(mark)
if len(runes) > 1 {
mark = string(runes[0])
}
nameEN := strings.TrimSpace(in.NameEN)
if len([]rune(nameEN)) > 64 {
return ErrInvalidSetting
}
slogan := strings.TrimSpace(in.Slogan)
if len([]rune(slogan)) > 200 {
return ErrInvalidSetting
}
updates := map[string]string{
SettingSiteName: name,
SettingSiteNameEN: nameEN,
SettingSiteSlogan: slogan,
SettingSiteLogoMark: mark,
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// SetSiteLogo 写入 Logo URL空串表示清除
func (s *ForumSettingsService) SetSiteLogo(url string) error {
return s.setString(SettingSiteLogo, strings.TrimSpace(url))
}
// SetSiteFavicon 写入 Favicon URL空串表示清除
func (s *ForumSettingsService) SetSiteFavicon(url string) error {
return s.setString(SettingSiteFavicon, strings.TrimSpace(url))
}
func normalizeRootURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
func normalizeRedirectURIs(raw string) string {
parts := splitRedirectURIs(raw)
return strings.Join(parts, ",")
}
func splitRedirectURIs(raw string) []string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\n", ",")
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
kw := trimRunes(keyword)
@@ -311,9 +822,8 @@ func (s *ForumSettingsService) NormalizePageSize(size int) int {
if size < 1 {
return s.PageSizeDefault()
}
maxSize := s.PageSizeMax()
if maxSize > 0 && size > maxSize {
return maxSize
if size > pageSizeAPIMax {
return pageSizeAPIMax
}
return size
}