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
}

283
service/community_test.go Normal file
View 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)
}
}

View File

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

View File

@@ -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, "")