[开发]站点统计(site_stats)

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:26:34

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/site_stats/说明.md。

站点统计(site_stats)

记录页面浏览,展示 PV/UV 与环比、流量来源、访客画像、技术健康与长周期趋势。

  • 插件版本:1.2.0
  • 数据层版本:Plugin::SCHEMA_VERSION = 6
  • 后台入口:/admin/site-stats

目录结构

plugins/site_stats/
├── plugin.json                  # hooks: init_after / route_after_dispatch / admin_route_register
├── Plugin.php                   # 数据层 + 采集 + 统计(含 class_alias,目录名含下划线)
├── AdminController.php          # 后台控制器(requireAdmin + requireAdminVerified)
├── 说明.md
├── hook/
│   ├── init_after.php           # 建表/补列/回填 惰性兜底 + shutdown 兜底注册
│   ├── route_after_dispatch.php # 每页浏览记录(正常路径)
│   └── admin_route_register.php # 注册后台路由(需 route:admin)
├── views/
│   └── admin.php                # 6 个 tab:概览 / 来源 / 内容 / 访客 / 技术 / 维护
├── assets/
│   ├── style.css                # 后台样式(用 --c-*,admin.css 自持变量)
│   └── script.js                # tab 切换 + ?tab= 恢复
├── lang/                        # zh / en / zh_tw 三语,键集合零差异
│   ├── zh.php
│   ├── en.php
│   └── zh_tw.php
└── data/
    ├── site_stats.sqlite        # 独立库(ss_views + ss_config + ss_visitors + ss_sessions + ss_daily)
    ├── .htaccess                # Apache 防护
    └── web.config               # IIS 防护


数据模型(插件独立库)

ss_views — 浏览明细

列类型说明
idINTEGER PK
pathTEXT去掉 BASE_PATH 的路径,≤255
user_idINTEGER0 = 游客
ip_hashTEXTsha256(客户端IP + 固定盐) 前 32 位,不落明文
uaTEXTUser-Agent,≤255
refererTEXT原始 Referer,≤255
ref_hostTEXTreferer 主机名(小写、去端口、去 www.)
ref_typeTEXTdirect / internal / search / social / external;'' = 未分类
deviceTEXTdesktop / mobile / tablet / bot / unknown('' 仅存在于 v4 之前的历史行)
bot_nameTEXT爬虫名(Googlebot / Baiduspider / OtherBot …),非爬虫为空
is_botINTEGER1 = 爬虫。所有 PV/UV 统计都排除它
elapsed_msINTEGER本请求耗时(microtime - REQUEST_TIME_FLOAT)
statusINTEGER响应码。0 = v4 之前的历史行(未知),404 用于死链统计
sidTEXT访问会话键(sha256(PHP 会话 id) 前 32 位),关联 ss_sessions
created_atINTEGERUnix 秒

索引(v6 共 9 条):

索引列服务对象
idx_ss_views_created(created_at)兜底
idx_ss_views_path(path, created_at)热门页面
idx_ss_views_refhost(ref_host, created_at)外链来源
idx_ss_views_reftype(ref_type, created_at)来源分类
idx_ss_views_iphash(ip_hash, created_at)UV 去重
idx_ss_views_err(status, created_at) 部分 WHERE status >= 400错误码分组
idx_ss_views_bot(is_bot, created_at)覆盖 PV/UV/趋势/时段/爬虫
idx_ss_views_perf(is_bot, elapsed_ms, created_at)覆盖 慢页面 / P95

⚠️ 部分索引的硬约束(v6 实测结论,SQLite 3.39.2)

SQLite 只在「查询 WHERE 能在 prepare 阶段证明蕴含索引 WHERE」时才使用部分索引。

干净隔离实测(每表仅 1 条索引):

索引谓词查询形态结果
WHERE status >= 400status = 404SCAN(用不上)
WHERE status >= 400status >= 400采用
WHERE ms >= 1000ms >= ?(绑定参数 1000)SCAN(用不上)
WHERE ms >= 1000ms >= 1000(字面量)采用
WHERE ms > 0ms >= ?(绑定参数 1000)SCAN(用不上)

→ 带绑定参数阈值的查询永远用不上部分索引;= 也推不出 >=。

  • idx_ss_views_err 保留部分索引:错误码分组查询写的是字面量 status >= 400,实测 1.4 ms 命中。

但 status = 404 的死链查询用不上它(规划器改走 idx_ss_views_path,约 430 ms)——已知限制。

  • v4 曾建的 idx_ss_views_slow(elapsed_ms, created_at) WHERE elapsed_ms >= 1000 是死索引

(慢页面查询走 elapsed_ms >= :ms 绑定参数)→ v6 删除,换成 idx_ss_views_perf。

为什么「覆盖」比「有索引」更重要

v5 及以前所有统计查询都带 is_bot = 0 AND created_at >= ?,而 idx_ss_views_created 不含 is_bot

→ 每条候选都要回表 → 20 万行 / 90 天下单条查询 430–540 ms。

补 (is_bot, created_at) 后同类查询走覆盖索引(零回表)→ 降到 35–55 ms。

实测对照(20 万行合成数据 / 90 天跨度 / 12 条真实查询 / 绑定参数):

v5 索引集v6 索引集
12 条查询合计4182 ms2222 ms(−47%)
时段分布 30d501 ms55 ms
爬虫统计 30d445 ms39 ms
性能聚合 30d489 ms34 ms
P95 30d549 ms1.9 ms
写入 2 万行662 ms915 ms(每条 +12.7 µs)

无任何查询回归(topPages +2 ms,其余持平或更快)。

ss_visitors — 访客(一个 ip_hash 一行)

列说明
ip_hashPK
first_seen / last_seen首次 / 最近访问时间 → 新老访客、留存
pv累计浏览数

ss_sessions — 访问会话(一次「会话」一行)

列说明
sidPK
ip_hash / user_id访客标识
started_at / last_at首末浏览时间 → 会话时长
pv会话内浏览数 → 访问深度、跳出率(pv = 1 即跳出)
entry_path / exit_path入口 / 出口页面
is_bot恒 0(爬虫不建会话)

ss_daily — 日表

day(PK,应用时区 YYYY-MM-DD)、pv、uv、bot_pv、updated_at。

供「概览」的 90 天长趋势免扫明细。由 1% 概率维护(近 3 天)与后台「重建日表」按钮(默认 90 天)写入。

ss_config — 插件配置(key/value)

key说明
site_hosts本站域名白名单,换行分隔。用于判定「站内跳转」
record_bots'1'(默认)记录爬虫 / '0' 丢弃
slow_ms慢页面阈值(毫秒,默认 1000)

⚠️ 配置存插件自己的库,不写核心 settings(§11.6 / §11.8)。


数据层版本号门控

public const SCHEMA_VERSION = 6;   // 改「建表 / 补列 / 回填 / 索引」逻辑必须 +1

  • 戳文件:protected/.site_stats_schema_v{N}.stamp
  • 门控入口:Plugin::ensureReady() —— 戳存在即 is_file() 短路(零 SQL);否则跑一次

「建表 → 补列 → 回填」并落戳。

  • 调用方:hook/init_after.php 与 Plugin::db() 自愈(双保险)。
  • ⚠️ 必须比对版本号,不能只判「戳是否存在」 —— 否则后加列时老安装永不重跑,新列静默建不出来。
  • ⚠️ SQLite 不支持 ADD COLUMN IF NOT EXISTS(实测 near "EXISTS" 语法错误),

补列必须 PRAGMA table_info 检测后再 ALTER(见 Plugin::ensureColumns())。

  • ⚠️ 新增表时必须同时 +1:只往 buildSchema() 里加 DDL 而不改版本号 → 老安装的戳仍有效

→ 新表静默建不出来,直到写入时才 no such table 报错。

  • ⚠️ ensureReady() 只在 migrate() 成功时才落戳(migrate() 失败返回 -1)。

v4 曾踩过:migrate() 里调了当时还不存在的 parseDevice() → 抛异常被内部吞掉 → 返回 0 →

戳照样写下 → 回填永久静默跳过(页面正常、不报错、无日志)。失败时宁可每请求重试(幂等且分批)。

  • ⚠️ 索引集变更也要 +1:CREATE INDEX IF NOT EXISTS 不会因定义变化而重建同名索引,

改定义必须换名或先 DROP INDEX IF EXISTS。

  • ⚠️ Plugin::db() 内 self::$schemaReady = true 必须写在 ensureReady() 之前:

ensureReady() → migrate() → siteHosts() → config() → db() 会回调本方法,

若在 ensureReady() 返回后才置位 → 无限递归(实测内存耗尽于 DBFactory.php)。


采集行为

Plugin::maybeRecord()(由 hook/route_after_dispatch.php 与 shutdown 兜底共用)判定顺序:

  1. PHP_SAPI === 'cli' 跳过
  2. 非 GET 跳过
  3. 路径命中 /(api|assets|plugins/*/assets|favicon.ico|robots.txt) 跳过
  4. UA 为空、或命中噪音 UA(python-requests|curl/|wget|go-http-client|okhttp|axios|guzzle …)跳过
  5. 命中爬虫特征 → 按 record_bots 配置:关则跳过,开则记 is_bot=1 + bot_name
  6. 会话级 60 秒同路径节流(_ss_v_<md5(path)>)

通过后一次事务写 3 张表:明细 + 访客表 + 会话表(爬虫只写明细)。

每请求查询数 = config 1 + 明细 1 + 访客 1 + 会话 1 = 4(§11.4 单插件上限 5)。

1% 概率顺带清理 90 天前明细并重算近 3 天日表。

404 / 5xx 的兜底采集

Router::dispatch() 的 404 分支末尾是 exit;(app/Core/Router.php:128)

→ index.php:321 的 route_after_dispatch 不会触发 → 404 明细天然采不到。

核心没有 404 钩子,故 Plugin::registerShutdownFallback() 在 init_after 注册一个

shutdown 回调:状态码 ≥ 400 且本请求尚未记录时补一次。

(正常页仍由 route_after_dispatch 负责,静态标志去重,不会双记。)

⚠️ 已知精度限制

限制说明
列表页游客 PV 偏低index.php:17-96 有前置静态缓存:纯游客 GET 首页 / /forum / /forum/category/{id} 命中 data/runtime/pages/ 后直接 exit,完全跳过 init.php(含本插件钩子)。→ 这几类列表页的游客浏览只在缓存失效后首次渲染时记 1 次。详情页与其他页面不受影响。这是核心既有行为,本插件未改核心
拒绝 cookie 的客户端会话偏高会话键取 PHP 会话 id;不接受 cookie 时每次请求都新建会话 → 会话数偏高、跳出率偏高、访问深度偏低
「会话时长」≠ 单页停留服务端只能算「末次 − 首次浏览」的间隔。真正的单页停留需前端 beacon 上报
留存为近似只用 first_seen / last_seen 两个时间点:D-N = 「首次访问后 ≥N 天仍出现过」,无法区分中间是否流失
爬虫量取决于抓取频率爬虫通常不接 cookie → 会话节流对它无效,每次抓取都会记一行。量大时可在「维护」页关掉 record_bots
跨域搜索关键词不可得浏览器 strict-origin-when-cross-origin 只发 origin,百度/Google 早已剥离 query
历史行 status = 0v4 之前的行没有响应码,不参与 404 / 错误码统计
3xx 跳转不采集Controller::redirect() 内部 exit → route_after_dispatch 不触发;shutdown 兜底只补 ≥ 400。即 302(如未登录访问 /admin/* 跳登录页)不记 PV —— 设计上不把跳转当浏览
404 死链查询慢status = 404 用不上部分索引 idx_ss_views_err(SQLite 谓词可证明性限制,见上文),规划器改走 idx_ss_views_path;20 万行约 430 ms


后台功能

左侧导航复用核心 admin/_sidebar(__nav_active = 'plugins'),多分区复用核心

.admin-tabs / .admin-tab-btn / .admin-tab-pane(§11.10),不自造 tab 类。

Tab内容
概览6 张卡片固定分两行:第 1 行 浏览量、第 2 行 访客(每行均为 今日 / 近 7 天 / 近 30 天)+ 环比(带涨跌箭头与百分比)+ 近 7 天柱状趋势 + 近 90 天长趋势(读日表)
来源来源类型分布(带占比条)+ 外链来源 Top 20 + 外链落地页 Top 10(均近 7 天)
内容热门页面 Top 10(近 7 天)
访客(近 30 天)会员 vs 游客、新老访客、设备分布、24 小时时段分布、星期 × 小时热力图、留存(近似)、会话指标(会话数 / 跳出率 / 会话时长 / 平均访问深度 + 深度分布)
技术(近 30 天)响应耗时基线(平均 / P95 / 最慢 / 样本数)、慢页面排行、404 死链 + 错误码分布、爬虫访问
维护数据概况(含爬虫行数)+ 采集设置(记录爬虫 / 慢页面阈值)+ 本站域名白名单 + 重建日表 + 清理旧数据

⚠️ 所有 PV / UV / 来源 / 访客统计一律排除爬虫(is_bot = 0);

爬虫单独在「技术」页统计。唯一例外是 404 死链(含爬虫)——爬虫撞 404 恰恰是 SEO 问题。

概览卡片的两行布局

6 张卡分两个 .ss-cards 栅格渲染(一个栅格 = 一行):

行内容环比基准
1今日 / 近 7 天 / 近 30 天 浏览量昨日 / 上周同期 / 上月同期
2今日 / 近 7 天 / 近 30 天 访客同上

⚠️ 顺序由 Plugin::getStats() 的 $cards 决定(前 3 张 = 浏览量行),

视图 views/admin.php 用 array_chunk($cards, 3) 拆行 —— 改卡片顺序必须两边一起看。

⚠️ 拆成两个栅格而不是靠列数凑:auto-fit 只在行内部折行、不会跨行,

所以窄屏下也保得住「两行」语义(别把两个栅格合回一个)。

本站域名白名单

referer 判定「站内跳转」需要一个本站域名白名单:本站有多个访问入口

(如主域名 p.zgzjw.top 与本机 localhost)时,只用当前请求 host 会把另一个入口的

站内跳转误判成外链(回填时实测踩到)。

生效的白名单 = 后台配置项 ∪ 自动推导(SITE_URL 常量 / 当前请求 host /

核心 seo_site_url 设置 / localhost 127.0.0.1 ::1)。

保存白名单后自动触发一次全量重新分类(reclassifyAll()),把历史误判纠正过来。

分类在写入时完成(此时能拿到白名单)——查询时分类拿不到历史行的请求 host,必然误判。

环比配色

涨 = 绿(--c-success)、跌 = 红(--c-danger)、持平 = 灰。这是「站点健康度」读法

(流量涨 = 健康),不是 A 股红涨绿跌口径;如需对调改 assets/style.css 的

.ss-cmp-up / .ss-cmp-down 两行即可。

热力图配色

按强度分 5 档 ss-heat-0(无访问)→ ss-heat-4(最热),档位在 PHP 侧算好,

视图不输出内联样式(零内联约束下的做法)。


安全

项做法
鉴权requireAdmin() + requireAdminVerified() —— 与核心 Admin\BaseController 同口径(管理员 + 二次验证)。插件继承 app\Core\Controller,不会触发核心后台构造函数门禁,必须自行补齐
管理员判定走核心 Auth::isAdmin()(role === 'admin' 且 group_id === 4),不自行实现
CSRF所有 POST 走 Csrf::verifyOrDie($_POST['csrf'] ?? '');4 个表单均带 csrf 隐藏域
客户端 IP⚠️ X-Forwarded-For 可伪造 → 仅当直连 REMOTE_ADDR 落在受信代理网段内才采信,否则恒用 REMOTE_ADDR(与核心 AuditLog::TRUSTED_PROXIES 同口径)
IP 隐私只存加盐哈希,不落明文
SQL全部参数绑定,无字符串拼接
输出视图全部经 $this->e();数字经 (int)
零内联无 <script> 逻辑块 / 无 on* / 无静态 style="";仅 style="--ss-h:N%"、style="--ss-w:N%" 属 §7.3 第 4 条合规的 CSS 变量注入
路由hook/admin_route_register.php 判 $router 存在;plugin.json 声明 route:admin


安装 / 升级

  1. 把 site_stats/ 放进 plugins/。
  2. 改过 plugin.json 的 hooks 清单才需要重建 plugins/plugins_cache.json

(本次 hooks 三项未变,不需要重建)。

  1. 后台 → 插件管理 → 启用。
  2. 升级:覆盖代码即可。SCHEMA_VERSION 变了会自动重跑一次「建表 + 补列 + 回填 + 建索引」。
  • v4:ss_views 补 6 列、建 3 张新表、回填历史行 device
  • v5:仅修「迁移失败仍落戳」缺陷,强制重跑一次 device 回填
  • v6:删死索引 idx_ss_views_slow,新增覆盖索引 idx_ss_views_bot / idx_ss_views_perf(纯 DDL,不动数据)
  1. ⚠️ 部署时不要覆盖 plugins/site_stats/data/(独立库)与 protected/*.stamp。

卸载

uninstall() 会 DROP TABLE 掉 ss_views / ss_config / ss_visitors / ss_sessions / ss_daily,

并清戳文件。卸载后请确认:① 五张表已删;② protected/.site_stats_schema_v*.stamp 已清;

③ plugins_cache.json 已由系统重建。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 3 | Replies 0

All Replies (0)

No replies yet

Please Log in