feat: 管理端网站监控,浏览量写入独立 monitor.db

请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 07:55:45 +08:00
parent 16b6a18096
commit 429956c594
54 changed files with 5504 additions and 146 deletions

View File

@@ -9,16 +9,16 @@ func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
{ID: AsideWidgetRecentComments, Enabled: false},
}
out := NormalizeAsideWidgets(in)
if len(out) != 4 {
t.Fatalf("want 4 widgets, got %d", len(out))
if len(out) != 5 {
t.Fatalf("want 5 widgets, got %d", len(out))
}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetShowcase}
for i, id := range want {
if out[i].ID != id {
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
}
}
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled || out[4].Enabled {
t.Fatalf("enabled flags mismatch: %+v", out)
}
}

View File

@@ -17,7 +17,7 @@ func NewBackupService(dbPath, dataDir string) *BackupService {
return &BackupService{dbPath: dbPath, dataDir: dataDir}
}
// ExportSQLite 导出 SQLite 备份文件到 data 目录
// ExportSQLite 导出主库 SQLite 备份到 data 目录(不含 monitor.db
func (s *BackupService) ExportSQLite() (string, error) {
src, err := os.Open(s.dbPath)
if err != nil {

View File

@@ -162,6 +162,10 @@ func (c *CommunityService) trySendHeartbeat() {
// 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
@@ -228,9 +232,9 @@ func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbe
}, nil
}
// ReceiveHeartbeat 枢纽接收心跳并 upsert
func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP string) error {
if !c.settings.CommunityConfig().HubEnabled {
// 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)
@@ -343,9 +347,9 @@ func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFe
}, nil
}
// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空
func (c *CommunityService) ListShowcase() ([]CommunityShowcaseItem, error) {
if !c.settings.CommunityConfig().HubEnabled {
// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空requestHint 用于按 Host 识别官网
func (c *CommunityService) ListShowcase(requestHint string) ([]CommunityShowcaseItem, error) {
if !c.settings.CommunityHubEnabled(requestHint) {
return []CommunityShowcaseItem{}, nil
}
var rows []model.CommunityInstance

View File

@@ -47,7 +47,7 @@ func TestCommunityHeartbeatHubDisabled(t *testing.T) {
Version: "1.0.0",
Users: 1,
Posts: 2,
}, "127.0.0.1")
}, "127.0.0.1", "https://other.example")
if !errors.Is(err, ErrCommunityHubDisabled) {
t.Fatalf("want ErrCommunityHubDisabled, got %v", err)
}
@@ -65,12 +65,12 @@ func TestCommunityHeartbeatAcceptAndList(t *testing.T) {
Users: 10,
Posts: 20,
}
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
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 {
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
t.Fatal(err)
}
@@ -129,7 +129,7 @@ func TestCommunityHeartbeatBadURL(t *testing.T) {
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
SiteURL: "javascript:alert(1)",
SiteName: "坏",
}, "127.0.0.1")
}, "127.0.0.1", "")
if !errors.Is(err, ErrCommunityBadPayload) {
t.Fatalf("want ErrCommunityBadPayload, got %v", err)
}
@@ -204,11 +204,11 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
SiteName: "示例论坛",
Version: "2.0.0",
}
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
t.Fatal(err)
}
empty, err := svc.ListShowcase()
empty, err := svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -227,7 +227,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
t.Fatalf("unexpected view: %+v", view)
}
items, err := svc.ListShowcase()
items, err := svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -237,10 +237,10 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
// 心跳更新不得清掉精选
payload.Users = 9
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
t.Fatal(err)
}
items, err = svc.ListShowcase()
items, err = svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -249,7 +249,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
}
settings.SetCommunityHubEnabled(false)
items, err = svc.ListShowcase()
items, err = svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -281,3 +281,154 @@ func TestCommunitySiteURLFromOrigin(t *testing.T) {
t.Fatalf("payload site_url=%s", payload.SiteURL)
}
}
func TestCommunityHubByOfficialHost(t *testing.T) {
settings, svc := setupCommunityTest(t)
// 其它域名、未开运维开关 → 拒绝
err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
InstanceID: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
SiteURL: "https://forum.example.org",
SiteName: "他站",
Version: "1.0.0",
}, "127.0.0.1", "https://other.example")
if !errors.Is(err, ErrCommunityHubDisabled) {
t.Fatalf("want disabled for other host, got %v", err)
}
// 请求 Host 为官网 → 自动枢纽
payload := CommunityHeartbeatPayload{
InstanceID: "cccccccc-dddd-eeee-ffff-000000000001",
SiteURL: "https://forum.example.org",
SiteName: "上报站",
Version: "1.1.0",
Users: 3,
Posts: 5,
}
if err := svc.ReceiveHeartbeat(payload, "203.0.113.1", "https://bbs.iioio.com"); err != nil {
t.Fatal(err)
}
if !settings.CommunityHubEnabled("https://www.bbs.iioio.com") {
t.Fatal("www 前缀也应识别为官网")
}
if !settings.CommunityConfigForRequest("https://bbs.iioio.com").HubEnabled {
t.Fatal("CommunityConfigForRequest should enable hub for official host")
}
list, err := svc.ListInstances()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("want 1 instance, got %d", len(list))
}
// 已存 ROOT_URL 为官网时,无请求上下文也应开启
if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
t.Fatal(err)
}
if !settings.CommunityHubEnabled("") {
t.Fatal("ROOT_URL as official should enable hub without request hint")
}
}
func TestCommunityHubSkipsOutboundHeartbeat(t *testing.T) {
settings, svc := setupCommunityTest(t)
var hits atomic.Int32
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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 })
if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); 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() != 0 {
t.Fatal("hub site must not send outbound heartbeat")
}
// 运维开关开启同样跳过
settings2, svc2 := setupCommunityTest(t)
settings2.SetCommunityHubEnabled(true)
if err := settings2.setString(SettingOIDCRootURL, "http://mirror.local"); err != nil {
t.Fatal(err)
}
if _, err := settings2.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
t.Fatal(err)
}
prevHub2 := communityHubBaseURL
communityHubBaseURL = hub.URL
t.Cleanup(func() { communityHubBaseURL = prevHub2 })
if err := svc2.SendHeartbeatOnce(""); err != nil {
t.Fatal(err)
}
if hits.Load() != 0 {
t.Fatal("ops hub must not send outbound heartbeat")
}
}
func TestHostFromURLOrHost(t *testing.T) {
cases := []struct {
in, want string
}{
{"https://bbs.iioio.com", "bbs.iioio.com"},
{"https://www.bbs.iioio.com/", "bbs.iioio.com"},
{"https://bbs.iioio.com:443/path", "bbs.iioio.com"},
{"BBS.IIOIO.COM", "bbs.iioio.com"},
{"https://other.example", "other.example"},
{"", ""},
}
for _, tc := range cases {
if got := hostFromURLOrHost(tc.in); got != tc.want {
t.Fatalf("hostFromURLOrHost(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
func TestAsideShowcaseEntrySync(t *testing.T) {
settings, _ := setupCommunityTest(t)
if settings.NavShowShowcase() || settings.FooterShowShowcase() || settings.AsideShowShowcase() {
t.Fatal("showcase entry should be off by default")
}
if err := settings.SetNavShowShowcase(true); err != nil {
t.Fatal(err)
}
if err := settings.SetFooterShowShowcase(true); err != nil {
t.Fatal(err)
}
if err := settings.SetAsideShowcaseEnabled(true); err != nil {
t.Fatal(err)
}
if !settings.NavShowShowcase() || !settings.FooterShowShowcase() || !settings.AsideShowShowcase() {
t.Fatal("showcase entry should be enabled")
}
cfg := settings.Limits()
if !cfg.NavShowShowcase || !cfg.FooterShowShowcase || !cfg.AsideShowShowcase {
t.Fatalf("limits missing showcase flags: %+v", cfg)
}
found := false
for _, w := range cfg.AsideWidgets {
if w.ID == AsideWidgetShowcase {
found = true
if !w.Enabled {
t.Fatal("aside showcase widget should be enabled")
}
}
}
if !found {
t.Fatal("aside widgets should include showcase")
}
}

258
service/geo_zh.go Normal file
View File

@@ -0,0 +1,258 @@
package service
import (
"strconv"
"strings"
)
// countryZh ISO2 → 中文国名(展示用;存储仍用 ISO
var countryZh = map[string]string{
"CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾",
"US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡",
"GB": "英国", "DE": "德国", "FR": "法国", "RU": "俄罗斯",
"CA": "加拿大", "AU": "澳大利亚", "IN": "印度", "TH": "泰国",
"VN": "越南", "MY": "马来西亚", "ID": "印度尼西亚", "PH": "菲律宾",
"NL": "荷兰", "IT": "意大利", "ES": "西班牙", "BR": "巴西",
"MX": "墨西哥", "SE": "瑞典", "CH": "瑞士", "PL": "波兰",
"TR": "土耳其", "SA": "沙特阿拉伯", "AE": "阿联酋", "NZ": "新西兰",
"IE": "爱尔兰", "BE": "比利时", "AT": "奥地利", "NO": "挪威",
"DK": "丹麦", "FI": "芬兰", "PT": "葡萄牙", "CZ": "捷克",
"UA": "乌克兰", "IL": "以色列", "ZA": "南非", "AR": "阿根廷",
}
type cnRegionMeta struct {
Name string
ISO string
}
// cnRegionMap 中国省级英文名/别名 → 中文名 + 地图 ISO
var cnRegionMap = map[string]cnRegionMeta{
"beijing": {Name: "北京", ISO: "BJ"},
"peking": {Name: "北京", ISO: "BJ"},
"tianjin": {Name: "天津", ISO: "TJ"},
"shanghai": {Name: "上海", ISO: "SH"},
"chongqing": {Name: "重庆", ISO: "CQ"},
"chungking": {Name: "重庆", ISO: "CQ"},
"hebei": {Name: "河北", ISO: "HE"},
"shanxi": {Name: "山西", ISO: "SX"},
"liaoning": {Name: "辽宁", ISO: "LN"},
"jilin": {Name: "吉林", ISO: "JL"},
"heilongjiang": {Name: "黑龙江", ISO: "HL"},
"jiangsu": {Name: "江苏", ISO: "JS"},
"zhejiang": {Name: "浙江", ISO: "ZJ"},
"anhui": {Name: "安徽", ISO: "AH"},
"fujian": {Name: "福建", ISO: "FJ"},
"jiangxi": {Name: "江西", ISO: "JX"},
"shandong": {Name: "山东", ISO: "SD"},
"henan": {Name: "河南", ISO: "HA"},
"hubei": {Name: "湖北", ISO: "HB"},
"hunan": {Name: "湖南", ISO: "HN"},
"guangdong": {Name: "广东", ISO: "GD"},
"guangxi": {Name: "广西", ISO: "GX"},
"hainan": {Name: "海南", ISO: "HI"},
"sichuan": {Name: "四川", ISO: "SC"},
"guizhou": {Name: "贵州", ISO: "GZ"},
"yunnan": {Name: "云南", ISO: "YN"},
"xizang": {Name: "西藏", ISO: "XZ"},
"tibet": {Name: "西藏", ISO: "XZ"},
"shaanxi": {Name: "陕西", ISO: "SN"},
"shaanxi province": {Name: "陕西", ISO: "SN"},
"gansu": {Name: "甘肃", ISO: "GS"},
"qinghai": {Name: "青海", ISO: "QH"},
"ningxia": {Name: "宁夏", ISO: "NX"},
"xinjiang": {Name: "新疆", ISO: "XJ"},
"inner mongolia": {Name: "内蒙古", ISO: "NM"},
"nei mongol": {Name: "内蒙古", ISO: "NM"},
"hong kong": {Name: "香港", ISO: "HK"},
"macau": {Name: "澳门", ISO: "MO"},
"macao": {Name: "澳门", ISO: "MO"},
"taiwan": {Name: "台湾", ISO: "TW"},
}
// cityZh 常见城市英文名 → 中文
var cityZh = map[string]string{
"beijing": "北京", "peking": "北京",
"shanghai": "上海",
"chongqing": "重庆", "chungking": "重庆",
"tianjin": "天津",
"hangzhou": "杭州",
"shenzhen": "深圳",
"guangzhou": "广州", "canton": "广州",
"chengdu": "成都",
"wuhan": "武汉",
"xi'an": "西安", "xian": "西安",
"nanjing": "南京", "nanking": "南京",
"suzhou": "苏州",
"qingdao": "青岛",
"dalian": "大连",
"ningbo": "宁波",
"xiamen": "厦门", "amoy": "厦门",
"changsha": "长沙",
"zhengzhou": "郑州",
"jinan": "济南",
"harbin": "哈尔滨",
"shenyang": "沈阳",
"kunming": "昆明",
"nanning": "南宁",
"urumqi": "乌鲁木齐",
"lhasa": "拉萨",
"hohhot": "呼和浩特",
"taipei": "台北",
"hong kong": "香港",
}
// asnOrgZh 常见 ASN / AS 组织名 → 中文运营商
var asnOrgZh = map[uint]string{
4134: "中国电信",
4812: "中国电信",
4837: "中国联通",
4808: "中国联通",
9808: "中国移动",
56040: "中国移动",
56041: "中国移动",
56042: "中国移动",
4538: "教育网",
23910: "教育网",
58539: "教育网",
7497: "教育网",
}
var asnOrgKeywordZh = []struct {
kw string
zh string
}{
{"chinanet", "中国电信"},
{"china telecom", "中国电信"},
{"chinatelecom", "中国电信"},
{"unicom", "中国联通"},
{"china unicom", "中国联通"},
{"mobile", "中国移动"},
{"china mobile", "中国移动"},
{"cernet", "教育网"},
{"education", "教育网"},
}
// CountryLabelZh ISO2 → 中文国名(未知则返回原码)
func CountryLabelZh(iso string) string {
iso = strings.ToUpper(strings.TrimSpace(iso))
if iso == "" {
return ""
}
if v, ok := countryZh[iso]; ok {
return v
}
return iso
}
// ApplyGeoZh 就地补充中文省/市/运营商country 保持 ISO2
func ApplyGeoZh(g *GeoInfo) {
if g == nil {
return
}
g.Country = normalizeCountryCode(g.Country)
if g.Country == "CN" || g.Country == "HK" || g.Country == "MO" || g.Country == "TW" {
mapCNRegionCity(g)
}
if g.ASN > 0 {
if zh, ok := asnOrgZh[g.ASN]; ok {
g.ASOrg = zh
} else if g.ASOrg != "" {
g.ASOrg = mapASOrgZh(g.ASOrg)
}
} else if g.ASOrg != "" {
g.ASOrg = mapASOrgZh(g.ASOrg)
}
g.Region = truncateRunes(strings.TrimSpace(g.Region), 64)
g.City = truncateRunes(strings.TrimSpace(g.City), 64)
g.ASOrg = truncateRunes(strings.TrimSpace(g.ASOrg), 128)
}
func mapCNRegionCity(g *GeoInfo) {
if g.RegionISO == "" {
if meta, ok := lookupCNRegion(g.Region); ok {
g.Region = meta.Name
g.RegionISO = meta.ISO
}
} else if g.Region != "" {
if meta, ok := lookupCNRegion(g.Region); ok {
if g.RegionISO == "" {
g.RegionISO = meta.ISO
}
g.Region = meta.Name
}
} else if g.RegionISO != "" {
for _, meta := range cnRegionMap {
if meta.ISO == strings.ToUpper(g.RegionISO) {
g.Region = meta.Name
break
}
}
}
if g.City != "" {
if zh, ok := lookupCityZh(g.City); ok {
g.City = zh
}
}
// 直辖市BIN 有时只给城市不给省
if g.Region == "" && g.City != "" {
switch g.City {
case "北京", "上海", "天津", "重庆":
g.Region = g.City
if meta, ok := cnRegionMap[strings.ToLower(g.City)]; ok {
g.RegionISO = meta.ISO
}
}
}
}
func lookupCNRegion(name string) (cnRegionMeta, bool) {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return cnRegionMeta{}, false
}
if meta, ok := cnRegionMap[key]; ok {
return meta, true
}
// 去掉常见后缀再试
for _, suffix := range []string{" province", " sheng", " autonomous region", " municipality"} {
if strings.HasSuffix(key, suffix) {
if meta, ok := cnRegionMap[strings.TrimSuffix(key, suffix)]; ok {
return meta, true
}
}
}
return cnRegionMeta{}, false
}
func lookupCityZh(name string) (string, bool) {
key := strings.ToLower(strings.TrimSpace(name))
if zh, ok := cityZh[key]; ok {
return zh, true
}
return "", false
}
func mapASOrgZh(org string) string {
l := strings.ToLower(strings.TrimSpace(org))
if l == "" {
return org
}
for _, rule := range asnOrgKeywordZh {
if strings.Contains(l, rule.kw) {
return rule.zh
}
}
// "AS4134 Chinanet" 等形式
if strings.HasPrefix(l, "as") {
num := strings.TrimPrefix(l, "as")
if i := strings.IndexByte(num, ' '); i >= 0 {
num = num[:i]
}
if n, err := strconv.ParseUint(num, 10, 32); err == nil {
if zh, ok := asnOrgZh[uint(n)]; ok {
return zh
}
}
}
return org
}

333
service/geoip.go Normal file
View File

@@ -0,0 +1,333 @@
package service
import (
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/ip2location/ip2location-go/v9"
"github.com/oschwald/maxminddb-golang"
)
// GeoInfo IP 地理与运营商解析结果
type GeoInfo struct {
Country string
Region string // 省/州(写入前经 ApplyGeoZh 中文化)
RegionISO string // 省/州 ISO如 GD
City string
ASN uint
ASOrg string
}
type geoIPSuite struct {
dataDir string
mu sync.RWMutex
binV4 *binHandle
binV6 *binHandle
asn *mmdbHandle
country *mmdbHandle // 可选BIN 未命中时国家兜底
}
type binHandle struct {
path string
db *ip2location.DB
mod time.Time
}
type mmdbHandle struct {
path string
db *maxminddb.Reader
mod time.Time
}
func newGeoIPSuite(dataDir string) *geoIPSuite {
s := &geoIPSuite{dataDir: dataDir}
s.binV4 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.BIN")}
s.binV6 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN")}
s.asn = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-ASN.mmdb")}
s.country = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-Country.mmdb")}
s.openAll()
return s
}
func (s *geoIPSuite) openAll() {
s.mu.Lock()
defer s.mu.Unlock()
openBIN(s.binV4)
openBIN(s.binV6)
openMMDB(s.asn)
openMMDB(s.country)
}
func openBIN(h *binHandle) {
if h == nil {
return
}
st, err := os.Stat(h.path)
if err != nil {
closeBIN(h)
return
}
if h.db != nil && st.ModTime().Equal(h.mod) {
return
}
db, err := ip2location.OpenDB(h.path)
if err != nil {
closeBIN(h)
return
}
closeBIN(h)
h.db = db
h.mod = st.ModTime()
}
func closeBIN(h *binHandle) {
if h != nil && h.db != nil {
h.db.Close()
h.db = nil
}
}
func openMMDB(h *mmdbHandle) {
if h == nil {
return
}
st, err := os.Stat(h.path)
if err != nil {
closeMMDB(h)
return
}
if h.db != nil && st.ModTime().Equal(h.mod) {
return
}
db, err := maxminddb.Open(h.path)
if err != nil {
closeMMDB(h)
return
}
closeMMDB(h)
h.db = db
h.mod = st.ModTime()
}
func closeMMDB(h *mmdbHandle) {
if h != nil && h.db != nil {
_ = h.db.Close()
h.db = nil
}
}
func (s *geoIPSuite) ReloadIfNeeded() {
s.mu.Lock()
defer s.mu.Unlock()
reloadBIN(s.binV4)
reloadBIN(s.binV6)
reloadMMDB(s.asn)
reloadMMDB(s.country)
}
func reloadBIN(h *binHandle) {
if h == nil {
return
}
st, err := os.Stat(h.path)
if err != nil {
closeBIN(h)
return
}
if h.db != nil && st.ModTime().Equal(h.mod) {
return
}
openBIN(h)
}
func reloadMMDB(h *mmdbHandle) {
if h == nil {
return
}
st, err := os.Stat(h.path)
if err != nil {
closeMMDB(h)
return
}
if h.db != nil && st.ModTime().Equal(h.mod) {
return
}
openMMDB(h)
}
func (s *geoIPSuite) Close() {
s.mu.Lock()
defer s.mu.Unlock()
closeBIN(s.binV4)
closeBIN(s.binV6)
closeMMDB(s.asn)
closeMMDB(s.country)
}
func (s *geoIPSuite) BINV4Available() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.binV4 != nil && s.binV4.db != nil
}
func (s *geoIPSuite) BINV6Available() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.binV6 != nil && s.binV6.db != nil
}
func (s *geoIPSuite) ASNAvailable() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.asn != nil && s.asn.db != nil
}
func (s *geoIPSuite) CountryAvailable() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.country != nil && s.country.db != nil
}
func (s *geoIPSuite) AnyAvailable() bool {
return s.BINV4Available() || s.BINV6Available() || s.ASNAvailable() || s.CountryAvailable()
}
// Paths 返回 v4 BIN、v6 BIN、ASN、Country 兜底库路径
func (s *geoIPSuite) Paths() (v4, v6, asn, country string) {
return filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.BIN"),
filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN"),
filepath.Join(s.dataDir, "GeoLite2-ASN.mmdb"),
filepath.Join(s.dataDir, "GeoLite2-Country.mmdb")
}
type geoCountryOnlyRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
}
type geoASNRecord struct {
AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"`
}
// Lookup 解析 IP 地理与 ASNBIN 负责国家/省/市ASN 独立查询)
func (s *geoIPSuite) Lookup(ipStr string) GeoInfo {
var out GeoInfo
ip := net.ParseIP(ipStr)
if ip == nil || s == nil {
return out
}
s.mu.RLock()
v4DB := (*ip2location.DB)(nil)
v6DB := (*ip2location.DB)(nil)
asnDB := (*maxminddb.Reader)(nil)
countryDB := (*maxminddb.Reader)(nil)
if s.binV4 != nil {
v4DB = s.binV4.db
}
if s.binV6 != nil {
v6DB = s.binV6.db
}
if s.asn != nil {
asnDB = s.asn.db
}
if s.country != nil {
countryDB = s.country.db
}
s.mu.RUnlock()
binDB := v6DB
if ip.To4() != nil {
binDB = v4DB
}
if binDB != nil {
rec, err := binDB.Get_all(ipStr)
if err == nil {
out.Country = normalizeCountryCode(rec.Country_short)
out.Region = strings.TrimSpace(rec.Region)
out.City = strings.TrimSpace(rec.City)
}
}
if out.Country == "" && countryDB != nil {
var rec geoCountryOnlyRecord
if err := countryDB.Lookup(ip, &rec); err == nil {
out.Country = normalizeCountryCode(rec.Country.ISOCode)
}
}
if asnDB != nil {
var rec geoASNRecord
if err := asnDB.Lookup(ip, &rec); err == nil {
out.ASN = rec.AutonomousSystemNumber
out.ASOrg = strings.TrimSpace(rec.AutonomousSystemOrganization)
}
}
ApplyGeoZh(&out)
return out
}
// GeoIPService 对外暴露的 Geo 查询(诊断命令等)
type GeoIPService struct {
inner *geoIPSuite
}
// NewGeoIPService 打开数据目录下的 BIN/MMDB
func NewGeoIPService(dataDir string) *GeoIPService {
return &GeoIPService{inner: newGeoIPSuite(dataDir)}
}
func (s *GeoIPService) Close() {
if s != nil && s.inner != nil {
s.inner.Close()
}
}
func (s *GeoIPService) Lookup(ip string) GeoInfo {
if s == nil || s.inner == nil {
return GeoInfo{}
}
return s.inner.Lookup(ip)
}
func (s *GeoIPService) Paths() (v4, v6, asn, country string) {
if s == nil || s.inner == nil {
return "", "", "", ""
}
return s.inner.Paths()
}
func (s *GeoIPService) BINV4Available() bool {
if s == nil || s.inner == nil {
return false
}
return s.inner.BINV4Available()
}
func (s *GeoIPService) BINV6Available() bool {
if s == nil || s.inner == nil {
return false
}
return s.inner.BINV6Available()
}
func (s *GeoIPService) ASNAvailable() bool {
if s == nil || s.inner == nil {
return false
}
return s.inner.ASNAvailable()
}
func (s *GeoIPService) CountryAvailable() bool {
if s == nil || s.inner == nil {
return false
}
return s.inner.CountryAvailable()
}

1225
service/monitor.go Normal file

File diff suppressed because it is too large Load Diff

85
service/monitor_test.go Normal file
View File

@@ -0,0 +1,85 @@
package service
import (
"strings"
"testing"
)
func TestNormalizeCountryCode(t *testing.T) {
if normalizeCountryCode("cn") != "CN" {
t.Fatal("expected CN")
}
if normalizeCountryCode("XX") != "" {
t.Fatal("XX should be empty")
}
if normalizeCountryCode("USA") != "" {
t.Fatal("USA should be empty")
}
}
func TestShouldSkipDefaults(t *testing.T) {
rules := DefaultMonitorExcludeRules()
for _, path := range []string{
"/health", "/uploads/a.png", "/api/admin/monitor/overview",
"/assets/app.js", "/admin", "/admin/dashboard", "/api/me", "/api/site-branding",
} {
skip := false
lower := strings.ToLower(path)
for _, rule := range rules {
r := strings.ToLower(strings.TrimSpace(rule))
if r == "" {
continue
}
if strings.HasPrefix(r, ".") {
if strings.HasSuffix(lower, r) {
skip = true
break
}
continue
}
if strings.HasPrefix(lower, r) || lower == strings.TrimSuffix(r, "/") {
skip = true
break
}
}
if !skip {
t.Fatalf("expected skip for %s", path)
}
}
}
func TestNormalizePageViewPath(t *testing.T) {
cases := []struct {
in string
ok bool
want string
}{
{"/", true, "/"},
{"/post/1", true, "/post/1"},
{"/post/1?x=1", true, "/post/1?x=1"},
{"/admin", false, ""},
{"/admin/dashboard", false, ""},
{"/login", false, ""},
{"/api/posts", false, ""},
{"https://evil.com/", false, ""},
{"", false, ""},
}
for _, c := range cases {
got, ok := NormalizePageViewPath(c.in)
if ok != c.ok || got != c.want {
t.Fatalf("NormalizePageViewPath(%q)=(%q,%v) want (%q,%v)", c.in, got, ok, c.want, c.ok)
}
}
}
func TestClassifyUA(t *testing.T) {
if classifyUA("Mozilla/5.0 Chrome/120", "browser") != "Chrome" {
t.Fatal("browser")
}
if classifyUA("Mozilla/5.0 (Windows NT 10.0)", "os") != "Windows" {
t.Fatal("os")
}
if classifyUA("iPhone", "device") != "Mobile" {
t.Fatal("device")
}
}

View File

@@ -57,6 +57,9 @@ func (r *RateLimiter) limitFor(action string) int {
if action == "community_heartbeat" {
return 30
}
if action == "monitor_pageview" {
return 120 // SPA 路由切换较频繁
}
return r.settings.RateLimitFor(action)
}
@@ -67,6 +70,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
if action == "community_heartbeat" {
return time.Hour
}
if action == "monitor_pageview" {
return time.Minute
}
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
}

View File

@@ -3,6 +3,7 @@ package service
import (
"encoding/json"
"errors"
"net"
"net/url"
"strconv"
"strings"
@@ -42,9 +43,12 @@ const (
SettingAsideShowTagCloud = "aside_show_tag_cloud"
SettingAsideShowRecentComments = "aside_show_recent_comments"
SettingAsideShowFriendLinks = "aside_show_friend_links"
SettingAsideShowShowcase = "aside_show_showcase"
SettingAsideWidgets = "aside_widgets"
SettingNavShowFriendLinks = "nav_show_friend_links"
SettingFooterShowFriendLinks = "footer_show_friend_links"
SettingNavShowShowcase = "nav_show_showcase"
SettingFooterShowShowcase = "footer_show_showcase"
SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt
@@ -99,9 +103,18 @@ const (
SettingCommunityHubURL = "community_hub_url"
SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL
SettingMonitorEnabled = "monitor_enabled"
SettingMonitorRetention = "monitor_retention_days"
SettingMonitorAccessLogRetention = "monitor_access_log_retention_days"
SettingMonitorExclude = "monitor_exclude_json"
SettingMonitorTrustProxy = "monitor_trust_proxy"
// DefaultCommunityHubURL 官方演示站(社区枢纽默认地址)
DefaultCommunityHubURL = "https://bbs.iioio.com"
// DefaultMonitorExcludeJSON 默认排除路径/后缀JSON 数组)
DefaultMonitorExcludeJSON = `["/admin","/api/admin/","/api/me","/api/site-branding","/health","/uploads/","/media/","/static/","/spa/","/api/admin/monitor","/api/monitor/pageview","/favicon.ico",".js",".css",".map",".woff",".woff2",".ttf",".png",".jpg",".jpeg",".gif",".webp",".avif",".svg",".ico"]`
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100
)
@@ -138,9 +151,12 @@ type ForumLimits struct {
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideShowShowcase bool `json:"aside_show_showcase"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
NavShowShowcase bool `json:"nav_show_showcase"`
FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
@@ -159,6 +175,7 @@ const (
AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links"
AsideWidgetShowcase = "showcase"
)
var asideWidgetDefaultOrder = []string{
@@ -166,6 +183,7 @@ var asideWidgetDefaultOrder = []string{
AsideWidgetRecentComments,
AsideWidgetRecentUsers,
AsideWidgetFriendLinks,
AsideWidgetShowcase,
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
@@ -189,14 +207,20 @@ type ForumLimitsPublic struct {
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideShowShowcase bool `json:"aside_show_showcase"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
NavShowShowcase bool `json:"nav_show_showcase"`
FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
// MonitorPageview 是否接受前台路由 pageview 信标(与 monitor_enabled 同步)
MonitorPageview bool `json:"monitor_pageview"`
}
type settingDef struct {
@@ -243,7 +267,8 @@ var asideSettingDefaults = map[string]string{
SettingAsideShowTagCloud: "0",
SettingAsideShowRecentComments: "0",
SettingAsideShowFriendLinks: "1",
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
SettingAsideShowShowcase: "0",
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true},{"id":"showcase","enabled":false}]`,
}
var mailSettingDefaults = map[string]string{
@@ -289,6 +314,8 @@ var friendLinkSettingDefaults = map[string]string{
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
SettingNavShowFriendLinks: "1",
SettingFooterShowFriendLinks: "1",
SettingNavShowShowcase: "0",
SettingFooterShowShowcase: "0",
}
var communitySettingDefaults = map[string]string{
@@ -299,6 +326,14 @@ var communitySettingDefaults = map[string]string{
SettingCommunitySiteURL: "",
}
var monitorSettingDefaults = map[string]string{
SettingMonitorEnabled: "0",
SettingMonitorRetention: "30",
SettingMonitorAccessLogRetention: "7",
SettingMonitorExclude: DefaultMonitorExcludeJSON,
SettingMonitorTrustProxy: "1",
}
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
@@ -392,15 +427,35 @@ type GiteaSyncConfig struct {
RepoCount int64 `json:"repo_count"`
}
// CommunityConfig 社区上报配置HubEnabled 只读,来自运维配置
// CommunityConfig 社区上报配置HubEnabled 只读:运维开关或官网域名
type CommunityConfig struct {
ReportEnabled bool `json:"report_enabled"`
HubEnabled bool `json:"hub_enabled"` // 只读app.ini / 环境变量
HubEnabled bool `json:"hub_enabled"` // 只读app.ini / 环境变量 / 官网 Host
HubURL string `json:"hub_url"`
SiteURL string `json:"site_url"` // 上报用的本站公开地址
InstanceID string `json:"instance_id"`
}
// MonitorConfig 网站监控采集设置
type MonitorConfig struct {
Enabled bool `json:"enabled"`
RetentionDays int `json:"retention_days"` // page_views 保留
AccessLogRetentionDays int `json:"access_log_retention_days"` // JSONL 请求日志保留
ExcludeRules []string `json:"exclude_rules"`
DefaultExcludeRules []string `json:"default_exclude_rules"` // 只读:恢复推荐规则
TrustProxy bool `json:"trust_proxy"`
AccessLogDir string `json:"access_log_dir"`
IP2LocationV4Path string `json:"ip2location_v4_path"`
IP2LocationV6Path string `json:"ip2location_v6_path"`
IP2LocationV4Available bool `json:"ip2location_v4_available"`
IP2LocationV6Available bool `json:"ip2location_v6_available"`
GeoIPAvailable bool `json:"geoip_available"`
GeoIPCountryPath string `json:"geoip_country_path"`
GeoIPASNPath string `json:"geoip_asn_path"`
GeoIPCountryAvailable bool `json:"geoip_country_available"`
GeoIPASNAvailable bool `json:"geoip_asn_available"`
}
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients
type OIDCConfig struct {
Enabled bool `json:"enabled"`
@@ -505,6 +560,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range monitorSettingDefaults {
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 {
@@ -586,9 +648,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
AsideShowTagCloud: bools.tagCloud,
AsideShowRecentComments: bools.recentComments,
AsideShowFriendLinks: bools.friendLinks,
AsideShowShowcase: bools.showcase,
AsideWidgets: widgets,
NavShowFriendLinks: s.NavShowFriendLinks(),
FooterShowFriendLinks: s.FooterShowFriendLinks(),
NavShowShowcase: s.NavShowShowcase(),
FooterShowShowcase: s.FooterShowShowcase(),
FeedListStyle: s.FeedListStyle(),
@@ -619,14 +684,19 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
AsideShowTagCloud: limits.AsideShowTagCloud,
AsideShowRecentComments: limits.AsideShowRecentComments,
AsideShowFriendLinks: limits.AsideShowFriendLinks,
AsideShowShowcase: limits.AsideShowShowcase,
AsideWidgets: limits.AsideWidgets,
NavShowFriendLinks: limits.NavShowFriendLinks,
FooterShowFriendLinks: limits.FooterShowFriendLinks,
NavShowShowcase: limits.NavShowShowcase,
FooterShowShowcase: limits.FooterShowShowcase,
FeedListStyle: limits.FeedListStyle,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
MonitorPageview: s.MonitorEnabled(),
}
}
@@ -669,8 +739,11 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks,
SettingAsideShowShowcase: bools.showcase,
SettingNavShowFriendLinks: in.NavShowFriendLinks,
SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
SettingNavShowShowcase: in.NavShowShowcase,
SettingFooterShowShowcase: in.FooterShowShowcase,
SettingPermalinkEnabled: in.PermalinkEnabled,
}
for key, on := range boolUpdates {
@@ -770,6 +843,26 @@ func (s *ForumSettingsService) FooterShowFriendLinks() bool {
return s.getString(SettingFooterShowFriendLinks, "1") == "1"
}
// NavShowShowcase 左侧栏「站点」是否展示开源展柜入口;缺省关闭
func (s *ForumSettingsService) NavShowShowcase() bool {
return s.getString(SettingNavShowShowcase, "0") == "1"
}
// FooterShowShowcase 页脚是否展示开源展柜入口;缺省关闭
func (s *ForumSettingsService) FooterShowShowcase() bool {
return s.getString(SettingFooterShowShowcase, "0") == "1"
}
// AsideShowShowcase 右侧栏是否展示开源展柜组件
func (s *ForumSettingsService) AsideShowShowcase() bool {
for _, w := range s.AsideWidgets() {
if w.ID == AsideWidgetShowcase {
return w.Enabled
}
}
return s.getString(SettingAsideShowShowcase, "0") == "1"
}
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
v := "0"
if enabled {
@@ -794,19 +887,44 @@ func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
return s.setString(SettingFooterShowFriendLinks, v)
}
func (s *ForumSettingsService) SetNavShowShowcase(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingNavShowShowcase, v)
}
func (s *ForumSettingsService) SetFooterShowShowcase(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingFooterShowShowcase, v)
}
// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
return s.setAsideWidgetEnabled(AsideWidgetFriendLinks, SettingAsideShowFriendLinks, enabled)
}
// SetAsideShowcaseEnabled 更新右侧栏开源展柜组件开关(与 aside_widgets 同步)
func (s *ForumSettingsService) SetAsideShowcaseEnabled(enabled bool) error {
return s.setAsideWidgetEnabled(AsideWidgetShowcase, SettingAsideShowShowcase, enabled)
}
func (s *ForumSettingsService) setAsideWidgetEnabled(widgetID, boolSettingKey string, enabled bool) error {
widgets := s.AsideWidgets()
found := false
for i := range widgets {
if widgets[i].ID == AsideWidgetFriendLinks {
if widgets[i].ID == widgetID {
widgets[i].Enabled = enabled
found = true
break
}
}
if !found {
widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled})
widgets = append(widgets, AsideWidget{ID: widgetID, Enabled: enabled})
}
widgets = NormalizeAsideWidgets(widgets)
bools := asideBoolsFromWidgets(widgets)
@@ -818,16 +936,28 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
return err
}
v := "0"
if bools.friendLinks {
v = "1"
switch widgetID {
case AsideWidgetFriendLinks:
if bools.friendLinks {
v = "1"
}
case AsideWidgetShowcase:
if bools.showcase {
v = "1"
}
default:
if enabled {
v = "1"
}
}
return s.setString(SettingAsideShowFriendLinks, v)
return s.setString(boolSettingKey, v)
}
type asideWidgetBools struct {
tagCloud bool
recentComments bool
friendLinks bool
showcase bool
}
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
@@ -835,6 +965,7 @@ func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWi
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
{ID: AsideWidgetRecentComments, Enabled: recentComments},
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
{ID: AsideWidgetShowcase, Enabled: false},
}
}
@@ -848,6 +979,8 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
out.recentComments = w.Enabled
case AsideWidgetFriendLinks:
out.friendLinks = w.Enabled
case AsideWidgetShowcase:
out.showcase = w.Enabled
}
}
return out
@@ -855,7 +988,7 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
func isValidAsideWidgetID(id string) bool {
switch id {
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks:
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase:
return true
default:
return false
@@ -1131,20 +1264,75 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
return nil
}
// CommunityConfig 读取社区上报配置
// CommunityConfig 读取社区上报配置(无请求上下文;官网需靠已存 ROOT_URL / site_url
func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
s.mu.RLock()
hubEnabled := s.communityHubEnabled
s.mu.RUnlock()
return s.CommunityConfigForRequest("")
}
// CommunityConfigForRequest 读取社区配置requestHint 可为 Origin / 公开 URL用于识别官网 Host
func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig {
return CommunityConfig{
ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
HubEnabled: hubEnabled,
HubEnabled: s.CommunityHubEnabled(requestHint),
HubURL: DefaultCommunityHubURL,
SiteURL: s.CommunitySiteURL(""),
SiteURL: s.CommunitySiteURL(requestHint),
InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")),
}
}
// CommunityHubEnabled 是否作为社区枢纽:运维开关,或本站即为官方演示站域名
func (s *ForumSettingsService) CommunityHubEnabled(requestHint string) bool {
s.mu.RLock()
ops := s.communityHubEnabled
s.mu.RUnlock()
if ops {
return true
}
official := officialCommunityHubHost()
if official == "" {
return false
}
for _, cand := range []string{
s.getString(SettingOIDCRootURL, ""),
s.getString(SettingCommunitySiteURL, ""),
requestHint,
} {
if hostFromURLOrHost(cand) == official {
return true
}
}
return false
}
// officialCommunityHubHost 官方枢纽规范化主机名(如 bbs.iioio.com
func officialCommunityHubHost() string {
return hostFromURLOrHost(DefaultCommunityHubURL)
}
// hostFromURLOrHost 从 URL 或裸 Host 提取规范化主机名(小写、去 www、去端口
func hostFromURLOrHost(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return ""
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
raw = u.Host
} else if i := strings.IndexAny(raw, "/?"); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return ""
}
if h, _, err := net.SplitHostPort(raw); err == nil {
raw = h
}
return strings.TrimPrefix(raw, "www.")
}
// CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin
func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
@@ -1199,6 +1387,104 @@ func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasRep
return wasReportEnabled, nil
}
// DefaultMonitorExcludeRules 导出推荐排除规则(供设置页「恢复默认」)
func DefaultMonitorExcludeRules() []string {
return parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
}
func normalizeMonitorRetentionDays(v int) int {
if v < 1 {
return 1
}
if v > 365 {
return 365
}
return v
}
// MonitorConfig 读取网站监控设置
func (s *ForumSettingsService) MonitorConfig() MonitorConfig {
retention, _ := strconv.Atoi(s.getString(SettingMonitorRetention, "30"))
accessRetention, _ := strconv.Atoi(s.getString(SettingMonitorAccessLogRetention, "7"))
return MonitorConfig{
Enabled: s.getString(SettingMonitorEnabled, "0") == "1",
RetentionDays: normalizeMonitorRetentionDays(retention),
AccessLogRetentionDays: normalizeMonitorRetentionDays(accessRetention),
ExcludeRules: parseMonitorExcludeJSON(s.getString(SettingMonitorExclude, DefaultMonitorExcludeJSON)),
DefaultExcludeRules: DefaultMonitorExcludeRules(),
TrustProxy: s.getString(SettingMonitorTrustProxy, "1") == "1",
}
}
// MonitorEnabled 采集是否开启
func (s *ForumSettingsService) MonitorEnabled() bool {
return s.getString(SettingMonitorEnabled, "0") == "1"
}
// UpdateMonitorConfig 更新网站监控设置
func (s *ForumSettingsService) UpdateMonitorConfig(in MonitorConfig) error {
enabled := "0"
if in.Enabled {
enabled = "1"
}
retention := normalizeMonitorRetentionDays(in.RetentionDays)
accessRetention := normalizeMonitorRetentionDays(in.AccessLogRetentionDays)
trust := "0"
if in.TrustProxy {
trust = "1"
}
rules := in.ExcludeRules
if rules == nil {
rules = parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
}
raw, err := json.Marshal(normalizeMonitorExclude(rules))
if err != nil {
return err
}
updates := map[string]string{
SettingMonitorEnabled: enabled,
SettingMonitorRetention: strconv.Itoa(retention),
SettingMonitorAccessLogRetention: strconv.Itoa(accessRetention),
SettingMonitorExclude: string(raw),
SettingMonitorTrustProxy: trust,
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
func parseMonitorExcludeJSON(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = DefaultMonitorExcludeJSON
}
var rules []string
if err := json.Unmarshal([]byte(raw), &rules); err != nil {
_ = json.Unmarshal([]byte(DefaultMonitorExcludeJSON), &rules)
}
return normalizeMonitorExclude(rules)
}
func normalizeMonitorExclude(rules []string) []string {
seen := make(map[string]struct{}, len(rules))
out := make([]string, 0, len(rules))
for _, r := range rules {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if _, ok := seen[r]; ok {
continue
}
seen[r] = struct{}{}
out = append(out, r)
}
return out
}
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
func (s *ForumSettingsService) StorageConfig() StorageConfig {
secret := s.getString(SettingStorageSecretKey, "")