feat: 可选社区上报与官方精选展柜

默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 04:22:59 +08:00
parent 0abfb32194
commit 95c2154087
26 changed files with 1730 additions and 147 deletions

379
service/community.go Normal file
View File

@@ -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
}