feat: 可选社区上报与官方精选展柜
默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
379
service/community.go
Normal file
379
service/community.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user