Files
jiang13-forum/service/community.go
freefire 429956c594 feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 07:55:45 +08:00

384 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
// 枢纽站(含官网)不出站上报,避免自己心跳给自己
if c.settings.CommunityHubEnabled(requestOrigin) {
return nil
}
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 枢纽接收心跳并 upsertrequestHint 用于按请求 Host 识别官网
func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP, requestHint string) error {
if !c.settings.CommunityHubEnabled(requestHint) {
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 公开展柜仅精选枢纽关闭时返回空requestHint 用于按 Host 识别官网
func (c *CommunityService) ListShowcase(requestHint string) ([]CommunityShowcaseItem, error) {
if !c.settings.CommunityHubEnabled(requestHint) {
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
}