最新帖子
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index f5bd65b..1b2bc05 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -14006,6 +14006,201 @@ button.post-poll__option,
}
.admin-inline-link:hover { text-decoration: underline; }
+.admin-community-support-strip {
+ display: inline-flex;
+ width: fit-content;
+ max-width: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 20px;
+ margin: 16px 0;
+ padding: 12px 14px;
+ border: 1px solid color-mix(in srgb, var(--j13-green) 22%, var(--j13-border-light));
+ border-left: 3px solid var(--j13-green);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--j13-green) 5%, var(--j13-bg-surface));
+}
+
+.admin-community-support-strip-main {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ min-width: 0;
+}
+
+.admin-community-support-strip-icon {
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--j13-green);
+}
+
+.admin-community-support-strip-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ max-width: 28rem;
+}
+
+.admin-community-support-strip-copy strong {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.admin-community-support-strip-copy span {
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--color-text-2);
+}
+
+.admin-community-support-strip .admin-settings-switch {
+ flex-shrink: 0;
+}
+
+.admin-community-support-strip .admin-settings-switch:disabled {
+ opacity: 0.6;
+ cursor: wait;
+}
+
+@media (max-width: 640px) {
+ .admin-community-support-strip {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ }
+
+ .admin-community-support-strip-copy {
+ max-width: none;
+ }
+}
+
+.admin-community-site {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.admin-community-site strong {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.admin-community-star {
+ color: var(--j13-green);
+ flex-shrink: 0;
+}
+
+.admin-community-url {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ word-break: break-all;
+}
+
+.showcase-page {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 28px 16px 48px;
+}
+
+.showcase-head {
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+ margin-bottom: 24px;
+}
+
+.showcase-head-mark {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ color: var(--j13-green);
+ background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-surface));
+ border: 1px solid color-mix(in srgb, var(--j13-green) 20%, var(--j13-border-light));
+ flex-shrink: 0;
+}
+
+.showcase-title {
+ margin: 0;
+ font-size: 1.35rem;
+ font-weight: 650;
+ color: var(--color-text-1);
+}
+
+.showcase-desc {
+ margin: 6px 0 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--color-text-2);
+}
+
+.showcase-empty {
+ margin: 0;
+ padding: 32px 0;
+ text-align: center;
+ font-size: 14px;
+ color: var(--color-text-2);
+}
+
+.showcase-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.showcase-item {
+ border: 1px solid var(--j13-border-light);
+ border-radius: 10px;
+ background: var(--j13-bg-surface);
+}
+
+.showcase-item-link {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px 16px;
+ text-decoration: none;
+ color: inherit;
+ border-radius: 10px;
+ transition: background 0.15s ease;
+}
+
+.showcase-item-link:hover {
+ background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-surface));
+}
+
+.showcase-item-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+
+.showcase-item-url {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--j13-green);
+ word-break: break-all;
+}
+
+.showcase-item-meta {
+ font-size: 12px;
+ color: var(--color-text-2);
+}
+
.admin-links-logo-thumb {
flex-shrink: 0;
width: 40px;
diff --git a/go.mod b/go.mod
index c43a285..7e155ff 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
+ github.com/google/uuid v1.6.0
github.com/kardianos/service v1.2.2
github.com/microcosm-cc/bluemonday v1.0.27
github.com/minio/minio-go/v7 v7.0.98
@@ -31,7 +32,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
diff --git a/handler/api.go b/handler/api.go
index 3887d5d..fa82e9f 100644
--- a/handler/api.go
+++ b/handler/api.go
@@ -9,10 +9,10 @@ import (
"strings"
"time"
- "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
)
// APIMe 当前登录用户
@@ -160,12 +160,12 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
- "comments": commentCount,
- "pending_posts": pendingPosts,
- "pending_comments": pendingComments,
- "pending_reports": pendingReports,
+ "comments": commentCount,
+ "pending_posts": pendingPosts,
+ "pending_comments": pendingComments,
+ "pending_reports": pendingReports,
"pending_friend_links": pendingFriendLinks,
- "recent_posts": recentPosts,
+ "recent_posts": recentPosts,
})
}
@@ -574,6 +574,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
"gitea": h.Settings.GiteaSyncConfigPublic(),
"storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(),
+ "community": h.Settings.CommunityConfig(),
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
@@ -994,10 +995,10 @@ func (h *Handlers) APIPosts(c *gin.Context) {
h.Badge.AttachBadgeSummaries(users, 2)
}
c.JSON(http.StatusOK, gin.H{
- "posts": items,
- "total": total,
- "page": page,
- "size": size,
+ "posts": items,
+ "total": total,
+ "page": page,
+ "size": size,
"has_more": int64(page*size) < total,
})
}
diff --git a/handler/community.go b/handler/community.go
new file mode 100644
index 0000000..3f4a1cb
--- /dev/null
+++ b/handler/community.go
@@ -0,0 +1,139 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "git.iioio.com/freefire/jiang13-forum/service"
+)
+
+// APICommunityHeartbeat 公开心跳入口(仅枢纽开启时写入)
+func (h *Handlers) APICommunityHeartbeat(c *gin.Context) {
+ if h.Community == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "未启用"})
+ return
+ }
+ var req service.CommunityHeartbeatPayload
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := h.Community.ReceiveHeartbeat(req, c.ClientIP()); err != nil {
+ if errors.Is(err, service.ErrCommunityHubDisabled) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"})
+ return
+ }
+ if errors.Is(err, service.ErrCommunityBadPayload) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "心跳参数无效"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+// APICommunityShowcase 公开展柜(仅精选)
+func (h *Handlers) APICommunityShowcase(c *gin.Context) {
+ if h.Community == nil {
+ c.JSON(http.StatusOK, gin.H{"items": []any{}})
+ return
+ }
+ list, err := h.Community.ListShowcase()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": list})
+}
+
+// APIAdminCommunityInstances 公网实例列表
+func (h *Handlers) APIAdminCommunityInstances(c *gin.Context) {
+ if h.Community == nil {
+ c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false})
+ return
+ }
+ cfg := h.Settings.CommunityConfig()
+ list, err := h.Community.ListInstances()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "hub_enabled": cfg.HubEnabled,
+ "instances": list,
+ })
+}
+
+// APIAdminFeatureCommunityInstance 精选 / 取消精选
+func (h *Handlers) APIAdminFeatureCommunityInstance(c *gin.Context) {
+ if h.Community == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "未启用"})
+ return
+ }
+ var req service.CommunityFeatureInput
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ view, err := h.Community.SetInstanceFeatured(c.Param("id"), req)
+ if err != nil {
+ if errors.Is(err, service.ErrCommunityBadPayload) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "实例无效"})
+ return
+ }
+ c.JSON(http.StatusNotFound, gin.H{"error": "实例不存在"})
+ return
+ }
+ msg := "已取消精选"
+ if view.Featured {
+ msg = "已设为精选,将出现在公开展柜"
+ }
+ c.JSON(http.StatusOK, gin.H{"message": msg, "instance": view})
+}
+
+// APIAdminUpdateCommunitySettings 更新社区上报设置
+func (h *Handlers) APIAdminUpdateCommunitySettings(c *gin.Context) {
+ var req service.CommunityConfig
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ _, err := h.Settings.UpdateCommunityConfig(req)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ cfg := h.Settings.CommunityConfig()
+ out := gin.H{
+ "message": "社区设置已保存",
+ "community": cfg,
+ }
+ if cfg.ReportEnabled && h.Community != nil {
+ origin := communityRequestOrigin(c)
+ if err := h.Community.SendHeartbeatOnce(origin); err != nil {
+ out["message"] = "社区设置已保存,但心跳未成功"
+ out["heartbeat_error"] = err.Error()
+ // 刷新 site_url(可能已由 Origin 持久化)
+ out["community"] = h.Settings.CommunityConfig()
+ }
+ }
+ c.JSON(http.StatusOK, out)
+}
+
+// communityRequestOrigin 优先用浏览器 Origin(Vite 代理时 Host 可能是后端端口)
+func communityRequestOrigin(c *gin.Context) string {
+ if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" {
+ return o
+ }
+ if ref := strings.TrimSpace(c.GetHeader("Referer")); ref != "" {
+ if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
+ return u.Scheme + "://" + u.Host
+ }
+ }
+ return requestOrigin(c)
+}
diff --git a/handler/handlers.go b/handler/handlers.go
index dbb47c9..3df33aa 100644
--- a/handler/handlers.go
+++ b/handler/handlers.go
@@ -8,37 +8,38 @@ import (
"strconv"
"strings"
- "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
)
// Handlers 聚合所有 HTTP 处理器
type Handlers struct {
- Cfg *config.Config
- Store *service.UploadStore
- Auth *service.AuthService
- User *service.UserService
- Board *service.BoardService
- Post *service.PostService
- Comment *service.CommentService
- Message *service.MessageService
- Notify *service.NotifyService
- Report *service.ReportService
- Backup *service.BackupService
- Filter *service.SensitiveFilter
- Limiter *service.RateLimiter
- Settings *service.ForumSettingsService
- Captcha *service.CaptchaService
- Mail *service.MailService
- EmailCode *service.EmailCodeService
- OIDC *service.OIDCService
- Gitea *service.GiteaService
- Points *service.PointsService
- Badge *service.BadgeService
- SitePage *service.SitePageService
+ Cfg *config.Config
+ Store *service.UploadStore
+ Auth *service.AuthService
+ User *service.UserService
+ Board *service.BoardService
+ Post *service.PostService
+ Comment *service.CommentService
+ Message *service.MessageService
+ Notify *service.NotifyService
+ Report *service.ReportService
+ Backup *service.BackupService
+ Community *service.CommunityService
+ Filter *service.SensitiveFilter
+ Limiter *service.RateLimiter
+ Settings *service.ForumSettingsService
+ Captcha *service.CaptchaService
+ Mail *service.MailService
+ EmailCode *service.EmailCodeService
+ OIDC *service.OIDCService
+ Gitea *service.GiteaService
+ Points *service.PointsService
+ Badge *service.BadgeService
+ SitePage *service.SitePageService
FriendLinkApply *service.FriendLinkApplyService
}
diff --git a/model/db.go b/model/db.go
index 94a1ad2..fea011c 100644
--- a/model/db.go
+++ b/model/db.go
@@ -43,6 +43,7 @@ func InitDB(dbPath string) error {
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
&BadgeDef{}, &UserBadge{},
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
+ &CommunityInstance{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
diff --git a/model/models.go b/model/models.go
index 7fdd7c8..f41543b 100644
--- a/model/models.go
+++ b/model/models.go
@@ -47,26 +47,26 @@ const (
// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
// 个人中心与后台列表请用 UserSelf / UserAdmin。
type User struct {
- ID uint `gorm:"primaryKey" json:"id"`
- Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
- Email string `gorm:"index;size:128;default:''" json:"-"`
- Password string `gorm:"size:128;not null" json:"-"`
- Nickname string `gorm:"size:64" json:"nickname"`
- Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
- Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
- Role Role `gorm:"size:16;default:user" json:"role"`
- Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
- Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
- Points int `gorm:"default:0" json:"points"` // 可用积分
- CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
- Banned bool `gorm:"default:false" json:"banned"`
- BannedAt *time.Time `json:"banned_at,omitempty"`
- LastLoginAt *time.Time `json:"-"`
- LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
- LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
+ Email string `gorm:"index;size:128;default:''" json:"-"`
+ Password string `gorm:"size:128;not null" json:"-"`
+ Nickname string `gorm:"size:64" json:"nickname"`
+ Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
+ Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
+ Role Role `gorm:"size:16;default:user" json:"role"`
+ Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
+ Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
+ Points int `gorm:"default:0" json:"points"` // 可用积分
+ CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
+ Banned bool `gorm:"default:false" json:"banned"`
+ BannedAt *time.Time `json:"banned_at,omitempty"`
+ LastLoginAt *time.Time `json:"-"`
+ LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
+ LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 仅序列化展示用,不落库
Level int `gorm:"-" json:"level"`
@@ -99,31 +99,31 @@ type Board struct {
// Post 帖子
type Post struct {
- ID uint `gorm:"primaryKey" json:"id"`
- BoardID uint `gorm:"index;not null" json:"board_id"`
- UserID uint `gorm:"index;not null" json:"user_id"`
- Title string `gorm:"size:256;not null" json:"title"`
- Content string `gorm:"type:text;not null" json:"content"`
- ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
- Tags string `gorm:"size:256" json:"tags"`
- PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
- QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
- BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
- BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
- BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
- LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
- LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
- Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
- BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
- Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
- EditLocked bool `gorm:"default:false" json:"edit_locked"`
- CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
- Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
- LikeCount int `gorm:"default:0" json:"like_count"`
- ViewCount int `gorm:"default:0" json:"view_count"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ BoardID uint `gorm:"index;not null" json:"board_id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Title string `gorm:"size:256;not null" json:"title"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
+ Tags string `gorm:"size:256" json:"tags"`
+ PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
+ QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
+ BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
+ BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
+ BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
+ LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
+ LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
+ Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
+ BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
+ Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
+ EditLocked bool `gorm:"default:false" json:"edit_locked"`
+ CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
+ Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
+ LikeCount int `gorm:"default:0" json:"like_count"`
+ ViewCount int `gorm:"default:0" json:"view_count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
@@ -162,21 +162,21 @@ type ForumSetting struct {
// Comment 楼层评论
type Comment struct {
- ID uint `gorm:"primaryKey" json:"id"`
- PostID uint `gorm:"index;not null" json:"post_id"`
- UserID uint `gorm:"index" json:"user_id"` // 0 表示游客
- Floor int `gorm:"not null" json:"floor"`
- Content string `gorm:"type:text;not null" json:"content"`
- ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"`
- GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"`
- GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"`
- GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
- IsPrivate bool `gorm:"default:false" json:"is_private"`
- Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
- LikeCount int `gorm:"default:0" json:"like_count"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"index;not null" json:"post_id"`
+ UserID uint `gorm:"index" json:"user_id"` // 0 表示游客
+ Floor int `gorm:"not null" json:"floor"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"`
+ GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"`
+ GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"`
+ GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
+ IsPrivate bool `gorm:"default:false" json:"is_private"`
+ Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
+ LikeCount int `gorm:"default:0" json:"like_count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
@@ -266,23 +266,23 @@ const (
// FriendLinkApply 友情链接申请
type FriendLinkApply struct {
- ID uint `gorm:"primaryKey" json:"id"`
- UserID uint `gorm:"index;not null" json:"user_id"`
- Name string `gorm:"size:32;not null" json:"name"`
- URL string `gorm:"size:512;not null" json:"url"`
- Description string `gorm:"size:200;default:''" json:"description,omitempty"`
- Logo string `gorm:"size:512;default:''" json:"logo"`
- ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
- LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
- ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
- ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
- ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
- Status string `gorm:"size:16;default:pending;index" json:"status"`
- ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
- ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Name string `gorm:"size:32;not null" json:"name"`
+ URL string `gorm:"size:512;not null" json:"url"`
+ Description string `gorm:"size:200;default:''" json:"description,omitempty"`
+ Logo string `gorm:"size:512;default:''" json:"logo"`
+ ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
+ LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
+ ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
+ ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
+ ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
+ Status string `gorm:"size:16;default:pending;index" json:"status"`
+ ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
+ ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
@@ -301,10 +301,10 @@ type PostReport struct {
CreatedAt time.Time `json:"created_at"`
HandledAt *time.Time `json:"handled_at,omitempty"`
- Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
- Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
- Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
- Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
+ Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
+ Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
+ Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
+ Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
}
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
@@ -377,17 +377,17 @@ type PostContentUnlock struct {
// SitePage 自定义单页(关于我们、版规等)
type SitePage struct {
- ID uint `gorm:"primaryKey" json:"id"`
- Title string `gorm:"size:128;not null" json:"title"`
- Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
- Content string `gorm:"type:text;not null" json:"content"`
- Published bool `gorm:"default:false;index" json:"published"`
- SortOrder int `gorm:"default:0" json:"sort_order"`
- ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
- ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ Title string `gorm:"size:128;not null" json:"title"`
+ Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ Published bool `gorm:"default:false;index" json:"published"`
+ SortOrder int `gorm:"default:0" json:"sort_order"`
+ ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
+ ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// Poll 投票帖配置
@@ -446,7 +446,7 @@ type BadgeDef struct {
Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:256;default:''" json:"description"`
- Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
+ Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited
Metric string `gorm:"size:32;default:''" json:"metric"`
Threshold int `gorm:"default:0" json:"threshold"`
@@ -458,10 +458,26 @@ type BadgeDef struct {
// UserBadge 用户已获徽章
type UserBadge struct {
- ID uint `gorm:"primaryKey" json:"id"`
- UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
- BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
- AwardedAt time.Time `json:"awarded_at"`
- AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
- Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
+ BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
+ AwardedAt time.Time `json:"awarded_at"`
+ AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
+ Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
+}
+
+// CommunityInstance 社区枢纽收到的公网实例心跳
+type CommunityInstance struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ InstanceID string `gorm:"size:64;uniqueIndex;not null" json:"instance_id"`
+ SiteURL string `gorm:"size:512;not null" json:"site_url"`
+ SiteName string `gorm:"size:128" json:"site_name"`
+ Version string `gorm:"size:32" json:"version"`
+ Users int64 `gorm:"default:0" json:"users"`
+ Posts int64 `gorm:"default:0" json:"posts"`
+ RemoteIP string `gorm:"size:64" json:"remote_ip,omitempty"`
+ Featured bool `gorm:"default:false;index" json:"featured"` // 人工精选后进入公开展柜
+ FeaturedNote string `gorm:"size:64" json:"featured_note"` // 展柜短注
+ FirstSeenAt time.Time `json:"first_seen_at"`
+ LastSeenAt time.Time `gorm:"index" json:"last_seen_at"`
}
diff --git a/router/router.go b/router/router.go
index 3270bc1..f2d05f1 100644
--- a/router/router.go
+++ b/router/router.go
@@ -36,6 +36,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := service.NewForumSettingsService()
+ settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub)
+ communitySvc := service.NewCommunityService(settingsSvc)
+ communitySvc.StartBackground()
+ if cfg.CommunityHub {
+ fmt.Fprintf(os.Stderr, "[community] 社区枢纽已开启(运维配置),可接收自愿上报\n")
+ }
// SPA 入口 HTML 注入标题与品牌 JSON,避免刷新时先闪默认文案
embed_static.SetSPADocumentTitle(func() string {
return settingsSvc.SiteBranding().DocumentTitle()
@@ -87,7 +93,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
h := &handler.Handlers{
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
- Backup: backupSvc,
+ Backup: backupSvc, Community: communitySvc,
Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc,
@@ -124,6 +130,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/me", h.APIMe)
pubAPI.GET("/boards", h.APIBoards)
pubAPI.GET("/stats", h.APIStats)
+ pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat)
+ pubAPI.GET("/community/showcase", h.APICommunityShowcase)
pubAPI.GET("/forum-limits", h.APIForumLimits)
pubAPI.GET("/site-branding", h.APISiteBranding)
pubAPI.GET("/pages", h.APIPages)
@@ -205,6 +213,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/dashboard", h.APIAdminDashboard)
adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
+ adminAPI.PUT("/settings/community", h.APIAdminUpdateCommunitySettings)
+ adminAPI.GET("/community/instances", h.APIAdminCommunityInstances)
+ adminAPI.PUT("/community/instances/:id/feature", h.APIAdminFeatureCommunityInstance)
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
diff --git a/service/community.go b/service/community.go
new file mode 100644
index 0000000..000126c
--- /dev/null
+++ b/service/community.go
@@ -0,0 +1,379 @@
+package service
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+)
+
+// AppVersion 由 cmd 通过 SetAppVersion 注入(ldflags)
+var AppVersion = "dev"
+
+// SetAppVersion 设置运行时版本号
+func SetAppVersion(v string) {
+ v = strings.TrimSpace(v)
+ if v != "" {
+ AppVersion = v
+ }
+}
+
+func newCommunityInstanceID() string {
+ return uuid.NewString()
+}
+
+const (
+ communityHeartbeatInterval = 24 * time.Hour
+ communityHeartbeatTimeout = 8 * time.Second
+ communityOnlineWithin = 72 * time.Hour
+ maxCommunitySiteURLLen = 512
+ maxCommunitySiteNameLen = 128
+ maxCommunityVersionLen = 32
+ maxCommunityInstanceIDLen = 64
+ maxCommunityFeaturedNoteLen = 64
+)
+
+var (
+ ErrCommunityHubDisabled = errors.New("本站未开启社区枢纽")
+ ErrCommunityBadPayload = errors.New("心跳参数无效")
+
+ // communityHubBaseURL 出站枢纽根地址(写死官方站;测试可临时覆盖)
+ communityHubBaseURL = DefaultCommunityHubURL
+)
+
+// CommunityHeartbeatPayload 出站 / 入站心跳体
+type CommunityHeartbeatPayload struct {
+ InstanceID string `json:"instance_id"`
+ SiteURL string `json:"site_url"`
+ SiteName string `json:"site_name"`
+ Version string `json:"version"`
+ Users int64 `json:"users"`
+ Posts int64 `json:"posts"`
+}
+
+// CommunityInstanceView 管理端列表项
+type CommunityInstanceView struct {
+ InstanceID string `json:"instance_id"`
+ SiteURL string `json:"site_url"`
+ SiteName string `json:"site_name"`
+ Version string `json:"version"`
+ Users int64 `json:"users"`
+ Posts int64 `json:"posts"`
+ FirstSeenAt time.Time `json:"first_seen_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+ Online bool `json:"online"`
+ Featured bool `json:"featured"`
+ FeaturedNote string `json:"featured_note"`
+}
+
+// CommunityShowcaseItem 公开展柜条目(不含敏感字段)
+type CommunityShowcaseItem struct {
+ SiteURL string `json:"site_url"`
+ SiteName string `json:"site_name"`
+ Version string `json:"version"`
+ FeaturedNote string `json:"featured_note,omitempty"`
+}
+
+// CommunityFeatureInput 管理端精选请求
+type CommunityFeatureInput struct {
+ Featured bool `json:"featured"`
+ FeaturedNote string `json:"featured_note"`
+}
+
+// CommunityService 可选社区上报 + 枢纽接收
+type CommunityService struct {
+ settings *ForumSettingsService
+ client *http.Client
+ stopCh chan struct{}
+ wg sync.WaitGroup
+ kickCh chan struct{}
+}
+
+// NewCommunityService 创建社区服务
+func NewCommunityService(settings *ForumSettingsService) *CommunityService {
+ return &CommunityService{
+ settings: settings,
+ client: &http.Client{Timeout: communityHeartbeatTimeout},
+ stopCh: make(chan struct{}),
+ kickCh: make(chan struct{}, 1),
+ }
+}
+
+// StartBackground 启动 24h 心跳循环
+func (c *CommunityService) StartBackground() {
+ c.wg.Add(1)
+ go func() {
+ defer c.wg.Done()
+ timer := time.NewTimer(30 * time.Second)
+ defer timer.Stop()
+ for {
+ select {
+ case <-c.stopCh:
+ return
+ case <-c.kickCh:
+ c.trySendHeartbeat()
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(communityHeartbeatInterval)
+ case <-timer.C:
+ c.trySendHeartbeat()
+ timer.Reset(communityHeartbeatInterval)
+ }
+ }
+ }()
+}
+
+// Stop 停止后台心跳
+func (c *CommunityService) Stop() {
+ select {
+ case <-c.stopCh:
+ default:
+ close(c.stopCh)
+ }
+ c.wg.Wait()
+}
+
+// KickHeartbeat 请求尽快发送一次心跳(开启上报时调用)
+func (c *CommunityService) KickHeartbeat() {
+ select {
+ case c.kickCh <- struct{}{}:
+ default:
+ }
+}
+
+func (c *CommunityService) trySendHeartbeat() {
+ _ = c.SendHeartbeatOnce("")
+}
+
+// SendHeartbeatOnce 立即发送一次心跳;requestOrigin 可在管理端保存时传入以补全本站地址
+func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error {
+ cfg := c.settings.CommunityConfig()
+ if !cfg.ReportEnabled {
+ return nil
+ }
+ if requestOrigin != "" {
+ if _, err := c.settings.EnsureCommunitySiteURL(requestOrigin); err != nil {
+ log.Printf("[community] 组装心跳失败: %v", err)
+ return err
+ }
+ }
+ payload, err := c.buildPayload(requestOrigin)
+ if err != nil {
+ log.Printf("[community] 组装心跳失败: %v", err)
+ return err
+ }
+ hub := strings.TrimRight(communityHubBaseURL, "/")
+ if hub == "" {
+ hub = DefaultCommunityHubURL
+ }
+ endpoint := hub + "/api/community/heartbeat"
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ log.Printf("[community] 创建请求失败: %v", err)
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "jiang13-forum/"+AppVersion)
+ resp, err := c.client.Do(req)
+ if err != nil {
+ log.Printf("[community] 上报失败: %v", err)
+ return err
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ err := fmt.Errorf("上报被拒绝: HTTP %d", resp.StatusCode)
+ log.Printf("[community] %v", err)
+ return err
+ }
+ return nil
+}
+
+func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbeatPayload, error) {
+ id, err := c.settings.EnsureCommunityInstanceID()
+ if err != nil {
+ return nil, err
+ }
+ siteURL := c.settings.CommunitySiteURL(requestOrigin)
+ if siteURL == "" {
+ return nil, fmt.Errorf("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报")
+ }
+ var users, posts int64
+ _ = model.DB.Model(&model.User{}).Count(&users).Error
+ _ = model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts).Error
+ brand := c.settings.SiteBranding()
+ return &CommunityHeartbeatPayload{
+ InstanceID: id,
+ SiteURL: truncateRunes(siteURL, maxCommunitySiteURLLen),
+ SiteName: truncateRunes(brand.Name, maxCommunitySiteNameLen),
+ Version: truncateRunes(AppVersion, maxCommunityVersionLen),
+ Users: users,
+ Posts: posts,
+ }, nil
+}
+
+// ReceiveHeartbeat 枢纽接收心跳并 upsert
+func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP string) error {
+ if !c.settings.CommunityConfig().HubEnabled {
+ return ErrCommunityHubDisabled
+ }
+ in.InstanceID = strings.TrimSpace(in.InstanceID)
+ in.SiteURL = strings.TrimSpace(in.SiteURL)
+ in.SiteName = strings.TrimSpace(in.SiteName)
+ in.Version = strings.TrimSpace(in.Version)
+ if in.InstanceID == "" || len(in.InstanceID) > maxCommunityInstanceIDLen {
+ return ErrCommunityBadPayload
+ }
+ if err := validateCommunitySiteURL(in.SiteURL); err != nil {
+ return err
+ }
+ in.SiteURL = truncateRunes(in.SiteURL, maxCommunitySiteURLLen)
+ in.SiteName = truncateRunes(in.SiteName, maxCommunitySiteNameLen)
+ in.Version = truncateRunes(in.Version, maxCommunityVersionLen)
+ if in.Users < 0 {
+ in.Users = 0
+ }
+ if in.Posts < 0 {
+ in.Posts = 0
+ }
+ now := time.Now()
+ var row model.CommunityInstance
+ res := model.DB.Where("instance_id = ?", in.InstanceID).Limit(1).Find(&row)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ row = model.CommunityInstance{
+ InstanceID: in.InstanceID,
+ SiteURL: in.SiteURL,
+ SiteName: in.SiteName,
+ Version: in.Version,
+ Users: in.Users,
+ Posts: in.Posts,
+ RemoteIP: truncateRunes(remoteIP, 64),
+ FirstSeenAt: now,
+ LastSeenAt: now,
+ }
+ return model.DB.Create(&row).Error
+ }
+ row.SiteURL = in.SiteURL
+ row.SiteName = in.SiteName
+ row.Version = in.Version
+ row.Users = in.Users
+ row.Posts = in.Posts
+ row.RemoteIP = truncateRunes(remoteIP, 64)
+ row.LastSeenAt = now
+ return model.DB.Save(&row).Error
+}
+
+// ListInstances 管理端实例列表(按最近心跳倒序)
+func (c *CommunityService) ListInstances() ([]CommunityInstanceView, error) {
+ var rows []model.CommunityInstance
+ if err := model.DB.Order("last_seen_at DESC").Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ out := make([]CommunityInstanceView, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, CommunityInstanceView{
+ InstanceID: r.InstanceID,
+ SiteURL: r.SiteURL,
+ SiteName: r.SiteName,
+ Version: r.Version,
+ Users: r.Users,
+ Posts: r.Posts,
+ FirstSeenAt: r.FirstSeenAt,
+ LastSeenAt: r.LastSeenAt,
+ Online: now.Sub(r.LastSeenAt) <= communityOnlineWithin,
+ Featured: r.Featured,
+ FeaturedNote: r.FeaturedNote,
+ })
+ }
+ return out, nil
+}
+
+// SetInstanceFeatured 人工精选 / 取消;心跳无法自助上柜
+func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFeatureInput) (*CommunityInstanceView, error) {
+ instanceID = strings.TrimSpace(instanceID)
+ if instanceID == "" {
+ return nil, ErrCommunityBadPayload
+ }
+ var row model.CommunityInstance
+ if err := model.DB.Where("instance_id = ?", instanceID).First(&row).Error; err != nil {
+ return nil, err
+ }
+ row.Featured = in.Featured
+ if in.Featured {
+ row.FeaturedNote = truncateRunes(strings.TrimSpace(in.FeaturedNote), maxCommunityFeaturedNoteLen)
+ } else {
+ row.FeaturedNote = ""
+ }
+ if err := model.DB.Save(&row).Error; err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ return &CommunityInstanceView{
+ InstanceID: row.InstanceID,
+ SiteURL: row.SiteURL,
+ SiteName: row.SiteName,
+ Version: row.Version,
+ Users: row.Users,
+ Posts: row.Posts,
+ FirstSeenAt: row.FirstSeenAt,
+ LastSeenAt: row.LastSeenAt,
+ Online: now.Sub(row.LastSeenAt) <= communityOnlineWithin,
+ Featured: row.Featured,
+ FeaturedNote: row.FeaturedNote,
+ }, nil
+}
+
+// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空
+func (c *CommunityService) ListShowcase() ([]CommunityShowcaseItem, error) {
+ if !c.settings.CommunityConfig().HubEnabled {
+ return []CommunityShowcaseItem{}, nil
+ }
+ var rows []model.CommunityInstance
+ if err := model.DB.Where("featured = ?", true).Order("last_seen_at DESC").Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ out := make([]CommunityShowcaseItem, 0, len(rows))
+ for _, r := range rows {
+ if validateCommunitySiteURL(r.SiteURL) != nil {
+ continue
+ }
+ out = append(out, CommunityShowcaseItem{
+ SiteURL: r.SiteURL,
+ SiteName: r.SiteName,
+ Version: r.Version,
+ FeaturedNote: r.FeaturedNote,
+ })
+ }
+ return out, nil
+}
+
+func validateCommunitySiteURL(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return ErrCommunityBadPayload
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return ErrCommunityBadPayload
+ }
+ return nil
+}
diff --git a/service/community_test.go b/service/community_test.go
new file mode 100644
index 0000000..0923016
--- /dev/null
+++ b/service/community_test.go
@@ -0,0 +1,283 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+)
+
+func setupCommunityTest(t *testing.T) (*ForumSettingsService, *CommunityService) {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(
+ &model.ForumSetting{},
+ &model.CommunityInstance{},
+ &model.User{},
+ &model.Post{},
+ ); err != nil {
+ t.Fatal(err)
+ }
+ prev := model.DB
+ model.DB = db
+ t.Cleanup(func() { model.DB = prev })
+
+ settings := NewForumSettingsService()
+ svc := NewCommunityService(settings)
+ return settings, svc
+}
+
+func TestCommunityHeartbeatHubDisabled(t *testing.T) {
+ _, svc := setupCommunityTest(t)
+ err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
+ InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ SiteURL: "https://example.com",
+ SiteName: "测试站",
+ Version: "1.0.0",
+ Users: 1,
+ Posts: 2,
+ }, "127.0.0.1")
+ if !errors.Is(err, ErrCommunityHubDisabled) {
+ t.Fatalf("want ErrCommunityHubDisabled, got %v", err)
+ }
+}
+
+func TestCommunityHeartbeatAcceptAndList(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+ settings.SetCommunityHubEnabled(true)
+
+ payload := CommunityHeartbeatPayload{
+ InstanceID: "11111111-2222-3333-4444-555555555555",
+ SiteURL: "https://forum.example.org",
+ SiteName: "示例论坛",
+ Version: "1.2.3",
+ Users: 10,
+ Posts: 20,
+ }
+ if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
+ t.Fatal(err)
+ }
+ payload.Users = 11
+ payload.Posts = 21
+ if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
+ t.Fatal(err)
+ }
+
+ list, err := svc.ListInstances()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 1 {
+ t.Fatalf("want 1 instance, got %d", len(list))
+ }
+ got := list[0]
+ if got.Users != 11 || got.Posts != 21 || !got.Online {
+ t.Fatalf("unexpected row: %+v", got)
+ }
+ if got.SiteURL != payload.SiteURL || got.SiteName != payload.SiteName {
+ t.Fatalf("site fields mismatch: %+v", got)
+ }
+}
+
+func TestCommunityUpdateIgnoresHubFields(t *testing.T) {
+ settings, _ := setupCommunityTest(t)
+ if settings.CommunityConfig().HubEnabled {
+ t.Fatal("hub should be off by default")
+ }
+ if _, err := settings.UpdateCommunityConfig(CommunityConfig{
+ ReportEnabled: true,
+ HubEnabled: true,
+ HubURL: "https://evil.example",
+ SiteURL: "https://should-be-ignored.example",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ cfg := settings.CommunityConfig()
+ if cfg.HubEnabled {
+ t.Fatal("UpdateCommunityConfig must not enable hub")
+ }
+ if cfg.HubURL != DefaultCommunityHubURL {
+ t.Fatalf("hub_url must stay official, got %s", cfg.HubURL)
+ }
+ if cfg.SiteURL == "https://should-be-ignored.example" {
+ t.Fatal("client site_url must be ignored")
+ }
+ if !cfg.ReportEnabled {
+ t.Fatal("report should be enabled")
+ }
+ settings.SetCommunityHubEnabled(true)
+ if !settings.CommunityConfig().HubEnabled {
+ t.Fatal("SetCommunityHubEnabled should enable hub")
+ }
+}
+
+func TestCommunityHeartbeatBadURL(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+ settings.SetCommunityHubEnabled(true)
+ err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
+ InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ SiteURL: "javascript:alert(1)",
+ SiteName: "坏",
+ }, "127.0.0.1")
+ if !errors.Is(err, ErrCommunityBadPayload) {
+ t.Fatalf("want ErrCommunityBadPayload, got %v", err)
+ }
+}
+
+func TestCommunityOutboundHeartbeat(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+ var hits atomic.Int32
+ var lastBody CommunityHeartbeatPayload
+
+ hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/community/heartbeat" {
+ http.NotFound(w, r)
+ return
+ }
+ defer r.Body.Close()
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &lastBody)
+ hits.Add(1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ t.Cleanup(hub.Close)
+
+ prevHub := communityHubBaseURL
+ communityHubBaseURL = hub.URL
+ t.Cleanup(func() { communityHubBaseURL = prevHub })
+
+ svc.trySendHeartbeat()
+ if hits.Load() != 0 {
+ t.Fatal("report disabled should not send")
+ }
+
+ if err := settings.setString(SettingOIDCRootURL, "http://reporter.local"); err != nil {
+ t.Fatal(err)
+ }
+ if err := settings.setString(SettingSiteName, "上报测试站"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := svc.SendHeartbeatOnce(""); err != nil {
+ t.Fatal(err)
+ }
+ if hits.Load() != 1 {
+ t.Fatalf("want 1 outbound hit, got %d", hits.Load())
+ }
+ if lastBody.InstanceID == "" || lastBody.SiteURL == "" {
+ t.Fatalf("empty payload: %+v", lastBody)
+ }
+
+ if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: false}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.SendHeartbeatOnce(""); err != nil {
+ t.Fatal(err)
+ }
+ if hits.Load() != 1 {
+ t.Fatalf("after disable want still 1 hit, got %d", hits.Load())
+ }
+}
+
+func TestCommunityFeatureAndShowcase(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+ settings.SetCommunityHubEnabled(true)
+
+ payload := CommunityHeartbeatPayload{
+ InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ SiteURL: "https://forum.example.org",
+ SiteName: "示例论坛",
+ Version: "2.0.0",
+ }
+ if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
+ t.Fatal(err)
+ }
+
+ empty, err := svc.ListShowcase()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(empty) != 0 {
+ t.Fatal("showcase should be empty before feature")
+ }
+
+ view, err := svc.SetInstanceFeatured(payload.InstanceID, CommunityFeatureInput{
+ Featured: true,
+ FeaturedNote: "精选自托管",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !view.Featured || view.FeaturedNote != "精选自托管" {
+ t.Fatalf("unexpected view: %+v", view)
+ }
+
+ items, err := svc.ListShowcase()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 1 || items[0].SiteURL != payload.SiteURL {
+ t.Fatalf("showcase=%+v", items)
+ }
+
+ // 心跳更新不得清掉精选
+ payload.Users = 9
+ if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
+ t.Fatal(err)
+ }
+ items, err = svc.ListShowcase()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 1 || items[0].FeaturedNote != "精选自托管" {
+ t.Fatalf("featured lost after heartbeat: %+v", items)
+ }
+
+ settings.SetCommunityHubEnabled(false)
+ items, err = svc.ListShowcase()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(items) != 0 {
+ t.Fatal("hub off should hide showcase")
+ }
+}
+
+func TestCommunitySiteURLFromOrigin(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+ if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
+ t.Fatal(err)
+ }
+ u, err := settings.EnsureCommunitySiteURL("http://localhost:5173")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u != "http://localhost:5173" {
+ t.Fatalf("got %s", u)
+ }
+ if settings.CommunitySiteURL("") != "http://localhost:5173" {
+ t.Fatal("should persist for ticker")
+ }
+ payload, err := svc.buildPayload("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if payload.SiteURL != "http://localhost:5173" {
+ t.Fatalf("payload site_url=%s", payload.SiteURL)
+ }
+}
diff --git a/service/ratelimit.go b/service/ratelimit.go
index c78826b..1aa6408 100644
--- a/service/ratelimit.go
+++ b/service/ratelimit.go
@@ -54,6 +54,9 @@ func (r *RateLimiter) limitFor(action string) int {
if action == "friend_link" {
return 5
}
+ if action == "community_heartbeat" {
+ return 30
+ }
return r.settings.RateLimitFor(action)
}
@@ -61,6 +64,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
if action == "friend_link" {
return time.Hour
}
+ if action == "community_heartbeat" {
+ return time.Hour
+ }
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
}
diff --git a/service/settings.go b/service/settings.go
index 42b2d51..962f26c 100644
--- a/service/settings.go
+++ b/service/settings.go
@@ -93,6 +93,15 @@ const (
SettingSiteFriendLinks = "site_friend_links"
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
+ SettingCommunityReportEnabled = "community_report_enabled"
+ SettingCommunityHubEnabled = "community_hub_enabled" // 遗留键,不再作为开关来源
+ SettingCommunityInstanceID = "community_instance_id"
+ SettingCommunityHubURL = "community_hub_url"
+ SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL)
+
+ // DefaultCommunityHubURL 官方演示站(社区枢纽默认地址)
+ DefaultCommunityHubURL = "https://bbs.iioio.com"
+
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
pageSizeAPIMax = 100
)
@@ -282,6 +291,14 @@ var friendLinkSettingDefaults = map[string]string{
SettingFooterShowFriendLinks: "1",
}
+var communitySettingDefaults = map[string]string{
+ SettingCommunityReportEnabled: "0",
+ SettingCommunityHubEnabled: "0",
+ SettingCommunityInstanceID: "",
+ SettingCommunityHubURL: DefaultCommunityHubURL,
+ SettingCommunitySiteURL: "",
+}
+
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
@@ -375,6 +392,15 @@ type GiteaSyncConfig struct {
RepoCount int64 `json:"repo_count"`
}
+// CommunityConfig 社区上报配置(HubEnabled 只读,来自运维配置)
+type CommunityConfig struct {
+ ReportEnabled bool `json:"report_enabled"`
+ HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量
+ HubURL string `json:"hub_url"`
+ SiteURL string `json:"site_url"` // 上报用的本站公开地址
+ InstanceID string `json:"instance_id"`
+}
+
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
type OIDCConfig struct {
Enabled bool `json:"enabled"`
@@ -391,7 +417,8 @@ type OIDCConfig struct {
// ForumSettingsService 论坛全局设置
type ForumSettingsService struct {
- mu sync.RWMutex
+ mu sync.RWMutex
+ communityHubEnabled bool // 运维配置注入,非后台可改
}
func NewForumSettingsService() *ForumSettingsService {
@@ -400,6 +427,13 @@ func NewForumSettingsService() *ForumSettingsService {
return s
}
+// SetCommunityHubEnabled 由启动配置注入是否作为社区枢纽
+func (s *ForumSettingsService) SetCommunityHubEnabled(enabled bool) {
+ s.mu.Lock()
+ s.communityHubEnabled = enabled
+ s.mu.Unlock()
+}
+
func (s *ForumSettingsService) ensureDefaults() {
for _, def := range forumSettingDefs {
var count int64
@@ -464,6 +498,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
+ for key, val := range communitySettingDefaults {
+ 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 {
@@ -1090,6 +1131,74 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
return nil
}
+// CommunityConfig 读取社区上报配置
+func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
+ s.mu.RLock()
+ hubEnabled := s.communityHubEnabled
+ s.mu.RUnlock()
+ return CommunityConfig{
+ ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
+ HubEnabled: hubEnabled,
+ HubURL: DefaultCommunityHubURL,
+ SiteURL: s.CommunitySiteURL(""),
+ InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")),
+ }
+}
+
+// CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin
+func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
+ if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
+ return strings.TrimRight(u, "/")
+ }
+ return s.SitePublicBaseURL(requestOrigin)
+}
+
+// EnsureCommunitySiteURL 在开启上报时确保有可用的本站公开地址;origin 可来自当前管理请求
+func (s *ForumSettingsService) EnsureCommunitySiteURL(requestOrigin string) (string, error) {
+ if u := s.CommunitySiteURL(requestOrigin); u != "" {
+ // 若仅靠 Origin 推断,持久化以便后台 ticker 使用
+ if normalizeRootURL(s.getString(SettingCommunitySiteURL, "")) == "" &&
+ normalizeRootURL(s.getString(SettingOIDCRootURL, "")) == "" {
+ if err := s.setString(SettingCommunitySiteURL, u); err != nil {
+ return "", err
+ }
+ }
+ return u, nil
+ }
+ return "", errors.New("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报")
+}
+
+// EnsureCommunityInstanceID 确保本机有稳定的匿名实例 ID
+func (s *ForumSettingsService) EnsureCommunityInstanceID() (string, error) {
+ id := strings.TrimSpace(s.getString(SettingCommunityInstanceID, ""))
+ if id != "" {
+ return id, nil
+ }
+ id = newCommunityInstanceID()
+ if err := s.setString(SettingCommunityInstanceID, id); err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+// UpdateCommunityConfig 仅更新上报开关;忽略客户端传入的 hub_url / site_url
+func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasReportEnabled bool, err error) {
+ wasReportEnabled = s.getString(SettingCommunityReportEnabled, "0") == "1"
+ report := "0"
+ if in.ReportEnabled {
+ report = "1"
+ }
+ if err := s.setString(SettingCommunityReportEnabled, report); err != nil {
+ return wasReportEnabled, err
+ }
+ if in.ReportEnabled {
+ if _, err := s.EnsureCommunityInstanceID(); err != nil {
+ return wasReportEnabled, err
+ }
+ }
+ return wasReportEnabled, nil
+}
+
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
func (s *ForumSettingsService) StorageConfig() StorageConfig {
secret := s.getString(SettingStorageSecretKey, "")