From ff5e05998a3011c5c85fc503fc71b6620a111880 Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 15:40:22 +0800 Subject: [PATCH] docs(deploy): document PV/UV stats and real client IP via nginx realip --- DEVELOPMENT.md | 33 +++++++- README.md | 33 +++++++- config.docker.example.json | 20 ++++- config.example.json | 20 ++++- deploy/nginx/README.md | 105 ++++++++++++++++++++++++++ deploy/nginx/reaction-board.conf | 26 +++++++ deploy/nginx/realip-cloudflare.conf | 12 +++ deploy/nginx/realip-edgeone.conf | 16 ++++ deploy/nginx/update-cloudflare-ips.sh | 21 ++++++ deploy/nginx/update-edgeone-ips.sh | 20 +++++ project.md | 8 ++ 11 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 deploy/nginx/README.md create mode 100644 deploy/nginx/reaction-board.conf create mode 100644 deploy/nginx/realip-cloudflare.conf create mode 100644 deploy/nginx/realip-edgeone.conf create mode 100644 deploy/nginx/update-cloudflare-ips.sh create mode 100644 deploy/nginx/update-edgeone-ips.sh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f85ab8f..e033f51 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -217,6 +217,7 @@ PRAGMA synchronous = NORMAL; │ ├── scheduler.py # 内置任务调度 │ ├── logging_json.py # 脱敏 JSON 日志 │ ├── classify.py # 标题归一化、时长解析与打分 +│ ├── client_ip.py # 来源 IP 提取、路由归一化、机器人判定(纯函数) │ ├── video_ref.py # BV/av/URL/b23.tv 引用解析(纯函数) │ ├── timeutil.py # 上海时区与每日时间 │ ├── bilibili/ @@ -238,8 +239,12 @@ PRAGMA synchronous = NORMAL; │ └── web/ # P2 只读看板 │ ├── server.py # HTTP、路由、鉴权、静态资源 │ ├── queries.py # 看板 SQL 与数据整形 +│ ├── visits.py # 访问统计内存缓冲与批量落库 │ └── static/ # 原生 JS/CSS、ECharts、预览页面 │ +├── deploy/ +│ └── nginx/ # 反代示例:真实来源 IP(realip)与站点配置 +│ ├── tools/ │ ├── seed_preview_db.py # 复制真实库并回填 7 天预览数据 │ └── import_fans_csv.py # 从 B 站粉丝历史 CSV 回填 up_stat @@ -329,6 +334,7 @@ PRAGMA synchronous = NORMAL; - `snapshot` 主键为 `(stat_date, aid)`,保证同日幂等; - `up_stat` 主键为 `(stat_date, mid)`; +- `visit` 访问统计表主键为 `(stat_date, ip, path)`,`hits` 累加;数据先落内存缓冲、批量 upsert,`PV = SUM(hits)`、`UV = COUNT(DISTINCT ip)`; - `folder` / `folder_resource` 保留为历史数据与重排序参考,**不再由服务写入**; - `up_blacklist` 只在发现任务中生效,不影响已入库视频; - `seen_aid` 永久保留搜索去重标记,候选过期删除后也不重复通知; @@ -499,6 +505,7 @@ P3 实现说明: ### 9.1 API 原则 - P2 看板**只读**:业务接口只注册 GET,写方法统一返回 `405`; +- 仅显式放行两个 POST 端点:`POST /login`(建立会话)与 `POST /api/visit`(访问统计埋点);二者都不修改业务数据,其余 POST/PUT/PATCH/DELETE 仍统一 `405`; - 数据管理(入库、忽略、拉黑、移出库、移除黑名单)继续通过 Telegram、CLI 或数据库完成; - `/api/health` 和 `/api/session` 可匿名; - 首页、榜单、视频/UP、粉丝页面和接口访客可见; @@ -525,6 +532,8 @@ P3 实现说明: | `GET` | `/api/candidates` | 候选池(只读) | | `GET` | `/api/system` | 最近任务、下次任务、备份、黑名单 | | `GET` | `/api/about` | 数据说明页当前口径(配置阈值、权重、覆盖度;无凭据) | +| `POST` | `/api/visit` | 访问统计埋点(匿名):归一化路径后写内存缓冲,定期批量落库 | +| `GET` | `/api/stats` | 访问统计(需登录):每日 PV/UV 序列、Top 页面,合并未落库缓冲 | ### 9.2 页面 @@ -538,7 +547,7 @@ P3 实现说明: | UP 页 | 该 UP 的头像、mid、首RE时间、最近发布时间、全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 | | 粉丝页 | 粉丝总数、日增、7 日净增、累计增长(标注起始日期);支持预设和自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示“较上一成功日”增量;不叠加视频数据。 | | 候选池 | 待确认候选、规则命中、剩余保留时间;操作指向 Telegram/CLI。 | -| 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权和黑名单。 | +| 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权、黑名单与访问统计(近 30 天 PV/UV 与 Top 页面)。 | ### 9.3 预览模式 @@ -550,6 +559,15 @@ P3 实现说明: - 重新生成预览库前必须停止预览服务,避免 Windows 文件占用; - 第 9 轮起本地不再保留 `data/app.db` 真实副本;重新生成预览时用 `--source` 指向 `data/backup/` 中的真实库备份(如 `app-local-before-reseed-20261101.db`)。 +### 9.4 访问统计与真实来源 IP + +- 前端在整页加载与站内路由切换时通过 `navigator.sendBeacon("/api/visit")` 上报路径(`sort`/筛选等同路径重渲染不重复计数); +- 服务端 `reaction_board/web/visits.py` 把上报按 `(日期, IP, 路由)` 聚合到内存缓冲,后台线程每 `web.track.flush_seconds`(默认 60 秒)批量 upsert 到 `visit` 表;`server_close()` 时做最后一次 flush; +- `/api/stats` 合并未落库缓冲,因此系统页基本实时;`keep_days` 到期数据由后台按日清理; +- 真实 IP 由 nginx 用 `realip` 模块按 CDN 网段归一化后写入 `X-Real-IP`;应用(`reaction_board/client_ip.py`)仅在 socket 对端属于 `web.track.trusted_proxies` 时采信请求头,否则回退对端,避免直连请求伪造 `CF-Connecting-IP`/`EO-Connecting-IP`/`X-Forwarded-For`; +- 直连公网 EIP、Cloudflare、EdgeOne 三种来源的 nginx 配置与网段刷新脚本见 `deploy/nginx/`;配置项见 README 的 `web.track` 说明; +- 进程被强杀时最多丢失一个刷新周期的上报(可接受)。 + --- ## 10. 热度与趋势口径 @@ -632,6 +650,8 @@ uv run python tests/test_moves.py - Telegram 用户与 chat ID 授权校验; - 看板查询的筛选、分页、增长冷启动与状态过滤; - 只读路由、鉴权、静态资源路径穿越和预览标记校验; +- 来源 IP 提取(直连 EIP/Cloudflare/EdgeOne、不可信对端伪造、IPv6、带端口、回退)与路由归一化、机器人判定; +- 访问统计内存缓冲(聚合、跨日、单键上限、flush 幂等、写失败并回、保留期清理)与 `POST /api/visit`、`GET /api/stats` 契约; - 预览库生成的确定性、单调性、异常样本与真实库隔离; - 粉丝历史 CSV 解析、同日去重、缺口计数与幂等/替换回填; - UP 粉丝快照:首次写入不可覆盖、同轮去重、失败不中断、`live_since` 实时/回填标记、`backfill` 的 dry-run 与 limit; @@ -759,6 +779,17 @@ uv run python tests/test_moves.py - `/api/ups` 返回 `up_face` 与 `follower_latest`,`/api/ups/{mid}` 返回 `up_face`; - 自动化测试更新:`test_migrations.py`、`test_database.py`、`test_web_queries.py`、`test_bilibili_client.py`、`test_library.py`、`test_snapshot.py`、`web_fixtures.py`。 +### 第 12 轮:访问统计(PV/UV)✅(2026-11-01) + +- 迁移 v7:新增 `visit(stat_date, ip, path, hits, first_at, last_at)` 聚合表; +- `reaction_board/client_ip.py`:来源 IP 提取、路由归一化与机器人判定纯函数(三种来源 + 伪造防护); +- `reaction_board/web/visits.py`:访问上报先落内存缓冲,后台每分钟批量 upsert,支持单键上限与保留期清理,关闭时最后一次 flush; +- 前端 `navigator.sendBeacon("/api/visit")` 埋点;Web 只读边界放宽为显式 POST 白名单(`/api/visit` + `/login`); +- `config.json` 新增 `web.track`(`enabled`/`header_order`/`trusted_proxies`/`count_bots`/`flush_seconds`/`keep_days`/`max_hits_per_ip_path_per_day`); +- 系统状态页展示近 30 天 PV/UV 与 Top 页面(`GET /api/stats`,需登录,合并未落库缓冲); +- `deploy/nginx/` 提供直连 EIP / Cloudflare / EdgeOne 的 realip 与站点配置、网段刷新脚本和说明; +- 自动化测试新增 `tests/test_client_ip.py`、`tests/test_visits.py`,并扩展 `test_config.py`、`test_web_queries.py`、`test_web_server.py`、`test_migrations.py` 等。 + ### P4:可选部署 - 反向代理、TLS、远程访问鉴权; diff --git a/README.md b/README.md index 7dfb487..690d82f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ | 搜索发现与候选池 | 可用(P3) | 每小时搜索前 2 页,分类打分后写入候选池。 | | Telegram 通知与入库 | 可用(P3) | 入库 / 忽略 / 拉黑按钮;私聊消息手动入库。 | | 本地数据看板 | 可用(P2/P9) | 只读总览、视频榜、UP 主榜、内容效率、视频/UP 详情、粉丝曲线、数据说明、候选池和系统状态。 | +| 访问统计(PV/UV) | 可用 | 前端埋点上报,按客户端 IP 与页面去重;先落内存、每分钟批量入库;系统页展示近 30 天。 | ## 核心工作流 @@ -176,10 +177,11 @@ uv run python app.py serve --preview --config data/preview/config.json - 粉丝页只绘制粉丝曲线,不叠加视频数据;支持 7/30/90/全部与自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示较上一成功日的增量; - 前端使用 History API 的干净路径路由(`/videos`、`/ups`、`/video/{aid}`、`/up/{mid}`、`/about`),不使用 `#/` hash;旧 `/rank` 链接仍可用; - 候选池和系统状态只读展示,操作入口指向 Telegram/CLI; +- 系统状态页展示访问统计(近 30 天每日 PV/UV 与 Top 页面):前端在整页加载与站内路由切换时通过 `navigator.sendBeacon("/api/visit")` 上报,服务端按客户端 IP 与归一化路由聚合,先写内存缓冲、每分钟批量落库(详见 [访问统计与真实来源 IP](#访问统计与真实来源-ip)); - 首页、视频榜、UP 主榜、视频/UP 详情、粉丝页和数据说明页对访客可见; - 配置 `web.auth_token` 后,“候选”和“系统”页面及其接口需要登录,启用登录页和 HttpOnly Cookie;访客的底部导航只显示公开页面; - 本地回环地址且未配置 token 时视为无鉴权,全部页面可见;非回环地址且未配置 token 时拒绝启动; -- 所有业务接口只接受 GET,写方法返回 `405`; +- 所有业务接口只接受 GET,写方法返回 `405`;仅显式放行 `POST /api/visit`(访问统计埋点)与 `POST /login`(会话),其余 POST/PUT/PATCH/DELETE 仍统一 `405`; - 首页/榜单聚合结果缓存 1 小时,粉丝结果缓存 24 小时;快照、发现、入库/忽略/拉黑、候选清理和粉丝 CSV 导入会立即让对应缓存失效。 ### 预览数据 @@ -227,6 +229,28 @@ uv run python tools/import_fans_csv.py --csv E:\Downloads\history.csv --replace - 写入前建议先备份 `data/app.db`,例如 `data/backup/app-pre-fans-YYYYMMDD.db`; - 容器内运行时用 `--db /data/app.db` 或 `--config /config/config.json`,避免默认去找 `/app/config.json`。 +## 访问统计与真实来源 IP + +看板提供基础访问统计(PV/UV)。前端在整页加载与站内路由切换时通过 +`navigator.sendBeacon("/api/visit")` 上报路径;服务端按 `(日期, 客户端 IP, 归一化路由)` +聚合,**先写入进程内缓冲、每分钟批量落库**到 `visit` 表,`/system` 系统状态页展示近 +30 天每日 PV/UV 与 Top 页面。 + +统计按客户端 IP 去重,因此反代必须还原真实客户端 IP。链路为 +`域名 → CDN → 公网 EIP → 云服务器内网 → 宿主机 nginx → 127.0.0.1:8080(容器)`: +容器内看到的 socket 对端是 Docker 网关,真实 IP 只能来自 nginx 注入的请求头。 + +- nginx 用 `realip` 模块按 CDN 网段把真实 IP 归一化到 `$remote_addr`,再写 + `X-Real-IP` 传给容器;直连公网 EIP 时 `$remote_addr` 本身就是客户端; +- 应用只读 `X-Real-IP`,且仅当 socket 对端属于可信代理网段时采信,否则回退对端, + 因此直连请求伪造的 `CF-Connecting-IP` / `EO-Connecting-IP` / `X-Forwarded-For` 无效; +- 三种来源(直连 EIP / Cloudflare / EdgeOne)的完整 nginx 配置、网段刷新脚本与 + 配置项说明见 [`deploy/nginx/README.md`](deploy/nginx/README.md)。 + +`config.json` 中的 `web.track` 控制统计行为:`enabled` / `header_order` / +`trusted_proxies` / `count_bots` / `flush_seconds` / `keep_days` / +`max_hits_per_ip_path_per_day`;默认过滤常见爬虫 UA、每分钟落库、保留 180 天。 + ## 快速部署(Docker) 仓库根目录已包含 `Dockerfile`、`compose.example.yml`、`.dockerignore` 和 `config.docker.example.json`。Compose 文件是示例,需要显式指定: @@ -298,6 +322,7 @@ Copy-Item config.example.json config.json - `track_fans`:需要每日追踪粉丝数的 UP mid; - `discover`:搜索关键词、页数与时间窗(`keywords` / `pages` / `window_days`); - `storage`:数据库与备份目录; +- `web`:绑定地址、端口、访问令牌与 `web.track`(访问统计开关、可信代理网段、上报头顺序、落库周期与保留期); - `schedule` / `notify` / `classify` / `heat_weights`:任务节奏、通知阈值、分类规则和热度权重。 配置每 60 秒检查一次 mtime,凭据、分类规则等变更无需重启即可在下次任务读取;但 `schedule.discover_minutes` 与 `schedule.snapshot_at` 在启动时固化,修改后需重启 `run` 进程。 @@ -398,6 +423,8 @@ uv run python tests/test_auth.py # Cookie 合并、设备指纹、Set uv run python tests/test_moves.py # 排序移动脚本:随机排列与头部用例 uv run python tests/test_favsort_http.py # favsort httpx 请求头与匿名回退 uv run python tests/test_config.py # 配置加载、校验、ENV 覆盖与热加载 +uv run python tests/test_client_ip.py # 来源 IP 提取、路由归一化与机器人判定(纯函数) +uv run python tests/test_visits.py # 访问统计内存缓冲、批量落库与保留期清理 uv run python tests/test_migrations.py # 迁移、备份、回滚与保留期 uv run python tests/test_database.py # 单写队列、事务与并发 uv run python tests/test_repository.py # 收藏夹/视频状态机与黑名单 @@ -416,8 +443,8 @@ uv run python tests/test_timeutil.py # 上海时区与每日任务时间 uv run python tests/test_analytics.py # 热度、互动率、增长与封面 uv run python tests/test_preview_seed.py # 预览库回填、确定性与安全边界 uv run python tests/test_fans_csv.py # 粉丝 CSV 解析、替换与幂等回填 -uv run python tests/test_web_queries.py # 看板查询层与冷启动 -uv run python tests/test_web_server.py # 只读路由、鉴权、静态资源与预览模式 +uv run python tests/test_web_queries.py # 看板查询层、冷启动与访问统计聚合 +uv run python tests/test_web_server.py # 只读路由、鉴权、静态资源、访问统计与预览模式 uv run python tests/test_scheduler.py # 内置调度器到期/失败隔离 uv run python tests/test_provider.py # 凭据变化重建客户端与配置 revision uv run python tests/test_favsort_run.py # 排序参数校验与混合资源类型 diff --git a/config.docker.example.json b/config.docker.example.json index 024ed1c..e8bae5b 100644 --- a/config.docker.example.json +++ b/config.docker.example.json @@ -20,7 +20,25 @@ "host": "0.0.0.0", "port": 8080, "auth_token": "replace-with-a-long-random-token", - "public_base_url": "" + "public_base_url": "", + "track": { + "enabled": true, + "header_order": ["X-Real-IP", "X-Forwarded-For"], + "trusted_proxies": [ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "fc00::/7", + "fe80::/10" + ], + "count_bots": false, + "flush_seconds": 60, + "keep_days": 180, + "max_hits_per_ip_path_per_day": 0 + } }, "storage": { "database_path": "/data/app.db", diff --git a/config.example.json b/config.example.json index b01571b..a108047 100644 --- a/config.example.json +++ b/config.example.json @@ -30,7 +30,25 @@ "host": "127.0.0.1", "port": 8080, "auth_token": "replace-with-a-long-random-token", - "public_base_url": "" + "public_base_url": "", + "track": { + "enabled": true, + "header_order": ["X-Real-IP", "X-Forwarded-For"], + "trusted_proxies": [ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "fc00::/7", + "fe80::/10" + ], + "count_bots": false, + "flush_seconds": 60, + "keep_days": 180, + "max_hits_per_ip_path_per_day": 0 + } }, "storage": { "database_path": "data/app.db", diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md new file mode 100644 index 0000000..50d6733 --- /dev/null +++ b/deploy/nginx/README.md @@ -0,0 +1,105 @@ +# 访问统计与真实来源 IP + +看板新增了基础访问统计(PV/UV)。前端在页面访问时通过 +`POST /api/visit`(`navigator.sendBeacon`)上报,服务端先写入进程内缓冲、 +每分钟批量落库到 `visit` 表(迁移 v7)。`/system` 系统状态页展示近 30 天 +PV/UV 与 Top 页面。 + +统计按客户端 IP 去重,因此**必须在反代层还原真实客户端 IP**。 + +## 链路与 socket 对端 + +```text +浏览器 → 域名 → CDN(Cloudflare/EdgeOne) → 公网 EIP → 10.10.8.x(云服务器内网) + → 宿主机 nginx → proxy_pass http://127.0.0.1:8080 → 容器 +``` + +容器内 socket 对端经端口映射 SNAT 后是 Docker 网关(如 `172.17.0.1`), +不是真实客户端。因此真实 IP 只能来自 nginx 注入的请求头,且有三条来源: + +| 场景 | nginx 处理 | +|---|---| +| 域名直接解析到公网 EIP | `$remote_addr` 即客户端,nginx 直接写 `X-Real-IP` | +| 经 Cloudflare | `realip` + `CF-Connecting-IP`(仅当对端属于 CF 网段才采信) | +| 经 EdgeOne | `realip` + `EO-Connecting-IP`(仅当对端属于 EdgeOne 网段才采信) | + +应用只读 `X-Real-IP`,且**仅当 socket 对端属于可信代理网段** +(默认回环 + RFC1918 + Docker 网段,可配置)时才采信,否则回退到对端。 +这样直连 EIP 的请求即使伪造 `CF-Connecting-IP` / `EO-Connecting-IP` / +`X-Forwarded-For` 也无法污染统计。 + +> 关键:用 `realip` 模块按 CDN 网段校验,而不是无条件相信 CDN 头。 +> 若无条件相信,任何能直连公网 EIP 的人都能伪造来源 IP。 + +## 文件 + +| 文件 | 用途 | +|---|---| +| `reaction-board.conf` | 站点 `server {}`,反代到 `127.0.0.1:8080` 并注入真实 IP 头 | +| `realip-cloudflare.conf` | Cloudflare 场景,放在 `http {}` 内 | +| `realip-edgeone.conf` | EdgeOne 场景,放在 `http {}` 内 | +| `update-cloudflare-ips.sh` | 生成 Cloudflare 网段清单 `cloudflare-ips.conf` | +| `update-edgeone-ips.sh` | 由运维维护的 CIDR 清单生成 `edgeone-ips.conf` | + +按实际来源**二选一**引入对应的 `realip-*.conf`(放在 `nginx.conf` 的 +`http {}` 内),再 `include` 站点配置。 + +## Cloudflare + +```nginx +# nginx.conf 的 http {} 内 +include /etc/nginx/realip-cloudflare.conf; +include /etc/nginx/conf.d/reaction-board.conf; +``` + +```bash +sudo bash deploy/nginx/update-cloudflare-ips.sh +``` + +Cloudflare 的 IP 段变化很少,建议加 cron 每月刷新一次。 + +## EdgeOne + +EdgeOne 节点网段会变动,需从 EdgeOne 控制台或 API 获取后维护成 CIDR 清单: + +```bash +# source 为每行一个 CIDR 的清单 +sudo bash deploy/nginx/update-edgeone-ips.sh /path/to/edgeone-cidrs.txt +``` + +```nginx +# nginx.conf 的 http {} 内 +include /etc/nginx/realip-edgeone.conf; +include /etc/nginx/conf.d/reaction-board.conf; +``` + +若无法自动同步网段,可退化为:在安全组/防火墙只放行 EdgeOne 节点访问源站, +此时可放宽为直接采信 `EO-Connecting-IP`;但**同时保留直连公网 EIP 访问**的 +场景下,仍建议使用 `realip` 网段校验。 + +## 应用侧配置 + +```jsonc +{ + "web": { + "track": { + "enabled": true, + "header_order": ["X-Real-IP", "X-Forwarded-For"], + "trusted_proxies": [ + "127.0.0.0/8", "::1/128", + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", + "fc00::/7", "fe80::/10" + ], + "count_bots": false, + "flush_seconds": 60, + "keep_days": 180, + "max_hits_per_ip_path_per_day": 0 + } + } +} +``` + +- `flush_seconds`:内存缓冲落库周期;设为 `0` 可关闭后台刷新(仅调试用)。 +- `count_bots`:是否统计命中常见爬虫/监控 UA 的请求,默认过滤。 +- `max_hits_per_ip_path_per_day`:单个(IP, 页面, 日期)计数上限,`0` 表示不限。 +- `keep_days`:保留天数,后台按日清理更早的记录。 \ No newline at end of file diff --git a/deploy/nginx/reaction-board.conf b/deploy/nginx/reaction-board.conf new file mode 100644 index 0000000..c992036 --- /dev/null +++ b/deploy/nginx/reaction-board.conf @@ -0,0 +1,26 @@ +# 站点反代:域名 → CDN → 公网 EIP → 本机 nginx → 127.0.0.1:8080(容器)。 +# +# 配合 realip-*.conf(放在 http {} 内)先把真实客户端 IP 归一化到 $remote_addr, +# 再通过 X-Real-IP 传给容器。应用仅在 socket 对端属于可信代理网段时采信该头。 + +server { + listen 80; + server_name board.example.com; # TODO: 改成你的域名 + + # 回源 HTTP(CDN 侧终结 TLS)。若要 nginx 直接对公网提供 HTTPS, + # 另建 443 server 并配置证书,下列 proxy_set_header 保持不变。 + + client_max_body_size 1m; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; # 归一化后的真实客户端 IP + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 追加真实对端 + proxy_set_header X-Forwarded-Proto $scheme; + + location / { + proxy_pass http://127.0.0.1:8080; + } + + # POST /api/visit(访问统计埋点)与其余只读接口走同一上游,无需额外配置。 +} \ No newline at end of file diff --git a/deploy/nginx/realip-cloudflare.conf b/deploy/nginx/realip-cloudflare.conf new file mode 100644 index 0000000..2cb36f2 --- /dev/null +++ b/deploy/nginx/realip-cloudflare.conf @@ -0,0 +1,12 @@ +# 放到 nginx.conf 的 http {} 内(或 http 级 include)。 +# +# 仅当与 nginx 建立连接的对端属于下列 Cloudflare 网段时,才采信 +# CF-Connecting-IP;直连公网 EIP 的请求无法伪造该头。 +# +# 网段清单由 update-cloudflare-ips.sh 生成到 /etc/nginx/cloudflare-ips.conf。 +# 若暂时未生成,可先注释掉 include(此时 CDN 访客会被记为 CF 节点 IP)。 + +real_ip_header CF-Connecting-IP; +real_ip_recursive on; + +include /etc/nginx/cloudflare-ips.conf; \ No newline at end of file diff --git a/deploy/nginx/realip-edgeone.conf b/deploy/nginx/realip-edgeone.conf new file mode 100644 index 0000000..b2bb103 --- /dev/null +++ b/deploy/nginx/realip-edgeone.conf @@ -0,0 +1,16 @@ +# 放到 nginx.conf 的 http {} 内(或 http 级 include)。 +# +# 仅当与 nginx 建立连接的对端属于 EdgeOne 节点网段时,才采信 +# EO-Connecting-IP;直连公网 EIP 的请求无法伪造该头。 +# +# 网段清单 /etc/nginx/edgeone-ips.conf 由 update-edgeone-ips.sh 生成, +# 源数据从 EdgeOne 控制台或 API 导出(会变动,建议定期刷新)。 + +real_ip_header EO-Connecting-IP; +real_ip_recursive on; + +include /etc/nginx/edgeone-ips.conf; + +# 备选:用 EdgeOne 的 X-Forwarded-For(首段为真实客户端)配合网段递归提取。 +# real_ip_header X-Forwarded-For; +# real_ip_recursive on; \ No newline at end of file diff --git a/deploy/nginx/update-cloudflare-ips.sh b/deploy/nginx/update-cloudflare-ips.sh new file mode 100644 index 0000000..57c7fe7 --- /dev/null +++ b/deploy/nginx/update-cloudflare-ips.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# 生成 Cloudflare 节点网段清单,供 realip-cloudflare.conf 使用。 +# +# 用法: sudo bash deploy/nginx/update-cloudflare-ips.sh [输出文件] +# 默认输出: /etc/nginx/cloudflare-ips.conf +set -euo pipefail + +OUT="${1:-/etc/nginx/cloudflare-ips.conf}" + +{ + for url in https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6; do + curl -fsSL "$url" | sed '/^[[:space:]]*$/d; s/^/set_real_ip_from /; s/$/;/' + done +} > "${OUT}.tmp" + +mv "${OUT}.tmp" "$OUT" +echo "已写入 $OUT" + +if command -v nginx >/dev/null 2>&1; then + nginx -t && nginx -s reload +fi \ No newline at end of file diff --git a/deploy/nginx/update-edgeone-ips.sh b/deploy/nginx/update-edgeone-ips.sh new file mode 100644 index 0000000..1cf3439 --- /dev/null +++ b/deploy/nginx/update-edgeone-ips.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# 由 EdgeOne 节点 CIDR 清单生成 realip 配置,供 realip-edgeone.conf 使用。 +# +# EdgeOne 节点网段会变动,需从 EdgeOne 控制台或 API 导出为「每行一个 CIDR」 +# 的清单后传入本脚本(不硬编码易过期的网段)。 +# +# 用法: sudo bash deploy/nginx/update-edgeone-ips.sh [输出文件] +# 默认输出: /etc/nginx/edgeone-ips.conf +set -euo pipefail + +SOURCE="${1:?用法: update-edgeone-ips.sh [输出文件]}" +OUT="${2:-/etc/nginx/edgeone-ips.conf}" + +sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d; s|^|set_real_ip_from |; s|$|;|' "$SOURCE" > "${OUT}.tmp" +mv "${OUT}.tmp" "$OUT" +echo "已写入 $OUT" + +if command -v nginx >/dev/null 2>&1; then + nginx -t && nginx -s reload +fi \ No newline at end of file diff --git a/project.md b/project.md index b605d69..a9c9716 100644 --- a/project.md +++ b/project.md @@ -883,5 +883,13 @@ P0、P1、P2、P3、第 9 轮与第 10 轮已实施;P2 交付了只读本地 2. 基于粉丝数的"破圈"维度(播放/入库时粉丝数、粉丝分段、UP 中位数)——数据已就绪,榜单未实现; 3. P4(容器化 + 反代)继续保持可选。 +第 12 轮(2026-11-01)交付:**基础访问统计(PV/UV)**: + +1. 迁移 v7 新增 `visit(stat_date, ip, path, hits, first_at, last_at)` 聚合表; +2. 前端 `sendBeacon` 埋点 `POST /api/visit`;服务端内存缓冲、每分钟批量落库,关闭时 flush; +3. 来源 IP 兼容「域名直连公网 EIP / Cloudflare / EdgeOne」:nginx 用 realip 按 CDN 网段归一化到 `X-Real-IP`,应用仅在 socket 对端可信时采信(见 `deploy/nginx/`); +4. `/system` 系统状态页展示近 30 天 PV/UV 与 Top 页面;Web 只读边界放宽为显式 POST 白名单; +5. 纯函数 `client_ip.py` 与内存缓冲 `web/visits.py` 均有离线单元测试。 + > 本地数据:第 9 轮起本地只保留可再生的预览库 `data/preview/app.db`(`tools/seed_preview_db.py` 生成), > 原 `data/app.db` 真实副本已删除;需要重新生成或恢复时,用 `--source data/backup/app-local-before-reseed-20261101.db`。