记录页面浏览,展示 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 — 浏览明细
| 列 | 类型 | 说明 |
|---|---|---|
id |
INTEGER PK | |
path |
TEXT | 去掉 BASE_PATH 的路径,≤255 |
user_id |
INTEGER | 0 = 游客 |
ip_hash |
TEXT | sha256(客户端IP + 固定盐) 前 32 位,不落明文 |
ua |
TEXT | User-Agent,≤255 |
referer |
TEXT | 原始 Referer,≤255 |
ref_host |
TEXT | referer 主机名(小写、去端口、去 www.) |
ref_type |
TEXT | direct / internal / search / social / external;'' = 未分类 |
device |
TEXT | desktop / mobile / tablet / bot / unknown('' 仅存在于 v4 之前的历史行) |
bot_name |
TEXT | 爬虫名(Googlebot / Baiduspider / OtherBot …),非爬虫为空 |
is_bot |
INTEGER | 1 = 爬虫。所有 PV/UV 统计都排除它 |
elapsed_ms |
INTEGER | 本请求耗时(microtime - REQUEST_TIME_FLOAT) |
status |
INTEGER | 响应码。0 = v4 之前的历史行(未知),404 用于死链统计 |
sid |
TEXT | 访问会话键(sha256(PHP 会话 id) 前 32 位),关联 ss_sessions |
created_at |
INTEGER | Unix 秒 |
索引(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 >= 400 |
status = 404 |
SCAN(用不上) |
WHERE status >= 400 |
status >= 400 |
采用 |
WHERE ms >= 1000 |
ms >= ?(绑定参数 1000) |
SCAN(用不上) |
WHERE ms >= 1000 |
ms >= 1000(字面量) |
采用 |
WHERE ms > 0 |
ms >= ?(绑定参数 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 ms | 2222 ms(−47%) |
| 时段分布 30d | 501 ms | 55 ms |
| 爬虫统计 30d | 445 ms | 39 ms |
| 性能聚合 30d | 489 ms | 34 ms |
| P95 30d | 549 ms | 1.9 ms |
| 写入 2 万行 | 662 ms | 915 ms(每条 +12.7 µs) |
无任何查询回归(topPages +2 ms,其余持平或更快)。
ss_visitors — 访客(一个 ip_hash 一行)
| 列 | 说明 |
|---|---|
ip_hash |
PK |
first_seen / last_seen |
首次 / 最近访问时间 → 新老访客、留存 |
pv |
累计浏览数 |
ss_sessions — 访问会话(一次「会话」一行)
| 列 | 说明 |
|---|---|
sid |
PK |
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 兜底共用)判定顺序:
PHP_SAPI === 'cli'跳过- 非 GET 跳过
- 路径命中
/(api|assets|plugins/*/assets|favicon.ico|robots.txt)跳过 - UA 为空、或命中噪音 UA(
python-requests|curl/|wget|go-http-client|okhttp|axios|guzzle…)跳过 - 命中爬虫特征 → 按
record_bots配置:关则跳过,开则记is_bot=1+bot_name - 会话级 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 = 0 |
v4 之前的行没有响应码,不参与 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 |
安装 / 升级
- 把
site_stats/放进plugins/。 - 改过
plugin.json的hooks清单才需要重建plugins/plugins_cache.json(本次hooks三项未变,不需要重建)。 - 后台 → 插件管理 → 启用。
- 升级:覆盖代码即可。
SCHEMA_VERSION变了会自动重跑一次「建表 + 补列 + 回填 + 建索引」。- v4:
ss_views补 6 列、建 3 张新表、回填历史行device - v5:仅修「迁移失败仍落戳」缺陷,强制重跑一次 device 回填
- v6:删死索引
idx_ss_views_slow,新增覆盖索引idx_ss_views_bot/idx_ss_views_perf(纯 DDL,不动数据)
- v4:
- ⚠️ 部署时不要覆盖
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 已由系统重建。

