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
|
||||
}
|
||||
283
service/community_test.go
Normal file
283
service/community_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
Reference in New Issue
Block a user