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

@@ -196,7 +196,7 @@ docker run -d --name jiang13 \
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret` | | `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret` |
| `JIANG13_CONFIG` | 配置文件路径 | | `JIANG13_CONFIG` | 配置文件路径 |
| `JIANG13_WORK_PATH` | 工作目录 | | `JIANG13_WORK_PATH` | 工作目录 |
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报(默认关闭,`app.ini.example` | | `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example` |
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。 **健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
@@ -293,7 +293,7 @@ JWT_SECRET =
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | | `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT``JIANG13_DATA``JIANG13_JWT_SECRET``JIANG13_CONFIG``JIANG13_WORK_PATH``JIANG13_COMMUNITY_HUB`维护者选项,见上表) **环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT``JIANG13_DATA``JIANG13_JWT_SECRET``JIANG13_CONFIG``JIANG13_WORK_PATH``JIANG13_COMMUNITY_HUB`非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
### 5. 注册为系统服务(可选) ### 5. 注册为系统服务(可选)
@@ -406,13 +406,15 @@ jiang13-forum/
``` ```
data/ data/
├── jiang13.db # SQLite 主数据库 ├── jiang13.db # SQLite 主数据库(不含浏览量)
├── monitor.db # 网站监控 page_views
├── jiang13.log # 运行日志 ├── jiang13.log # 运行日志
├── filter_words.txt # 敏感词配置 ├── filter_words.txt # 敏感词配置
├── .jwt_secret # JWT 密钥(自动生成) ├── .jwt_secret # JWT 密钥(自动生成)
├── logs/access/ # 网站监控请求日志(按日 jsonl
├── uploads/avatars/ # 用户头像 ├── uploads/avatars/ # 用户头像
├── uploads/posts/ # 帖子正文图片 ├── uploads/posts/ # 帖子正文图片
└── jiang13_backup_*.db # 后台导出的备份 └── jiang13_backup_*.db # 后台导出的主库备份
``` ```
--- ---

View File

@@ -17,6 +17,8 @@ JWT_SECRET =
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------
; 维护者选项(默认关闭;普通自托管无需开启) ; 维护者选项(默认关闭;普通自托管无需开启)
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。 ; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
; 等价环境变量JIANG13_COMMUNITY_HUB=1 ; 等价环境变量JIANG13_COMMUNITY_HUB=1
; --------------------------------------------------------------------------- ; ---------------------------------------------------------------------------
; [community] ; [community]

View File

@@ -78,6 +78,9 @@ func (p *program) setup() error {
if err := model.InitDB(cfg.DBPath()); err != nil { if err := model.InitDB(cfg.DBPath()); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err) return fmt.Errorf("数据库初始化失败: %w", err)
} }
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
return fmt.Errorf("监控库初始化失败: %w", err)
}
engine, err := router.Setup(cfg) engine, err := router.Setup(cfg)
if err != nil { if err != nil {

View File

@@ -0,0 +1,313 @@
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
//
// go run ./cmd/monitor-geo-check -db data/monitor.db
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite默认 data/monitor.db")
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN不写库")
flag.Parse()
abs, err := filepath.Abs(*dbPath)
if err != nil {
fatalf("解析路径失败: %v", err)
}
dataDir := filepath.Dir(abs)
if *ipLookup == "" {
if st, err := os.Stat(abs); err != nil || st.IsDir() {
fatalf("数据库不存在: %s", abs)
}
}
fmt.Printf("数据库: %s\n", abs)
fmt.Printf("数据目录: %s\n\n", dataDir)
suite := service.NewGeoIPService(dataDir)
defer suite.Close()
printGeoFiles(dataDir, suite)
if ip := strings.TrimSpace(*ipLookup); ip != "" {
lookupIP(suite, ip)
fmt.Println()
if _, err := os.Stat(abs); err != nil {
return
}
}
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
fatalf("打开数据库失败: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
fatalf("获取连接失败: %v", err)
}
defer sqlDB.Close()
sqlDB.SetMaxOpenConns(1)
if !tableExists(db, "page_views") {
fatalf("表 page_views 不存在(请确认指向 data/monitor.db或尚未写入过浏览量")
}
have := columnSet(db)
printColumnPresence(have)
since := time.Now().AddDate(0, 0, -30)
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
_ = db.Table("page_views").Count(&total).Error
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
fmt.Println("=== page_views 统计(近 30 日非 bot===")
fmt.Printf("总量: %d\n", total)
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
if have["country"] {
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
fmt.Printf("有国家: %d\n", withCountry)
} else {
fmt.Println("有国家: (列缺失)")
}
if have["region"] || have["region_iso"] {
cond := "created_at >= ? AND is_bot = ?"
parts := []string{}
if have["region"] {
parts = append(parts, "region <> ''")
}
if have["region_iso"] {
parts = append(parts, "region_iso <> ''")
}
_ = db.Table("page_views").
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
Count(&withRegion).Error
fmt.Printf("有省字段: %d\n", withRegion)
} else {
fmt.Println("有省字段: (列缺失)")
}
if have["city"] {
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
fmt.Printf("有城市: %d\n", withCity)
} else {
fmt.Println("有城市: (列缺失)")
}
if have["asn"] {
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
fmt.Printf("有 ASN: %d\n", withASN)
} else {
fmt.Println("有 ASN: (列缺失)")
}
fmt.Println()
fmt.Println("=== 近 30 日省级 Topcountry / region / region_iso===")
if !(have["region"] || have["region_iso"]) {
fmt.Println("(省列尚未迁移,跳过)")
} else {
type regionRow struct {
Country string
Region string
RegionISO string `gorm:"column:region_iso"`
Count int64
}
parts := []string{}
if have["region"] {
parts = append(parts, "region <> ''")
}
if have["region_iso"] {
parts = append(parts, "region_iso <> ''")
}
selParts := []string{}
groupParts := []string{}
if have["country"] {
selParts = append(selParts, "country")
groupParts = append(groupParts, "country")
} else {
selParts = append(selParts, "'' as country")
}
if have["region"] {
selParts = append(selParts, "region")
groupParts = append(groupParts, "region")
} else {
selParts = append(selParts, "'' as region")
}
if have["region_iso"] {
selParts = append(selParts, "region_iso")
groupParts = append(groupParts, "region_iso")
} else {
selParts = append(selParts, "'' as region_iso")
}
selParts = append(selParts, "COUNT(*) as count")
var regions []regionRow
_ = db.Table("page_views").
Select(strings.Join(selParts, ", ")).
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
Group(strings.Join(groupParts, ", ")).
Order("count DESC").
Limit(20).
Scan(&regions).Error
if len(regions) == 0 {
fmt.Println("(空)")
} else {
for i, r := range regions {
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
}
}
}
fmt.Println()
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
cols := []string{"id", "created_at", "path", "is_bot"}
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
if have[c] {
cols = append(cols, c)
}
}
type sample struct {
ID uint
CreatedAt time.Time
IP string
Country string
Region string
RegionISO string `gorm:"column:region_iso"`
City string
ASN uint
ASOrg string `gorm:"column:as_org"`
Path string
IsBot bool
}
var samples []sample
_ = db.Table("page_views").
Select(strings.Join(cols, ", ")).
Where("created_at >= ?", since).
Order("id DESC").
Limit(10).
Scan(&samples).Error
if len(samples) == 0 {
fmt.Println("(无近 30 日 pageview)")
} else {
for _, s := range samples {
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
}
}
regionReady := have["region"] || have["region_iso"]
if !regionReady || withRegion == 0 {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
if !regionReady {
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
}
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb且有公网访问产生的 pageview。")
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
os.Exit(2)
}
}
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
fmt.Println("=== Geo 数据文件 ===")
v4, v6, asn, country := suite.Paths()
for _, item := range []struct {
name string
path string
ok bool
}{
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
} {
st, err := os.Stat(item.path)
if err != nil {
fmt.Printf("%s: 不存在\n", item.name)
continue
}
loaded := "未加载"
if item.ok {
loaded = "已加载"
}
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
}
fmt.Println()
}
func lookupIP(suite *service.GeoIPService, ipStr string) {
fmt.Printf("=== IP 试查 %s中文映射后===\n", ipStr)
info := suite.Lookup(ipStr)
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
return
}
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
if info.Region != "" || info.RegionISO != "" {
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
}
if info.City != "" {
fmt.Printf("城市: %s\n", info.City)
}
if info.ASN > 0 || info.ASOrg != "" {
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
}
}
func tableExists(db *gorm.DB, name string) bool {
var n int
err := db.Raw(
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
).Scan(&n).Error
return err == nil && n > 0
}
func columnSet(db *gorm.DB) map[string]bool {
type colRow struct {
Name string `gorm:"column:name"`
}
var cols []colRow
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
have := map[string]bool{}
for _, c := range cols {
have[c.Name] = true
}
return have
}
func printColumnPresence(have map[string]bool) {
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
fmt.Println("=== page_views 列 ===")
missing := false
for _, n := range need {
if have[n] {
fmt.Printf("%s: 有\n", n)
} else {
fmt.Printf("%s: 缺失\n", n)
missing = true
}
}
fmt.Println()
if missing {
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
fmt.Fprintln(os.Stderr, "")
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

View File

@@ -32,7 +32,8 @@ type Config struct {
ServiceAction string ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源) // 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭) // CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
CommunityHub bool CommunityHub bool
} }
@@ -192,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
filepath.Join(dataDir, "uploads", "avatars"), filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"), filepath.Join(dataDir, "uploads", "posts"),
filepath.Join(dataDir, "uploads", "site"), filepath.Join(dataDir, "uploads", "site"),
filepath.Join(dataDir, "logs", "access"),
} { } {
if err := os.MkdirAll(sub, 0755); err != nil { if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err) return fmt.Errorf("创建上传目录失败: %w", err)
@@ -226,11 +228,16 @@ func validServiceAction(action string) bool {
} }
} }
// DBPath 返回 SQLite 数据库文件路径 // DBPath 返回主库 SQLite 路径
func (c *Config) DBPath() string { func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db") return filepath.Join(c.DataDir, "jiang13.db")
} }
// MonitorDBPath 返回监控独立库路径page_views
func (c *Config) MonitorDBPath() string {
return filepath.Join(c.DataDir, "monitor.db")
}
// AvatarUploadDir 返回头像上传目录 // AvatarUploadDir 返回头像上传目录
func (c *Config) AvatarUploadDir() string { func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars") return filepath.Join(c.DataDir, "uploads", "avatars")

105
docs/docker-data.md Normal file
View File

@@ -0,0 +1,105 @@
# Docker 数据持久化说明
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
## 挂载对应关系
| 宿主机 | 容器内 |
|--------|--------|
| 任意目录或 named volume`jiang13-data` | **`/data`** |
**示例:**
```bash
docker run -d --name jiang13 \
-p 3000:3000 \
-v /你的路径/jiang13-data:/data \
--restart unless-stopped \
hangzhang714128/jiang13-forum:latest
```
或在 `docker-compose.yml` 中:
```yaml
volumes:
- jiang13-data:/data
# 或绑定宿主机目录:
# - ./data:/data
```
## `/data` 目录结构
数据库与附件都在同一个 `/data` 挂载内:
```
/data/
├── jiang13.db # SQLite 主数据库(不含浏览量)
├── monitor.db # 网站监控 page_views
├── uploads/ # 附件根目录
│ ├── avatars/ # 用户头像
│ └── posts/ # 帖子图片等
├── logs/
│ └── access/ # 网站监控请求日志(按日 jsonl
│ └── YYYY-MM-DD.jsonl
├── IP2LOCATION-LITE-DB3.BIN # 可选IPv4 城市库(监控)
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选IPv6 城市库
├── GeoLite2-ASN.mmdb # 可选ASN/运营商
├── GeoLite2-Country.mmdb # 可选:国家兜底
├── .jwt_secret # JWT 密钥(自动生成)
├── filter_words.txt # 敏感词配置
└── jiang13.log # 运行日志(若启用)
```
| 用途 | 路径 |
|------|------|
| 主数据库 | `/data/jiang13.db` |
| 监控浏览量 | `/data/monitor.db` |
| 附件 | `/data/uploads/` |
| 请求日志(监控) | `/data/logs/access/` |
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN``GeoLite2-ASN.mmdb` 等 |
官方镜像不要求拆成两个 volume挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP不做脱敏
## 设计说明:为何只挂一个 `/data`
对本项目单容器、SQLite、默认可本地存附件而言**一个数据卷是合理默认**
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
## 后续计划(可选增强,非必须)
按优先级,仅在有明确需求时做:
1. **文档与面板提示(优先)**
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
- 本页保持为权威说明,避免用户误以为漏了一个卷。
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
```yaml
volumes:
# 推荐:父目录一次挂载
- /ssd/jiang13:/data
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
- /ssd/jiang13:/data
- /hdd/jiang13-uploads:/data/uploads
```
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
3. **产品级:可配置上传根目录(中期)**
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
4. **不做的事**
- 不为「看起来像 WordPress」而强制双卷。
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。

76
docs/monitor.md Normal file
View File

@@ -0,0 +1,76 @@
# 网站监控设计说明
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
## 双通道数据
| 数据 | 存储 | 用途 |
|------|------|------|
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
| page_views | `{DataDir}/monitor.db`SQLite | 浏览量/访客、国家/省/城市/运营商排行与地图 |
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
### 文件行格式JSONL
每行一条 JSON字段包括`t`RFC3339`method``path``status``bytes``duration_ms``ip``ua``referer``country``region``region_iso``city``asn``as_org``is_bot`
### 保留天数
- `monitor_retention_days`:仅 **page_views**(默认 30
- `monitor_access_log_retention_days`:请求日志文件(默认 7
## 地理数据文件
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
| 文件 | 用途 |
|------|------|
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
| `GeoLite2-Country.mmdb` | 可选BIN 未命中时国家兜底 |
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
写入时做本地中文映射(省/常见城市/运营商);`country` 存 ISO2展示用中文名。
## 写入路径(性能)
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘
2. 后台 flush出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
3. 队列上限 8192满则丢弃新日志保护内存与磁盘
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
**热路径零 Geo、零写库、零写文件。**
## 请求日志口径(排除规则)
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
- `/admin``/api/admin/`
- `/api/me``/api/site-branding`
- `/health``/uploads/``/media/`、静态后缀、`/api/monitor/pageview`
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
浏览量/访客仍来自前台路由 pageview后台路径本身不上报
## 实时与聚合
- 近 1 分钟 / 近 1 小时内存环flush 时更新),避免扫全文件。
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
- 地理排行:仅 `page_views`
## 诊断
```bash
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
```
## 明确不做
- 不用 CIDR不把请求日志写入 SQLite不把 page_views 写入主库
- 不做街道级地图Docker 镜像不内置 BIN/MMDB

View File

@@ -18,6 +18,8 @@
"@radix-ui/react-label": "^2.1.9", "@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-switch": "^1.3.0",
"@svg-maps/china": "^2.0.0",
"@svg-maps/world": "^2.0.0",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1", "@tiptap/core": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1", "@tiptap/extension-code-block": "^3.26.1",
@@ -1971,6 +1973,18 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@svg-maps/china": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@svg-maps/china/-/china-2.0.0.tgz",
"integrity": "sha512-/B0a4hYJHRtfcUG+x6BudQFWhLQyPoaN2WbqfdEeWeXnwef6oXaE93Msrq2odu72c09vE+G4Ds9+n60Qsr4p1g==",
"license": "CC-BY-4.0"
},
"node_modules/@svg-maps/world": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@svg-maps/world/-/world-2.0.0.tgz",
"integrity": "sha512-VmYYnyygJ5Zhr48xPqukFBoq4aCBgVNIjBzqBnWKXSdHhrRK3pntdvbCa8lMsxwVCFzEw9a+smIMEGI2sC1xcQ==",
"license": "CC-BY-4.0"
},
"node_modules/@tanstack/react-virtual": { "node_modules/@tanstack/react-virtual": {
"version": "3.14.2", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",

View File

@@ -19,6 +19,8 @@
"@radix-ui/react-label": "^2.1.9", "@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-switch": "^1.3.0",
"@svg-maps/china": "^2.0.0",
"@svg-maps/world": "^2.0.0",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1", "@tiptap/core": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1", "@tiptap/extension-code-block": "^3.26.1",

View File

@@ -40,6 +40,7 @@ const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsP
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage')); const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage'));
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage')); const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage')); const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
@@ -70,6 +71,7 @@ const router = createBrowserRouter(
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} /> <Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} /> <Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} /> <Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
<Route path="monitor" element={<Suspense fallback={<PageLoader />}><AdminMonitorPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} /> <Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} /> <Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
</Route> </Route>

View File

@@ -1,4 +1,4 @@
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem } from './types'; import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem, MonitorConfig, MonitorOverview, MonitorGeoResult, MonitorStatItem, MonitorRealtime, MonitorLogItem } from './types';
const BASE = ''; const BASE = '';
@@ -25,6 +25,27 @@ export const api = {
me: () => request<{ user: User | null }>('/api/me'), me: () => request<{ user: User | null }>('/api/me'),
stats: () => request<ForumStats>('/api/stats'), stats: () => request<ForumStats>('/api/stats'),
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'), forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
/** 前台路由 pageview失败静默204 无 body */
monitorPageview: (body: { path: string; referrer?: string }) => {
const payload = JSON.stringify(body);
try {
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const blob = new Blob([payload], { type: 'application/json' });
if (navigator.sendBeacon('/api/monitor/pageview', blob)) {
return Promise.resolve();
}
}
} catch {
/* 回退 fetch */
}
return fetch('/api/monitor/pageview', {
method: 'POST',
credentials: 'omit',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: payload,
}).then(() => undefined).catch(() => undefined);
},
siteBranding: () => request<SiteBranding>('/api/site-branding'), siteBranding: () => request<SiteBranding>('/api/site-branding'),
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'), pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`), page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
@@ -67,6 +88,32 @@ export const api = {
request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', { request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', {
method: 'PUT', body: JSON.stringify(body), method: 'PUT', body: JSON.stringify(body),
}), }),
adminMonitorSettings: () => request<MonitorConfig>('/api/admin/settings/monitor'),
adminUpdateMonitorSettings: (body: MonitorConfig) =>
request<{ message: string; monitor: MonitorConfig }>('/api/admin/settings/monitor', {
method: 'PUT', body: JSON.stringify(body),
}),
adminMonitorOverview: () => request<MonitorOverview>('/api/admin/monitor/overview'),
adminMonitorGeo: (range = '30d') =>
request<MonitorGeoResult>(`/api/admin/monitor/geo?range=${encodeURIComponent(range)}`),
adminMonitorStats: (dim: string, range = '30d') =>
request<{ dim: string; range: string; items: MonitorStatItem[] }>(
`/api/admin/monitor/stats?dim=${encodeURIComponent(dim)}&range=${encodeURIComponent(range)}`,
),
adminMonitorLogs: (params: { page?: number; size?: number; method?: string; path?: string; status?: string; ip?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.size) q.set('size', String(params.size));
if (params.method) q.set('method', params.method);
if (params.path) q.set('path', params.path);
if (params.status) q.set('status', params.status);
if (params.ip) q.set('ip', params.ip);
const qs = q.toString();
return request<{ items: MonitorLogItem[]; total: number; page: number; size: number }>(
`/api/admin/monitor/logs${qs ? `?${qs}` : ''}`,
);
},
adminMonitorRealtime: () => request<MonitorRealtime>('/api/admin/monitor/realtime'),
adminCommunityInstances: () => adminCommunityInstances: () =>
request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'), request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'),
adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) => adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) =>
@@ -74,6 +121,20 @@ export const api = {
`/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`, `/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`,
{ method: 'PUT', body: JSON.stringify(body) }, { method: 'PUT', body: JSON.stringify(body) },
), ),
adminUpdateShowcaseEntry: (body: {
nav_show_showcase?: boolean;
footer_show_showcase?: boolean;
aside_show_showcase?: boolean;
}) =>
request<{
message: string;
nav_show_showcase: boolean;
footer_show_showcase: boolean;
aside_show_showcase: boolean;
}>('/api/admin/community/showcase-entry', {
method: 'PUT',
body: JSON.stringify(body),
}),
communityShowcase: () => communityShowcase: () =>
request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'), request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'),
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => { adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {

View File

@@ -179,6 +179,14 @@ export interface Comment {
reply_target?: Comment; reply_target?: Comment;
} }
export interface AdminDashboardTraffic {
enabled: boolean;
today_pv: number;
today_uv: number;
yesterday_pv: number;
total_pv: number;
}
export interface AdminDashboard { export interface AdminDashboard {
users: number; users: number;
posts: number; posts: number;
@@ -189,9 +197,10 @@ export interface AdminDashboard {
pending_reports?: number; pending_reports?: number;
pending_friend_links?: number; pending_friend_links?: number;
recent_posts: PostItem[]; recent_posts: PostItem[];
traffic?: AdminDashboardTraffic;
} }
export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links'; export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links' | 'showcase';
export interface AsideWidget { export interface AsideWidget {
id: AsideWidgetId; id: AsideWidgetId;
@@ -203,6 +212,7 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
{ id: 'recent_comments', enabled: false }, { id: 'recent_comments', enabled: false },
{ id: 'recent_users', enabled: false }, { id: 'recent_users', enabled: false },
{ id: 'friend_links', enabled: true }, { id: 'friend_links', enabled: true },
{ id: 'showcase', enabled: false },
]; ];
export interface ForumLimits { export interface ForumLimits {
@@ -231,12 +241,18 @@ export interface ForumLimits {
aside_show_recent_comments: boolean; aside_show_recent_comments: boolean;
/** 右侧栏友情链接 */ /** 右侧栏友情链接 */
aside_show_friend_links: boolean; aside_show_friend_links: boolean;
/** 右侧栏开源展柜 */
aside_show_showcase: boolean;
/** 右侧栏可选组件顺序与开关 */ /** 右侧栏可选组件顺序与开关 */
aside_widgets: AsideWidget[]; aside_widgets: AsideWidget[];
/** 左侧栏「站点」展示友情链接入口 */ /** 左侧栏「站点」展示友情链接入口 */
nav_show_friend_links: boolean; nav_show_friend_links: boolean;
/** 页脚展示友情链接入口 */ /** 页脚展示友情链接入口 */
footer_show_friend_links: boolean; footer_show_friend_links: boolean;
/** 左侧栏「站点」展示开源展柜入口 */
nav_show_showcase: boolean;
/** 页脚展示开源展柜入口 */
footer_show_showcase: boolean;
/** 首页列表样式title 仅标题 / thumbnail 缩略图 */ /** 首页列表样式title 仅标题 / thumbnail 缩略图 */
feed_list_style: 'title' | 'excerpt' | 'thumbnail'; feed_list_style: 'title' | 'excerpt' | 'thumbnail';
/** 伪静态(固定链接)开关 */ /** 伪静态(固定链接)开关 */
@@ -262,12 +278,17 @@ export interface ForumLimitsPublic {
aside_show_tag_cloud: boolean; aside_show_tag_cloud: boolean;
aside_show_recent_comments: boolean; aside_show_recent_comments: boolean;
aside_show_friend_links: boolean; aside_show_friend_links: boolean;
aside_show_showcase: boolean;
aside_widgets: AsideWidget[]; aside_widgets: AsideWidget[];
nav_show_friend_links: boolean; nav_show_friend_links: boolean;
footer_show_friend_links: boolean; footer_show_friend_links: boolean;
nav_show_showcase: boolean;
footer_show_showcase: boolean;
feed_list_style: 'title' | 'excerpt' | 'thumbnail'; feed_list_style: 'title' | 'excerpt' | 'thumbnail';
permalink_enabled: boolean; permalink_enabled: boolean;
permalink_ext: string; permalink_ext: string;
/** 是否上报前台路由 pageview与后台监控采集开关同步 */
monitor_pageview: boolean;
} }
export interface FriendLink { export interface FriendLink {
@@ -393,6 +414,109 @@ export interface CommunityConfig {
instance_id: string; instance_id: string;
} }
/** 网站监控设置 */
export interface MonitorConfig {
enabled: boolean;
retention_days: number;
access_log_retention_days: number;
exclude_rules: string[];
default_exclude_rules?: string[];
trust_proxy: boolean;
access_log_dir?: string;
ip2location_v4_path?: string;
ip2location_v6_path?: string;
ip2location_v4_available?: boolean;
ip2location_v6_available?: boolean;
geoip_available: boolean;
geoip_country_path?: string;
geoip_asn_path?: string;
geoip_country_available?: boolean;
geoip_asn_available?: boolean;
}
export interface MonitorOverview {
enabled: boolean;
pageviews: number;
visitors: number;
unique_ips: number;
traffic: number;
bots: number;
requests: number;
status_4xx: number;
status_5xx: number;
}
export interface MonitorGeoItem {
country: string;
count: number;
}
export interface MonitorRegionItem {
country: string;
region: string;
region_iso: string;
count: number;
}
export interface MonitorCityItem {
country: string;
region: string;
city: string;
count: number;
}
export interface MonitorASNItem {
asn: number;
as_org: string;
count: number;
}
export interface MonitorGeoResult {
range: string;
countries: MonitorGeoItem[];
regions: MonitorRegionItem[];
cities: MonitorCityItem[];
asns: MonitorASNItem[];
has_data: boolean;
}
export interface MonitorStatItem {
key: string;
count: number;
}
export interface MonitorRealtimePoint {
minute: string;
count: number;
bytes: number;
}
export interface MonitorRealtime {
enabled: boolean;
requests_1m: number;
traffic_1m: number;
hourly_series: MonitorRealtimePoint[];
}
export interface MonitorLogItem {
id: number;
created_at: string;
method: string;
path: string;
status: number;
bytes: number;
duration_ms: number;
ip: string;
ua: string;
referer: string;
country: string;
region?: string;
city?: string;
asn?: number;
as_org?: string;
is_bot: boolean;
}
export interface CommunityInstance { export interface CommunityInstance {
instance_id: string; instance_id: string;
site_url: string; site_url: string;

View File

@@ -13,6 +13,7 @@ import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline'; import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard'; import PostAuthorCard from './PostAuthorCard';
import AsideCheckInStrip from './AsideCheckInStrip'; import AsideCheckInStrip from './AsideCheckInStrip';
import ShowcaseAsideWidget from './ShowcaseAsideWidget';
export type PostDetailAside = { export type PostDetailAside = {
author?: User | null; author?: User | null;
@@ -114,6 +115,8 @@ export default function RightPanel({
const renderWidget = (widget: AsideWidget) => { const renderWidget = (widget: AsideWidget) => {
switch (widget.id) { switch (widget.id) {
case 'showcase':
return <ShowcaseAsideWidget key="showcase" />;
case 'friend_links': case 'friend_links':
return ( return (
<div key="friend_links" className="widget-card widget-card--friend-links"> <div key="friend_links" className="widget-card widget-card--friend-links">

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { Globe2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
/** 右侧栏:开源展柜精简列表 */
export default function ShowcaseAsideWidget() {
const nav = useNavigate();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
api.communityShowcase()
.then((r) => {
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
return (
<div className="widget-card widget-card--showcase">
<div className="widget-card-head widget-card-head--split">
<span className="widget-card-head-main">
<Globe2 className="widget-card-icon widget-card-icon--showcase" aria-hidden />
<button type="button" className="widget-friend-links-title" onClick={() => nav('/showcase')}>
</button>
</span>
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
</button>
</div>
<div className="widget-card-body widget-card-body--friend-links">
{loading ? (
<div className="widget-empty"></div>
) : items.length === 0 ? (
<div className="widget-empty">
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
</button>
</div>
) : (
<>
<ul className="widget-friend-links-list">
{items.slice(0, 8).map((item) => (
<li key={item.site_url}>
<a href={item.site_url} target="_blank" rel="noopener noreferrer" title={item.site_name || item.site_url}>
{item.site_name || '未命名站点'}
</a>
</li>
))}
</ul>
{items.length > 8 && (
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
{items.length}
</button>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { import {
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
@@ -30,6 +30,7 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st
if (pathname.startsWith('/favorites')) return 'favorites'; if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects'; if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/links')) return 'links'; if (pathname.startsWith('/links')) return 'links';
if (pathname.startsWith('/showcase')) return 'showcase';
if (pathname.startsWith('/page/')) return 'pages'; if (pathname.startsWith('/page/')) return 'pages';
if (pathname.startsWith('/admin')) return 'admin'; if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard); return activeBoard === 0 ? 'all' : String(activeBoard);
@@ -67,7 +68,8 @@ export default function Sidebar({
const { navPages } = useSitePages(); const { navPages } = useSitePages();
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const showFriendLinksNav = limits.nav_show_friend_links !== false; const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showSiteSection = navPages.length > 0 || showFriendLinksNav; const showShowcaseNav = !!limits.nav_show_showcase;
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
const keyword = params.get('keyword') || ''; const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword); const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
@@ -197,6 +199,7 @@ export default function Sidebar({
<div className="sidebar-section sidebar-section--spaced"></div> <div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))} {showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
{navPages.map(p => ( {navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits))) navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
))} ))}

View File

@@ -9,7 +9,7 @@ function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>; return <span className="site-footer__sep" aria-hidden>·</span>;
} }
/** 站点页脚:版权、Sitemap、备案号 */ /** 站点页脚:版权、友链/展柜入口、单页、备案号 */
export default function SiteFooter() { export default function SiteFooter() {
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
const { footerPages } = useSitePages(); const { footerPages } = useSitePages();
@@ -17,6 +17,10 @@ export default function SiteFooter() {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const icp = branding.icp_beian?.trim() || ''; const icp = branding.icp_beian?.trim() || '';
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/'; const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
const showFriendLinks = limits.footer_show_friend_links !== false;
const showShowcase = !!limits.footer_show_showcase;
const hasNavBeforePages = showFriendLinks || showShowcase;
const hasNavBeforeIcp = hasNavBeforePages || footerPages.length > 0;
return ( return (
<footer className="site-footer"> <footer className="site-footer">
@@ -34,20 +38,26 @@ export default function SiteFooter() {
</div> </div>
<nav className="site-footer__nav" aria-label="站点链接"> <nav className="site-footer__nav" aria-label="站点链接">
{limits.footer_show_friend_links !== false && ( {showFriendLinks && (
<span className="site-footer__friend"> <span className="site-footer__friend">
<Link to="/links"></Link> <Link to="/links"></Link>
</span> </span>
)} )}
{showShowcase && (
<span className="site-footer__friend">
{showFriendLinks && <FooterSep />}
<Link to="/showcase"></Link>
</span>
)}
{footerPages.map((p, i) => ( {footerPages.map((p, i) => (
<span key={p.slug} className="site-footer__friend"> <span key={p.slug} className="site-footer__friend">
{(limits.footer_show_friend_links !== false || i > 0) && <FooterSep />} {(hasNavBeforePages || i > 0) && <FooterSep />}
<Link to={pagePath(p.slug, limits)}>{p.title}</Link> <Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span> </span>
))} ))}
{icp && ( {icp && (
<> <>
{(limits.footer_show_friend_links !== false || footerPages.length > 0) && <FooterSep />} {hasNavBeforeIcp && <FooterSep />}
<a <a
href={icpURL} href={icpURL}
target="_blank" target="_blank"

View File

@@ -18,6 +18,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
label: '友情链接', label: '友情链接',
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请', hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
}, },
showcase: {
label: '开源展柜',
hint: '展示精选公网部署;关闭后仍可直接访问 /showcase',
},
}; };
type Props = { type Props = {

View File

@@ -12,7 +12,7 @@ const EMPTY_COMMUNITY: CommunityConfig = {
instance_id: '', instance_id: '',
}; };
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存) */ /** 仪表盘页脚:自愿社区上报开关(默认关,即时保存;枢纽站不显示 */
export default function CommunitySupportStrip() { export default function CommunitySupportStrip() {
const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY); const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -58,6 +58,11 @@ export default function CommunitySupportStrip() {
} }
}; };
// 官网 / 枢纽站本身就是收报方,无需「支持开源」上报条
if (!ready || community.hub_enabled) {
return null;
}
return ( return (
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源"> <div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
<div className="admin-community-support-strip-main"> <div className="admin-community-support-strip-main">

View File

@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
import china from '@svg-maps/china';
import { cn } from '@/lib/utils';
import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
type RegionStat = {
country: string;
region?: string;
region_iso?: string;
count: number;
};
type Props = {
regions: RegionStat[];
className?: string;
};
/** 中国省区地图:按 BIN 解析出的省/区填色 */
export default function MonitorChinaMap({ regions, className }: Props) {
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
const hasData = max > 0;
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
const viewBox = (china as { viewBox: string }).viewBox;
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
className="admin-monitor-svg-map"
viewBox={viewBox}
role="img"
aria-label="中国访客地图"
>
{locations.map((loc) => {
const count = counts[loc.id] || 0;
return (
<path
key={loc.id}
d={loc.path}
fill={heatFill(count, max, hasData)}
stroke="hsl(var(--background, 0 0% 100%))"
strokeWidth={0.6}
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
onMouseEnter={() => setHover({ name: loc.name, count })}
onMouseLeave={() => setHover(null)}
>
<title>
{loc.name}
{count > 0 ? ` · ${count}` : ''}
</title>
</path>
);
})}
</svg>
{hover && (
<div className="admin-monitor-map-tip" role="status">
<strong>{hover.name}</strong>
<span>{hover.count > 0 ? hover.count : '—'}</span>
</div>
)}
{!hasData && (
<div className="admin-monitor-map-empty-overlay">
<p>访</p>
<p> IP2LOCATION-LITE-DB3.BIN /</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import world from '@svg-maps/world';
import { cn } from '@/lib/utils';
import {
buildCountMap,
countryLabelZh,
emptyFill,
heatFill,
type GeoCountMap,
} from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
type Props = {
items: { country: string; count: number }[];
className?: string;
/** 裁切到东亚(中国模式) */
focusChina?: boolean;
};
const CHINA_VIEWBOX = '680 260 220 200';
export default function MonitorWorldMap({ items, className, focusChina }: Props) {
const [hover, setHover] = useState<{ code: string; name: string; count: number } | null>(null);
const counts = useMemo(() => buildCountMap(items), [items]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
const hasData = max > 0;
const locations = (world as { locations: Loc[]; viewBox: string }).locations;
const viewBox = focusChina ? CHINA_VIEWBOX : (world as { viewBox: string }).viewBox;
const filteredCounts: GeoCountMap = useMemo(() => {
if (!focusChina) return counts;
const allow = new Set(['CN', 'HK', 'MO', 'TW']);
const m: GeoCountMap = {};
for (const [k, v] of Object.entries(counts)) {
if (allow.has(k)) m[k] = v;
}
return m;
}, [counts, focusChina]);
const focusMax = useMemo(
() => Math.max(0, ...Object.values(filteredCounts)),
[filteredCounts],
);
const focusHas = focusMax > 0;
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
className="admin-monitor-svg-map"
viewBox={viewBox}
role="img"
aria-label={focusChina ? '中国及周边访客地图' : '世界访客地图'}
>
{locations.map((loc) => {
const code = loc.id.toUpperCase();
if (focusChina) {
// 中国模式下非本区域保持极淡灰,本区域按数据着色
const inRegion = code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW'
|| code === 'JP' || code === 'KR' || code === 'MN' || code === 'KP'
|| code === 'RU' || code === 'IN' || code === 'VN' || code === 'LA'
|| code === 'MM' || code === 'BT' || code === 'NP' || code === 'KZ'
|| code === 'KG' || code === 'TJ' || code === 'UZ' || code === 'AF'
|| code === 'PK' || code === 'PH' || code === 'MY' || code === 'TH'
|| code === 'KH' || code === 'BD' || code === 'LK';
if (!inRegion) return null;
}
const count = filteredCounts[code] || (focusChina ? 0 : counts[code]) || 0;
const useMax = focusChina ? focusMax : max;
const useHas = focusChina ? focusHas : hasData;
const isCore = !focusChina || code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW';
const fill = isCore
? heatFill(count, useMax, useHas)
: emptyFill(useHas);
return (
<path
key={loc.id}
d={loc.path}
data-code={code}
fill={fill}
stroke="hsl(var(--background, 0 0% 100%))"
strokeWidth={focusChina ? 0.35 : 0.4}
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
onMouseEnter={() => setHover({
code,
name: countryLabelZh(code, loc.name),
count: focusChina && !isCore ? 0 : count,
})}
onMouseLeave={() => setHover(null)}
>
<title>
{countryLabelZh(code, loc.name)}
{count > 0 ? ` · ${count}` : ''}
</title>
</path>
);
})}
</svg>
{hover && (
<div className="admin-monitor-map-tip" role="status">
<strong>{hover.name}</strong>
<span>{hover.count > 0 ? hover.count : '—'}</span>
</div>
)}
{!hasData && !focusChina && (
<div className="admin-monitor-map-empty-overlay">
<p></p>
<p> GeoIP CDN </p>
</div>
)}
{focusChina && !focusHas && (
<div className="admin-monitor-map-empty-overlay">
<p>访</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
/** 监控地图共用:国家码 → 数量色阶(本站绿色) */
export type GeoCountMap = Record<string, number>;
const EMPTY = 'var(--j13-border, #e5e7eb)';
const BASE = 'hsl(var(--muted, 210 20% 94%))';
/** 由浅到深的绿色阶 */
const GREEN_STEPS = [
'color-mix(in srgb, var(--j13-green) 28%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 45%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 62%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 80%, hsl(var(--card)))',
'var(--j13-green)',
];
export function buildCountMap(items: { country: string; count: number }[]): GeoCountMap {
const m: GeoCountMap = {};
for (const it of items) {
const code = (it.country || '').toUpperCase();
if (!code) continue;
m[code] = (m[code] || 0) + it.count;
}
return m;
}
/** MaxMind region_iso / 中英文省名 → @svg-maps/china 的 location id */
const CN_ISO_TO_SVG: Record<string, string> = {
AH: 'anhui', BJ: 'beijing', CQ: 'chongqing', FJ: 'fujian', GS: 'gansu',
GD: 'guangdong', GX: 'guangxi-zhuang', GZ: 'guizhou', HI: 'hainan', HE: 'hebei',
HL: 'heilongjiang', HA: 'henan', HB: 'hubei', HN: 'hunan', JS: 'jiangsu',
JX: 'jiangxi', JL: 'jilin', LN: 'liaoning', NM: 'nei-mongol', NX: 'ningxia-hui',
QH: 'quinghai', SN: 'shaanxi', SD: 'shandong', SH: 'shanghai', SX: 'shanxi',
SC: 'sichuan', TJ: 'tianjin', XJ: 'xinjiang-uygur', XZ: 'xizang', YN: 'yunnan',
ZJ: 'zhejiang', HK: 'hong-kong', MO: 'macau',
};
const CN_NAME_TO_SVG: Record<string, string> = {
: 'anhui', anhui: 'anhui',
: 'beijing', beijing: 'beijing',
: 'chongqing', chongqing: 'chongqing',
: 'fujian', fujian: 'fujian',
: 'gansu', gansu: 'gansu',
广: 'guangdong', guangdong: 'guangdong',
广西: 'guangxi-zhuang', '广西壮族自治区': 'guangxi-zhuang', 'guangxi zhuang': 'guangxi-zhuang', guangxi: 'guangxi-zhuang',
: 'guizhou', guizhou: 'guizhou',
: 'hainan', hainan: 'hainan',
: 'hebei', hebei: 'hebei',
: 'heilongjiang', heilongjiang: 'heilongjiang',
: 'henan', henan: 'henan',
: 'hubei', hubei: 'hubei',
: 'hunan', hunan: 'hunan',
: 'jiangsu', jiangsu: 'jiangsu',
西: 'jiangxi', jiangxi: 'jiangxi',
: 'jilin', jilin: 'jilin',
: 'liaoning', liaoning: 'liaoning',
: 'nei-mongol', '内蒙古自治区': 'nei-mongol', 'nei mongol': 'nei-mongol', 'inner mongolia': 'nei-mongol',
: 'ningxia-hui', '宁夏回族自治区': 'ningxia-hui', 'ningxia hui': 'ningxia-hui', ningxia: 'ningxia-hui',
: 'quinghai', qinghai: 'quinghai', quinghai: 'quinghai',
西: 'shaanxi', shaanxi: 'shaanxi',
: 'shandong', shandong: 'shandong',
: 'shanghai', shanghai: 'shanghai',
西: 'shanxi', shanxi: 'shanxi',
: 'sichuan', sichuan: 'sichuan',
: 'tianjin', tianjin: 'tianjin',
: 'xinjiang-uygur', '新疆维吾尔自治区': 'xinjiang-uygur', 'xinjiang uygur': 'xinjiang-uygur', xinjiang: 'xinjiang-uygur',
西: 'xizang', '西藏自治区': 'xizang', xizang: 'xizang', tibet: 'xizang',
: 'yunnan', yunnan: 'yunnan',
: 'zhejiang', zhejiang: 'zhejiang',
: 'hong-kong', 'hong kong': 'hong-kong',
: 'macau', macau: 'macau', macao: 'macau',
};
export function chinaRegionToSvgId(regionISO?: string, regionName?: string): string | null {
const iso = (regionISO || '').trim().toUpperCase().replace(/^CN-/, '');
if (iso && CN_ISO_TO_SVG[iso]) return CN_ISO_TO_SVG[iso];
const name = (regionName || '').trim().toLowerCase();
if (!name) return null;
if (CN_NAME_TO_SVG[name]) return CN_NAME_TO_SVG[name];
// 尝试去掉「省/市/自治区」后缀后再匹配中文键
const zh = (regionName || '').trim()
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
.replace(/(省|市)$/, '');
if (CN_NAME_TO_SVG[zh]) return CN_NAME_TO_SVG[zh];
return null;
}
/** 将省级统计聚合为 svg-maps/china 的 id → count */
export function buildChinaRegionCountMap(
regions: { country: string; region?: string; region_iso?: string; count: number }[],
): GeoCountMap {
const m: GeoCountMap = {};
for (const it of regions) {
const c = (it.country || '').toUpperCase();
if (c && c !== 'CN' && c !== 'HK' && c !== 'MO' && c !== 'TW') continue;
const id = chinaRegionToSvgId(it.region_iso, it.region);
if (!id) continue;
m[id] = (m[id] || 0) + it.count;
}
return m;
}
export function heatFill(count: number, max: number, hasAnyData: boolean): string {
if (!hasAnyData) return BASE;
if (!count || max <= 0) return BASE;
const t = Math.log1p(count) / Math.log1p(max);
const idx = Math.min(GREEN_STEPS.length - 1, Math.floor(t * GREEN_STEPS.length));
return GREEN_STEPS[Math.max(0, idx)];
}
export function emptyFill(hasAnyData: boolean): string {
return hasAnyData ? BASE : EMPTY;
}
export const COUNTRY_NAMES_ZH: Record<string, string> = {
CN: '中国', US: '美国', JP: '日本', KR: '韩国', HK: '中国香港', TW: '中国台湾', MO: '中国澳门',
SG: '新加坡', DE: '德国', GB: '英国', FR: '法国', RU: '俄罗斯', AU: '澳大利亚',
CA: '加拿大', IN: '印度', BR: '巴西', NL: '荷兰', IT: '意大利', ES: '西班牙',
MY: '马来西亚', TH: '泰国', VN: '越南', ID: '印尼', PH: '菲律宾', MX: '墨西哥',
TR: '土耳其', SA: '沙特', AE: '阿联酋', ZA: '南非', NZ: '新西兰', SE: '瑞典',
NO: '挪威', FI: '芬兰', DK: '丹麦', PL: '波兰', CH: '瑞士', AT: '奥地利',
BE: '比利时', IE: '爱尔兰', PT: '葡萄牙', AR: '阿根廷', CL: '智利', CO: '哥伦比亚',
PK: '巴基斯坦', BD: '孟加拉', EG: '埃及', NG: '尼日利亚', KE: '肯尼亚',
UA: '乌克兰', CZ: '捷克', RO: '罗马尼亚', HU: '匈牙利', GR: '希腊', IL: '以色列',
};
export function countryLabelZh(code: string, fallbackName?: string) {
const c = code.toUpperCase();
return COUNTRY_NAMES_ZH[c] || fallbackName || c;
}

View File

@@ -20,12 +20,16 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
aside_show_tag_cloud: false, aside_show_tag_cloud: false,
aside_show_recent_comments: false, aside_show_recent_comments: false,
aside_show_friend_links: true, aside_show_friend_links: true,
aside_show_showcase: false,
aside_widgets: DEFAULT_ASIDE_WIDGETS, aside_widgets: DEFAULT_ASIDE_WIDGETS,
nav_show_friend_links: true, nav_show_friend_links: true,
footer_show_friend_links: true, footer_show_friend_links: true,
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title', feed_list_style: 'title',
permalink_enabled: false, permalink_enabled: false,
permalink_ext: 'html', permalink_ext: 'html',
monitor_pageview: false,
}; };
let cached: ForumLimitsPublic | null = null; let cached: ForumLimitsPublic | null = null;

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../api/client';
import { useForumLimits } from './useForumLimits';
const DEDUPE_MS = 800;
function shouldSkipPath(pathname: string) {
const p = pathname.toLowerCase();
return (
p === '/admin' ||
p.startsWith('/admin/') ||
p === '/login' ||
p === '/register' ||
p === '/forgot-password' ||
p.startsWith('/oauth')
);
}
/** 主站布局:路由变化时上报第一方 pageview采集关闭则不请求 */
export function useMonitorPageview() {
const loc = useLocation();
const { limits } = useForumLimits();
const lastRef = useRef<{ key: string; at: number }>({ key: '', at: 0 });
const enabled = !!limits.monitor_pageview;
useEffect(() => {
if (!enabled) return;
if (shouldSkipPath(loc.pathname)) return;
const path = `${loc.pathname}${loc.search || ''}`;
const now = Date.now();
if (lastRef.current.key === path && now - lastRef.current.at < DEDUPE_MS) {
return;
}
lastRef.current = { key: path, at: now };
const referrer = typeof document !== 'undefined' ? document.referrer || '' : '';
void api.monitorPageview({ path, referrer });
}, [enabled, loc.pathname, loc.search]);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
@@ -38,6 +38,8 @@ const NAV_GROUPS: NavGroup[] = [
label: '概览', label: '概览',
items: [ items: [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard }, { to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ to: '/admin/monitor', label: '网站监控', icon: Activity },
{ to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true },
], ],
}, },
{ {
@@ -56,7 +58,6 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/admin/users', label: '用户管理', icon: Users }, { to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/badges', label: '徽章管理', icon: Award }, { to: '/admin/badges', label: '徽章管理', icon: Award },
{ to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' }, { to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' },
{ to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true },
], ],
}, },
{ {

View File

@@ -34,6 +34,7 @@ import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost'; import { openForumPost } from '../utils/openPost';
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
import { useMonitorPageview } from '../hooks/useMonitorPageview';
import SiteBrandMark from '../components/SiteBrandMark'; import SiteBrandMark from '../components/SiteBrandMark';
import SiteFooter from '../components/SiteFooter'; import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';
@@ -43,6 +44,7 @@ export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
useMonitorPageview();
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const hideAside = useMediaQuery('(max-width: 1100px)'); const hideAside = useMediaQuery('(max-width: 1100px)');
const nav = useNavigate(); const nav = useNavigate();

View File

@@ -1,30 +1,80 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Globe2, ExternalLink, Star } from 'lucide-react'; import { Globe2, ExternalLink, Star } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { CommunityInstance } from '../../api/types'; import type { CommunityInstance, ForumLimits } from '../../api/types';
import { useAdminGuard } from '../../layouts/AdminLayout'; import { useAdminGuard } from '../../layouts/AdminLayout';
import { formatTime } from '../../utils/content'; import { formatTime } from '../../utils/content';
import { cn } from '@/lib/utils'; import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import {
mergeForumLimitsWithAsideWidgets,
normalizeAsideWidgets,
resolveAsideWidgets,
} from '../../utils/asideWidgets';
type EntryFlags = {
nav: boolean;
footer: boolean;
aside: boolean;
};
/** 后台:公网实例列表 + 开源展柜入口位置 */
export default function AdminCommunityPage() { export default function AdminCommunityPage() {
const { ready } = useAdminGuard(); const { ready } = useAdminGuard();
const [hubEnabled, setHubEnabled] = useState(false); const [hubEnabled, setHubEnabled] = useState(false);
const [list, setList] = useState<CommunityInstance[]>([]); const [list, setList] = useState<CommunityInstance[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [featuringId, setFeaturingId] = useState<string | null>(null); const [featuringId, setFeaturingId] = useState<string | null>(null);
const [forumLimits, setForumLimits] = useState<ForumLimits | null>(null);
const [entrySaving, setEntrySaving] = useState(false);
const entryFlags = useMemo<EntryFlags>(() => {
const widgets = resolveAsideWidgets({
aside_widgets: forumLimits?.aside_widgets,
aside_show_tag_cloud: forumLimits?.aside_show_tag_cloud ?? false,
aside_show_recent_comments: forumLimits?.aside_show_recent_comments ?? false,
aside_show_friend_links: forumLimits?.aside_show_friend_links ?? true,
aside_show_showcase: forumLimits?.aside_show_showcase ?? false,
});
return {
nav: !!forumLimits?.nav_show_showcase,
footer: !!forumLimits?.footer_show_showcase,
aside: widgets.find(w => w.id === 'showcase')?.enabled ?? false,
};
}, [forumLimits]);
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
setLoading(true); setLoading(true);
api.adminCommunityInstances() Promise.all([
.then((r) => { api.adminCommunityInstances(),
setHubEnabled(!!r.hub_enabled); api.adminSettings().catch(() => null),
setList(Array.isArray(r.instances) ? r.instances : []); ])
.then(([instancesRes, settings]) => {
setHubEnabled(!!instancesRes.hub_enabled);
setList(Array.isArray(instancesRes.instances) ? instancesRes.instances : []);
if (settings?.limits) {
const loaded = normalizeAsideWidgets(
resolveAsideWidgets({
aside_widgets: settings.limits.aside_widgets,
aside_show_tag_cloud: settings.limits.aside_show_tag_cloud ?? false,
aside_show_recent_comments: settings.limits.aside_show_recent_comments ?? false,
aside_show_friend_links: settings.limits.aside_show_friend_links ?? true,
aside_show_showcase: settings.limits.aside_show_showcase ?? false,
}),
);
setForumLimits(mergeForumLimitsWithAsideWidgets({
...settings.limits,
nav_show_showcase: !!settings.limits.nav_show_showcase,
footer_show_showcase: !!settings.limits.footer_show_showcase,
aside_show_showcase: !!settings.limits.aside_show_showcase,
}, loaded));
}
}) })
.catch(() => { .catch(() => {
setList([]); setList([]);
@@ -32,6 +82,66 @@ export default function AdminCommunityPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [ready]); }, [ready]);
const patchEntryVisibility = async (patch: Partial<EntryFlags>) => {
if (entrySaving) return;
const prev = entryFlags;
const nextFlags = { ...prev, ...patch };
setForumLimits((fl) => {
if (!fl) return fl;
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
w.id === 'showcase' ? { ...w, enabled: nextFlags.aside } : w
));
return mergeForumLimitsWithAsideWidgets({
...fl,
nav_show_showcase: nextFlags.nav,
footer_show_showcase: nextFlags.footer,
aside_show_showcase: nextFlags.aside,
}, widgets);
});
setEntrySaving(true);
try {
const body: {
nav_show_showcase?: boolean;
footer_show_showcase?: boolean;
aside_show_showcase?: boolean;
} = {};
if (patch.nav !== undefined) body.nav_show_showcase = patch.nav;
if (patch.footer !== undefined) body.footer_show_showcase = patch.footer;
if (patch.aside !== undefined) body.aside_show_showcase = patch.aside;
const r = await api.adminUpdateShowcaseEntry(body);
setForumLimits((base) => {
if (!base) return base;
const widgets = normalizeAsideWidgets(base.aside_widgets).map(w => (
w.id === 'showcase' ? { ...w, enabled: r.aside_show_showcase } : w
));
return mergeForumLimitsWithAsideWidgets({
...base,
nav_show_showcase: r.nav_show_showcase,
footer_show_showcase: r.footer_show_showcase,
aside_show_showcase: r.aside_show_showcase,
}, widgets);
});
invalidateForumLimitsCache();
notify.success(r.message);
} catch (e: unknown) {
setForumLimits((fl) => {
if (!fl) return fl;
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
w.id === 'showcase' ? { ...w, enabled: prev.aside } : w
));
return mergeForumLimitsWithAsideWidgets({
...fl,
nav_show_showcase: prev.nav,
footer_show_showcase: prev.footer,
aside_show_showcase: prev.aside,
}, widgets);
});
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setEntrySaving(false);
}
};
const handleToggleFeatured = async (row: CommunityInstance) => { const handleToggleFeatured = async (row: CommunityInstance) => {
if (featuringId) return; if (featuringId) return;
setFeaturingId(row.instance_id); setFeaturingId(row.instance_id);
@@ -66,7 +176,7 @@ export default function AdminCommunityPage() {
<p className="admin-page-desc"> <p className="admin-page-desc">
{' '} {' '}
<Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer"></Link> <Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer"></Link>
</p> </p>
</div> </div>
</div> </div>
@@ -84,6 +194,39 @@ export default function AdminCommunityPage() {
</div> </div>
)} )}
{hubEnabled && (
<div className="admin-card admin-links-entry-card" style={{ marginBottom: 16 }}>
<div className="admin-card-head"></div>
<p className="admin-card-desc">
访 /showcase
</p>
<div className="admin-card-body admin-links-entry-body">
{(
[
{ key: 'nav' as const, label: '左侧栏(站点)', on: entryFlags.nav },
{ key: 'aside' as const, label: '右侧栏', on: entryFlags.aside },
{ key: 'footer' as const, label: '页脚', on: entryFlags.footer },
]
).map(item => (
<div key={item.key} className="admin-links-entry-row">
<span id={`admin-showcase-entry-${item.key}`}>{item.label}</span>
<button
type="button"
role="switch"
aria-checked={item.on}
aria-labelledby={`admin-showcase-entry-${item.key}`}
disabled={entrySaving}
className={cn('admin-settings-switch', item.on && 'is-on')}
onClick={() => void patchEntryVisibility({ [item.key]: !item.on })}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
))}
</div>
</div>
)}
<div className="admin-card"> <div className="admin-card">
<div className="admin-card-head"> <div className="admin-card-head">
<span></span> <span></span>

View File

@@ -1,14 +1,38 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { FileText, Flag, MessageSquare, Link2 } from 'lucide-react'; import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout'; import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types'; import type { AdminDashboard, AdminDashboardTraffic } from '../../api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip'; import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip';
function formatDashNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
return String(n ?? 0);
}
function pvTrendLabel(today: number, yesterday: number) {
if (yesterday <= 0) {
return today > 0 ? '昨日无数据' : '较昨日 —';
}
const delta = ((today - yesterday) / yesterday) * 100;
if (Math.abs(delta) < 0.5) return '与昨日持平';
const abs = Math.abs(delta).toFixed(0);
return delta > 0 ? `较昨日 ↑ ${abs}%` : `较昨日 ↓ ${abs}%`;
}
function trendTone(today: number, yesterday: number): 'up' | 'down' | 'flat' {
if (yesterday <= 0) return 'flat';
const delta = today - yesterday;
if (delta > 0) return 'up';
if (delta < 0) return 'down';
return 'flat';
}
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const nav = useNavigate(); const nav = useNavigate();
const { ready } = useAdminGuard(); const { ready } = useAdminGuard();
@@ -32,20 +56,16 @@ export default function AdminDashboardPage() {
const pendingReports = data.pending_reports ?? 0; const pendingReports = data.pending_reports ?? 0;
const pendingFriendLinks = data.pending_friend_links ?? 0; const pendingFriendLinks = data.pending_friend_links ?? 0;
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks; const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
const traffic: AdminDashboardTraffic = data.traffic ?? {
const stats = [ enabled: false, today_pv: 0, today_uv: 0, yesterday_pv: 0, total_pv: 0,
{ label: '注册用户', value: data.users }, };
{ label: '帖子总数', value: data.posts },
{ label: '板块数量', value: data.boards },
{ label: '评论总数', value: data.comments },
];
const queues = [ const queues = [
{ {
key: 'posts', key: 'posts',
label: '待审帖子', label: '待审帖子',
count: pendingPosts, count: pendingPosts,
hint: '新帖与修改待审核', hint: '新帖与修改',
to: '/admin/posts', to: '/admin/posts',
icon: FileText, icon: FileText,
}, },
@@ -53,7 +73,7 @@ export default function AdminDashboardPage() {
key: 'comments', key: 'comments',
label: '待审评论', label: '待审评论',
count: pendingComments, count: pendingComments,
hint: '评论与回复待审核', hint: '评论与回复',
to: '/admin/comments', to: '/admin/comments',
icon: MessageSquare, icon: MessageSquare,
}, },
@@ -61,7 +81,7 @@ export default function AdminDashboardPage() {
key: 'reports', key: 'reports',
label: '待处理举报', label: '待处理举报',
count: pendingReports, count: pendingReports,
hint: '用户举报需人工处理', hint: '用户举报',
to: '/admin/reports', to: '/admin/reports',
icon: Flag, icon: Flag,
}, },
@@ -69,60 +89,121 @@ export default function AdminDashboardPage() {
key: 'links', key: 'links',
label: '待审友链', label: '待审友链',
count: pendingFriendLinks, count: pendingFriendLinks,
hint: '用户提交的友情链接申请', hint: '友链申请',
to: '/admin/links', to: '/admin/links',
icon: Link2, icon: Link2,
}, },
]; ];
return ( const scale = [
<div className="admin-page"> { label: '注册用户', value: data.users },
<div className="admin-page-head"> { label: '帖子总数', value: data.posts },
<h1></h1> { label: '板块数量', value: data.boards },
<p></p> { label: '评论总数', value: data.comments },
</div> ];
<section className="admin-queue-section" aria-label="待处理事项"> const tone = trendTone(traffic.today_pv, traffic.yesterday_pv);
<div className="admin-section-label">
<span></span> return (
{pendingTotal > 0 ? ( <div className="admin-page admin-dash-page">
<Badge variant="orange">{pendingTotal} </Badge> <div className="admin-page-head admin-dash-head">
) : ( <div className="admin-dash-head-title">
<span className="admin-section-muted"></span> <h1></h1>
{pendingTotal > 0 && (
<Badge variant="orange">{pendingTotal} </Badge>
)} )}
</div> </div>
<div className="admin-queue-grid"> <p>访</p>
{queues.map(q => { </div>
const Icon = q.icon;
const hasWork = q.count > 0;
return (
<button
key={q.key}
type="button"
className={cn('admin-queue-card', hasWork && 'has-work')}
onClick={() => nav(q.to)}
>
<div className="admin-queue-card-top">
<Icon size={18} aria-hidden />
<span className="admin-queue-count">{q.count}</span>
</div>
<div className="admin-queue-label">{q.label}</div>
<div className="admin-queue-hint">{q.hint}</div>
</button>
);
})}
</div>
</section>
<section className="admin-stat-section" aria-label="运行概览"> <div className="admin-dash-bento">
<div className="admin-section-label"> <section className="admin-dash-traffic" aria-label="今日访问">
<span></span> <div className="admin-dash-traffic-top">
<div className="admin-dash-section-label">
<span className="admin-monitor-section-bar" aria-hidden />
<h2>访</h2>
</div>
<button
type="button"
className="admin-text-link admin-dash-link"
onClick={() => nav('/admin/monitor')}
>
<Activity size={14} aria-hidden />
</button>
</div>
{!traffic.enabled ? (
<div className="admin-dash-traffic-empty">
<p>访</p>
<button
type="button"
className="admin-text-link"
onClick={() => nav('/admin/monitor?tab=settings')}
>
</button>
</div>
) : (
<>
<div className="admin-dash-traffic-pv">{formatDashNum(traffic.today_pv)}</div>
<div className="admin-dash-traffic-meta">
<span>访 (PV)</span>
<span className="admin-dash-traffic-uv">访 (UV) {formatDashNum(traffic.today_uv)}</span>
</div>
<div className={cn('admin-dash-traffic-trend', `is-${tone}`)}>
{pvTrendLabel(traffic.today_pv, traffic.yesterday_pv)}
</div>
<p className="admin-dash-traffic-total">
访 {formatDashNum(traffic.total_pv)}
<span className="admin-dash-traffic-hint"></span>
</p>
</>
)}
</section>
<section className="admin-dash-queue" aria-label="待处理事项">
<div className="admin-dash-section-label admin-dash-queue-head">
<span className="admin-monitor-section-bar" aria-hidden />
<h2></h2>
{pendingTotal === 0 && (
<span className="admin-section-muted"></span>
)}
</div>
<div className="admin-dash-queue-grid">
{queues.map((q) => {
const Icon = q.icon;
const hasWork = q.count > 0;
return (
<button
key={q.key}
type="button"
className={cn('admin-dash-queue-card', hasWork && 'has-work')}
onClick={() => nav(q.to)}
>
<div className="admin-dash-queue-card-top">
<Icon size={16} aria-hidden />
<span className="admin-dash-queue-count">{q.count}</span>
</div>
<div className="admin-dash-queue-label">{q.label}</div>
<div className="admin-dash-queue-hint">{q.hint}</div>
</button>
);
})}
</div>
</section>
</div>
<section className="admin-dash-scale" aria-label="内容规模">
<div className="admin-dash-section-label">
<span className="admin-monitor-section-bar" aria-hidden />
<h2></h2>
</div> </div>
<div className="admin-stat-grid"> <div className="admin-dash-metric-grid">
{stats.map(s => ( {scale.map((s) => (
<div key={s.label} className="admin-stat-card"> <div key={s.label} className="admin-dash-metric-card">
<div className="admin-stat-value">{s.value}</div> <div className="admin-dash-metric-value">{formatDashNum(s.value)}</div>
<div className="admin-stat-label">{s.label}</div> <div className="admin-dash-metric-label">{s.label}</div>
</div> </div>
))} ))}
</div> </div>
@@ -148,7 +229,7 @@ export default function AdminDashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.recent_posts.map(p => ( {data.recent_posts.map((p) => (
<tr key={p.id}> <tr key={p.id}>
<td>{p.id}</td> <td>{p.id}</td>
<td> <td>

View File

@@ -178,9 +178,12 @@ export default function AdminLinksPage() {
aside_show_tag_cloud: false, aside_show_tag_cloud: false,
aside_show_recent_comments: false, aside_show_recent_comments: false,
aside_show_friend_links: true, aside_show_friend_links: true,
aside_show_showcase: false,
aside_widgets: loadedAsideWidgets, aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true, nav_show_friend_links: true,
footer_show_friend_links: true, footer_show_friend_links: true,
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title', feed_list_style: 'title',
permalink_enabled: false, permalink_enabled: false,
permalink_ext: 'html', permalink_ext: 'html',
@@ -350,14 +353,18 @@ export default function AdminLinksPage() {
aside_show_tag_cloud: false, aside_show_tag_cloud: false,
aside_show_recent_comments: false, aside_show_recent_comments: false,
aside_show_friend_links: nextFlags.aside, aside_show_friend_links: nextFlags.aside,
aside_show_showcase: false,
aside_widgets: normalizeAsideWidgets([ aside_widgets: normalizeAsideWidgets([
{ id: 'tag_cloud', enabled: false }, { id: 'tag_cloud', enabled: false },
{ id: 'recent_comments', enabled: false }, { id: 'recent_comments', enabled: false },
{ id: 'recent_users', enabled: false }, { id: 'recent_users', enabled: false },
{ id: 'friend_links', enabled: nextFlags.aside }, { id: 'friend_links', enabled: nextFlags.aside },
{ id: 'showcase', enabled: false },
]), ]),
nav_show_friend_links: nextFlags.nav, nav_show_friend_links: nextFlags.nav,
footer_show_friend_links: nextFlags.footer, footer_show_friend_links: nextFlags.footer,
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title', feed_list_style: 'title',
permalink_enabled: false, permalink_enabled: false,
permalink_ext: 'html', permalink_ext: 'html',

View File

@@ -0,0 +1,707 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Activity, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../../api/client';
import type {
MonitorASNItem,
MonitorCityItem,
MonitorConfig,
MonitorGeoResult,
MonitorLogItem,
MonitorOverview,
MonitorRealtime,
MonitorStatItem,
} from '../../api/types';
import { useAdminGuard } from '../../layouts/AdminLayout';
import { formatTime } from '../../utils/content';
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import MonitorWorldMap from '../../components/admin/MonitorWorldMap';
import MonitorChinaMap from '../../components/admin/MonitorChinaMap';
import { countryLabelZh } from '../../components/admin/monitorMapUtils';
type TabKey = 'overview' | 'stats' | 'logs' | 'settings';
type StatsDim = 'url' | 'referer' | 'browser' | 'os' | 'device' | 'status';
type StatsRange = '1d' | '7d' | '30d' | '90d';
type MapMode = 'world' | 'china';
type RankMode = 'city' | 'asn';
function countryLabel(code: string) {
return countryLabelZh(code);
}
function formatBytes(n: number) {
if (!n || n < 0) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i++;
}
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
}
function formatNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
return String(n ?? 0);
}
function MiniSparkline({ series }: { series: { count: number }[] }) {
const max = Math.max(1, ...series.map((p) => p.count));
const w = 240;
const h = 48;
const pts = series.map((p, i) => {
const x = series.length <= 1 ? 0 : (i / (series.length - 1)) * w;
const y = h - (p.count / max) * (h - 4) - 2;
return `${x},${y}`;
}).join(' ');
return (
<svg className="admin-monitor-spark" viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden>
<polyline fill="none" stroke="var(--j13-green)" strokeWidth="2" points={pts} />
</svg>
);
}
const CHINA_CODES = new Set(['CN', 'HK', 'TW', 'MO']);
function geoCountries(geo: MonitorGeoResult | null) {
return geo?.countries || [];
}
function GeoRankTable({
cities,
asns,
mode,
mapMode,
rankMode,
onRankMode,
}: {
cities: MonitorCityItem[];
asns: MonitorASNItem[];
mode: MapMode;
rankMode: RankMode;
onRankMode: (m: RankMode) => void;
}) {
const cityRows = mode === 'china'
? cities.filter((i) => CHINA_CODES.has((i.country || '').toUpperCase()))
: cities;
const rows = rankMode === 'city' ? cityRows : asns;
const emptyHint = rankMode === 'city'
? '放置 IP2Location DB3 BIN 后可见城市排行'
: '放置 GeoLite2-ASN.mmdb 后可见运营商排行';
return (
<div className="admin-monitor-rank">
<div className="admin-monitor-seg" role="group" aria-label="排行维度">
<button
type="button"
className={cn('admin-monitor-seg-btn', rankMode === 'city' && 'active')}
onClick={() => onRankMode('city')}
>
</button>
<button
type="button"
className={cn('admin-monitor-seg-btn', rankMode === 'asn' && 'active')}
onClick={() => onRankMode('asn')}
>
</button>
</div>
{rows.length === 0 ? (
<div className="admin-monitor-geo-empty">
<p></p>
<p className="admin-monitor-muted">{emptyHint}</p>
</div>
) : (
<table className="admin-monitor-geo-table">
<thead>
<tr>
<th>{rankMode === 'city' ? '城市' : '运营商'}</th>
<th></th>
</tr>
</thead>
<tbody>
{rankMode === 'city'
? cityRows.slice(0, 12).map((item, idx) => (
<tr key={`${item.country}-${item.city}-${idx}`}>
<td title={[item.region, item.city].filter(Boolean).join(' · ')}>
{item.city}
{item.region ? <span className="admin-monitor-muted"> · {item.region}</span> : null}
</td>
<td>{formatNum(item.count)}</td>
</tr>
))
: asns.slice(0, 12).map((item) => (
<tr key={item.asn}>
<td title={item.as_org || `AS${item.asn}`}>
{item.as_org || `AS${item.asn}`}
<span className="admin-monitor-muted"> · AS{item.asn}</span>
</td>
<td>{formatNum(item.count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function parseMonitorTab(raw: string | null): TabKey {
if (raw === 'stats' || raw === 'logs' || raw === 'settings' || raw === 'overview') {
return raw;
}
return 'overview';
}
export default function AdminMonitorPage() {
const { ready } = useAdminGuard();
const [searchParams, setSearchParams] = useSearchParams();
const [tab, setTab] = useState<TabKey>(() => parseMonitorTab(searchParams.get('tab')));
const [overview, setOverview] = useState<MonitorOverview | null>(null);
const [geo, setGeo] = useState<MonitorGeoResult | null>(null);
const [realtime, setRealtime] = useState<MonitorRealtime | null>(null);
const [mapMode, setMapMode] = useState<MapMode>('world');
const [rankMode, setRankMode] = useState<RankMode>('city');
const [loading, setLoading] = useState(true);
const [statsDim, setStatsDim] = useState<StatsDim>('url');
const [statsRange, setStatsRange] = useState<StatsRange>('30d');
const [statsItems, setStatsItems] = useState<MonitorStatItem[]>([]);
const [statsLoading, setStatsLoading] = useState(false);
const [logs, setLogs] = useState<MonitorLogItem[]>([]);
const [logsTotal, setLogsTotal] = useState(0);
const [logsPage, setLogsPage] = useState(1);
const [logMethod, setLogMethod] = useState('');
const [logPath, setLogPath] = useState('');
const [logStatus, setLogStatus] = useState('');
const [logIP, setLogIP] = useState('');
const [logsLoading, setLogsLoading] = useState(false);
const [settings, setSettings] = useState<MonitorConfig | null>(null);
const [excludeText, setExcludeText] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
const next = parseMonitorTab(searchParams.get('tab'));
setTab((prev) => (prev === next ? prev : next));
}, [searchParams]);
const selectTab = (key: TabKey) => {
setTab(key);
const next = new URLSearchParams(searchParams);
if (key === 'overview') next.delete('tab');
else next.set('tab', key);
setSearchParams(next, { replace: true });
};
const loadOverview = useCallback(async () => {
const [ov, g, rt] = await Promise.all([
api.adminMonitorOverview(),
api.adminMonitorGeo('30d'),
api.adminMonitorRealtime(),
]);
setOverview(ov);
setGeo(g);
setRealtime(rt);
}, []);
useEffect(() => {
if (!ready) return;
setLoading(true);
loadOverview()
.catch(() => notify.error('加载监控概览失败'))
.finally(() => setLoading(false));
}, [ready, loadOverview]);
// 概览页实时轮询
useEffect(() => {
if (!ready || tab !== 'overview') return;
const id = window.setInterval(() => {
api.adminMonitorRealtime().then(setRealtime).catch(() => {});
api.adminMonitorOverview().then(setOverview).catch(() => {});
}, 5000);
return () => window.clearInterval(id);
}, [ready, tab]);
useEffect(() => {
if (!ready || tab !== 'stats') return;
setStatsLoading(true);
api.adminMonitorStats(statsDim, statsRange)
.then((r) => setStatsItems(r.items || []))
.catch(() => notify.error('加载统计失败'))
.finally(() => setStatsLoading(false));
}, [ready, tab, statsDim, statsRange]);
const loadLogs = useCallback(async (page = 1) => {
setLogsLoading(true);
try {
const r = await api.adminMonitorLogs({
page,
size: 20,
method: logMethod || undefined,
path: logPath || undefined,
status: logStatus || undefined,
ip: logIP || undefined,
});
setLogs(r.items || []);
setLogsTotal(r.total || 0);
setLogsPage(r.page || page);
} catch {
notify.error('加载请求日志失败');
} finally {
setLogsLoading(false);
}
}, [logMethod, logPath, logStatus, logIP]);
useEffect(() => {
if (!ready || tab !== 'logs') return;
loadLogs(1);
}, [ready, tab, loadLogs]);
useEffect(() => {
if (!ready || tab !== 'settings') return;
api.adminMonitorSettings()
.then((cfg) => {
setSettings(cfg);
setExcludeText((cfg.exclude_rules || []).join('\n'));
})
.catch(() => notify.error('加载设置失败'));
}, [ready, tab]);
const enabled = overview?.enabled ?? settings?.enabled ?? false;
const metrics = useMemo(() => {
if (!overview) return [];
return [
{ label: '浏览量', value: formatNum(overview.pageviews) },
{ label: '访客数', value: formatNum(overview.visitors) },
{ label: '独立 IP', value: formatNum(overview.unique_ips) },
{ label: '流量', value: formatBytes(overview.traffic) },
{ label: '蜘蛛', value: formatNum(overview.bots) },
{ label: '请求数', value: formatNum(overview.requests) },
{ label: '4xx', value: formatNum(overview.status_4xx) },
{ label: '5xx', value: formatNum(overview.status_5xx) },
];
}, [overview]);
const saveSettings = async () => {
if (!settings) return;
setSaving(true);
try {
const rules = excludeText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
const r = await api.adminUpdateMonitorSettings({
...settings,
exclude_rules: rules,
});
setSettings(r.monitor);
setExcludeText((r.monitor.exclude_rules || []).join('\n'));
invalidateForumLimitsCache();
notify.success(r.message);
const ov = await api.adminMonitorOverview();
setOverview(ov);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
if (!ready || (loading && tab === 'overview')) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
const tabs: { key: TabKey; label: string }[] = [
{ key: 'overview', label: '概览' },
{ key: 'stats', label: '访问统计' },
{ key: 'logs', label: '请求日志' },
{ key: 'settings', label: '设置' },
];
const dims: { key: StatsDim; label: string }[] = [
{ key: 'url', label: 'URL' },
{ key: 'referer', label: '来源' },
{ key: 'browser', label: '浏览器' },
{ key: 'os', label: '系统' },
{ key: 'device', label: '设备' },
{ key: 'status', label: '状态码' },
];
const ranges: { key: StatsRange; label: string }[] = [
{ key: '1d', label: '今日' },
{ key: '7d', label: '7 日' },
{ key: '30d', label: '30 日' },
{ key: '90d', label: '90 日' },
];
return (
<div className="admin-page admin-monitor-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">
<Activity size={22} aria-hidden />
</h1>
<p className="admin-page-desc">
/访 pageview访 /api
</p>
</div>
</div>
<div className="admin-tabs" role="tablist" aria-label="监控分类">
{tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={cn('admin-tab', tab === t.key && 'active')}
onClick={() => selectTab(t.key)}
>
{t.label}
</button>
))}
</div>
{!enabled && tab !== 'settings' && (
<div className="admin-monitor-banner" role="status">
<AlertTriangle size={16} aria-hidden />
<span>访</span>
<button type="button" className="admin-text-link" onClick={() => selectTab('settings')}>
</button>
</div>
)}
{tab === 'overview' && overview && (
<>
<section className="admin-card admin-monitor-today" aria-label="今日状态">
<div className="admin-monitor-today-head">
<span className="admin-monitor-section-bar" aria-hidden />
<h2 className="admin-monitor-section-title"></h2>
</div>
<div className="admin-monitor-today-grid">
{metrics.map((m) => (
<div key={m.label} className="admin-monitor-today-item">
<div className="admin-monitor-today-label">{m.label}</div>
<div className="admin-monitor-today-value">{m.value}</div>
</div>
))}
</div>
</section>
<div className="admin-monitor-main">
<section className="admin-card admin-monitor-map-panel">
<div className="admin-card-head admin-monitor-map-head">
<div className="admin-monitor-today-head" style={{ marginBottom: 0 }}>
<span className="admin-monitor-section-bar" aria-hidden />
<h2 className="admin-monitor-section-title">访30 </h2>
</div>
<div className="admin-monitor-seg" role="group" aria-label="地图范围">
<button
type="button"
className={cn('admin-monitor-seg-btn', mapMode === 'world' && 'active')}
onClick={() => setMapMode('world')}
>
</button>
<button
type="button"
className={cn('admin-monitor-seg-btn', mapMode === 'china' && 'active')}
onClick={() => setMapMode('china')}
>
</button>
</div>
</div>
<div className="admin-card-body admin-monitor-map-body">
<div className="admin-monitor-map-visual">
{mapMode === 'world' ? (
<MonitorWorldMap items={geoCountries(geo)} />
) : (
<MonitorChinaMap regions={geo?.regions || []} />
)}
</div>
<div className="admin-monitor-map-side">
<GeoRankTable
cities={geo?.cities || []}
asns={geo?.asns || []}
mode={mapMode}
rankMode={rankMode}
onRankMode={setRankMode}
/>
</div>
</div>
</section>
<div className="admin-monitor-rt-stack">
<section className="admin-card admin-monitor-rt-card">
<div className="admin-monitor-today-head">
<span className="admin-monitor-section-bar" aria-hidden />
<h2 className="admin-monitor-section-title">1 </h2>
</div>
<div className="admin-monitor-rt-value">{formatNum(realtime?.requests_1m || 0)}</div>
</section>
<section className="admin-card admin-monitor-rt-card">
<div className="admin-monitor-today-head">
<span className="admin-monitor-section-bar" aria-hidden />
<h2 className="admin-monitor-section-title">1 </h2>
</div>
<div className="admin-monitor-rt-value">{formatBytes(realtime?.traffic_1m || 0)}</div>
<p className="admin-monitor-muted admin-monitor-rt-spark-label"> 1 </p>
<MiniSparkline series={realtime?.hourly_series || []} />
</section>
</div>
</div>
</>
)}
{tab === 'stats' && (
<div className="admin-card">
<div className="admin-card-head">
<div className="admin-tabs" style={{ marginBottom: 0 }}>
{dims.map((d) => (
<button
key={d.key}
type="button"
className={cn('admin-tab', statsDim === d.key && 'active')}
onClick={() => setStatsDim(d.key)}
>
{d.label}
</button>
))}
</div>
<div className="admin-tabs" style={{ marginBottom: 0 }}>
{ranges.map((r) => (
<button
key={r.key}
type="button"
className={cn('admin-tab', statsRange === r.key && 'active')}
onClick={() => setStatsRange(r.key)}
>
{r.label}
</button>
))}
</div>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{statsLoading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : statsItems.length === 0 ? (
<p className="admin-empty"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: 48 }}>#</th>
<th></th>
<th style={{ width: 100 }}></th>
</tr>
</thead>
<tbody>
{statsItems.map((item, idx) => (
<tr key={`${item.key}-${idx}`}>
<td>{idx + 1}</td>
<td className="admin-table-mono" title={item.key}>{item.key}</td>
<td>{formatNum(item.count)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{tab === 'logs' && (
<div className="admin-card">
<div className="admin-form-row admin-monitor-log-filters">
<Input value={logMethod} onChange={(e) => setLogMethod(e.target.value)} placeholder="方法 GET" aria-label="方法" style={{ width: 100 }} />
<Input value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder="路径包含…" aria-label="路径" />
<Input value={logStatus} onChange={(e) => setLogStatus(e.target.value)} placeholder="状态码" aria-label="状态码" style={{ width: 90 }} />
<Input value={logIP} onChange={(e) => setLogIP(e.target.value)} placeholder="IP 包含…" aria-label="IP" style={{ width: 140 }} />
<Button type="button" variant="outline" onClick={() => loadLogs(1)}></Button>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{logsLoading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : logs.length === 0 ? (
<p className="admin-empty"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th>IP</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{logs.map((row) => {
const geoBits = [
row.country ? countryLabel(row.country) : '',
row.city || '',
row.as_org || '',
].filter(Boolean);
return (
<tr key={row.id}>
<td>{formatTime(row.created_at)}</td>
<td>{row.method}</td>
<td className="admin-table-mono" title={row.path}>{row.path}</td>
<td>{row.status}</td>
<td className="admin-table-mono">
{row.ip || '—'}
{row.is_bot ? ' · bot' : ''}
{(row.city || row.as_org) ? (
<div className="admin-monitor-muted" style={{ fontSize: 12 }}>
{[row.city, row.as_org].filter(Boolean).join(' · ')}
</div>
) : null}
</td>
<td>{row.duration_ms}ms</td>
<td title={geoBits.join(' · ')}>{row.country ? countryLabel(row.country) : '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{logsTotal > 20 && (
<div className="admin-form-row" style={{ justifyContent: 'flex-end' }}>
<Button type="button" variant="outline" disabled={logsPage <= 1} onClick={() => loadLogs(logsPage - 1)}></Button>
<span className="admin-monitor-muted"> {logsPage} / {logsTotal} </span>
<Button type="button" variant="outline" disabled={logsPage * 20 >= logsTotal} onClick={() => loadLogs(logsPage + 1)}></Button>
</div>
)}
</div>
</div>
)}
{tab === 'settings' && settings && (
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<div className="admin-card-body admin-monitor-settings">
<label className="admin-monitor-switch-row">
<div>
<strong>访</strong>
<p className="admin-monitor-muted">
/访 API pageview
</p>
</div>
<Switch
checked={settings.enabled}
onCheckedChange={(v) => setSettings({ ...settings, enabled: v })}
/>
</label>
<label className="admin-monitor-field">
<span> monitor.db1365</span>
<Input
type="number"
min={1}
max={365}
value={settings.retention_days}
onChange={(e) => setSettings({ ...settings, retention_days: Number(e.target.value) || 30 })}
/>
</label>
<label className="admin-monitor-field">
<span>JSONL1365</span>
<Input
type="number"
min={1}
max={365}
value={settings.access_log_retention_days ?? 7}
onChange={(e) => setSettings({ ...settings, access_log_retention_days: Number(e.target.value) || 7 })}
/>
</label>
<label className="admin-monitor-switch-row">
<div>
<strong></strong>
<p className="admin-monitor-muted"> X-Forwarded-For / CF-Connecting-IP IP</p>
</div>
<Switch
checked={settings.trust_proxy}
onCheckedChange={(v) => setSettings({ ...settings, trust_proxy: v })}
/>
</label>
<label className="admin-monitor-field">
<span> .js</span>
<textarea
className="admin-monitor-textarea"
rows={8}
value={excludeText}
onChange={(e) => setExcludeText(e.target.value)}
/>
<Button
type="button"
variant="outline"
style={{ marginTop: 8 }}
onClick={() => setExcludeText((settings.default_exclude_rules || []).join('\n'))}
>
</Button>
</label>
<div className="admin-monitor-geo-note">
<strong>GeoIP</strong>
<p className="admin-monitor-muted">
// IP2Location DB3 BIN ASN GeoLite2-ASN.mmdb Docker
</p>
<ul className="admin-monitor-muted" style={{ margin: '8px 0 0', paddingLeft: 18 }}>
<li>
IPv4 BIN<code>{settings.ip2location_v4_path || 'data/IP2LOCATION-LITE-DB3.BIN'}</code>
{' · '}{settings.ip2location_v4_available ? '已加载' : '未检测到'}
</li>
<li>
IPv6 BIN<code>{settings.ip2location_v6_path || 'data/IP2LOCATION-LITE-DB3.IPV6.BIN'}</code>
{' · '}{settings.ip2location_v6_available ? '已加载' : '未检测到'}
</li>
<li>
ASN<code>{settings.geoip_asn_path || 'data/GeoLite2-ASN.mmdb'}</code>
{' · '}{settings.geoip_asn_available ? '已加载' : '未检测到'}
</li>
<li>
Country <code>{settings.geoip_country_path || 'data/GeoLite2-Country.mmdb'}</code>
{' · '}{settings.geoip_country_available ? '已加载' : '未检测到(可选)'}
</li>
</ul>
<p className="admin-monitor-muted" style={{ marginTop: 8 }}>
<code>{settings.access_log_dir || 'data/logs/access'}</code>
JSONL
</p>
<p className="admin-monitor-muted" style={{ marginTop: 8 }}>
CDN CF-IPCountry IP
</p>
</div>
<div style={{ marginTop: 16 }}>
<Button type="button" onClick={saveSettings} disabled={saving}>
{saving ? '保存中…' : '保存设置'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -298,9 +298,12 @@ export default function AdminSettingsPage() {
aside_show_tag_cloud: false, aside_show_tag_cloud: false,
aside_show_recent_comments: false, aside_show_recent_comments: false,
aside_show_friend_links: true, aside_show_friend_links: true,
aside_show_showcase: false,
aside_widgets: loadedAsideWidgets, aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true, nav_show_friend_links: true,
footer_show_friend_links: true, footer_show_friend_links: true,
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title', feed_list_style: 'title',
permalink_enabled: false, permalink_enabled: false,
permalink_ext: 'html', permalink_ext: 'html',
@@ -1676,7 +1679,7 @@ export default function AdminSettingsPage() {
<div className="admin-card admin-settings-card"> <div className="admin-card admin-settings-card">
<div className="admin-card-head"></div> <div className="admin-card-head"></div>
<div className="admin-card-body admin-settings-backup-body"> <div className="admin-card-body admin-settings-backup-body">
<p> SQLite 便</p> <p> SQLite 便 <code>monitor.db</code> </p>
<p className="admin-settings-backup-name"> <p className="admin-settings-backup-name">
<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code> <code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
</p> </p>

View File

@@ -7210,6 +7210,7 @@ a.waline-comment-author:hover {
.widget-card-icon--hot { color: #e74c3c; } .widget-card-icon--hot { color: #e74c3c; }
.widget-card-icon--notice { color: #3498db; } .widget-card-icon--notice { color: #3498db; }
.widget-card-icon--links { color: #2d6a4f; } .widget-card-icon--links { color: #2d6a4f; }
.widget-card-icon--showcase { color: #1d4ed8; }
.widget-card-icon--users { color: #8b5cf6; } .widget-card-icon--users { color: #8b5cf6; }
.widget-card-head--split { .widget-card-head--split {
@@ -11199,6 +11200,216 @@ button.profile-stat:hover strong {
.admin-page-head { margin-bottom: 20px; } .admin-page-head { margin-bottom: 20px; }
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; } .admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); } .admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
/* ========== 仪表盘 bento ========== */
.admin-dash-head-title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.admin-dash-head-title h1 { margin-bottom: 0; }
.admin-dash-section-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.admin-dash-section-label h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.admin-dash-bento {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 16px;
margin-bottom: 20px;
align-items: stretch;
}
.admin-dash-traffic {
border: 1px solid var(--j13-border);
border-radius: 12px;
background: hsl(var(--card));
padding: 18px 20px 20px;
display: flex;
flex-direction: column;
min-height: 220px;
}
.admin-dash-traffic-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.admin-dash-traffic-top .admin-dash-section-label { margin-bottom: 0; }
.admin-dash-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
white-space: nowrap;
}
.admin-dash-traffic-pv {
font-size: 48px;
font-weight: 700;
color: var(--j13-green);
font-variant-numeric: tabular-nums;
line-height: 1.1;
margin-top: 8px;
}
.admin-dash-traffic-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 8px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.admin-dash-traffic-uv {
color: hsl(var(--foreground));
font-weight: 500;
}
.admin-dash-traffic-trend {
margin-top: 10px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.admin-dash-traffic-trend.is-up { color: var(--j13-green); }
.admin-dash-traffic-trend.is-down { color: hsl(24 90% 42%); }
.admin-dash-traffic-total {
margin-top: auto;
padding-top: 16px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.admin-dash-traffic-hint { opacity: 0.85; margin-left: 4px; }
.admin-dash-traffic-empty {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
font-size: 13px;
color: hsl(var(--muted-foreground));
padding: 12px 0;
}
.admin-dash-queue {
border: 1px solid var(--j13-border);
border-radius: 12px;
background: hsl(var(--card));
padding: 18px 20px 20px;
display: flex;
flex-direction: column;
min-height: 220px;
}
.admin-dash-queue-head { margin-bottom: 12px; }
.admin-dash-queue-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
flex: 1;
}
.admin-dash-queue-card {
display: flex;
flex-direction: column;
text-align: left;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--j13-border);
background: hsl(var(--background));
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
}
.admin-dash-queue-card:hover {
border-color: color-mix(in srgb, var(--j13-green) 45%, var(--j13-border));
background: var(--j13-green-bg);
}
.admin-dash-queue-card.has-work {
border-color: hsl(24 80% 55% / 0.45);
background: hsl(24 90% 48% / 0.06);
}
.admin-dash-queue-card.has-work:hover {
border-color: hsl(24 80% 50%);
background: hsl(24 90% 48% / 0.1);
}
.admin-dash-queue-card-top {
display: flex;
align-items: center;
justify-content: space-between;
color: hsl(var(--muted-foreground));
margin-bottom: 6px;
}
.admin-dash-queue-count {
font-size: 22px;
font-weight: 700;
color: hsl(var(--foreground));
line-height: 1;
font-variant-numeric: tabular-nums;
}
.admin-dash-queue-card.has-work .admin-dash-queue-count {
color: hsl(24 90% 40%);
}
.dark .admin-dash-queue-card.has-work .admin-dash-queue-count {
color: hsl(24 95% 68%);
}
.admin-dash-queue-label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.admin-dash-queue-hint {
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-top: 2px;
}
.admin-dash-scale {
margin-bottom: 20px;
}
.admin-dash-metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.admin-dash-metric-card {
padding: 16px 18px;
border-radius: 10px;
border: 1px solid var(--j13-border);
background: hsl(var(--card));
}
.admin-dash-metric-value {
font-size: 24px;
font-weight: 700;
color: hsl(var(--foreground));
font-variant-numeric: tabular-nums;
line-height: 1.2;
margin-bottom: 6px;
}
.admin-dash-metric-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
@media (max-width: 960px) {
.admin-dash-bento {
grid-template-columns: 1fr;
}
.admin-dash-traffic-pv {
font-size: 40px;
}
}
@media (max-width: 640px) {
.admin-dash-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.admin-section-label { .admin-section-label {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
margin-bottom: 10px; font-size: 13px; font-weight: 600; margin-bottom: 10px; font-size: 13px; font-weight: 600;
@@ -15091,3 +15302,339 @@ button.post-poll__option,
margin: 0 4px; margin: 0 4px;
} }
/* ========== 网站监控 ========== */
.admin-monitor-banner {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--j13-green) 30%, var(--j13-border));
background: var(--j13-green-bg);
font-size: 13px;
color: var(--color-text-2);
}
.admin-monitor-section-bar {
display: inline-block;
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--j13-green);
flex-shrink: 0;
}
.admin-monitor-today-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.admin-monitor-section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.admin-monitor-today {
padding: 16px 20px 20px;
margin-bottom: 16px;
}
.admin-monitor-today-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 8px 12px;
}
.admin-monitor-today-item {
min-width: 0;
text-align: left;
padding: 4px 8px;
}
.admin-monitor-today-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-bottom: 6px;
}
.admin-monitor-today-value {
font-size: 22px;
font-weight: 700;
color: var(--j13-green);
font-variant-numeric: tabular-nums;
line-height: 1.2;
word-break: break-all;
}
.admin-monitor-main {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.75fr);
gap: 16px;
align-items: stretch;
}
.admin-monitor-map-panel .admin-card-head,
.admin-monitor-map-head {
gap: 12px;
flex-wrap: wrap;
}
.admin-monitor-map-body {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(160px, 0.7fr);
gap: 16px;
min-height: 320px;
padding-top: 12px;
}
.admin-monitor-map-visual {
position: relative;
min-height: 280px;
border-radius: 8px;
background: color-mix(in srgb, hsl(var(--muted)) 35%, hsl(var(--card)));
overflow: hidden;
}
.admin-monitor-map-side {
min-width: 0;
display: flex;
flex-direction: column;
max-height: 360px;
overflow: auto;
}
.admin-monitor-seg {
display: inline-flex;
border: 1px solid var(--j13-border);
border-radius: 8px;
overflow: hidden;
background: hsl(var(--background));
}
.admin-monitor-seg-btn {
height: 28px;
padding: 0 12px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.admin-monitor-seg-btn + .admin-monitor-seg-btn {
border-left: 1px solid var(--j13-border);
}
.admin-monitor-seg-btn.active {
background: var(--j13-green);
color: #fff;
font-weight: 600;
}
.admin-monitor-svg-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 280px;
}
.admin-monitor-svg-map {
display: block;
width: 100%;
height: 100%;
min-height: 280px;
}
.admin-monitor-svg-path {
cursor: default;
transition: fill 0.15s ease, opacity 0.15s ease;
}
.admin-monitor-svg-path.has-data {
cursor: pointer;
}
.admin-monitor-svg-path.has-data:hover {
opacity: 0.88;
filter: brightness(1.05);
}
.admin-monitor-map-tip {
position: absolute;
left: 12px;
bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 8px;
background: color-mix(in srgb, hsl(var(--card)) 92%, transparent);
border: 1px solid var(--j13-border);
font-size: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
pointer-events: none;
}
.admin-monitor-map-tip strong {
font-weight: 600;
}
.admin-monitor-map-tip span {
color: var(--j13-green);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.admin-monitor-map-empty-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
font-size: 13px;
color: hsl(var(--muted-foreground));
background: color-mix(in srgb, hsl(var(--card)) 55%, transparent);
pointer-events: none;
padding: 16px;
}
.admin-monitor-map-side .admin-monitor-seg {
margin-bottom: 10px;
}
.admin-monitor-rank .admin-monitor-geo-empty {
margin-top: 4px;
}
.admin-monitor-geo-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.admin-monitor-geo-table th,
.admin-monitor-geo-table td {
padding: 8px 10px;
text-align: left;
}
.admin-monitor-geo-table th:last-child,
.admin-monitor-geo-table td:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
.admin-monitor-geo-table thead th {
background: var(--j13-green);
color: #fff;
font-weight: 600;
font-size: 12px;
}
.admin-monitor-geo-table thead th:first-child {
border-radius: 6px 0 0 0;
}
.admin-monitor-geo-table thead th:last-child {
border-radius: 0 6px 0 0;
}
.admin-monitor-geo-table tbody tr:nth-child(even) {
background: var(--j13-green-bg);
}
.admin-monitor-geo-table tbody td {
border-bottom: 1px solid var(--j13-border);
color: hsl(var(--foreground));
}
.admin-monitor-geo-empty {
padding: 24px 8px;
text-align: center;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.admin-monitor-rt-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-monitor-rt-card {
padding: 16px 20px 20px;
flex: 1;
display: flex;
flex-direction: column;
}
.admin-monitor-rt-value {
font-size: 40px;
font-weight: 700;
color: var(--j13-green);
font-variant-numeric: tabular-nums;
line-height: 1.15;
margin: 8px 0 4px;
}
.admin-monitor-rt-spark-label {
margin: 16px 0 6px;
}
.admin-monitor-spark {
display: block;
max-width: 100%;
}
.admin-monitor-muted {
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.admin-monitor-log-filters {
border-bottom: 1px solid var(--j13-border);
}
.admin-monitor-settings {
display: flex;
flex-direction: column;
gap: 18px;
}
.admin-monitor-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.admin-monitor-field {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
}
.admin-monitor-textarea {
width: 100%;
min-height: 140px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--j13-border);
background: hsl(var(--background));
color: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
resize: vertical;
}
.admin-monitor-geo-note code {
display: inline-block;
margin: 0 4px;
padding: 1px 6px;
border-radius: 4px;
background: var(--j13-green-bg);
font-size: 12px;
}
@media (max-width: 1100px) {
.admin-monitor-today-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.admin-monitor-main,
.admin-monitor-map-body {
grid-template-columns: 1fr;
}
.admin-monitor-rt-value {
font-size: 32px;
}
}
@media (max-width: 640px) {
.admin-monitor-today-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.dark .admin-monitor-today-value,
.dark .admin-monitor-rt-value {
color: var(--j13-green);
}
.dark .admin-monitor-map-visual {
background: color-mix(in srgb, hsl(var(--muted)) 40%, hsl(var(--card)));
}
.dark .admin-monitor-geo-table thead th {
background: var(--j13-green);
color: #fff;
}

17
frontend/src/types/svg-maps.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
declare module '@svg-maps/world' {
const map: {
label: string;
viewBox: string;
locations: Array<{ id: string; name: string; path: string }>;
};
export default map;
}
declare module '@svg-maps/china' {
const map: {
label: string;
viewBox: string;
locations: Array<{ id: string; name: string; path: string }>;
};
export default map;
}

View File

@@ -1,19 +1,29 @@
import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types'; import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../api/types'; import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
const ASIDE_WIDGET_IDS: AsideWidgetId[] = ['tag_cloud', 'recent_comments', 'recent_users', 'friend_links']; const ASIDE_WIDGET_IDS: AsideWidgetId[] = [
'tag_cloud',
'recent_comments',
'recent_users',
'friend_links',
'showcase',
];
/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */ /** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
export function resolveAsideWidgets( export function resolveAsideWidgets(
limits: Pick<ForumLimitsPublic, 'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'>, limits: Partial<Pick<
ForumLimitsPublic,
'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
>>,
): AsideWidget[] { ): AsideWidget[] {
if (limits.aside_widgets?.length) { if (limits.aside_widgets?.length) {
return normalizeAsideWidgets(limits.aside_widgets); return normalizeAsideWidgets(limits.aside_widgets);
} }
return [ return [
{ id: 'tag_cloud', enabled: limits.aside_show_tag_cloud }, { id: 'tag_cloud', enabled: !!limits.aside_show_tag_cloud },
{ id: 'recent_comments', enabled: limits.aside_show_recent_comments }, { id: 'recent_comments', enabled: !!limits.aside_show_recent_comments },
{ id: 'friend_links', enabled: limits.aside_show_friend_links }, { id: 'friend_links', enabled: limits.aside_show_friend_links !== false },
{ id: 'showcase', enabled: !!limits.aside_show_showcase },
]; ];
} }
@@ -33,12 +43,16 @@ export function normalizeAsideWidgets(widgets: AsideWidget[]): AsideWidget[] {
} }
/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */ /** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<ForumLimits, 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'> { export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<
ForumLimits,
'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
> {
const normalized = normalizeAsideWidgets(widgets); const normalized = normalizeAsideWidgets(widgets);
return { return {
aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false, aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false, aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true, aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
aside_show_showcase: normalized.find(w => w.id === 'showcase')?.enabled ?? false,
}; };
} }
@@ -64,6 +78,7 @@ export function isAsideWidgetEnabled(widgets: AsideWidget[], id: AsideWidgetId):
aside_show_tag_cloud: false, aside_show_tag_cloud: false,
aside_show_recent_comments: false, aside_show_recent_comments: false,
aside_show_friend_links: false, aside_show_friend_links: false,
aside_show_showcase: false,
}).find(w => w.id === id)?.enabled ?? false; }).find(w => w.id === id)?.enabled ?? false;
} }

3
go.mod
View File

@@ -8,9 +8,11 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/ip2location/ip2location-go/v9 v9.8.0
github.com/kardianos/service v1.2.2 github.com/kardianos/service v1.2.2
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/minio/minio-go/v7 v7.0.98 github.com/minio/minio-go/v7 v7.0.98
github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
golang.org/x/image v0.44.0 golang.org/x/image v0.44.0
gopkg.in/ini.v1 v1.67.3 gopkg.in/ini.v1 v1.67.3
@@ -59,6 +61,7 @@ require (
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/libc v1.22.5 // indirect modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect

6
go.sum
View File

@@ -48,6 +48,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -82,6 +84,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -142,6 +146,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=

View File

@@ -158,6 +158,12 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
if recentPosts == nil { if recentPosts == nil {
recentPosts = []model.Post{} recentPosts = []model.Post{}
} }
traffic := service.DashboardTraffic{}
if h.Monitor != nil {
traffic = h.Monitor.DashboardTraffic()
} else {
traffic.Enabled = h.Settings.MonitorEnabled()
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount, "users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount, "comments": commentCount,
@@ -166,6 +172,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
"pending_reports": pendingReports, "pending_reports": pendingReports,
"pending_friend_links": pendingFriendLinks, "pending_friend_links": pendingFriendLinks,
"recent_posts": recentPosts, "recent_posts": recentPosts,
"traffic": traffic,
}) })
} }
@@ -574,7 +581,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
"gitea": h.Settings.GiteaSyncConfigPublic(), "gitea": h.Settings.GiteaSyncConfigPublic(),
"storage": h.Settings.StorageConfigPublic(), "storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(), "branding": h.Settings.SiteBranding(),
"community": h.Settings.CommunityConfig(), "community": h.Settings.CommunityConfigForRequest(communityRequestOrigin(c)),
"filter_words": filterContent, "filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent), "filter_word_count": service.CountFilterWords(filterContent),
}) })

View File

@@ -22,7 +22,7 @@ func (h *Handlers) APICommunityHeartbeat(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
if err := h.Community.ReceiveHeartbeat(req, c.ClientIP()); err != nil { if err := h.Community.ReceiveHeartbeat(req, c.ClientIP(), requestOrigin(c)); err != nil {
if errors.Is(err, service.ErrCommunityHubDisabled) { if errors.Is(err, service.ErrCommunityHubDisabled) {
c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"}) c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"})
return return
@@ -43,7 +43,7 @@ func (h *Handlers) APICommunityShowcase(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": []any{}}) c.JSON(http.StatusOK, gin.H{"items": []any{}})
return return
} }
list, err := h.Community.ListShowcase() list, err := h.Community.ListShowcase(requestOrigin(c))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
return return
@@ -57,7 +57,7 @@ func (h *Handlers) APIAdminCommunityInstances(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false}) c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false})
return return
} }
cfg := h.Settings.CommunityConfig() cfg := h.Settings.CommunityConfigForRequest(communityRequestOrigin(c))
list, err := h.Community.ListInstances() list, err := h.Community.ListInstances()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
@@ -108,23 +108,64 @@ func (h *Handlers) APIAdminUpdateCommunitySettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
cfg := h.Settings.CommunityConfig() origin := communityRequestOrigin(c)
cfg := h.Settings.CommunityConfigForRequest(origin)
out := gin.H{ out := gin.H{
"message": "社区设置已保存", "message": "社区设置已保存",
"community": cfg, "community": cfg,
} }
if cfg.ReportEnabled && h.Community != nil { if cfg.ReportEnabled && !cfg.HubEnabled && h.Community != nil {
origin := communityRequestOrigin(c)
if err := h.Community.SendHeartbeatOnce(origin); err != nil { if err := h.Community.SendHeartbeatOnce(origin); err != nil {
out["message"] = "社区设置已保存,但心跳未成功" out["message"] = "社区设置已保存,但心跳未成功"
out["heartbeat_error"] = err.Error() out["heartbeat_error"] = err.Error()
// 刷新 site_url可能已由 Origin 持久化) // 刷新 site_url可能已由 Origin 持久化)
out["community"] = h.Settings.CommunityConfig() out["community"] = h.Settings.CommunityConfigForRequest(origin)
} }
} }
c.JSON(http.StatusOK, out) c.JSON(http.StatusOK, out)
} }
// APIAdminUpdateShowcaseEntry 更新开源展柜入口展示位置(左栏 / 右栏 / 页脚)
func (h *Handlers) APIAdminUpdateShowcaseEntry(c *gin.Context) {
var req struct {
NavShowShowcase *bool `json:"nav_show_showcase"`
FooterShowShowcase *bool `json:"footer_show_showcase"`
AsideShowShowcase *bool `json:"aside_show_showcase"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if req.NavShowShowcase == nil && req.FooterShowShowcase == nil && req.AsideShowShowcase == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if req.NavShowShowcase != nil {
if err := h.Settings.SetNavShowShowcase(*req.NavShowShowcase); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if req.FooterShowShowcase != nil {
if err := h.Settings.SetFooterShowShowcase(*req.FooterShowShowcase); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if req.AsideShowShowcase != nil {
if err := h.Settings.SetAsideShowcaseEnabled(*req.AsideShowShowcase); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{
"message": "展柜入口已保存",
"nav_show_showcase": h.Settings.NavShowShowcase(),
"footer_show_showcase": h.Settings.FooterShowShowcase(),
"aside_show_showcase": h.Settings.AsideShowShowcase(),
})
}
// communityRequestOrigin 优先用浏览器 OriginVite 代理时 Host 可能是后端端口) // communityRequestOrigin 优先用浏览器 OriginVite 代理时 Host 可能是后端端口)
func communityRequestOrigin(c *gin.Context) string { func communityRequestOrigin(c *gin.Context) string {
if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" { if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" {

View File

@@ -29,6 +29,7 @@ type Handlers struct {
Report *service.ReportService Report *service.ReportService
Backup *service.BackupService Backup *service.BackupService
Community *service.CommunityService Community *service.CommunityService
Monitor *service.MonitorService
Filter *service.SensitiveFilter Filter *service.SensitiveFilter
Limiter *service.RateLimiter Limiter *service.RateLimiter
Settings *service.ForumSettingsService Settings *service.ForumSettingsService

108
handler/monitor.go Normal file
View File

@@ -0,0 +1,108 @@
package handler
import (
"net/http"
"strconv"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
)
// APIAdminMonitorOverview 今日概览
func (h *Handlers) APIAdminMonitorOverview(c *gin.Context) {
if h.Monitor == nil {
c.JSON(http.StatusOK, service.MonitorOverview{})
return
}
c.JSON(http.StatusOK, h.Monitor.OverviewToday())
}
// APIAdminMonitorGeo 地理分布
func (h *Handlers) APIAdminMonitorGeo(c *gin.Context) {
if h.Monitor == nil {
c.JSON(http.StatusOK, service.MonitorGeoResult{
Countries: []service.MonitorGeoItem{},
Regions: []service.MonitorRegionItem{},
Cities: []service.MonitorCityItem{},
ASNs: []service.MonitorASNItem{},
})
return
}
c.JSON(http.StatusOK, h.Monitor.GeoStats(c.Query("range")))
}
// APIAdminMonitorStats 维度排行
func (h *Handlers) APIAdminMonitorStats(c *gin.Context) {
if h.Monitor == nil {
c.JSON(http.StatusOK, gin.H{"items": []service.MonitorStatItem{}})
return
}
items := h.Monitor.DimStats(c.Query("dim"), c.Query("range"))
c.JSON(http.StatusOK, gin.H{"dim": c.Query("dim"), "range": c.Query("range"), "items": items})
}
// APIAdminMonitorLogs 请求日志
func (h *Handlers) APIAdminMonitorLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if h.Monitor == nil {
c.JSON(http.StatusOK, gin.H{"items": []service.MonitorLogItem{}, "total": 0, "page": page, "size": size})
return
}
items, total := h.Monitor.ListLogs(page, size, c.Query("method"), c.Query("path"), c.Query("status"), c.Query("ip"))
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "size": size})
}
// APIAdminMonitorRealtime 实时
func (h *Handlers) APIAdminMonitorRealtime(c *gin.Context) {
if h.Monitor == nil {
c.JSON(http.StatusOK, service.MonitorRealtime{HourlySeries: []service.MonitorRealtimePoint{}})
return
}
c.JSON(http.StatusOK, h.Monitor.Realtime())
}
// APIAdminGetMonitorSettings 读取监控设置
func (h *Handlers) APIAdminGetMonitorSettings(c *gin.Context) {
cfg := h.Settings.MonitorConfig()
if h.Monitor != nil {
h.Monitor.EnrichGeoMeta(&cfg)
}
c.JSON(http.StatusOK, cfg)
}
// APIAdminUpdateMonitorSettings 更新监控设置
func (h *Handlers) APIAdminUpdateMonitorSettings(c *gin.Context) {
var req service.MonitorConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateMonitorConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg := h.Settings.MonitorConfig()
if h.Monitor != nil {
h.Monitor.EnrichGeoMeta(&cfg)
}
c.JSON(http.StatusOK, gin.H{"message": "监控设置已保存", "monitor": cfg})
}
// APIMonitorPageview 前台 SPA 路由 pageview 信标(公开,受 monitor_enabled 控制)
func (h *Handlers) APIMonitorPageview(c *gin.Context) {
if h.Monitor == nil || !h.Settings.MonitorEnabled() {
c.Status(http.StatusNoContent)
return
}
var req service.PageViewInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Monitor.RecordPageView(c.Request, c.Request.RemoteAddr, req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
return
}
c.Status(http.StatusNoContent)
}

33
middleware/accesslog.go Normal file
View File

@@ -0,0 +1,33 @@
package middleware
import (
"time"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
)
// AccessLogMiddleware 服务端访问日志采集(受 monitor_enabled 控制;热路径仅入队,后台写 jsonl
func AccessLogMiddleware(mon *service.MonitorService) gin.HandlerFunc {
return func(c *gin.Context) {
if mon == nil || !mon.Enabled() {
c.Next()
return
}
path := c.Request.URL.Path
if mon.ShouldSkip(path) {
c.Next()
return
}
start := time.Now()
c.Next()
status := c.Writer.Status()
bytes := int64(c.Writer.Size())
if bytes < 0 {
bytes = 0
}
dur := int(time.Since(start).Milliseconds())
row := mon.BuildAccessLog(c.Request, c.Request.RemoteAddr, status, bytes, dur)
mon.Enqueue(row)
}
}

View File

@@ -13,25 +13,36 @@ import (
var DB *gorm.DB var DB *gorm.DB
// InitDB 初始化 SQLite 并自动迁移 // MonitorDB 网站监控独立库page_views与主库 jiang13.db 分离以免撑大备份
func InitDB(dbPath string) error { var MonitorDB *gorm.DB
func openSQLite(dbPath string) (*gorm.DB, error) {
dir := filepath.Dir(dbPath) dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建数据库目录失败: %w", err) return nil, fmt.Errorf("创建数据库目录失败: %w", err)
} }
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn), Logger: logger.Default.LogMode(logger.Warn),
}) })
if err != nil { if err != nil {
return fmt.Errorf("连接 SQLite 失败: %w", err) return nil, fmt.Errorf("连接 SQLite 失败: %w", err)
} }
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return err return nil, err
} }
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
return db, nil
}
// InitDB 初始化主库 SQLite 并自动迁移(不含 page_views
func InitDB(dbPath string) error {
db, err := openSQLite(dbPath)
if err != nil {
return err
}
if err := db.AutoMigrate( if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &User{}, &Board{}, &Post{}, &Comment{},
@@ -60,6 +71,20 @@ func InitDB(dbPath string) error {
return nil return nil
} }
// InitMonitorDB 打开独立监控库page_views 只写此处)
func InitMonitorDB(monitorDBPath string) error {
db, err := openSQLite(monitorDBPath)
if err != nil {
return fmt.Errorf("监控库初始化失败: %w", err)
}
if err := db.AutoMigrate(&PageView{}); err != nil {
return fmt.Errorf("监控库迁移失败: %w", err)
}
MonitorDB = db
log.Println("[model] 监控库初始化完成:", monitorDBPath)
return nil
}
// PingDB 检测数据库连接是否可用(供健康检查使用) // PingDB 检测数据库连接是否可用(供健康检查使用)
func PingDB() error { func PingDB() error {
if DB == nil { if DB == nil {

View File

@@ -481,3 +481,20 @@ type CommunityInstance struct {
FirstSeenAt time.Time `json:"first_seen_at"` FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `gorm:"index" json:"last_seen_at"` LastSeenAt time.Time `gorm:"index" json:"last_seen_at"`
} }
// PageView 前台路由浏览量(第一方 SPA 信标;存独立 monitor.db请求日志走 jsonl
type PageView struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `gorm:"index;not null" json:"created_at"`
Path string `gorm:"size:512;index" json:"path"`
Referrer string `gorm:"size:512" json:"referrer"`
IP string `gorm:"size:64;index" json:"ip"` // 完整客户端 IP
UA string `gorm:"size:512" json:"ua"`
Country string `gorm:"size:8;index" json:"country"`
Region string `gorm:"size:64" json:"region"`
RegionISO string `gorm:"size:16;index" json:"region_iso"`
City string `gorm:"size:64;index" json:"city"`
ASN uint `gorm:"index" json:"asn"`
ASOrg string `gorm:"size:128" json:"as_org"`
IsBot bool `gorm:"default:false;index" json:"is_bot"`
}

54
model/monitor_db_test.go Normal file
View File

@@ -0,0 +1,54 @@
package model
import (
"path/filepath"
"testing"
"gorm.io/gorm"
)
func TestInitMonitorDBUsesSeparateFile(t *testing.T) {
dir := t.TempDir()
mainPath := filepath.Join(dir, "jiang13.db")
monPath := filepath.Join(dir, "monitor.db")
prevDB, prevMon := DB, MonitorDB
t.Cleanup(func() {
closeGorm(MonitorDB)
closeGorm(DB)
DB, MonitorDB = prevDB, prevMon
})
if err := InitDB(mainPath); err != nil {
t.Fatal(err)
}
if err := InitMonitorDB(monPath); err != nil {
t.Fatal(err)
}
if tableExists(DB, "page_views") {
t.Fatal("主库不应创建 page_views")
}
if !tableExists(MonitorDB, "page_views") {
t.Fatal("监控库缺少 page_views")
}
}
func tableExists(db *gorm.DB, name string) bool {
if db == nil {
return false
}
var n int
_ = db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n).Error
return n > 0
}
func closeGorm(db *gorm.DB) {
if db == nil {
return
}
sqlDB, err := db.DB()
if err != nil {
return
}
_ = sqlDB.Close()
}

View File

@@ -21,6 +21,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(gin.Logger()) r.Use(gin.Logger())
filter := service.NewSensitiveFilter()
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := service.NewForumSettingsService()
settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub)
monitorSvc := service.NewMonitorService(settingsSvc, cfg.DataDir, cfg.JWTSecret)
monitorSvc.StartBackground()
r.Use(middleware.AccessLogMiddleware(monitorSvc))
// dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供 // dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供
// 用户应访问 5173 端口Vite 通过 proxy 将 /api 等请求转发到本服务(:3000) // 用户应访问 5173 端口Vite 通过 proxy 将 /api 等请求转发到本服务(:3000)
if !cfg.DevMode { if !cfg.DevMode {
@@ -31,12 +41,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API前端请访问 http://localhost:5173\n") fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API前端请访问 http://localhost:5173\n")
} }
filter := service.NewSensitiveFilter()
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := service.NewForumSettingsService()
settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub)
communitySvc := service.NewCommunityService(settingsSvc) communitySvc := service.NewCommunityService(settingsSvc)
communitySvc.StartBackground() communitySvc.StartBackground()
if cfg.CommunityHub { if cfg.CommunityHub {
@@ -93,7 +97,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
h := &handler.Handlers{ h := &handler.Handlers{
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
Backup: backupSvc, Community: communitySvc, Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
Filter: filter, Limiter: limiter, Settings: settingsSvc, Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc, OIDC: oidcSvc, Gitea: giteaSvc,
@@ -132,6 +136,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/stats", h.APIStats) pubAPI.GET("/stats", h.APIStats)
pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat) pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat)
pubAPI.GET("/community/showcase", h.APICommunityShowcase) pubAPI.GET("/community/showcase", h.APICommunityShowcase)
pubAPI.POST("/monitor/pageview", middleware.RateLimitMiddleware(limiter, "monitor_pageview"), h.APIMonitorPageview)
pubAPI.GET("/forum-limits", h.APIForumLimits) pubAPI.GET("/forum-limits", h.APIForumLimits)
pubAPI.GET("/site-branding", h.APISiteBranding) pubAPI.GET("/site-branding", h.APISiteBranding)
pubAPI.GET("/pages", h.APIPages) pubAPI.GET("/pages", h.APIPages)
@@ -214,8 +219,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/settings", h.APIAdminSettings) adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings) adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
adminAPI.PUT("/settings/community", h.APIAdminUpdateCommunitySettings) adminAPI.PUT("/settings/community", h.APIAdminUpdateCommunitySettings)
adminAPI.GET("/settings/monitor", h.APIAdminGetMonitorSettings)
adminAPI.PUT("/settings/monitor", h.APIAdminUpdateMonitorSettings)
adminAPI.GET("/monitor/overview", h.APIAdminMonitorOverview)
adminAPI.GET("/monitor/geo", h.APIAdminMonitorGeo)
adminAPI.GET("/monitor/stats", h.APIAdminMonitorStats)
adminAPI.GET("/monitor/logs", h.APIAdminMonitorLogs)
adminAPI.GET("/monitor/realtime", h.APIAdminMonitorRealtime)
adminAPI.GET("/community/instances", h.APIAdminCommunityInstances) adminAPI.GET("/community/instances", h.APIAdminCommunityInstances)
adminAPI.PUT("/community/instances/:id/feature", h.APIAdminFeatureCommunityInstance) adminAPI.PUT("/community/instances/:id/feature", h.APIAdminFeatureCommunityInstance)
adminAPI.PUT("/community/showcase-entry", h.APIAdminUpdateShowcaseEntry)
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings) adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail) adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings) adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
@@ -293,7 +306,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin()) adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{ {
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") }) adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
for _, page := range []string{"dashboard", "boards", "pages", "links", "posts", "comments", "reports", "users", "badges", "media", "settings"} { for _, page := range []string{
"dashboard", "boards", "pages", "links", "community", "posts",
"comments", "reports", "users", "badges", "media", "monitor", "settings",
} {
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex) adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
} }
} }

View File

@@ -9,16 +9,16 @@ func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
{ID: AsideWidgetRecentComments, Enabled: false}, {ID: AsideWidgetRecentComments, Enabled: false},
} }
out := NormalizeAsideWidgets(in) out := NormalizeAsideWidgets(in)
if len(out) != 4 { if len(out) != 5 {
t.Fatalf("want 4 widgets, got %d", len(out)) 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 { for i, id := range want {
if out[i].ID != id { if out[i].ID != id {
t.Fatalf("index %d: want %s, got %s", i, id, out[i].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) 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} return &BackupService{dbPath: dbPath, dataDir: dataDir}
} }
// ExportSQLite 导出 SQLite 备份文件到 data 目录 // ExportSQLite 导出主库 SQLite 备份到 data 目录(不含 monitor.db
func (s *BackupService) ExportSQLite() (string, error) { func (s *BackupService) ExportSQLite() (string, error) {
src, err := os.Open(s.dbPath) src, err := os.Open(s.dbPath)
if err != nil { if err != nil {

View File

@@ -162,6 +162,10 @@ func (c *CommunityService) trySendHeartbeat() {
// SendHeartbeatOnce 立即发送一次心跳requestOrigin 可在管理端保存时传入以补全本站地址 // SendHeartbeatOnce 立即发送一次心跳requestOrigin 可在管理端保存时传入以补全本站地址
func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error { func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error {
// 枢纽站(含官网)不出站上报,避免自己心跳给自己
if c.settings.CommunityHubEnabled(requestOrigin) {
return nil
}
cfg := c.settings.CommunityConfig() cfg := c.settings.CommunityConfig()
if !cfg.ReportEnabled { if !cfg.ReportEnabled {
return nil return nil
@@ -228,9 +232,9 @@ func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbe
}, nil }, nil
} }
// ReceiveHeartbeat 枢纽接收心跳并 upsert // ReceiveHeartbeat 枢纽接收心跳并 upsertrequestHint 用于按请求 Host 识别官网
func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP string) error { func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP, requestHint string) error {
if !c.settings.CommunityConfig().HubEnabled { if !c.settings.CommunityHubEnabled(requestHint) {
return ErrCommunityHubDisabled return ErrCommunityHubDisabled
} }
in.InstanceID = strings.TrimSpace(in.InstanceID) in.InstanceID = strings.TrimSpace(in.InstanceID)
@@ -343,9 +347,9 @@ func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFe
}, nil }, nil
} }
// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空 // ListShowcase 公开展柜:仅精选;枢纽关闭时返回空requestHint 用于按 Host 识别官网
func (c *CommunityService) ListShowcase() ([]CommunityShowcaseItem, error) { func (c *CommunityService) ListShowcase(requestHint string) ([]CommunityShowcaseItem, error) {
if !c.settings.CommunityConfig().HubEnabled { if !c.settings.CommunityHubEnabled(requestHint) {
return []CommunityShowcaseItem{}, nil return []CommunityShowcaseItem{}, nil
} }
var rows []model.CommunityInstance var rows []model.CommunityInstance

View File

@@ -47,7 +47,7 @@ func TestCommunityHeartbeatHubDisabled(t *testing.T) {
Version: "1.0.0", Version: "1.0.0",
Users: 1, Users: 1,
Posts: 2, Posts: 2,
}, "127.0.0.1") }, "127.0.0.1", "https://other.example")
if !errors.Is(err, ErrCommunityHubDisabled) { if !errors.Is(err, ErrCommunityHubDisabled) {
t.Fatalf("want ErrCommunityHubDisabled, got %v", err) t.Fatalf("want ErrCommunityHubDisabled, got %v", err)
} }
@@ -65,12 +65,12 @@ func TestCommunityHeartbeatAcceptAndList(t *testing.T) {
Users: 10, Users: 10,
Posts: 20, 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) t.Fatal(err)
} }
payload.Users = 11 payload.Users = 11
payload.Posts = 21 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) t.Fatal(err)
} }
@@ -129,7 +129,7 @@ func TestCommunityHeartbeatBadURL(t *testing.T) {
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
SiteURL: "javascript:alert(1)", SiteURL: "javascript:alert(1)",
SiteName: "坏", SiteName: "坏",
}, "127.0.0.1") }, "127.0.0.1", "")
if !errors.Is(err, ErrCommunityBadPayload) { if !errors.Is(err, ErrCommunityBadPayload) {
t.Fatalf("want ErrCommunityBadPayload, got %v", err) t.Fatalf("want ErrCommunityBadPayload, got %v", err)
} }
@@ -204,11 +204,11 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
SiteName: "示例论坛", SiteName: "示例论坛",
Version: "2.0.0", 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) t.Fatal(err)
} }
empty, err := svc.ListShowcase() empty, err := svc.ListShowcase("")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -227,7 +227,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
t.Fatalf("unexpected view: %+v", view) t.Fatalf("unexpected view: %+v", view)
} }
items, err := svc.ListShowcase() items, err := svc.ListShowcase("")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -237,10 +237,10 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
// 心跳更新不得清掉精选 // 心跳更新不得清掉精选
payload.Users = 9 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) t.Fatal(err)
} }
items, err = svc.ListShowcase() items, err = svc.ListShowcase("")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -249,7 +249,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
} }
settings.SetCommunityHubEnabled(false) settings.SetCommunityHubEnabled(false)
items, err = svc.ListShowcase() items, err = svc.ListShowcase("")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -281,3 +281,154 @@ func TestCommunitySiteURLFromOrigin(t *testing.T) {
t.Fatalf("payload site_url=%s", payload.SiteURL) 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" { if action == "community_heartbeat" {
return 30 return 30
} }
if action == "monitor_pageview" {
return 120 // SPA 路由切换较频繁
}
return r.settings.RateLimitFor(action) return r.settings.RateLimitFor(action)
} }
@@ -67,6 +70,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
if action == "community_heartbeat" { if action == "community_heartbeat" {
return time.Hour return time.Hour
} }
if action == "monitor_pageview" {
return time.Minute
}
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
} }

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"net"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
@@ -42,9 +43,12 @@ const (
SettingAsideShowTagCloud = "aside_show_tag_cloud" SettingAsideShowTagCloud = "aside_show_tag_cloud"
SettingAsideShowRecentComments = "aside_show_recent_comments" SettingAsideShowRecentComments = "aside_show_recent_comments"
SettingAsideShowFriendLinks = "aside_show_friend_links" SettingAsideShowFriendLinks = "aside_show_friend_links"
SettingAsideShowShowcase = "aside_show_showcase"
SettingAsideWidgets = "aside_widgets" SettingAsideWidgets = "aside_widgets"
SettingNavShowFriendLinks = "nav_show_friend_links" SettingNavShowFriendLinks = "nav_show_friend_links"
SettingFooterShowFriendLinks = "footer_show_friend_links" SettingFooterShowFriendLinks = "footer_show_friend_links"
SettingNavShowShowcase = "nav_show_showcase"
SettingFooterShowShowcase = "footer_show_showcase"
SettingFeedListStyle = "feed_list_style" SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt // 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt
@@ -99,9 +103,18 @@ const (
SettingCommunityHubURL = "community_hub_url" SettingCommunityHubURL = "community_hub_url"
SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_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 官方演示站(社区枢纽默认地址)
DefaultCommunityHubURL = "https://bbs.iioio.com" 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 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100 pageSizeAPIMax = 100
) )
@@ -138,9 +151,12 @@ type ForumLimits struct {
AsideShowTagCloud bool `json:"aside_show_tag_cloud"` AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"` AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"` AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideShowShowcase bool `json:"aside_show_showcase"`
AsideWidgets []AsideWidget `json:"aside_widgets"` AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"` NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_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"` FeedListStyle string `json:"feed_list_style"`
@@ -159,6 +175,7 @@ const (
AsideWidgetRecentComments = "recent_comments" AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users" AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links" AsideWidgetFriendLinks = "friend_links"
AsideWidgetShowcase = "showcase"
) )
var asideWidgetDefaultOrder = []string{ var asideWidgetDefaultOrder = []string{
@@ -166,6 +183,7 @@ var asideWidgetDefaultOrder = []string{
AsideWidgetRecentComments, AsideWidgetRecentComments,
AsideWidgetRecentUsers, AsideWidgetRecentUsers,
AsideWidgetFriendLinks, AsideWidgetFriendLinks,
AsideWidgetShowcase,
} }
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置) // ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
@@ -189,14 +207,20 @@ type ForumLimitsPublic struct {
AsideShowTagCloud bool `json:"aside_show_tag_cloud"` AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"` AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"` AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideShowShowcase bool `json:"aside_show_showcase"`
AsideWidgets []AsideWidget `json:"aside_widgets"` AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"` NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_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"` FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"` PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"` PermalinkExt string `json:"permalink_ext"`
// MonitorPageview 是否接受前台路由 pageview 信标(与 monitor_enabled 同步)
MonitorPageview bool `json:"monitor_pageview"`
} }
type settingDef struct { type settingDef struct {
@@ -243,7 +267,8 @@ var asideSettingDefaults = map[string]string{
SettingAsideShowTagCloud: "0", SettingAsideShowTagCloud: "0",
SettingAsideShowRecentComments: "0", SettingAsideShowRecentComments: "0",
SettingAsideShowFriendLinks: "1", 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{ var mailSettingDefaults = map[string]string{
@@ -289,6 +314,8 @@ var friendLinkSettingDefaults = map[string]string{
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测 SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
SettingNavShowFriendLinks: "1", SettingNavShowFriendLinks: "1",
SettingFooterShowFriendLinks: "1", SettingFooterShowFriendLinks: "1",
SettingNavShowShowcase: "0",
SettingFooterShowShowcase: "0",
} }
var communitySettingDefaults = map[string]string{ var communitySettingDefaults = map[string]string{
@@ -299,6 +326,14 @@ var communitySettingDefaults = map[string]string{
SettingCommunitySiteURL: "", SettingCommunitySiteURL: "",
} }
var monitorSettingDefaults = map[string]string{
SettingMonitorEnabled: "0",
SettingMonitorRetention: "30",
SettingMonitorAccessLogRetention: "7",
SettingMonitorExclude: DefaultMonitorExcludeJSON,
SettingMonitorTrustProxy: "1",
}
var siteBrandingDefaults = map[string]string{ var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛", SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流", SettingSiteSlogan: "拾三一隅,自在交流",
@@ -392,15 +427,35 @@ type GiteaSyncConfig struct {
RepoCount int64 `json:"repo_count"` RepoCount int64 `json:"repo_count"`
} }
// CommunityConfig 社区上报配置HubEnabled 只读,来自运维配置 // CommunityConfig 社区上报配置HubEnabled 只读:运维开关或官网域名
type CommunityConfig struct { type CommunityConfig struct {
ReportEnabled bool `json:"report_enabled"` 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"` HubURL string `json:"hub_url"`
SiteURL string `json:"site_url"` // 上报用的本站公开地址 SiteURL string `json:"site_url"` // 上报用的本站公开地址
InstanceID string `json:"instance_id"` 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 // OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients
type OIDCConfig struct { type OIDCConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -505,6 +560,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) 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 { func (s *ForumSettingsService) getString(key, fallback string) string {
@@ -586,9 +648,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
AsideShowTagCloud: bools.tagCloud, AsideShowTagCloud: bools.tagCloud,
AsideShowRecentComments: bools.recentComments, AsideShowRecentComments: bools.recentComments,
AsideShowFriendLinks: bools.friendLinks, AsideShowFriendLinks: bools.friendLinks,
AsideShowShowcase: bools.showcase,
AsideWidgets: widgets, AsideWidgets: widgets,
NavShowFriendLinks: s.NavShowFriendLinks(), NavShowFriendLinks: s.NavShowFriendLinks(),
FooterShowFriendLinks: s.FooterShowFriendLinks(), FooterShowFriendLinks: s.FooterShowFriendLinks(),
NavShowShowcase: s.NavShowShowcase(),
FooterShowShowcase: s.FooterShowShowcase(),
FeedListStyle: s.FeedListStyle(), FeedListStyle: s.FeedListStyle(),
@@ -619,14 +684,19 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
AsideShowTagCloud: limits.AsideShowTagCloud, AsideShowTagCloud: limits.AsideShowTagCloud,
AsideShowRecentComments: limits.AsideShowRecentComments, AsideShowRecentComments: limits.AsideShowRecentComments,
AsideShowFriendLinks: limits.AsideShowFriendLinks, AsideShowFriendLinks: limits.AsideShowFriendLinks,
AsideShowShowcase: limits.AsideShowShowcase,
AsideWidgets: limits.AsideWidgets, AsideWidgets: limits.AsideWidgets,
NavShowFriendLinks: limits.NavShowFriendLinks, NavShowFriendLinks: limits.NavShowFriendLinks,
FooterShowFriendLinks: limits.FooterShowFriendLinks, FooterShowFriendLinks: limits.FooterShowFriendLinks,
NavShowShowcase: limits.NavShowShowcase,
FooterShowShowcase: limits.FooterShowShowcase,
FeedListStyle: limits.FeedListStyle, FeedListStyle: limits.FeedListStyle,
PermalinkEnabled: limits.PermalinkEnabled, PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt, PermalinkExt: limits.PermalinkExt,
MonitorPageview: s.MonitorEnabled(),
} }
} }
@@ -669,8 +739,11 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
SettingAsideShowTagCloud: bools.tagCloud, SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments, SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks, SettingAsideShowFriendLinks: bools.friendLinks,
SettingAsideShowShowcase: bools.showcase,
SettingNavShowFriendLinks: in.NavShowFriendLinks, SettingNavShowFriendLinks: in.NavShowFriendLinks,
SettingFooterShowFriendLinks: in.FooterShowFriendLinks, SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
SettingNavShowShowcase: in.NavShowShowcase,
SettingFooterShowShowcase: in.FooterShowShowcase,
SettingPermalinkEnabled: in.PermalinkEnabled, SettingPermalinkEnabled: in.PermalinkEnabled,
} }
for key, on := range boolUpdates { for key, on := range boolUpdates {
@@ -770,6 +843,26 @@ func (s *ForumSettingsService) FooterShowFriendLinks() bool {
return s.getString(SettingFooterShowFriendLinks, "1") == "1" 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 { func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
v := "0" v := "0"
if enabled { if enabled {
@@ -794,19 +887,44 @@ func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
return s.setString(SettingFooterShowFriendLinks, v) 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 同步) // SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error { 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() widgets := s.AsideWidgets()
found := false found := false
for i := range widgets { for i := range widgets {
if widgets[i].ID == AsideWidgetFriendLinks { if widgets[i].ID == widgetID {
widgets[i].Enabled = enabled widgets[i].Enabled = enabled
found = true found = true
break break
} }
} }
if !found { if !found {
widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled}) widgets = append(widgets, AsideWidget{ID: widgetID, Enabled: enabled})
} }
widgets = NormalizeAsideWidgets(widgets) widgets = NormalizeAsideWidgets(widgets)
bools := asideBoolsFromWidgets(widgets) bools := asideBoolsFromWidgets(widgets)
@@ -818,16 +936,28 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
return err return err
} }
v := "0" v := "0"
if bools.friendLinks { switch widgetID {
v = "1" 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 { type asideWidgetBools struct {
tagCloud bool tagCloud bool
recentComments bool recentComments bool
friendLinks bool friendLinks bool
showcase bool
} }
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget { func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
@@ -835,6 +965,7 @@ func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWi
{ID: AsideWidgetTagCloud, Enabled: tagCloud}, {ID: AsideWidgetTagCloud, Enabled: tagCloud},
{ID: AsideWidgetRecentComments, Enabled: recentComments}, {ID: AsideWidgetRecentComments, Enabled: recentComments},
{ID: AsideWidgetFriendLinks, Enabled: friendLinks}, {ID: AsideWidgetFriendLinks, Enabled: friendLinks},
{ID: AsideWidgetShowcase, Enabled: false},
} }
} }
@@ -848,6 +979,8 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
out.recentComments = w.Enabled out.recentComments = w.Enabled
case AsideWidgetFriendLinks: case AsideWidgetFriendLinks:
out.friendLinks = w.Enabled out.friendLinks = w.Enabled
case AsideWidgetShowcase:
out.showcase = w.Enabled
} }
} }
return out return out
@@ -855,7 +988,7 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
func isValidAsideWidgetID(id string) bool { func isValidAsideWidgetID(id string) bool {
switch id { switch id {
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks: case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase:
return true return true
default: default:
return false return false
@@ -1131,20 +1264,75 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
return nil return nil
} }
// CommunityConfig 读取社区上报配置 // CommunityConfig 读取社区上报配置(无请求上下文;官网需靠已存 ROOT_URL / site_url
func (s *ForumSettingsService) CommunityConfig() CommunityConfig { func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
s.mu.RLock() return s.CommunityConfigForRequest("")
hubEnabled := s.communityHubEnabled }
s.mu.RUnlock()
// CommunityConfigForRequest 读取社区配置requestHint 可为 Origin / 公开 URL用于识别官网 Host
func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig {
return CommunityConfig{ return CommunityConfig{
ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1", ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
HubEnabled: hubEnabled, HubEnabled: s.CommunityHubEnabled(requestHint),
HubURL: DefaultCommunityHubURL, HubURL: DefaultCommunityHubURL,
SiteURL: s.CommunitySiteURL(""), SiteURL: s.CommunitySiteURL(requestHint),
InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")), 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 // CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin
func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string { func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" { if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
@@ -1199,6 +1387,104 @@ func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasRep
return wasReportEnabled, nil 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 读取上传存储配置(含密钥明文,供内部使用) // StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
func (s *ForumSettingsService) StorageConfig() StorageConfig { func (s *ForumSettingsService) StorageConfig() StorageConfig {
secret := s.getString(SettingStorageSecretKey, "") secret := s.getString(SettingStorageSecretKey, "")