From 429956c5946da16fe281b778dbe0acb886ed55c9 Mon Sep 17 00:00:00 2001 From: freefire Date: Mon, 31 Aug 2026 07:55:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E7=AB=AF=E7=BD=91?= =?UTF-8?q?=E7=AB=99=E7=9B=91=E6=8E=A7=EF=BC=8C=E6=B5=8F=E8=A7=88=E9=87=8F?= =?UTF-8?q?=E5=86=99=E5=85=A5=E7=8B=AC=E7=AB=8B=20monitor.db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。 Co-authored-by: Cursor --- README.md | 10 +- app.ini.example | 2 + cmd/jiang13/program.go | 3 + cmd/monitor-geo-check/main.go | 313 +++++ config/config.go | 11 +- docs/docker-data.md | 105 ++ docs/monitor.md | 76 + frontend/package-lock.json | 14 + frontend/package.json | 2 + frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 63 +- frontend/src/api/types.ts | 126 +- frontend/src/components/RightPanel.tsx | 3 + .../src/components/ShowcaseAsideWidget.tsx | 72 + frontend/src/components/Sidebar.tsx | 7 +- frontend/src/components/SiteFooter.tsx | 18 +- .../src/components/admin/AsideWidgetList.tsx | 4 + .../admin/CommunitySupportStrip.tsx | 7 +- .../src/components/admin/MonitorChinaMap.tsx | 72 + .../src/components/admin/MonitorWorldMap.tsx | 121 ++ .../src/components/admin/monitorMapUtils.ts | 130 ++ frontend/src/hooks/useForumLimits.ts | 4 + frontend/src/hooks/useMonitorPageview.ts | 41 + frontend/src/layouts/AdminLayout.tsx | 5 +- frontend/src/layouts/MainLayout.tsx | 2 + .../src/pages/admin/AdminCommunityPage.tsx | 159 ++- .../src/pages/admin/AdminDashboardPage.tsx | 195 ++- frontend/src/pages/admin/AdminLinksPage.tsx | 7 + frontend/src/pages/admin/AdminMonitorPage.tsx | 707 ++++++++++ .../src/pages/admin/AdminSettingsPage.tsx | 5 +- frontend/src/styles/global.css | 547 ++++++++ frontend/src/types/svg-maps.d.ts | 17 + frontend/src/utils/asideWidgets.ts | 27 +- go.mod | 3 + go.sum | 6 + handler/api.go | 9 +- handler/community.go | 55 +- handler/handlers.go | 1 + handler/monitor.go | 108 ++ middleware/accesslog.go | 33 + model/db.go | 35 +- model/models.go | 17 + model/monitor_db_test.go | 54 + router/router.go | 32 +- service/aside_widgets_test.go | 8 +- service/backup.go | 2 +- service/community.go | 16 +- service/community_test.go | 171 ++- service/geo_zh.go | 258 ++++ service/geoip.go | 333 +++++ service/monitor.go | 1225 +++++++++++++++++ service/monitor_test.go | 85 ++ service/ratelimit.go | 6 + service/settings.go | 316 ++++- 54 files changed, 5504 insertions(+), 146 deletions(-) create mode 100644 cmd/monitor-geo-check/main.go create mode 100644 docs/docker-data.md create mode 100644 docs/monitor.md create mode 100644 frontend/src/components/ShowcaseAsideWidget.tsx create mode 100644 frontend/src/components/admin/MonitorChinaMap.tsx create mode 100644 frontend/src/components/admin/MonitorWorldMap.tsx create mode 100644 frontend/src/components/admin/monitorMapUtils.ts create mode 100644 frontend/src/hooks/useMonitorPageview.ts create mode 100644 frontend/src/pages/admin/AdminMonitorPage.tsx create mode 100644 frontend/src/types/svg-maps.d.ts create mode 100644 handler/monitor.go create mode 100644 middleware/accesslog.go create mode 100644 model/monitor_db_test.go create mode 100644 service/geo_zh.go create mode 100644 service/geoip.go create mode 100644 service/monitor.go create mode 100644 service/monitor_test.go diff --git a/README.md b/README.md index 4f321ff..4b05e5d 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ docker run -d --name jiang13 \ | `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) | | `JIANG13_CONFIG` | 配置文件路径 | | `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 / 负载均衡探活。 @@ -293,7 +293,7 @@ JWT_SECRET = | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) | | `--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. 注册为系统服务(可选) @@ -406,13 +406,15 @@ jiang13-forum/ ``` data/ -├── jiang13.db # SQLite 主数据库 +├── jiang13.db # SQLite 主数据库(不含浏览量) +├── monitor.db # 网站监控 page_views ├── jiang13.log # 运行日志 ├── filter_words.txt # 敏感词配置 ├── .jwt_secret # JWT 密钥(自动生成) +├── logs/access/ # 网站监控请求日志(按日 jsonl) ├── uploads/avatars/ # 用户头像 ├── uploads/posts/ # 帖子正文图片 -└── jiang13_backup_*.db # 后台导出的备份 +└── jiang13_backup_*.db # 后台导出的主库备份 ``` --- diff --git a/app.ini.example b/app.ini.example index 07deba0..77cf9f0 100644 --- a/app.ini.example +++ b/app.ini.example @@ -17,6 +17,8 @@ JWT_SECRET = ; --------------------------------------------------------------------------- ; 维护者选项(默认关闭;普通自托管无需开启) ; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。 +; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。 +; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。 ; 等价环境变量:JIANG13_COMMUNITY_HUB=1 ; --------------------------------------------------------------------------- ; [community] diff --git a/cmd/jiang13/program.go b/cmd/jiang13/program.go index b68391d..ea3df21 100644 --- a/cmd/jiang13/program.go +++ b/cmd/jiang13/program.go @@ -78,6 +78,9 @@ func (p *program) setup() error { if err := model.InitDB(cfg.DBPath()); err != nil { return fmt.Errorf("数据库初始化失败: %w", err) } + if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil { + return fmt.Errorf("监控库初始化失败: %w", err) + } engine, err := router.Setup(cfg) if err != nil { diff --git a/cmd/monitor-geo-check/main.go b/cmd/monitor-geo-check/main.go new file mode 100644 index 0000000..2527000 --- /dev/null +++ b/cmd/monitor-geo-check/main.go @@ -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 日省级 Top(country / 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(®ions).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) +} diff --git a/config/config.go b/config/config.go index c3739a7..3a8c1be 100644 --- a/config/config.go +++ b/config/config.go @@ -32,7 +32,8 @@ type Config struct { ServiceAction string // 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源) DevMode bool - // CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭) + // CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。 + // 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。 CommunityHub bool } @@ -192,6 +193,7 @@ func ensureDataDirs(dataDir string) error { filepath.Join(dataDir, "uploads", "avatars"), filepath.Join(dataDir, "uploads", "posts"), filepath.Join(dataDir, "uploads", "site"), + filepath.Join(dataDir, "logs", "access"), } { if err := os.MkdirAll(sub, 0755); err != nil { return fmt.Errorf("创建上传目录失败: %w", err) @@ -226,11 +228,16 @@ func validServiceAction(action string) bool { } } -// DBPath 返回 SQLite 数据库文件路径 +// DBPath 返回主库 SQLite 路径 func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "jiang13.db") } +// MonitorDBPath 返回监控独立库路径(page_views) +func (c *Config) MonitorDBPath() string { + return filepath.Join(c.DataDir, "monitor.db") +} + // AvatarUploadDir 返回头像上传目录 func (c *Config) AvatarUploadDir() string { return filepath.Join(c.DataDir, "uploads", "avatars") diff --git a/docs/docker-data.md b/docs/docker-data.md new file mode 100644 index 0000000..bdc6413 --- /dev/null +++ b/docs/docker-data.md @@ -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 里假装有两个独立数据根。 diff --git a/docs/monitor.md b/docs/monitor.md new file mode 100644 index 0000000..0e3adeb --- /dev/null +++ b/docs/monitor.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a11ae09..890a268 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "@radix-ui/react-label": "^2.1.9", "@radix-ui/react-slot": "^1.2.5", "@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", "@tiptap/core": "^3.26.1", "@tiptap/extension-code-block": "^3.26.1", @@ -1971,6 +1973,18 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "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": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 70460e7..394b148 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,8 @@ "@radix-ui/react-label": "^2.1.9", "@radix-ui/react-slot": "^1.2.5", "@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", "@tiptap/core": "^3.26.1", "@tiptap/extension-code-block": "^3.26.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9873195..3d07bf5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,7 @@ const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsP const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage')); const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); +const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage')); const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage')); const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage')); const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); @@ -70,6 +71,7 @@ const router = createBrowserRouter( }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 21dec71..a303736 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 = ''; @@ -25,6 +25,27 @@ export const api = { me: () => request<{ user: User | null }>('/api/me'), stats: () => request('/api/stats'), forumLimits: () => request('/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('/api/site-branding'), pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'), 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', { method: 'PUT', body: JSON.stringify(body), }), + adminMonitorSettings: () => request('/api/admin/settings/monitor'), + adminUpdateMonitorSettings: (body: MonitorConfig) => + request<{ message: string; monitor: MonitorConfig }>('/api/admin/settings/monitor', { + method: 'PUT', body: JSON.stringify(body), + }), + adminMonitorOverview: () => request('/api/admin/monitor/overview'), + adminMonitorGeo: (range = '30d') => + request(`/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('/api/admin/monitor/realtime'), adminCommunityInstances: () => request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'), adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) => @@ -74,6 +121,20 @@ export const api = { `/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`, { 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: () => request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'), adminPosts: (params: { page?: number; keyword?: string; status?: string }) => { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 25dfe94..60adb93 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -179,6 +179,14 @@ export interface Comment { reply_target?: Comment; } +export interface AdminDashboardTraffic { + enabled: boolean; + today_pv: number; + today_uv: number; + yesterday_pv: number; + total_pv: number; +} + export interface AdminDashboard { users: number; posts: number; @@ -189,9 +197,10 @@ export interface AdminDashboard { pending_reports?: number; pending_friend_links?: number; 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 { id: AsideWidgetId; @@ -203,6 +212,7 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [ { id: 'recent_comments', enabled: false }, { id: 'recent_users', enabled: false }, { id: 'friend_links', enabled: true }, + { id: 'showcase', enabled: false }, ]; export interface ForumLimits { @@ -231,12 +241,18 @@ export interface ForumLimits { aside_show_recent_comments: boolean; /** 右侧栏友情链接 */ aside_show_friend_links: boolean; + /** 右侧栏开源展柜 */ + aside_show_showcase: boolean; /** 右侧栏可选组件顺序与开关 */ aside_widgets: AsideWidget[]; /** 左侧栏「站点」展示友情链接入口 */ nav_show_friend_links: boolean; /** 页脚展示友情链接入口 */ footer_show_friend_links: boolean; + /** 左侧栏「站点」展示开源展柜入口 */ + nav_show_showcase: boolean; + /** 页脚展示开源展柜入口 */ + footer_show_showcase: boolean; /** 首页列表样式:title 仅标题 / thumbnail 缩略图 */ feed_list_style: 'title' | 'excerpt' | 'thumbnail'; /** 伪静态(固定链接)开关 */ @@ -262,12 +278,17 @@ export interface ForumLimitsPublic { aside_show_tag_cloud: boolean; aside_show_recent_comments: boolean; aside_show_friend_links: boolean; + aside_show_showcase: boolean; aside_widgets: AsideWidget[]; nav_show_friend_links: boolean; footer_show_friend_links: boolean; + nav_show_showcase: boolean; + footer_show_showcase: boolean; feed_list_style: 'title' | 'excerpt' | 'thumbnail'; permalink_enabled: boolean; permalink_ext: string; + /** 是否上报前台路由 pageview(与后台监控采集开关同步) */ + monitor_pageview: boolean; } export interface FriendLink { @@ -393,6 +414,109 @@ export interface CommunityConfig { 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 { instance_id: string; site_url: string; diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index e178d18..80543fb 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -13,6 +13,7 @@ import UserLink from './UserLink'; import ArticleOutline from './ArticleOutline'; import PostAuthorCard from './PostAuthorCard'; import AsideCheckInStrip from './AsideCheckInStrip'; +import ShowcaseAsideWidget from './ShowcaseAsideWidget'; export type PostDetailAside = { author?: User | null; @@ -114,6 +115,8 @@ export default function RightPanel({ const renderWidget = (widget: AsideWidget) => { switch (widget.id) { + case 'showcase': + return ; case 'friend_links': return (
diff --git a/frontend/src/components/ShowcaseAsideWidget.tsx b/frontend/src/components/ShowcaseAsideWidget.tsx new file mode 100644 index 0000000..5b1b19e --- /dev/null +++ b/frontend/src/components/ShowcaseAsideWidget.tsx @@ -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([]); + 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 ( +
+
+ + + + + +
+
+ {loading ? ( +
加载中…
+ ) : items.length === 0 ? ( +
+ 暂无精选实例 + +
+ ) : ( + <> + + {items.length > 8 && ( + + )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 160d147..aa33f53 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { - Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, + Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2, } from 'lucide-react'; import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; 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('/projects')) return 'projects'; if (pathname.startsWith('/links')) return 'links'; + if (pathname.startsWith('/showcase')) return 'showcase'; if (pathname.startsWith('/page/')) return 'pages'; if (pathname.startsWith('/admin')) return 'admin'; return activeBoard === 0 ? 'all' : String(activeBoard); @@ -67,7 +68,8 @@ export default function Sidebar({ const { navPages } = useSitePages(); const { limits } = useForumLimits(); 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 menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword); @@ -197,6 +199,7 @@ export default function Sidebar({
站点