feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View File

@@ -13,7 +13,7 @@ import (
// 论坛设置键名
const (
SettingPostEditWindowHours = "post_edit_window_hours"
SettingPostEditWindowHours = "post_edit_window_hours"
SettingCommentEditWindowMinutes = "comment_edit_window_minutes"
SettingRateLimitPost = "rate_limit_post"
@@ -39,6 +39,11 @@ const (
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
SettingAsideShowTagCloud = "aside_show_tag_cloud"
SettingAsideShowRecentComments = "aside_show_recent_comments"
SettingAsideShowFriendLinks = "aside_show_friend_links"
SettingAsideWidgets = "aside_widgets"
SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt
@@ -73,17 +78,18 @@ const (
SettingStorageForcePathStyle = "storage_force_path_style"
SettingStorageImageDelivery = "storage_image_delivery"
SettingSiteName = "site_name"
SettingSiteSlogan = "site_slogan"
SettingSiteDescription = "site_description"
SettingSiteKeywords = "site_keywords"
SettingSiteLogoMark = "site_logo_mark"
SettingSiteLogo = "site_logo"
SettingSiteFavicon = "site_favicon"
SettingSiteOGImage = "site_og_image"
SettingSiteICPBeian = "site_icp_beian"
SettingSiteICPBeianURL = "site_icp_beian_url"
SettingSiteFriendLinks = "site_friend_links"
SettingSiteName = "site_name"
SettingSiteSlogan = "site_slogan"
SettingSiteDescription = "site_description"
SettingSiteKeywords = "site_keywords"
SettingSiteLogoMark = "site_logo_mark"
SettingSiteLogo = "site_logo"
SettingSiteFavicon = "site_favicon"
SettingSiteOGImage = "site_og_image"
SettingSiteICPBeian = "site_icp_beian"
SettingSiteICPBeianURL = "site_icp_beian_url"
SettingSiteFriendLinks = "site_friend_links"
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100
@@ -118,10 +124,35 @@ type ForumLimits struct {
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
// AsideWidget 右侧栏可选组件
type AsideWidget struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
const (
AsideWidgetTagCloud = "tag_cloud"
AsideWidgetRecentComments = "recent_comments"
AsideWidgetFriendLinks = "friend_links"
)
var asideWidgetDefaultOrder = []string{
AsideWidgetTagCloud,
AsideWidgetRecentComments,
AsideWidgetFriendLinks,
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
@@ -140,15 +171,22 @@ type ForumLimitsPublic struct {
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
type settingDef struct {
key string
key string
defaultVal string
min int
max int // 0 表示不限制上限
min int
max int // 0 表示不限制上限
}
var forumSettingDefs = []settingDef{
@@ -180,6 +218,17 @@ var forumSettingDefs = []settingDef{
{SettingOpenContentLinksInNewTab, "1", 0, 1},
}
var feedSettingDefaults = map[string]string{
SettingFeedListStyle: "title",
}
var asideSettingDefaults = map[string]string{
SettingAsideShowTagCloud: "0",
SettingAsideShowRecentComments: "0",
SettingAsideShowFriendLinks: "1",
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
}
var mailSettingDefaults = map[string]string{
SettingSMTPEnabled: "0",
SettingSMTPHost: "",
@@ -219,6 +268,10 @@ var storageSettingDefaults = map[string]string{
SettingStorageImageDelivery: ImageDeliveryWebP,
}
var friendLinkSettingDefaults = map[string]string{
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
}
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
@@ -245,10 +298,11 @@ const (
defaultICPBeianURL = "https://beian.miit.gov.cn/"
)
// FriendLink 页脚友情链接
// FriendLink 友情链接
type FriendLink struct {
Name string `json:"name"`
URL string `json:"url"`
Logo string `json:"logo,omitempty"`
}
// SiteBranding 站点品牌配置名称、Logo、Favicon、页脚等
@@ -264,6 +318,7 @@ type SiteBranding struct {
ICPBeian string `json:"icp_beian"`
ICPBeianURL string `json:"icp_beian_url"`
FriendLinks []FriendLink `json:"friend_links"`
SiteURL string `json:"site_url,omitempty" gorm:"-"` // 公开站点根 URL仅 API 填充
}
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
@@ -343,6 +398,20 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
for key, val := range feedSettingDefaults {
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 asideSettingDefaults {
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 mailSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
@@ -378,6 +447,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range friendLinkSettingDefaults {
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 {
@@ -426,6 +502,8 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
func (s *ForumSettingsService) Limits() ForumLimits {
permalink := s.Permalink()
widgets := s.AsideWidgets()
bools := asideBoolsFromWidgets(widgets)
return ForumLimits{
PostEditWindowHours: s.PostEditWindowHours(),
CommentEditWindowMinutes: s.CommentEditWindowMinutes(),
@@ -454,6 +532,13 @@ func (s *ForumSettingsService) Limits() ForumLimits {
OpenPostsInNewTab: s.OpenPostsInNewTab(),
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
AsideShowTagCloud: bools.tagCloud,
AsideShowRecentComments: bools.recentComments,
AsideShowFriendLinks: bools.friendLinks,
AsideWidgets: widgets,
FeedListStyle: s.FeedListStyle(),
PermalinkEnabled: permalink.Enabled,
PermalinkExt: permalink.Ext,
}
@@ -478,6 +563,13 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
AsideShowTagCloud: limits.AsideShowTagCloud,
AsideShowRecentComments: limits.AsideShowRecentComments,
AsideShowFriendLinks: limits.AsideShowFriendLinks,
AsideWidgets: limits.AsideWidgets,
FeedListStyle: limits.FeedListStyle,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
}
@@ -487,21 +579,21 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,
SettingCommentEditWindowMinutes: in.CommentEditWindowMinutes,
SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister,
SettingRateLimitLogin: in.RateLimitLogin,
SettingRateLimitWindow: in.RateLimitWindowSec,
SettingPostTitleMax: in.PostTitleMax,
SettingPostTagsMax: in.PostTagsMax,
SettingPostContentMax: in.PostContentMax,
SettingCommentMax: in.CommentMax,
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
SettingSignatureMax: in.SignatureMax,
SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister,
SettingRateLimitLogin: in.RateLimitLogin,
SettingRateLimitWindow: in.RateLimitWindowSec,
SettingPostTitleMax: in.PostTitleMax,
SettingPostTagsMax: in.PostTagsMax,
SettingPostContentMax: in.PostContentMax,
SettingCommentMax: in.CommentMax,
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
SettingSignatureMax: in.SignatureMax,
}
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting
@@ -511,9 +603,17 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
return err
}
}
widgets := NormalizeAsideWidgets(in.AsideWidgets)
if len(widgets) == 0 {
widgets = asideWidgetsFromBools(in.AsideShowTagCloud, in.AsideShowRecentComments, in.AsideShowFriendLinks)
}
bools := asideBoolsFromWidgets(widgets)
boolUpdates := map[string]bool{
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks,
SettingPermalinkEnabled: in.PermalinkEnabled,
}
for key, on := range boolUpdates {
@@ -525,6 +625,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
return err
}
}
widgetsJSON, err := json.Marshal(widgets)
if err != nil {
return err
}
if err := s.setString(SettingAsideWidgets, string(widgetsJSON)); err != nil {
return err
}
ext, ok := NormalizePermalinkExt(in.PermalinkExt)
if !ok {
return ErrInvalidSetting
@@ -532,6 +639,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
if err := s.setString(SettingPermalinkExt, ext); err != nil {
return err
}
style, ok := NormalizeFeedListStyle(in.FeedListStyle)
if !ok {
return ErrInvalidSetting
}
if err := s.setString(SettingFeedListStyle, style); err != nil {
return err
}
return nil
}
@@ -584,6 +698,126 @@ func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
}
// FriendLinkReciprocalCheckEnabled 是否启用友链回链检测;缺省为关闭
func (s *ForumSettingsService) FriendLinkReciprocalCheckEnabled() bool {
return s.getString(SettingFriendLinkReciprocalCheck, "0") == "1"
}
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingFriendLinkReciprocalCheck, v)
}
type asideWidgetBools struct {
tagCloud bool
recentComments bool
friendLinks bool
}
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
return []AsideWidget{
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
{ID: AsideWidgetRecentComments, Enabled: recentComments},
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
}
}
func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
out := asideWidgetBools{}
for _, w := range widgets {
switch w.ID {
case AsideWidgetTagCloud:
out.tagCloud = w.Enabled
case AsideWidgetRecentComments:
out.recentComments = w.Enabled
case AsideWidgetFriendLinks:
out.friendLinks = w.Enabled
}
}
return out
}
func isValidAsideWidgetID(id string) bool {
switch id {
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetFriendLinks:
return true
default:
return false
}
}
// NormalizeAsideWidgets 校验并补全右侧栏组件列表(顺序保留,缺失项按默认顺序追加)
func NormalizeAsideWidgets(in []AsideWidget) []AsideWidget {
seen := make(map[string]bool, len(asideWidgetDefaultOrder))
out := make([]AsideWidget, 0, len(asideWidgetDefaultOrder))
for _, w := range in {
id := strings.TrimSpace(w.ID)
if !isValidAsideWidgetID(id) || seen[id] {
continue
}
seen[id] = true
out = append(out, AsideWidget{ID: id, Enabled: w.Enabled})
}
for _, id := range asideWidgetDefaultOrder {
if seen[id] {
continue
}
out = append(out, AsideWidget{ID: id, Enabled: false})
}
return out
}
func (s *ForumSettingsService) AsideWidgets() []AsideWidget {
raw := strings.TrimSpace(s.getString(SettingAsideWidgets, ""))
if raw != "" {
var widgets []AsideWidget
if err := json.Unmarshal([]byte(raw), &widgets); err == nil {
normalized := NormalizeAsideWidgets(widgets)
if len(normalized) > 0 {
return normalized
}
}
}
return asideWidgetsFromBools(s.AsideShowTagCloud(), s.AsideShowRecentComments(), s.AsideShowFriendLinks())
}
func (s *ForumSettingsService) AsideShowTagCloud() bool {
return s.getString(SettingAsideShowTagCloud, "0") == "1"
}
func (s *ForumSettingsService) AsideShowRecentComments() bool {
return s.getString(SettingAsideShowRecentComments, "0") == "1"
}
func (s *ForumSettingsService) AsideShowFriendLinks() bool {
return s.getString(SettingAsideShowFriendLinks, "1") == "1"
}
// NormalizeFeedListStyle 校验首页列表样式
func NormalizeFeedListStyle(v string) (string, bool) {
switch strings.TrimSpace(strings.ToLower(v)) {
case "title", "":
return "title", true
case "excerpt":
return "excerpt", true
case "thumbnail":
return "thumbnail", true
default:
return "", false
}
}
func (s *ForumSettingsService) FeedListStyle() string {
v, ok := NormalizeFeedListStyle(s.getString(SettingFeedListStyle, feedSettingDefaults[SettingFeedListStyle]))
if !ok {
return "title"
}
return v
}
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
@@ -903,6 +1137,11 @@ func (s *ForumSettingsService) SiteBranding() SiteBranding {
mark = string(runes[0])
}
links := parseFriendLinksJSON(s.getString(SettingSiteFriendLinks, "[]"))
links = EnrichFriendLinksLogos(links)
if err := s.maybePersistEnrichedFriendLinks(links); err != nil {
// 回填失败不阻断读取
_ = err
}
return SiteBranding{
Name: name,
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
@@ -1111,7 +1350,7 @@ func normalizeFriendLinks(in []FriendLink) ([]FriendLink, error) {
if scheme != "http" && scheme != "https" {
return nil, ErrInvalidSetting
}
out = append(out, FriendLink{Name: name, URL: href})
out = append(out, FriendLink{Name: name, URL: href, Logo: normalizeFriendLinkLogoOptional(item.Logo)})
}
if out == nil {
out = []FriendLink{}