码桶
发现社区成员的开源项目
版本历史
8 次提交
1.0.14geekssl 提交于 2026-09-21 08:53
f8d02891.0.14geekssl 提交于 2026-09-21 08:52eb6110f1.0.14geekssl 提交于 2026-09-21 08:52c221b4a1.0.14geekssl 提交于 2026-09-21 08:5057d01b1更新项目文件geekssl 提交于 2026-09-16 17:17b82a3e1更新项目文件geekssl 提交于 2026-09-16 17:173ccfa18更新项目文件geekssl 提交于 2026-09-16 17:15ba23a60创建仓库geekssl 提交于 2026-09-16 17:13a04b89a 提交详情
c221b4a提交编号
c221b4ad678a7cd0ff2a9846ce62832b11ecec15提交作者geekssl<codebucket@localhost>
提交时间2026-09-21 08:52:01 +0800
更新说明1.0.14
文件变更 22 个文件
文件新增删除
.dockerignore+15-0.env.example+40-0.gitignore+13-0CHANGELOG.md+151-145Dockerfile+52-0LICENSE+21-0README.md+352-181RELEASE.md+42-42THIRD-PARTY-NOTICES.md+26-0config.php+224-224deploy/apache.htaccess.sample+47-47deploy/nginx.conf.sample+113-113docker-compose.yml+127-0docker/cron-loop.sh+46-0docker/entrypoint.sh+116-0docs/deploy.md+494-460scripts/cron.php+177-177scripts/doctor.php+258-258scripts/fetch-geo.php+113-113scripts/geo-doctor.php+225-225scripts/selfcheck.php+282-191scripts/worker.php+318-318文件差异 逐行查看本次提交的修改
CHANGELOG.md+151-145
@@ -1,145 +1,151 @@1
-# 更新日志2
-3
-格式约定:`## 版本号` 开头的小节,其下的列表行即为该版本的更新条目。4
-5
-## 1.0.10(未发布)6
-7
-- **IP 地域解析支持 IPv6(IPv4 / IPv6 双离线库)**:`Support\IpLocator` 从「只认 IPv4」改为**按地址族分派** ——8
- IPv4 走 `data/ip2region.xdb`(配置 `collect.geo_xdb`),IPv6 走 `data/ip2region_v6.xdb`(新增配置9
- `collect.geo_xdb6`)。两库结构相同,但**索引项布局与字节序都不同**:IPv4 索引项 14 字节(`4+4+2+4`,10
- sip/eip 为**小端**,是 ip2region 早期实现的历史包袱)、IPv6 为 38 字节(`16+16+2+4`,sip/eip 为11
- **大端原样**)。因此两族的比较方式也不同(v4 比整数、v6 比 16 字节二进制串),差异全部收敛在12
- `addrKey()` / `recKey()` / `keyCmp()` 三个函数里,检索流程只写一份。IPv4-mapped 地址13
- (`::ffff:a.b.c.d`,双栈套接字常见)会**改判为 IPv4** 并走 v4 库(它本质就是 v4 地址,且 v4 数据更全);14
- IPv6 内网/保留段(`::1`、`fc00::/7`、`fe80::/10`、组播、6to4 等)直接跳过检索,不做无效匹配15
-16
-## 1.0.917
-18
-- 新增**个人中心**(`/profile`):个人资料查看与修改(昵称 / 界面语言;邮箱是登录凭证,只读展示);19
- 修改密码(需当前密码,成功后**其它设备立即下线**、当前设备自动换用新令牌继续有效);20
- **我的操作日志**:登录 / 登出 / 改资料 / 改密,以及管理员对本账号的全部操作(创建、改密、禁用、删除等)21
- 逐条留痕(时间 / 动作 / 说明 / IP),分页展示22
-- 新增**用户管理**(`/users`,仅系统管理员):全部注册用户的增删改查 —— 关键词与状态筛选、分页列表23
- (含站点数、最近登录时间);新建用户(可设为管理员);编辑昵称 / 语言、重置密码24
- (重置后该用户全部会话立即失效);**禁用 / 启用**(禁用立即踢下线且无法登录);25
- 删除用户(级联清理其令牌 / 站点成员关系 / 告警配置等个人数据,`alert_logs` 留痕保留);26
- 一键查看某用户**名下与被授权的全部站点**。防锁死保护:不能禁用 / 删除 / 降级自己,27
- 不能动最后一个管理员;名下有站点的用户拒绝删除28
-- 新增操作日志基建 `user_op_logs`(增量 SQL `sql/upgrade-2026-09-16-profile-users.sql`,29
- 幂等可重复执行;老库未执行时功能自动降级并给出明确提示,不影响其它功能);30
-31
-## 1.0.832
-33
-- **访客真实 IP 改为「手动指定取值来源」,程序不再做任何自动判断**:此前会自动识别 Cloudflare 回源段、34
- 按可信代理列表与头优先级顺序推断,分支多、结果难以预期。现在管理员在系统设置 →「真实 IP 采集」中35
- 从 **X-Forwarded-For / X-Real-IP / REMOTE_ADDR / 自定义头** 四项里**显式选一个**,程序只做一件事:36
- 取该来源头里**最右侧**的合法 IP(Cloudflare 与 Nginx 都把真实地址**追加**到末尾,客户端伪造值只能残留在37
- 左侧 —— 取最左等于采信攻击者输入);指定头缺失或值非法时回落 `REMOTE_ADDR` 并标注38
-- **真实 IP 自检升级**:新增「候选来源实测表」,一次性列出四个来源在**当前连接**下的真实取值 ——39
- 哪一行显示出你的真实地址就选哪一行,并支持一键套用;同时回显取值来源与头名、是否发生回落、40
- `REMOTE_ADDR` 是否属于 CDN 回源段、地域解析结果与处置建议41
-- **站点验证弹窗新增「下载验证文件」按钮**:下载验证文件放到站点根目录即可完成验证,42
- 不必再手动复制令牌(下载失败时仍回显可访问 URL 作为兜底)43
-44
-## 1.0.745
-46
-- 修复**「概览 IP 数」与「IP 地域页」数字对不上**(如概览显示 4、地域页只有 3):47
- 概览的 UV / 独立 IP 此前在 Redis 可用时走 HyperLogLog 估算(`PFCOUNT`),而估算结构在极小基数下会多估,48
- 且与地域页的精确值天然是两个口径。现在**UV / 独立 IP 一律由 `events` 明细精确去重**49
- (跨天合并也在 SQL 内完成),概览、环比、IP 地域页、大屏、开放接口全部同源同口径,50
- **Redis 与无 Redis 两种部署形态结果完全一致**;估算路径(含采集端 `PFADD`)已整体移除51
-- 修复**大屏 / 开放接口区间 UV、独立 IP 重复计数**:此前把每日汇总值逐日相加(跨天不去重),52
- 区间越长越偏大;现改为区间精确去重53
-- 修复**每日独立 IP 把空 IP 计为 1 个**:取不到真实 IP 的事件不再计入独立 IP(与 IP 地域页 `ip<>''` 口径一致)54
-- IP 地域页的「IP 统计」改为按 `events` 聚合(PV / UV / 会话数),并显示区间独立 IP 总数,55
- 与概览「IP 数」严格一致56
-57
-## 1.0.658
-59
-- 新增**外部代码注入**:系统设置里可粘贴第三方统计(百度统计、Google Analytics 等)或在线客服代码,60
- 保存后按原样注入本站每个页面 `</head>` 之前,用于统计本站自身访问、挂载客服窗口(仅管理员可配置;61
- 与「站点接入代码模板」相互独立——后者是给被统计站点站长粘贴的模板,本站不渲染)62
-- 新增**每个页面独立标题**:浏览器标签页标题按路由显示「页面名 · 应用名」(跟随中英文切换),63
- 移动看板 `/m` 的标题跟随当前 Tab64
- 对确属 CF 回源的请求优先读取 `CF-Connecting-IP`;同时修好代理匹配的 IPv6 支持65
- (旧实现用 `ip2long`,对 IPv6 恒 false → IPv6 回源时判定会静默失效)66
-- 新增**真实 IP 自检**:系统设置 →「真实 IP 采集(CDN / 反向代理)」一键检测,回显 REMOTE_ADDR、67
- 最终判定的访客 IP、取值来源、每个代理头的原始值与是否采用、地域解析结果与处置建议68
- (新接口 `GET /api/settings/ip-check`,仅系统管理员)69
-70
-## 1.0.571
-72
-- 新增**站点接入代码自定义**:系统设置里可填写本系统的接入代码模板(支持 `{site_key}` / `{host}` / `{origin}`73
- 占位符,留空即用内置默认片段),Sites 页「查看 SDK 接入代码」与复制按钮直接使用该模板,74
- 站点成员无需管理员权限即可查看(新接口 `GET /api/sdk-code`)75
-- 修复**安装向导创建的第一个账号没有管理员权限**:`users.is_admin` 列默认值为 0,76
- 而「首个账号即管理员」的回退逻辑只在 `is_admin` 列缺失时才生效,列存在却没写 1 就只是普通用户,77
- 导致装完进不了「系统设置」;现在安装向导与注册接口(库中还没有用户时)都会显式写入管理员标记78
-- 安装向导**只展示必须项**:环境检测默认只展开必须项并显示「必须项 N/M」与未通过项,79
- 其余可选/提示项收进「查看其余检测项」折叠区80
-- 修复安装向导**数据库与 Redis 表单错位**:输入框改为固定两列布局并占满列宽(此前列数随内容跳变、81
- 控件宽度随默认字符数伸缩),复选框移出网格单独成行,窄屏自动转为单列82
-- 发版脚本新增**已发布版本守卫**:`versions.json` 中已存在同版本号的包时,构建会在改动任何文件之前中止,83
- 避免开发副本忘记升版本号直接打包、悄悄替换掉已发布包导致老客户端校验失败(确需重打用 `--republish`)84
-85
-## 1.0.486
-87
-- 新增**移动端看板**(独立入口 `/m`):概览 / 实时 / 来源 / 页面 四个主界面,88
- 底部 Tab 切换、区间可切换、与桌面端共享站点选择与登录态;手机与平板打开站点时**登录后自动进入移动看板**,89
- 桌面端仍进概览;桌面侧栏不再放移动看板入口(移动看板自身顶部保留「桌面版」入口)90
-- 前端**代码分割与按需加载**:26 个页面由静态 import 改为路由级懒加载(React.lazy + Suspense),91
- ECharts 由整包引入改为按需注册(line/bar/pie/map/funnel/sankey + 必要组件 + Canvas 渲染器);92
- 首屏 JS 2625KB → 525KB(gzip 172KB),ECharts 653KB 独立成 chunk,只在进入带图页面时下载93
-- 界面语言**按浏览器语言自动适配**:首次访问按 `navigator.languages` 判定(中文 → 简体中文,其余语言 → 英文),94
- 登录 / 注册 / 忘记密码等未登录页面同样生效;手动切换过的语言记入 `localStorage` 并优先于浏览器语言95
-- 移除「功能全景」页面及其侧栏入口(页面为静态清单,不涉及后端接口);96
- 相关说明与指标口径请见「指标口径说明」页与 `CHANGELOG.md`97
-- 移动端判定兼容 iPadOS 13+(该版本 Safari 默认上报 Macintosh UA,以 `maxTouchPoints` 补判)98
-99
-## 1.0.3100
-101
-- 修复页脚版本号长期停在旧版(升级到 1.0.2 后仍显示 `v1.0.0`):以前版本号有两个来源,102
- 其中一个 `data/installed.php` 的 `version` 是「安装当刻的快照」,在手工覆盖代码 / 目录迁移这两种103
- 最常见的升级方式下**永不刷新**,于是生效版本永远停在安装那一版104
-- 修复「检查更新一直提示存在更新」:上一条的旧版本号被原样报给更新服务,远端返回105
- `latest=1.0.2 > 本地 1.0.0`,于是 `has_update` 永远为真;点「一键升级」又被布局守卫拒绝,形成死循环106
-- 版本号与页脚品牌改为**单一事实来源的密封载荷**(`app/version.php`):异或密钥流 + HMAC-SHA256,107
- 源码与配置里不再出现明文版本号/品牌名;手改密文即校验失败并回落官方默认(版本显示 `0.0.0`),108
- 而不是「改一个字就生效」;改版本号只有一条正当路径 —— 改 `install/lib.php` 的 `VERSION` 后执行109
- `php scripts/seal-version.php`(注意:这是混淆而非防破解,密钥就在源码里)110
-- 版本号随代码走:密封载荷放在 `app/` 下,属更新白名单且不受 `preserve` 保护,111
- 一键升级或手工覆盖代码都会一并更新,**以后升级不再需要手工改任何版本号**112
-- `data/installed.php` 的 `version` 降级为纯记录字段(安装快照 + 部署凭据),合并配置时被显式忽略;113
- 一键升级不再回写它,升级流水改记到 `data/version.json`(含最近 20 条历史)114
-- 发版脚本新增两道硬校验:密封载荷与 `Installer::VERSION` 不一致、或包内解出的版本与构建版本不一致,115
- 直接中止构建,杜绝「发出去的包页脚版本对不上」116
-- 修复 `doctor.php` 队列类型诊断行输出为空:全角逗号紧跟 `$qt` 被 PHP 当成变量名的一部分,实际输出117
- 成了「实际==list」118
-119
-## 1.0.2120
-121
-- 修复国旗图标在部分国家下导致整页报错:马来西亚 / 澳大利亚 / 土耳其的星形坐标被写成字符串,122
- `cx + r * Math.cos()` 变成字符串拼接,渲染时抛 `toFixed is not a function`,123
- 且因图标是条件渲染,仅当访客来自这些国家时才复现;现已在组件内强制转数字并修正写法124
-- 构建前静态门禁新增一类检查:几何数值属性被写成字符串(`cx="8.4"` 应为 `cx={8.4}`),125
- 这类问题 `vite build` 不报错、只在运行期炸;另新增图标真渲染冒烟脚本,覆盖全部国旗与环境图标分支126
-127
-## 1.0.1128
-129
-- 发布包目录结构统一为 `app/` `data/` `public/` `scripts/`,`public` 即网站运行目录,不再区分普通版与宝塔版130
-- 新增在线更新:`https://wstats-update.kfw.cc/update.php` 提供版本索引与增量更新包,面板「关于与更新」支持检查更新与一键升级131
-- 升级前自动备份被覆盖文件到 `data/backup/`,执行新增的 `sql/upgrade-*.sql` 增量脚本132
-- 安装向导按新目录结构重新探测项目根与建表脚本位置133
-- 发布包内附 `data/ip2region.xdb` 离线 IP 库,地域统计开箱可用134
-- 发布包瘦身:去掉开发/造数脚本(`selftest-*`、`seed-*`)、历史参考 SQL(`schema.sql`、`wstat.sql`)、135
- 开发文档(架构说明、内部功能审计报告)、已废弃的宝塔扁平包配置样例、重复的 `sdk/` 副本与陈旧前端产物;136
- 构建时双向校验「开发产物不得入包 + 运行期必需文件必须在包」,包体 18.6MB → 16.0MB137
-138
-## 1.0.0139
-140
-- 首个正式版本:PV/UV/IP/会话/停留时长/跳出率、来源、地域、终端环境、页面下钻141
-- 实时概览、数据大屏(时间筛选 / 刷新倒计时 / 全屏)142
-- 漏斗与转化目标、广告追踪、热力图、访客旅程、受众分析143
-- 开放 API(PAT 令牌)、CSV 导出、流量异常告警与日报推送144
-- 多用户协作(所有者 / 编辑者 / 只读)、系统设置、注册邮箱验证码与忘记密码145
-- 宝塔/通用 Nginx、Apache 部署样例,Web 安装向导与 CLI 安装1
+# 更新日志2
+3
+格式约定:`## 版本号` 开头的小节,其下的列表行即为该版本的更新条目。4
+5
+## 1.0.116
+7
+- **新增:搜索引擎来路关键词**8
+- **新增:IP 追踪溯源**9
+- **SDK v1.5:来源保真(301/302 跳板场景)**10
+11
+## 1.0.1012
+13
+- **IP 地域解析支持 IPv6(IPv4 / IPv6 双离线库)**:`Support\IpLocator` 从「只认 IPv4」改为**按地址族分派** ——14
+ IPv4 走 `data/ip2region.xdb`(配置 `collect.geo_xdb`),IPv6 走 `data/ip2region_v6.xdb`(新增配置15
+ `collect.geo_xdb6`)。两库结构相同,但**索引项布局与字节序都不同**:IPv4 索引项 14 字节(`4+4+2+4`,16
+ sip/eip 为**小端**,是 ip2region 早期实现的历史包袱)、IPv6 为 38 字节(`16+16+2+4`,sip/eip 为17
+ **大端原样**)。因此两族的比较方式也不同(v4 比整数、v6 比 16 字节二进制串),差异全部收敛在18
+ `addrKey()` / `recKey()` / `keyCmp()` 三个函数里,检索流程只写一份。IPv4-mapped 地址19
+ (`::ffff:a.b.c.d`,双栈套接字常见)会**改判为 IPv4** 并走 v4 库(它本质就是 v4 地址,且 v4 数据更全);20
+ IPv6 内网/保留段(`::1`、`fc00::/7`、`fe80::/10`、组播、6to4 等)直接跳过检索,不做无效匹配21
+22
+## 1.0.923
+24
+- 新增**个人中心**(`/profile`):个人资料查看与修改(昵称 / 界面语言;邮箱是登录凭证,只读展示);25
+ 修改密码(需当前密码,成功后**其它设备立即下线**、当前设备自动换用新令牌继续有效);26
+ **我的操作日志**:登录 / 登出 / 改资料 / 改密,以及管理员对本账号的全部操作(创建、改密、禁用、删除等)27
+ 逐条留痕(时间 / 动作 / 说明 / IP),分页展示28
+- 新增**用户管理**(`/users`,仅系统管理员):全部注册用户的增删改查 —— 关键词与状态筛选、分页列表29
+ (含站点数、最近登录时间);新建用户(可设为管理员);编辑昵称 / 语言、重置密码30
+ (重置后该用户全部会话立即失效);**禁用 / 启用**(禁用立即踢下线且无法登录);31
+ 删除用户(级联清理其令牌 / 站点成员关系 / 告警配置等个人数据,`alert_logs` 留痕保留);32
+ 一键查看某用户**名下与被授权的全部站点**。防锁死保护:不能禁用 / 删除 / 降级自己,33
+ 不能动最后一个管理员;名下有站点的用户拒绝删除34
+- 新增操作日志基建 `user_op_logs`(增量 SQL `sql/upgrade-2026-09-16-profile-users.sql`,35
+ 幂等可重复执行;老库未执行时功能自动降级并给出明确提示,不影响其它功能);36
+37
+## 1.0.838
+39
+- **访客真实 IP 改为「手动指定取值来源」,程序不再做任何自动判断**:此前会自动识别 Cloudflare 回源段、40
+ 按可信代理列表与头优先级顺序推断,分支多、结果难以预期。现在管理员在系统设置 →「真实 IP 采集」中41
+ 从 **X-Forwarded-For / X-Real-IP / REMOTE_ADDR / 自定义头** 四项里**显式选一个**,程序只做一件事:42
+ 取该来源头里**最右侧**的合法 IP(Cloudflare 与 Nginx 都把真实地址**追加**到末尾,客户端伪造值只能残留在43
+ 左侧 —— 取最左等于采信攻击者输入);指定头缺失或值非法时回落 `REMOTE_ADDR` 并标注44
+- **真实 IP 自检升级**:新增「候选来源实测表」,一次性列出四个来源在**当前连接**下的真实取值 ——45
+ 哪一行显示出你的真实地址就选哪一行,并支持一键套用;同时回显取值来源与头名、是否发生回落、46
+ `REMOTE_ADDR` 是否属于 CDN 回源段、地域解析结果与处置建议47
+- **站点验证弹窗新增「下载验证文件」按钮**:下载验证文件放到站点根目录即可完成验证,48
+ 不必再手动复制令牌(下载失败时仍回显可访问 URL 作为兜底)49
+50
+## 1.0.751
+52
+- 修复**「概览 IP 数」与「IP 地域页」数字对不上**(如概览显示 4、地域页只有 3):53
+ 概览的 UV / 独立 IP 此前在 Redis 可用时走 HyperLogLog 估算(`PFCOUNT`),而估算结构在极小基数下会多估,54
+ 且与地域页的精确值天然是两个口径。现在**UV / 独立 IP 一律由 `events` 明细精确去重**55
+ (跨天合并也在 SQL 内完成),概览、环比、IP 地域页、大屏、开放接口全部同源同口径,56
+ **Redis 与无 Redis 两种部署形态结果完全一致**;估算路径(含采集端 `PFADD`)已整体移除57
+- 修复**大屏 / 开放接口区间 UV、独立 IP 重复计数**:此前把每日汇总值逐日相加(跨天不去重),58
+ 区间越长越偏大;现改为区间精确去重59
+- 修复**每日独立 IP 把空 IP 计为 1 个**:取不到真实 IP 的事件不再计入独立 IP(与 IP 地域页 `ip<>''` 口径一致)60
+- IP 地域页的「IP 统计」改为按 `events` 聚合(PV / UV / 会话数),并显示区间独立 IP 总数,61
+ 与概览「IP 数」严格一致62
+63
+## 1.0.664
+65
+- 新增**外部代码注入**:系统设置里可粘贴第三方统计(百度统计、Google Analytics 等)或在线客服代码,66
+ 保存后按原样注入本站每个页面 `</head>` 之前,用于统计本站自身访问、挂载客服窗口(仅管理员可配置;67
+ 与「站点接入代码模板」相互独立——后者是给被统计站点站长粘贴的模板,本站不渲染)68
+- 新增**每个页面独立标题**:浏览器标签页标题按路由显示「页面名 · 应用名」(跟随中英文切换),69
+ 移动看板 `/m` 的标题跟随当前 Tab70
+ 对确属 CF 回源的请求优先读取 `CF-Connecting-IP`;同时修好代理匹配的 IPv6 支持71
+ (旧实现用 `ip2long`,对 IPv6 恒 false → IPv6 回源时判定会静默失效)72
+- 新增**真实 IP 自检**:系统设置 →「真实 IP 采集(CDN / 反向代理)」一键检测,回显 REMOTE_ADDR、73
+ 最终判定的访客 IP、取值来源、每个代理头的原始值与是否采用、地域解析结果与处置建议74
+ (新接口 `GET /api/settings/ip-check`,仅系统管理员)75
+76
+## 1.0.577
+78
+- 新增**站点接入代码自定义**:系统设置里可填写本系统的接入代码模板(支持 `{site_key}` / `{host}` / `{origin}`79
+ 占位符,留空即用内置默认片段),Sites 页「查看 SDK 接入代码」与复制按钮直接使用该模板,80
+ 站点成员无需管理员权限即可查看(新接口 `GET /api/sdk-code`)81
+- 修复**安装向导创建的第一个账号没有管理员权限**:`users.is_admin` 列默认值为 0,82
+ 而「首个账号即管理员」的回退逻辑只在 `is_admin` 列缺失时才生效,列存在却没写 1 就只是普通用户,83
+ 导致装完进不了「系统设置」;现在安装向导与注册接口(库中还没有用户时)都会显式写入管理员标记84
+- 安装向导**只展示必须项**:环境检测默认只展开必须项并显示「必须项 N/M」与未通过项,85
+ 其余可选/提示项收进「查看其余检测项」折叠区86
+- 修复安装向导**数据库与 Redis 表单错位**:输入框改为固定两列布局并占满列宽(此前列数随内容跳变、87
+ 控件宽度随默认字符数伸缩),复选框移出网格单独成行,窄屏自动转为单列88
+- 发版脚本新增**已发布版本守卫**:`versions.json` 中已存在同版本号的包时,构建会在改动任何文件之前中止,89
+ 避免开发副本忘记升版本号直接打包、悄悄替换掉已发布包导致老客户端校验失败(确需重打用 `--republish`)90
+91
+## 1.0.492
+93
+- 新增**移动端看板**(独立入口 `/m`):概览 / 实时 / 来源 / 页面 四个主界面,94
+ 底部 Tab 切换、区间可切换、与桌面端共享站点选择与登录态;手机与平板打开站点时**登录后自动进入移动看板**,95
+ 桌面端仍进概览;桌面侧栏不再放移动看板入口(移动看板自身顶部保留「桌面版」入口)96
+- 前端**代码分割与按需加载**:26 个页面由静态 import 改为路由级懒加载(React.lazy + Suspense),97
+ ECharts 由整包引入改为按需注册(line/bar/pie/map/funnel/sankey + 必要组件 + Canvas 渲染器);98
+ 首屏 JS 2625KB → 525KB(gzip 172KB),ECharts 653KB 独立成 chunk,只在进入带图页面时下载99
+- 界面语言**按浏览器语言自动适配**:首次访问按 `navigator.languages` 判定(中文 → 简体中文,其余语言 → 英文),100
+ 登录 / 注册 / 忘记密码等未登录页面同样生效;手动切换过的语言记入 `localStorage` 并优先于浏览器语言101
+- 移除「功能全景」页面及其侧栏入口(页面为静态清单,不涉及后端接口);102
+ 相关说明与指标口径请见「指标口径说明」页与 `CHANGELOG.md`103
+- 移动端判定兼容 iPadOS 13+(该版本 Safari 默认上报 Macintosh UA,以 `maxTouchPoints` 补判)104
+105
+## 1.0.3106
+107
+- 修复页脚版本号长期停在旧版(升级到 1.0.2 后仍显示 `v1.0.0`):以前版本号有两个来源,108
+ 其中一个 `data/installed.php` 的 `version` 是「安装当刻的快照」,在手工覆盖代码 / 目录迁移这两种109
+ 最常见的升级方式下**永不刷新**,于是生效版本永远停在安装那一版110
+- 修复「检查更新一直提示存在更新」:上一条的旧版本号被原样报给更新服务,远端返回111
+ `latest=1.0.2 > 本地 1.0.0`,于是 `has_update` 永远为真;点「一键升级」又被布局守卫拒绝,形成死循环112
+- 版本号与页脚品牌改为**单一事实来源的密封载荷**(`app/version.php`):异或密钥流 + HMAC-SHA256,113
+ 源码与配置里不再出现明文版本号/品牌名;手改密文即校验失败并回落官方默认(版本显示 `0.0.0`),114
+ 而不是「改一个字就生效」;改版本号只有一条正当路径 —— 改 `install/lib.php` 的 `VERSION` 后执行115
+ `php scripts/seal-version.php`(注意:这是混淆而非防破解,密钥就在源码里)116
+- 版本号随代码走:密封载荷放在 `app/` 下,属更新白名单且不受 `preserve` 保护,117
+ 一键升级或手工覆盖代码都会一并更新,**以后升级不再需要手工改任何版本号**118
+- `data/installed.php` 的 `version` 降级为纯记录字段(安装快照 + 部署凭据),合并配置时被显式忽略;119
+ 一键升级不再回写它,升级流水改记到 `data/version.json`(含最近 20 条历史)120
+- 发版脚本新增两道硬校验:密封载荷与 `Installer::VERSION` 不一致、或包内解出的版本与构建版本不一致,121
+ 直接中止构建,杜绝「发出去的包页脚版本对不上」122
+- 修复 `doctor.php` 队列类型诊断行输出为空:全角逗号紧跟 `$qt` 被 PHP 当成变量名的一部分,实际输出123
+ 成了「实际==list」124
+125
+## 1.0.2126
+127
+- 修复国旗图标在部分国家下导致整页报错:马来西亚 / 澳大利亚 / 土耳其的星形坐标被写成字符串,128
+ `cx + r * Math.cos()` 变成字符串拼接,渲染时抛 `toFixed is not a function`,129
+ 且因图标是条件渲染,仅当访客来自这些国家时才复现;现已在组件内强制转数字并修正写法130
+- 构建前静态门禁新增一类检查:几何数值属性被写成字符串(`cx="8.4"` 应为 `cx={8.4}`),131
+ 这类问题 `vite build` 不报错、只在运行期炸;另新增图标真渲染冒烟脚本,覆盖全部国旗与环境图标分支132
+133
+## 1.0.1134
+135
+- 发布包目录结构统一为 `app/` `data/` `public/` `scripts/`,`public` 即网站运行目录,不再区分普通版与宝塔版136
+- 新增在线更新:`https://wstats-update.kfw.cc/update.php` 提供版本索引与增量更新包,面板「关于与更新」支持检查更新与一键升级137
+- 升级前自动备份被覆盖文件到 `data/backup/`,执行新增的 `sql/upgrade-*.sql` 增量脚本138
+- 安装向导按新目录结构重新探测项目根与建表脚本位置139
+- 发布包内附 `data/ip2region.xdb` 离线 IP 库,地域统计开箱可用140
+- 发布包瘦身:去掉开发/造数脚本(`selftest-*`、`seed-*`)、历史参考 SQL(`schema.sql`、`wstat.sql`)、141
+ 开发文档(架构说明、内部功能审计报告)、已废弃的宝塔扁平包配置样例、重复的 `sdk/` 副本与陈旧前端产物;142
+ 构建时双向校验「开发产物不得入包 + 运行期必需文件必须在包」,包体 18.6MB → 16.0MB143
+144
+## 1.0.0145
+146
+- 首个正式版本:PV/UV/IP/会话/停留时长/跳出率、来源、地域、终端环境、页面下钻147
+- 实时概览、数据大屏(时间筛选 / 刷新倒计时 / 全屏)148
+- 漏斗与转化目标、广告追踪、热力图、访客旅程、受众分析149
+- 开放 API(PAT 令牌)、CSV 导出、流量异常告警与日报推送150
+- 多用户协作(所有者 / 编辑者 / 只读)、系统设置、注册邮箱验证码与忘记密码151
+- 宝塔/通用 Nginx、Apache 部署样例,Web 安装向导与 CLI 安装README.md+352-181
@@ -1,181 +1,352 @@1
-# 网站统计系统(WebStats)2
-3
-自建网站统计平台:**原生 PHP + MySQL + Redis** 后端,**React + Semi Design** 管理端,**零依赖 JS SDK** 前端采集。支持多站点、多用户(注册登录 + 文件验证)、多语言(中/EN)。4
-5
-> 当前交付:概览看板(PV/UV/IP/跳出率/平均时长/活跃趋势/今日实时分时)、**来源分析**(渠道构成/趋势/外链/UTM 系列/广告平台)、6
-> 会话列表 + 单会话完整行为日志、注册登录、多站点管理 + 两步文件验证、SDK 安装引导(**已验证站点不可修改,仅验证后可用 SDK**)。7
-8
-9
-## 1. 架构总览10
-11
-```12
-┌──────────── 被统计站点 ────────────┐ ┌────────────── WebStats 服务端 ──────────────┐13
-│ <script src="…/sdk/wstat.js" │ │ 入口:/collect.php(采集) │14
-│ data-site-key="xxx"> │ ──► │ ├ 站点校验(Redis缓存) │15
-│ · Cookie: uid(2y)/sid(30min滑动) │ │ ├ Redis 实时计数(PV/在线/分时) │16
-│ · UTM 五参 + ClickID 首触留存 │ │ └ 事件 JSON → Redis 队列 │17
-│ · Web Vitals(采集) │ │ │ │18
-│ · SPA 路由自动上报 / sendBeacon │ │ 常驻 worker(批量100/条) ──► events 明细表 │19
-└────────────────────────────────────┘ │ · 会话聚合(Redis ssn hash) │20
- │ · 空闲30min → sessions 表 │21
- │ cron: session/rollup/partition/clean │22
- │ ↓ site_daily 日汇总 │23
- │ 管理API:/api/*(token 认证,多用户多站点) │24
- │ └── React + Semi Design 前端 │25
- └──────────────────────────────────────────┘26
-```27
-28
-**高流量瓶颈对策(核心设计)**29
-| 瓶颈 | 对策 |30
-|---|---|31
-| 埋点写放大 | 采集端只写 Redis(毫秒级);worker 每 100 行/条 SQL 批量写库,DB 峰值写量降 2 个数量级 |32
-| events 表膨胀 | 按月 `RANGE(TO_DAYS(day))` 分区,`cron partition` 自动预建分区,`cron clean` 直接 DROP 过期分区 |33
-| 明细读报表慢 | `cron rollup` 每5分钟刷新 `site_daily` 汇总,概览读汇总表(缺失天自动回填) |34
-| 高并发唯一统计 | UV/IP 走事件明细**精确去重**(一次扫描同时取 UV 与独立 IP,口径全站统一);Redis 只承担 PV/在线/进行中会话等精确实时计数 |35
-| Redis 故障 | 采集端自动降级直写 MySQL(不丢点,会话退化为单页近似),恢复后自动切回 |36
-| Redis 无扩展 | 自带纯 PHP RESP 协议客户端(`Support/RedisClient`),无需 phpredis/Predis |37
-38
-## 2. 目录结构39
-40
-发布包布局(v1.0.1 起统一,不再区分普通版 / 宝塔版):**包根 = 站点目录,`public/` 为网站运行目录**。41
-42
-```43
-<包根>/44
-├── public/ # ← Web 根(运行目录):只有这个目录能被访问45
-│ ├── index.php # 入口 + 管理 API(未安装时自动 302 → /install/)46
-│ ├── collect.php # 采集入口47
-│ ├── index.html assets/ # 前端产物(SPA)48
-│ ├── geo/ # 地图数据(世界 / 中国)49
-│ ├── sdk/wstat.js # 对外提供的采集 SDK50
-│ └── install/ # 安装向导(Web 四步向导 + CLI + 自带 install.sql),装完请删除51
-├── app/ # 后端代码(无 Composer 依赖)52
-│ ├── bootstrap.php # 自动加载 / 全局异常 / 未安装守卫53
-│ ├── Http/ # Router / Request54
-│ ├── Support/ # Db / RedisClient(RESP) / Rds / Auth / Util / Settings / Updater …55
-│ └── Controllers/ # Auth / Site / Stats / Collect / Setting / Alert / Export / Open56
-├── config.php # 出厂默认配置(凭据在 data/installed.php,WSTAT_* 环境变量优先级最高)57
-├── scripts/ # worker.php(常驻消费)· cron.php(定时)· doctor.php / geo-doctor.php(诊断)58
-│ # · selfcheck.php(离线自检)· fetch-geo.php(更新 IP 库)59
-├── data/ # 运行期数据:installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb / verify/ / backup/60
-├── deploy/ # Nginx / Apache 配置样例61
-├── docs/deploy.md # 部署与运维手册(含排查速查表)62
-└── README.md RELEASE.md CHANGELOG.md63
-```64
-65
-`app/ scripts/ data/ sql/ config.php` 全在 Web 根之外 → **天然不可直访,无需任何屏蔽规则**,66
-`data/installed.php`(含库 / Redis 凭据)也不可能被下载。67
-68
-> 完整部署手册见 [`docs/deploy.md`](docs/deploy.md)。69
-70
-## 3. 部署(安装向导)71
-72
-### 3.1 上传与目录权限73
-把发布包解压到服务器(如 `/www/wwwroot/webstats`),**站点目录 = 包根、运行目录 = `/public`**74
-(宝塔:网站 → 设置 → 网站目录选包根,运行目录选 `/public`;自建 Nginx:`root <包根>/public;`),75
-并确保 PHP 进程可写 `data/`:76
-77
-```bash78
-chown -R www:www /www/wwwroot/webstats/data79
-```80
-81
-**环境要求**:PHP ≥ 7.4(推荐 8.1/8.2,扩展 `pdo_mysql`/`json`/`mbstring`;**无需 redis 扩展**)、82
-MySQL ≥ 5.7、Redis(可选,缺失自动降级)、Node 18+(仅自行构建前端时需要)。83
-84
-### 3.2 安装(Web 向导 / 命令行二选一)85
-86
-**A. Web 向导(推荐)**:访问 `http://你的域名/install/`,四步完成 —— 环境检测 → 数据库与 Redis(自动实测连接)→ 创建管理员 → 完成页给出后续命令与站点接入代码。向导会写入 `data/installed.php` 并加安装锁,防止被他人重装。**安装后请删除 `public/install` 目录。**87
-88
-**B. 命令行(SSH / 自动化)**:89
-90
-```bash91
-php public/install/cli.php --check # 环境自检92
-php public/install/cli.php \ # 一键安装(不带参数则为交互问答)93
- --db-name=webstats --db-user=webstats --db-pass='密码' --create-db \94
- --redis-host=127.0.0.1 --redis-port=6379 --redis-auth='密码' \95
- [email protected] --admin-pass='Admin@12345'96
-php public/install/cli.php --status # 查看安装状态97
-php public/install/cli.php --unlock # 解除安装锁(重装/迁移用)98
-```99
-100
-> 配置优先级:`config.php` 默认值 ← `data/installed.php`(向导生成)← `WSTAT_*` 环境变量。101
-> 因此也支持「不写配置文件、纯环境变量部署」(容器/K8s 场景):102
-> `WSTAT_DB_HOST/WSTAT_DB_PORT/WSTAT_DB_NAME/WSTAT_DB_USER/WSTAT_DB_PASS`、103
-> `WSTAT_REDIS_HOST/WSTAT_REDIS_PORT/WSTAT_REDIS_AUTH/WSTAT_REDIS_DB`、`WSTAT_DEBUG=0`。104
-105
-### 3.3 启动后台进程(必须)106
-107
-```bash108
-cd /www/wwwroot/webstats109
-nohup php scripts/worker.php >> data/worker.log 2>&1 & # 常驻,建议 supervisor/宝塔守护110
-# crontab(在项目根目录下执行):111
-# * * * * * php {path}/scripts/cron.php session # 回收空闲会话112
-# 0 * * * * php {path}/scripts/cron.php rollup # 生成 site_daily113
-# 0 3 * * * php {path}/scripts/cron.php partition # 预建未来分区114
-# 0 4 * * * php {path}/scripts/cron.php clean # 清理过期明细115
-```116
-117
-> ⚠️ 没有 worker:`events` 明细不入库、会话/停留时长/实时数据永远为空。118
-> ⚠️ 更新代码后必须**重启 worker**(PHP 常驻进程不会热加载);请从项目根目录启动,避免误加载其它副本的旧代码。119
-120
-### 3.4 Nginx 站点配置(管理端)121
-122
-完整样例见 [`deploy/nginx.conf.sample`](deploy/nginx.conf.sample)(Apache 用户见 `deploy/apache.htaccess.sample`):123
-124
-```nginx125
-root {包根}/public; # ← 关键:根目录指向包根下的 public126
-index index.php index.html; # 须含 index.php(/install/ 与 SPA 回退都靠它)127
-128
-location / { # SPA 前端 + 未安装 302 /install/129
- try_files $uri $uri/ /index.php$is_args$args;130
-}131
-location ^~ /api/ {132
- try_files $uri /index.php$is_args$args; # 管理 API133
-}134
-# 采集入口 /collect.php 不要单独写 location:POST 会被当静态文件拦成 405,135
-# 交给 PHP-FPM 处理即可(宝塔站点配置里已自带 location ~ [^/]\.php(/|$) 块)。136
-location /sdk/ { try_files $uri =404; } # SDK 静态直出137
-# 安装完成后建议:location ^~ /install/ { deny all; }138
-```139
-> `app/ scripts/ data/ sql/ config.php` 都在 Web 根之外,**不需要任何 deny 规则**。140
-> 对外提供的 SDK 就是 `public/sdk/wstat.js`(源码改完后重新打包即可,无需手工同步)。141
-> 站点安装代码中的 `data-host` 填本系统域名(如 `https://stats.your.com`)。142
-> 若安装了统计脚本但无数据,先跑 `php scripts/doctor.php` 一键诊断(常见:SDK 404、站点未验证、worker 未启动)。143
->144
-> **events 有数据但看板为空**:概览读 `site_daily` 汇总(缺失天自动按 events 回填,接口含 0 值行兜底校正)。145
-> 依次执行 `php scripts/cron.php session rollup` → 刷新概览;仍无则用 doctor 的 `[4b] 数据链路快照`146
-> 核对:events 的 `site_id` 与所选站点一致?`day`(站点本地日 YYYY-MM-DD)在所选区间?`type='pageview'`?147
-> PV 正常但 visits/跳出率/时长为 0 → sessions 表为空,需 worker 常驻 + cron session 回收(Redis 可用时会话先驻留 Redis)。148
-149
-## 4. 使用流程(一次闭环)150
-1. 注册/登录 → 2. 「站点管理」添加站点(域名仅主机名)→ 3. 文件验证:**两步** —— 打开验证弹窗自动生成 `wstat_verify_{token}.txt`(令牌落库)→ 将该文件放到站点根目录(内容为 token)→ 点击「我已上传,立即校验」(统计服务器回源拉取比对,通过后状态变绿)→ 4. **验证通过后 SDK 按钮才可用**,复制安装代码到目标站 `<head>` → 5. 「概览看板」看总量与趋势,「来源分析」看渠道构成/外链/UTM/广告,「会话列表」查看每次访问与逐条行为日志。151
-> **验证后站点即锁定**:不可再编辑(名称/域名/时区),如需变更删除后重建。数据采集中断排查:`php scripts/doctor.php`。152
-153
-> 采集端默认拒绝未验证站点的上报(HTTP 403)。本地联调需放开时:启动环境加 `WSTAT_ALLOW_UNVERIFIED=1`。154
-155
-## 5. API 一览156
-| 方法 | 路径 | 说明 |157
-|---|---|---|158
-| POST | /api/auth/register · login · logout | 注册 / 登录 / 退出 |159
-| GET | /api/auth/me | 当前用户 |160
-| GET/PATCH | /api/profile | 个人中心:资料查看 / 修改昵称与界面语言(邮箱为登录凭证,只读) |161
-| POST | /api/profile/password | 修改密码(需当前密码;成功后吊销全部旧会话并换发新令牌) |162
-| GET | /api/profile/logs | 我的操作日志(登录/改资料/改密 + 管理员对本账号的操作留痕) |163
-| GET/POST | /api/users | 用户管理(**仅管理员**):分页列表(关键词/状态筛选、站点数、最近登录)/ 新建 |164
-| PATCH | /api/users/{id} | 编辑昵称/语言/管理员标记/重置密码(重置后该用户全部会话失效) |165
-| POST | /api/users/{id}/status | 禁用(立即踢下线)/ 启用 |166
-| DELETE | /api/users/{id} | 删除(不能删自己/最后一个管理员;名下有站点时拒绝) |167
-| GET | /api/users/{id}/sites | 查看某用户的站点(自有 owner + 被授权 editor/viewer) |168
-| GET/POST | /api/sites | 站点列表 / 新增 |169
-| GET/PATCH/DELETE | /api/sites/{id} | 详情 / 修改 / 删除(**已验证站点拒绝修改**,需删除重建) |170
-| GET | /api/sites/{id}/verify-file | 生成/复用验证文件(返回文件名与令牌,不联网) |171
-| POST | /api/sites/{id}/verify | 远端校验(服务端回源拉取验证文件比对) |172
-| GET | /api/stats/overview?site_id&start&end | 概览(totals/trend/live,site_daily 快路径) |173
-| GET | /api/stats/sources?site_id&start&end | 来源分析(渠道/外链/UTM/广告平台) |174
-| GET | /api/stats/sessions?site_id&page&size&q | 会话分页 |175
-| GET | /api/stats/sessions/{id}?site_id | 会话详情 + 行为日志 |176
-| GET | /api/stats/online?site_id | 实时在线访客 |177
-| POST | /collect.php | 采集上报(任意 Origin;**仅已验证站点**接收,未验证 403) |178
-179
-响应统一 `{code,msg,data}`;认证 `Authorization: Bearer {token}`。180
-1
+# 网站统计系统(WebStats)2
+3
+自建网站统计平台:**原生 PHP + MySQL + Redis** 后端,**React + Semi Design** 管理端,**零依赖 JS SDK** 前端采集。支持多站点、多用户(注册登录 + 文件验证)、多语言(中/EN)。4
+5
+> 当前交付:概览看板(PV/UV/IP/跳出率/平均时长/活跃趋势/今日实时分时)、**来源分析**(渠道构成/趋势/外链/UTM 系列/广告平台)、6
+> 会话列表 + 单会话完整行为日志、注册登录、多站点管理 + 两步文件验证、SDK 安装引导(**已验证站点不可修改,仅验证后可用 SDK**)。7
+> 其余 12 个深度模块(访客/旅程/广告/性能/回放/热力/漏斗/地域/大屏…)已按计划在采集层与库表预留全量字段,逐期迭代交付。8
+9
+---10
+11
+## 1. 架构总览12
+13
+```14
+┌──────────── 被统计站点 ────────────┐ ┌────────────── WebStats 服务端 ──────────────┐15
+│ <script src="…/sdk/wstat.js" │ │ 入口:/collect.php(采集) │16
+│ data-site-key="xxx"> │ ──► │ ├ 站点校验(Redis缓存) │17
+│ · Cookie: uid(2y)/sid(30min滑动) │ │ ├ Redis 实时计数(PV/在线/分时) │18
+│ · UTM 五参 + ClickID 首触留存 │ │ └ 事件 JSON → Redis 队列 │19
+│ · Web Vitals(采集) │ │ │ │20
+│ · SPA 路由自动上报 / sendBeacon │ │ 常驻 worker(批量100/条) ──► events 明细表 │21
+└────────────────────────────────────┘ │ · 会话聚合(Redis ssn hash) │22
+ │ · 空闲30min → sessions 表 │23
+ │ cron: session/rollup/partition/clean │24
+ │ ↓ site_daily 日汇总 │25
+ │ 管理API:/api/*(token 认证,多用户多站点) │26
+ │ └── React + Semi Design 前端 │27
+ └──────────────────────────────────────────┘28
+```29
+30
+**高流量瓶颈对策(核心设计)**31
+| 瓶颈 | 对策 |32
+|---|---|33
+| 埋点写放大 | 采集端只写 Redis(毫秒级);worker 每 100 行/条 SQL 批量写库,DB 峰值写量降 2 个数量级 |34
+| events 表膨胀 | 按月 `RANGE(TO_DAYS(day))` 分区,`cron partition` 自动预建分区,`cron clean` 直接 DROP 过期分区 |35
+| 明细读报表慢 | `cron rollup` 每5分钟刷新 `site_daily` 汇总,概览读汇总表(缺失天自动回填) |36
+| 高并发唯一统计 | UV/IP 走事件明细**精确去重**(一次扫描同时取 UV 与独立 IP,口径全站统一);Redis 只承担 PV/在线/进行中会话等精确实时计数 |37
+| Redis 故障 | 采集端自动降级直写 MySQL(不丢点,会话退化为单页近似),恢复后自动切回 |38
+| Redis 无扩展 | 自带纯 PHP RESP 协议客户端(`Support/RedisClient`),无需 phpredis/Predis |39
+40
+## 2. 目录结构41
+42
+发布包布局(v1.0.1 起统一,不再区分普通版 / 宝塔版):**包根 = 站点目录,`public/` 为网站运行目录**。43
+44
+```45
+<包根>/46
+├── public/ # ← Web 根(运行目录):只有这个目录能被访问47
+│ ├── index.php # 入口 + 管理 API(未安装时自动 302 → /install/)48
+│ ├── collect.php # 采集入口49
+│ ├── index.html assets/ # 前端产物(SPA)50
+│ ├── geo/ # 地图数据(世界 / 中国)51
+│ ├── sdk/wstat.js # 对外提供的采集 SDK52
+│ └── install/ # 安装向导(Web 四步向导 + CLI + 自带 install.sql),装完请删除53
+├── app/ # 后端代码(无 Composer 依赖)54
+│ ├── bootstrap.php # 自动加载 / 全局异常 / 未安装守卫55
+│ ├── Http/ # Router / Request56
+│ ├── Support/ # Db / RedisClient(RESP) / Rds / Auth / Util / Settings / Updater …57
+│ └── Controllers/ # Auth / Site / Stats / Collect / Setting / Alert / Export / Open58
+├── config.php # 出厂默认配置(凭据在 data/installed.php,WSTAT_* 环境变量优先级最高)59
+├── scripts/ # worker.php(常驻消费)· cron.php(定时)· doctor.php / geo-doctor.php(诊断)60
+│ # · selfcheck.php(离线自检)· fetch-geo.php(更新 IP 库)61
+├── data/ # 运行期数据:installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb / verify/ / backup/62
+├── sql/ # install.sql(幂等全量建表)+ upgrade-*.sql(增量升级)63
+├── deploy/ # Nginx / Apache 配置样例64
+├── docker/ # 容器入口脚本(entrypoint.sh:可选自动安装 / cron-loop.sh:容器内定时任务)65
+├── Dockerfile # 官方镜像(php:8.2-apache,Web 根 = public/)66
+├── docker-compose.yml # 一键起 db + redis + web + worker + cron(配 .env.example 使用)67
+├── docs/deploy.md # 部署与运维手册(含排查速查表)68
+└── README.md RELEASE.md CHANGELOG.md69
+```70
+71
+`app/ scripts/ data/ sql/ config.php` 全在 Web 根之外 → **天然不可直访,无需任何屏蔽规则**,72
+`data/installed.php`(含库 / Redis 凭据)也不可能被下载。73
+74
+> 源码仓库比发布包多出:`server/`(后端源码,打包时平铺到包根)、`install/`(安装器源码 → `public/install`)、75
+> `frontend/`(React 源码)、`dev/`(自测与探针脚本,不进包)、`scripts/build-release.php`(构建脚本)、76
+> `update-server/`(在线更新服务端)、`sql/schema.sql`、`sql/wstat.sql`、`docs/architecture.md`(以上三项仅源码仓库保留)。77
+78
+> 完整部署手册见 [`docs/deploy.md`](docs/deploy.md)。79
+80
+## 3. 部署(安装向导)81
+82
+### 3.1 上传与目录权限83
+把发布包解压到服务器(如 `/www/wwwroot/webstats`),**站点目录 = 包根、运行目录 = `/public`**84
+(宝塔:网站 → 设置 → 网站目录选包根,运行目录选 `/public`;自建 Nginx:`root <包根>/public;`),85
+并确保 PHP 进程可写 `data/`:86
+87
+```bash88
+chown -R www:www /www/wwwroot/webstats/data89
+```90
+91
+**环境要求**:PHP ≥ 7.4(推荐 8.1/8.2,扩展 `pdo_mysql`/`json`/`mbstring`;**无需 redis 扩展**)、92
+MySQL ≥ 5.7、Redis(可选,缺失自动降级)、Node 18+(仅自行构建前端时需要);93
+容器部署另需 Docker ≥ 20.10 + Docker Compose v2(见 [3.6 Docker 部署](#36-docker--docker-compose-部署))。94
+95
+### 3.2 安装(Web 向导 / 命令行二选一)96
+97
+**A. Web 向导(推荐)**:访问 `http://你的域名/install/`,四步完成 —— 环境检测 → 数据库与 Redis(自动实测连接)→ 创建管理员 → 完成页给出后续命令与站点接入代码。向导会写入 `data/installed.php` 并加安装锁,防止被他人重装。**安装后请删除 `public/install` 目录。**98
+99
+**B. 命令行(SSH / 自动化)**:100
+101
+```bash102
+php public/install/cli.php --check # 环境自检103
+php public/install/cli.php \ # 一键安装(不带参数则为交互问答)104
+ --db-name=webstats --db-user=webstats --db-pass='密码' --create-db \105
+ --redis-host=127.0.0.1 --redis-port=6379 --redis-auth='密码' \106
+ [email protected] --admin-pass='Admin@12345'107
+php public/install/cli.php --status # 查看安装状态108
+php public/install/cli.php --unlock # 解除安装锁(重装/迁移用)109
+```110
+111
+> 配置优先级:`config.php` 默认值 ← `data/installed.php`(向导生成)← `WSTAT_*` 环境变量。112
+> 因此也支持「不写配置文件、纯环境变量部署」(容器/K8s 场景):113
+> `WSTAT_DB_HOST/WSTAT_DB_PORT/WSTAT_DB_NAME/WSTAT_DB_USER/WSTAT_DB_PASS`、114
+> `WSTAT_REDIS_HOST/WSTAT_REDIS_PORT/WSTAT_REDIS_AUTH/WSTAT_REDIS_DB`、`WSTAT_DEBUG=0`。115
+116
+### 3.3 启动后台进程(必须)117
+118
+```bash119
+cd /www/wwwroot/webstats120
+nohup php scripts/worker.php >> data/worker.log 2>&1 & # 常驻,建议 supervisor/宝塔守护121
+# crontab(在项目根目录下执行):122
+# * * * * * php {path}/scripts/cron.php session # 回收空闲会话123
+# 0 * * * * php {path}/scripts/cron.php rollup # 生成 site_daily124
+# 0 3 * * * php {path}/scripts/cron.php partition # 预建未来分区125
+# 0 4 * * * php {path}/scripts/cron.php clean # 清理过期明细126
+```127
+128
+> ⚠️ 没有 worker:`events` 明细不入库、会话/停留时长/实时数据永远为空。129
+> ⚠️ 更新代码后必须**重启 worker**(PHP 常驻进程不会热加载);请从项目根目录启动,避免误加载其它副本的旧代码。130
+131
+### 3.4 自行构建前端(仅在改动前端源码时需要)132
+133
+```bash134
+cd frontend135
+npm install136
+npm run build # 产物 dist/137
+php ../scripts/build-release.php --zip # 重新出包(dist 会自动复制到 public/)138
+# 本地联调时同步到 PHP 静态目录:php dev/sync-dist.php(会先清掉旧 assets)139
+```140
+141
+### 3.5 Nginx 站点配置(管理端)142
+143
+完整样例见 [`deploy/nginx.conf.sample`](deploy/nginx.conf.sample)(Apache 用户见 `deploy/apache.htaccess.sample`):144
+145
+```nginx146
+root {包根}/public; # ← 关键:根目录指向包根下的 public147
+index index.php index.html; # 须含 index.php(/install/ 与 SPA 回退都靠它)148
+149
+location / { # SPA 前端 + 未安装 302 /install/150
+ try_files $uri $uri/ /index.php$is_args$args;151
+}152
+location ^~ /api/ {153
+ try_files $uri /index.php$is_args$args; # 管理 API154
+}155
+# 采集入口 /collect.php 不要单独写 location:POST 会被当静态文件拦成 405,156
+# 交给 PHP-FPM 处理即可(宝塔站点配置里已自带 location ~ [^/]\.php(/|$) 块)。157
+location /sdk/ { try_files $uri =404; } # SDK 静态直出158
+# 安装完成后建议:location ^~ /install/ { deny all; }159
+```160
+> `app/ scripts/ data/ sql/ config.php` 都在 Web 根之外,**不需要任何 deny 规则**。161
+> 对外提供的 SDK 就是 `public/sdk/wstat.js`(源码改完后重新打包即可,无需手工同步)。162
+> 站点安装代码中的 `data-host` 填本系统域名(如 `https://stats.your.com`)。163
+> 若安装了统计脚本但无数据,先跑 `php scripts/doctor.php` 一键诊断(常见:SDK 404、站点未验证、worker 未启动)。164
+>165
+> **events 有数据但看板为空**:概览读 `site_daily` 汇总(缺失天自动按 events 回填,接口含 0 值行兜底校正)。166
+> 依次执行 `php scripts/cron.php session rollup` → 刷新概览;仍无则用 doctor 的 `[4b] 数据链路快照`167
+> 核对:events 的 `site_id` 与所选站点一致?`day`(站点本地日 YYYY-MM-DD)在所选区间?`type='pageview'`?168
+> PV 正常但 visits/跳出率/时长为 0 → sessions 表为空,需 worker 常驻 + cron session 回收(Redis 可用时会话先驻留 Redis)。169
+170
+### 3.6 Docker / Docker Compose 部署171
+172
+官方镜像 **`ghcr.io/kfwcc/wstats`**(仓库打 tag 后由 GitHub Actions 自动构建发布,见 `.github/workflows/docker-publish.yml`),173
+仓库根目录自带 `Dockerfile` 与 `docker-compose.yml` + `.env.example`。174
+175
+**镜像内布局**:`/var/www/html` = 包根,Web 根 = `/var/www/html/public`(与发布包完全一致,`app/ scripts/ data/ config.php` 在 Web 根之外)。176
+镜像内置 Apache(已开 `mod_rewrite`/`mod_expires`,并放好 `public/.htaccess` ≈ `deploy/apache.htaccess.sample`)、177
+PHP 8.2(`pdo_mysql`/`mbstring`/`gd`/`opcache`);MySQL 与 Redis 为外部服务(compose 已带)。178
+179
+#### A. Compose 一键起(推荐)180
+181
+```bash182
+cp .env.example .env # 改 WSTAT_DB_PASS / MYSQL_ROOT_PASSWORD / 管理员账号与口令183
+docker compose up -d --build # 首次构建;若已拉取 ghcr 镜像可用 docker compose up -d --no-build184
+docker compose logs -f web # 出现「自动安装完成」即为就绪185
+```186
+187
+访问 `http://<服务器IP>:8080`(端口由 `.env` 的 `HTTP_PORT` 控制),用 `WSTAT_ADMIN_EMAIL` / `WSTAT_ADMIN_PASS` 登录。188
+189
+服务构成(`docker compose ps`):190
+191
+| 服务 | 作用 | 说明 |192
+|---|---|---|193
+| `web` | Apache + PHP | 管理端 + `/api/*` + `/collect.php`,首次启动负责自动安装 |194
+| `worker` | `php scripts/worker.php` | **常驻事件消费**:不启动则 events 明细不入库、会话/实时数据永远为空 |195
+| `cron` | `wstat-cron`(容器内循环) | 等价 3.3 的 crontab:session 每分钟 · rollup 每 5 分钟 · partition 03:00 · clean 04:00 |196
+| `db` | MySQL 8 | 数据卷 `db-data`,端口不对宿主机暴露 |197
+| `redis` | Redis 7 | 数据卷 `redis-data`,可整体去掉(见 `WSTAT_NO_REDIS`) |198
+199
+#### B. 已有 MySQL / Redis,只跑应用容器200
+201
+```bash202
+docker network create wstats203
+204
+docker run -d --name wstats-web --restart unless-stopped --network wstats -p 8080:80 \205
+ -e WSTAT_DB_HOST=10.0.0.5 -e WSTAT_DB_NAME=webstats \206
+ -e WSTAT_DB_USER=webstats -e WSTAT_DB_PASS='你的密码' \207
+ -e WSTAT_REDIS_HOST=10.0.0.6 \208
+ -e WSTAT_AUTO_INSTALL=1 \209
+ -e [email protected] -e WSTAT_ADMIN_PASS='Admin@12345' \210
+ -v wstat-data:/var/www/html/data \211
+ ghcr.io/kfwcc/wstats:latest212
+213
+# worker / cron:同一镜像、同样的 WSTAT_* 环境变量与同一个 data 卷,仅命令不同214
+docker run -d --name wstats-worker --restart unless-stopped --network wstats \215
+ <同样的 -e 参数> -v wstat-data:/var/www/html/data \216
+ ghcr.io/kfwcc/wstats:latest php scripts/worker.php217
+```218
+219
+> `data` 卷必须在 web / worker / cron 之间共享:安装配置(`installed.php`)、站点验证文件、IP 库都在里面。220
+221
+#### 首次自动安装222
+223
+`WSTAT_AUTO_INSTALL=1` 且 `data/install.lock` 不存在时,容器入口脚本会先等 MySQL 就绪,再执行:224
+225
+```bash226
+php public/install/cli.php --no-interactive --create-db --db-* --redis-* --admin-email --admin-pass227
+```228
+229
+(建表脚本幂等,不会删已有数据),完成后写入 `data/installed.php` + 安装锁。230
+安装失败时容器照常启动,可访问 `/install/` 用 Web 向导补装。231
+**装完建议删掉安装目录**:`docker compose exec web rm -rf public/install`(升级镜像后会恢复,可再删一次)。232
+233
+#### 手动安装 / 运维命令(不想自动安装时先设 `WSTAT_AUTO_INSTALL=0`)234
+235
+```bash236
+docker compose exec web php public/install/cli.php --check # 环境自检237
+docker compose exec web php public/install/cli.php --status # 安装状态与布局238
+docker compose exec web php public/install/cli.php --unlock # 解除安装锁(重装/迁移)239
+docker compose exec web php scripts/doctor.php # 采集链路诊断240
+docker compose exec web php scripts/selfcheck.php # 离线自检(31 项)241
+docker compose exec web php scripts/cron.php rollup # 手动刷新汇总242
+```243
+244
+#### 环境变量(优先级高于 `data/installed.php`,见 3.2 末尾说明)245
+246
+| 变量 | 说明 | 默认 / 示例 |247
+|---|---|---|248
+| `WSTAT_DB_HOST` / `_PORT` / `_NAME` / `_USER` / `_PASS` | MySQL 连接 | compose 内 `db:3306` |249
+| `WSTAT_REDIS_HOST` / `_PORT` / `_AUTH` / `_DB` | Redis 连接(用自带 RESP 客户端,无需扩展) | compose 内 `redis:6379` |250
+| `WSTAT_NO_REDIS=1` | 关闭 Redis:采集直写 MySQL,统计走库聚合(可不部署 redis 服务) | `0` |251
+| `WSTAT_INSTALL_DB_USER` / `_PASS` | 应用账号无建库权限时,用它执行建库 | 空(用 `WSTAT_DB_*`) |252
+| `WSTAT_AUTO_INSTALL` | 首次启动自动安装 | compose 内 `1` |253
+| `WSTAT_ADMIN_EMAIL` / `WSTAT_ADMIN_PASS` | 自动安装创建的管理员(密码 8-72 位) | `[email protected]` / `Admin@12345` |254
+| `WSTAT_ALLOW_REGISTER=1` | 安装后开放注册 | `0`(仅管理员建号) |255
+| `WSTAT_TZ` | 时区(同时决定 cron 的 03:00 / 04:00 时刻) | `Asia/Shanghai` |256
+| `WSTAT_IP_SOURCE` / `WSTAT_IP_HEADER` | 真实 IP 取值来源(前面套 Nginx/CDN 时用 `x_real_ip` 等,详见 6) | `remote_addr` |257
+| `WSTAT_DB_MAXW` | 每秒最多批量写事件数 | `5000` |258
+| `WSTAT_DEBUG` / `WSTAT_ALLOW_UNVERIFIED` / `WSTAT_UPDATE_OFF` | 调试 / 放开采集校验 / 关闭一键升级 | 关闭 |259
+260
+#### 数据、备份与升级261
+262
+```bash263
+# 备份数据库(结构与数据)264
+docker compose exec db sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' > webstats-$(date +%F).sql265
+266
+# 源码方式升级 / 镜像方式升级(worker、cron 会一并重建 = 重启 worker,避免跑旧代码)267
+git pull && docker compose up -d --build268
+docker compose pull && docker compose up -d269
+270
+# 日志与状态271
+docker compose logs -f worker cron272
+docker compose ps273
+```274
+275
+> ⚠️ `docker compose down` 只停容器、数据卷保留;`docker compose down -v` 会**连数据库一起清空**(统计数据全部丢失)。276
+> ⚠️ 反代 / HTTPS:容器内 Apache 监听 80,前面套 Nginx 或 Caddy 即可;反代场景务必按 6 的说明设置277
+> `WSTAT_IP_SOURCE`,否则访客 IP 会全变成反代地址。278
+279
+### 3.7 本地联调(源码仓库)280
+```bash281
+php -S 127.0.0.1:8080 -t server/public server/router.php # 后端(开发副本在 server/ 下)282
+cd frontend && npm run dev # 前端 5173(已配 /api 代理)283
+```284
+浏览器打开 `http://localhost:5173` → 注册账号 → 添加站点 → 复制安装代码(在 `data-host` 指到本系统)→ 打开被统计站点即可看到数据。285
+286
+## 4. 使用流程(一次闭环)287
+1. 注册/登录 → 2. 「站点管理」添加站点(域名仅主机名)→ 3. 文件验证:**两步** —— 打开验证弹窗自动生成 `wstat_verify_{token}.txt`(令牌落库)→ 将该文件放到站点根目录(内容为 token)→ 点击「我已上传,立即校验」(统计服务器回源拉取比对,通过后状态变绿)→ 4. **验证通过后 SDK 按钮才可用**,复制安装代码到目标站 `<head>` → 5. 「概览看板」看总量与趋势,「来源分析」看渠道构成/外链/UTM/广告,「会话列表」查看每次访问与逐条行为日志。288
+> **验证后站点即锁定**:不可再编辑(名称/域名/时区),如需变更删除后重建。数据采集中断排查:`php scripts/doctor.php`。289
+290
+> 采集端默认拒绝未验证站点的上报(HTTP 403)。本地联调需放开时:启动环境加 `WSTAT_ALLOW_UNVERIFIED=1`。291
+292
+## 5. API 一览293
+| 方法 | 路径 | 说明 |294
+|---|---|---|295
+| POST | /api/auth/register · login · logout | 注册 / 登录 / 退出 |296
+| GET | /api/auth/me | 当前用户 |297
+| GET/PATCH | /api/profile | 个人中心:资料查看 / 修改昵称与界面语言(邮箱为登录凭证,只读) |298
+| POST | /api/profile/password | 修改密码(需当前密码;成功后吊销全部旧会话并换发新令牌) |299
+| GET | /api/profile/logs | 我的操作日志(登录/改资料/改密 + 管理员对本账号的操作留痕) |300
+| GET/POST | /api/users | 用户管理(**仅管理员**):分页列表(关键词/状态筛选、站点数、最近登录)/ 新建 |301
+| PATCH | /api/users/{id} | 编辑昵称/语言/管理员标记/重置密码(重置后该用户全部会话失效) |302
+| POST | /api/users/{id}/status | 禁用(立即踢下线)/ 启用 |303
+| DELETE | /api/users/{id} | 删除(不能删自己/最后一个管理员;名下有站点时拒绝) |304
+| GET | /api/users/{id}/sites | 查看某用户的站点(自有 owner + 被授权 editor/viewer) |305
+| GET/POST | /api/sites | 站点列表 / 新增 |306
+| GET/PATCH/DELETE | /api/sites/{id} | 详情 / 修改 / 删除(**已验证站点拒绝修改**,需删除重建) |307
+| GET | /api/sites/{id}/verify-file | 生成/复用验证文件(返回文件名与令牌,不联网) |308
+| POST | /api/sites/{id}/verify | 远端校验(服务端回源拉取验证文件比对) |309
+| GET | /api/stats/overview?site_id&start&end | 概览(totals/trend/live,site_daily 快路径) |310
+| GET | /api/stats/sources?site_id&start&end | 来源分析(渠道/外链/UTM/广告平台) |311
+| GET | /api/stats/sessions?site_id&page&size&q | 会话分页 |312
+| GET | /api/stats/sessions/{id}?site_id | 会话详情 + 行为日志 |313
+| GET | /api/stats/online?site_id | 实时在线访客 |314
+| POST | /collect.php | 采集上报(任意 Origin;**仅已验证站点**接收,未验证 403) |315
+316
+响应统一 `{code,msg,data}`;认证 `Authorization: Bearer {token}`。317
+318
+## 6. 已知限制 / 演进319
+- 概览走 `site_daily` 预聚合快路径(缺失天自动回填预热),但 **UV/IP 一律由 `events` 明细精确去重**(`StatsController::exactUniq` / `todayExact`),跨天合并也在 SQL 内完成;Redis 是否部署都不影响该口径,概览 / IP 地域页 / 大屏 / 开放接口数字完全一致。320
+- 来源分析当前按 PV 计数;渠道级 UV 去重因成本暂未提供(后续按需加入)。321
+- 事件归因列(utm_source/utm_medium/click_source/ref_host)为增量字段:已有部署需执行 `sql/upgrade-2026-09-09-sources.sql` 后重启 worker/collect;未迁移期间写入自动过滤这些列,不会中断。322
+- 会话“新访客”判定基于 Redis 90 天窗口,Redis 重置后首次会误判(影响极小)。323
+- IP 地域默认走**本地离线库**(`geo_driver: xdb`),**IPv4 / IPv6 各一份、互相独立降级**:324
+ `data/ip2region.xdb`(IPv4,约 11MB,`collect.geo_xdb`)/ `data/ip2region_v6.xdb`(IPv6,约 36MB,325
+ `collect.geo_xdb6`),均随包附带;也可切 `http` 自备接口或 `none` 关闭解析,通过 `config.php` /326
+ `WSTAT_GEO_*` 配置。缺 v6 库时只有 IPv6 访客无地域(IPv4 不受影响),跑 `php scripts/fetch-geo.php` 补齐。327
+- **访客真实 IP 的取值来源为手动指定**(`Support\Util::ipTrace()`,程序不做任何自动判断):可选328
+ `remote_addr`(默认,TCP 对端地址、客户端不可伪造)/ `x_real_ip` / `x_forwarded_for` /329
+ `custom`(配 `ip_source_header`,如 `CF-Connecting-IP`)。一律取所选头**最右侧**的合法 IP330
+ (CDN / Nginx 都把真值追加到末尾,取最左等于采信客户端伪造值);头缺失或值非法时回落 `REMOTE_ADDR`,331
+ **绝不写出空 IP**。配置项 `collect.ip_source` / `ip_source_header`(环境变量 `WSTAT_IP_SOURCE` /332
+ `WSTAT_IP_HEADER`),面板「系统设置 → 真实 IP 采集」保存的值优先;面板同时提供候选来源实测表,333
+ 哪一行显示出你的真实地址就选哪一行。334
+- 会话回放当前可还原事件时序(浏览/点击/滚动),像素级 DOM 回放建议二期接 rrweb。335
+- 登录限流依赖 Redis;Redis 不可用时放行(可结合 Nginx limit_req 兜底)。336
+337
+## 7. 验证338
+- `php scripts/selfcheck.php` —— RESP 协议/UA/来源分类/工具 31 项离线自检,100% 通过。339
+- 全量 PHP `php -l` 语法零错误;前端 `npm run build` 构建通过。340
+341
+## 8. 许可证342
+本项目采用 **MIT 许可证**(见 [`LICENSE`](LICENSE)),Copyright (c) 2026 kfwcc:343
+344
+- ✅ **可免费商用**:可用于个人或商业项目,可修改、可再分发、可闭源集成、可自行销售;345
+- ✅ 无附加限制:不要求开源你的修改,不限制部署站点数量,无「禁止转售」类条款;346
+- ⚠️ **唯一义务**:保留版权与许可声明 —— 再分发(含打包发布包、Docker 镜像、二次开发后对外交付)时,347
+ 必须保留 `LICENSE` 全文与 `THIRD-PARTY-NOTICES.md`,不得移除源码中的版权声明;348
+- ⚠️ 软件按「现状」提供,无任何担保,作者不承担使用后果的责任。349
+350
+> 第三方组件(React / Semi Design / Apache ECharts / tslib / lodash / ip2region)均为宽松许可证,351
+> 允许商用与再分发,各自的版权归属与保留要求见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。352
+> `public/geo/*.json` 为地图数据,对外公开地图展示需另行遵守地图数据来源条款与审图号要求。RELEASE.md+42-42
@@ -1,42 +1,42 @@1
-# WebStats 发布包 v1.0.102
-3
-- 构建时间:2026-09-16 15:48:544
-- 构建环境:PHP 8.2.205
-- 安装方式:访问 https://你的域名/install/ 或执行 php public/install/cli.php6
-7
-## 目录结构8
-9
-包根 = 站点目录;**Web 运行目录 = `public`**。10
-11
-| 路径 | 作用 |12
-| --- | --- |13
-| public/ | **Web 根目录(运行目录)**:index.php(API/入口)、collect.php(采集)、index.html + assets/(前端)、geo/、sdk/ |14
-| public/install/ | 安装向导(含自带 sql/install.sql 兜底),部署完成后请删除 |15
-| app/ | 后端代码(无 Composer 依赖;入口引导 app/bootstrap.php) |16
-| app/version.php | **版本号唯一来源**(明文,改版本号见下) |17
-| config.php | 出厂默认配置(部署凭据在 data/installed.php,环境变量 WSTAT_* 优先级最高) |18
-| scripts/ | worker.php(常驻消费)、cron.php(定时)、doctor.php / geo-doctor.php(诊断)、selfcheck.php(离线自检)、fetch-geo.php(更新 IP 库) |19
-| data/ | 运行期数据:installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb(IPv4/IPv6 离线库)/ verify/ / backup/(不在 Web 根内,天然不可直访) |20
-| deploy/ | Nginx / Apache 配置样例 |21
-| docs/deploy.md | 部署与运维手册 |22
-23
-## 部署三步24
-25
-1. 上传本包到服务器(例如 /www/wwwroot/webstats),**站点目录=包根、运行目录=`/public`**26
- (宝塔:网站 → 设置 → 网站目录 = 包根,运行目录选 `/public`;其他面板/自建:root 指向 `<包根>/public`);27
-2. 访问站点首页或 `/install/`:未安装时会自动跳转安装向导,按提示填数据库、Redis、管理员账号;28
-3. 按安装完成页提示启动 worker 常驻进程并配置 cron,最后删除 `public/install` 目录。29
-30
-> app/ 、scripts/ 、data/ 、sql/ 都在 Web 根之外,无需额外屏蔽规则;31
-> 目录结构特殊(面板 open_basedir 受限)时:安装向导会显示「目录结构诊断」页,32
-> 可直接填入项目根生成 `public/install/local.php`,或执行 `php public/install/cli.php --check`。33
-34
-## 版本号怎么改(自己二次开发时)35
-36
-版本号只有一处需要手改:`install/lib.php` 的 `Installer::VERSION`;改完执行37
-`php scripts/write-version.php` 把 `app/version.php`(一个明文版本号)同步成同一个值。38
-发版脚本会校验两者一致,不一致直接中止构建。39
-**不要**去改 `data/installed.php` 的 version —— 那只是安装快照,不参与版本判定;40
-也不要指望在 `config.php` 里写版本号 —— 版本号只在 `app/version.php` 这一处。41
-42
-详见 `docs/deploy.md`。1
+# WebStats 发布包 v1.0.102
+3
+- 构建时间:2026-09-16 15:48:544
+- 构建环境:PHP 8.2.205
+- 安装方式:访问 https://你的域名/install/ 或执行 php public/install/cli.php6
+7
+## 目录结构8
+9
+包根 = 站点目录;**Web 运行目录 = `public`**。10
+11
+| 路径 | 作用 |12
+| --- | --- |13
+| public/ | **Web 根目录(运行目录)**:index.php(API/入口)、collect.php(采集)、index.html + assets/(前端)、geo/、sdk/ |14
+| public/install/ | 安装向导(含自带 sql/install.sql 兜底),部署完成后请删除 |15
+| app/ | 后端代码(无 Composer 依赖;入口引导 app/bootstrap.php) |16
+| app/version.php | **版本号唯一来源**(明文,改版本号见下) |17
+| config.php | 出厂默认配置(部署凭据在 data/installed.php,环境变量 WSTAT_* 优先级最高) |18
+| scripts/ | worker.php(常驻消费)、cron.php(定时)、doctor.php / geo-doctor.php(诊断)、selfcheck.php(离线自检)、fetch-geo.php(更新 IP 库) |19
+| data/ | 运行期数据:installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb(IPv4/IPv6 离线库)/ verify/ / backup/(不在 Web 根内,天然不可直访) |20
+| deploy/ | Nginx / Apache 配置样例 |21
+| docs/deploy.md | 部署与运维手册 |22
+23
+## 部署三步24
+25
+1. 上传本包到服务器(例如 /www/wwwroot/webstats),**站点目录=包根、运行目录=`/public`**26
+ (宝塔:网站 → 设置 → 网站目录 = 包根,运行目录选 `/public`;其他面板/自建:root 指向 `<包根>/public`);27
+2. 访问站点首页或 `/install/`:未安装时会自动跳转安装向导,按提示填数据库、Redis、管理员账号;28
+3. 按安装完成页提示启动 worker 常驻进程并配置 cron,最后删除 `public/install` 目录。29
+30
+> app/ 、scripts/ 、data/ 、sql/ 都在 Web 根之外,无需额外屏蔽规则;31
+> 目录结构特殊(面板 open_basedir 受限)时:安装向导会显示「目录结构诊断」页,32
+> 可直接填入项目根生成 `public/install/local.php`,或执行 `php public/install/cli.php --check`。33
+34
+## 版本号怎么改(自己二次开发时)35
+36
+版本号只有一处需要手改:`install/lib.php` 的 `Installer::VERSION`;改完执行37
+`php scripts/write-version.php` 把 `app/version.php`(一个明文版本号)同步成同一个值。38
+发版脚本会校验两者一致,不一致直接中止构建。39
+**不要**去改 `data/installed.php` 的 version —— 那只是安装快照,不参与版本判定;40
+也不要指望在 `config.php` 里写版本号 —— 版本号只在 `app/version.php` 这一处。41
+42
+详见 `docs/deploy.md`。config.php+224-224
@@ -1,224 +1,224 @@1
-<?php2
-/**3
- * 网站统计系统 - 全局配置4
- *5
- * 三层合并,后者覆盖前者:6
- * 1) 下方 $defaults 出厂默认值(不含任何真实凭据,可直接对外发布)7
- * 2) data/installed.php 安装向导写入的实际部署配置(打包发布时不含此文件)8
- * 3) 环境变量 WSTAT_* 最高优先级,适合容器/K8s 等以环境变量注入的场景9
- *10
- * 未安装时本文件依然可用(返回默认值),安装向导依赖此特性在「无数据库」状态下运行。11
- */12
-declare(strict_types=1);13
-14
-$selfDir = __DIR__; // server/15
-16
-/* ===================== 0) 版本号:唯一事实来源 =====================17
- * 从 app/version.php 读取(就是一个明文版本号,见该文件头)。18
- *19
- * 为什么不是写死在这里,也不从 data/installed.php 读:20
- * 以前 version 有两个来源 —— 本文件的出厂值 与 installed.php 的安装快照,21
- * 而快照在「手工覆盖代码 / 目录迁移」下永远不刷新,于是页脚长期显示旧版本、22
- * 更新检查永远提示有新版本、点升级又被布局守卫拒绝(死循环)。23
- * 现在版本号只由 app/version.php 提供;app/ 会被升级包覆盖 → 自动跟进,无需人工同步。24
- * ========================================================================= */25
-$wstatVersion = '0.0.0';26
-$versionFile = $selfDir . '/app/version.php';27
-if (is_file($versionFile)) {28
- $v = require $versionFile; // 纯数据文件:return '1.0.9';29
- // 只接受 x.y / x.y.z(可带 -beta.1 这类后缀);写坏了回落 0.0.0,30
- // 不让页脚与更新检查拿着一个非法版本号去"胡说"。31
- if (is_string($v) && preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.\-]+)?$/', $v) === 1) {32
- $wstatVersion = $v;33
- }34
-}35
-36
-/* ===================== 1) 出厂默认值 ===================== */37
-$defaults = [38
- // 生效版本号 = app/version.php(唯一事实来源)39
- 'version' => $wstatVersion,40
- // 页脚「Powered by Wstats」的品牌名与官网链接**写死在前端**41
- // (frontend/src/components/PoweredBy.jsx 与安装向导页脚),服务端不再下发品牌:42
- // 品牌是展示层的事,后端只负责提供版本号。43
- // 在线更新服务地址(官方:https://wstats-update.kfw.cc/update.php)。44
- // 协议:带 action=check&version=&php= 查询时返回最新版本、增量包地址与校验值;45
- // 也兼容旧格式:不带参数直接返回 {"version":"x.y.z"}。46
- // 空串 = 关闭在线更新(设置页仅显示当前版本)。47
- // 私有化部署可指向自建服务(见 update-server/README.md)。48
- // 说明:写成站点根地址(不带 /update.php)通常也能用 —— 站点根的 index.php 会转发到接口,49
- // 但 index 文件的优先级随 Web 服务器配置而异,固定带 /update.php 最稳妥。50
- 'update_check_url' => getenv('WSTAT_UPDATE_URL') ?: 'https://wstats-update.kfw.cc/update.php',51
-52
- // 在线更新(一键升级)行为53
- 'update' => [54
- 'enabled' => !((bool) getenv('WSTAT_UPDATE_OFF')), // WSTAT_UPDATE_OFF=1 关闭一键升级(仅保留检查)55
- 'timeout' => (int) (getenv('WSTAT_UPDATE_TIMEOUT') ?: 30), // 远端请求/下载超时(秒)56
- 'keep_backup' => (int) (getenv('WSTAT_UPDATE_BACKUP') ?: 3), // 保留最近 N 份升级前备份57
- // 允许更新的文件前缀白名单(相对项目根;data/、上传目录等运行期数据永不覆盖)58
- 'allow' => ['app/', 'public/', 'scripts/', 'sql/', 'sdk/', 'config.php', 'README.md', 'LICENSE'],59
- // 无论如何都不得被更新包删除/覆盖的路径60
- 'preserve' => ['data/', 'config.php', 'public/install/local.php', '.git/', '.env'],61
- ],62
- 'installed' => false, // 由 installed.php 置 true63
- 'debug' => (bool) (getenv('WSTAT_DEBUG') ?: false),64
- 'timezone' => getenv('WSTAT_TZ') ?: 'Asia/Shanghai',65
-66
- // 数据库(写库;明细大表按月分区)67
- 'db' => [68
- 'host' => getenv('WSTAT_DB_HOST') ?: '127.0.0.1',69
- 'port' => (int) (getenv('WSTAT_DB_PORT') ?: 3306),70
- 'name' => getenv('WSTAT_DB_NAME') ?: '',71
- 'user' => getenv('WSTAT_DB_USER') ?: '',72
- 'pass' => getenv('WSTAT_DB_PASS') ?: '',73
- 'charset' => 'utf8mb4',74
- // 每秒最多批量写多少事件(限流保护,防止 DB 被打满)75
- 'max_write_per_sec' => (int) (getenv('WSTAT_DB_MAXW') ?: 5000),76
- ],77
-78
- // Redis(实时计数/队列/在线会话)。本库自带纯 PHP RESP 客户端,无需 phpredis 扩展79
- 'redis' => [80
- // 无 Redis 模式开关:false 时采集数据直接写入 MySQL(events/sessions),81
- // 统计全部走数据库聚合,无需部署 Redis 与 worker。安装向导「跳过 Redis」会写入 false。82
- // 环境变量 WSTAT_NO_REDIS=1 可强制关闭(最高优先级,见文件末尾)。83
- 'enabled' => true,84
- 'host' => getenv('WSTAT_REDIS_HOST') ?: '127.0.0.1',85
- 'port' => (int) (getenv('WSTAT_REDIS_PORT') ?: 6379),86
- 'auth' => getenv('WSTAT_REDIS_AUTH') ?: '',87
- 'db' => (int) (getenv('WSTAT_REDIS_DB') ?: 0),88
- 'prefix' => getenv('WSTAT_REDIS_PREFIX') ?: 'wstat:',89
- 'timeout' => 2.0,90
- // 允许在 Redis 不可用时降级(直写 events 表),安装向导会写入检测结果91
- 'required' => false,92
- ],93
-94
- // 会话 / 安全95
- 'security' => [96
- 'token_ttl' => 30 * 86400, // 登录 token 30 天97
- 'session_idle' => 30 * 60, // 会话 30 分钟无活动即结束98
- 'new_visitor_win'=> 90 * 86400, // 90 天内未见 = 新访客99
- 'pwd_min' => 8,100
- 'login_rate' => 10, // 10 分钟内最多登录尝试101
- // 允许注册(安装后建议按需关闭,只保留管理员创建账号)102
- 'allow_register' => true,103
- ],104
-105
- // 采集服务106
- 'collect' => [107
- 'max_body' => 262144, // 单次上报 256KB108
- 'beacon_method' => 'POST', // 预留109
- // IP 地理解析驱动:none | http | xdb110
- // xdb = 本地离线库(随发布包附带,推荐),**IPv4 / IPv6 两个库互相独立降级**:111
- // 缺 v6 库只影响 IPv6 访客,IPv4 照常解析112
- // http = 接口型,需自备可用服务,见下方 geo_http_url/key113
- // none = 不解析(历史行为)114
- 'geo_driver' => getenv('WSTAT_GEO_DRIVER') ?: 'xdb',115
- 'geo_xdb' => getenv('WSTAT_GEO_XDB') ?: $selfDir . '/data/ip2region.xdb', // IPv4 库116
- 'geo_xdb6' => getenv('WSTAT_GEO_XDB6') ?: $selfDir . '/data/ip2region_v6.xdb', // IPv6 库117
- 'geo_http_url' => getenv('WSTAT_GEO_URL') ?: '', // 形如 {ip} 占位118
- 'geo_http_key' => getenv('WSTAT_GEO_KEY') ?: '',119
- // 访客真实 IP 的取值来源(**手动指定,程序不做任何自动判断**;详见 Support\Util::ipTrace):120
- // remote_addr 直接取 REMOTE_ADDR(TCP 对端,客户端无法伪造;但套 CDN 时是节点地址)121
- // x_real_ip 取 X-Real-IP 最右侧的合法 IP122
- // x_forwarded_for 取 X-Forwarded-For 最右侧的合法 IP123
- // custom 取下方 ip_source_header 指定的请求头(如 CF-Connecting-IP)124
- // 优先级:管理端「系统设置 → 真实 IP 采集」保存的值(sys_settings.ip_source)> 这里 > remote_addr。125
- // 设为某个头但该头本次不存在/非法时,会回落 REMOTE_ADDR 并只在诊断里标注,不会写出空 IP。126
- 'ip_source' => getenv('WSTAT_IP_SOURCE') ?: 'remote_addr',127
- 'ip_source_header' => getenv('WSTAT_IP_HEADER') ?: '',128
- 'event_retention'=> 180, // 原始明细保留天数129
- ],130
-131
- // 站点验证(文件验证)132
- 'verify' => [133
- 'file_prefix' => 'wstat_verify_', // 文件: {domain}/wstat_verify_{token}.txt134
- 'timeout' => 8,135
- ],136
-137
- // CORS138
- 'cors' => [139
- 'allow_origin' => '*', // 采集端任意站点跨域;管理端建议收紧为自身域名140
- 'allow_methods'=> 'GET,POST,PATCH,DELETE,OPTIONS',141
- 'allow_headers'=> 'Content-Type,Authorization',142
- 'max_age' => 86400,143
- ],144
-];145
-146
-/* ===================== 2) 安装器写入的配置 ===================== */147
-$installed = [];148
-$installedFile = $selfDir . '/data/installed.php';149
-if (is_file($installedFile)) {150
- $loaded = require $installedFile;151
- if (is_array($loaded)) {152
- $installed = $loaded;153
- }154
-}155
-156
-/* 版本号不接受 installed.php 覆盖:157
- * installed.php 的 version 是「安装当刻的快照」,手工覆盖代码 / 目录迁移都不会刷新它,158
- * 一旦参与合并就会让页脚长期显示旧版本、更新检查永远提示有新版本(真实故障)。159
- * 旧版写过的 brand 键也一并忽略 —— 品牌已改为前端写死,服务端不再下发。160
- * 老库里的这两个键会被安静忽略(不报错、也不需要用户改文件)。 */161
-unset($installed['version'], $installed['brand']);162
-163
-/** 递归合并:$over 覆盖 $base(数组按 key 深入合并,标量直接覆盖) */164
-$merge = static function (array $base, array $over) use (&$merge): array {165
- foreach ($over as $k => $v) {166
- if (is_array($v) && isset($base[$k]) && is_array($base[$k])) {167
- $base[$k] = $merge($base[$k], $v);168
- } else {169
- $base[$k] = $v;170
- }171
- }172
- return $base;173
-};174
-175
-$cfg = $merge($defaults, $installed);176
-177
-/* ===================== 3) 环境变量最高优先级 ===================== */178
-$envOver = [179
- 'debug' => getenv('WSTAT_DEBUG'),180
- 'timezone' => getenv('WSTAT_TZ'),181
- 'db.host' => getenv('WSTAT_DB_HOST'),182
- 'db.port' => getenv('WSTAT_DB_PORT'),183
- 'db.name' => getenv('WSTAT_DB_NAME'),184
- 'db.user' => getenv('WSTAT_DB_USER'),185
- 'db.pass' => getenv('WSTAT_DB_PASS'),186
- 'db.max_write_per_sec' => getenv('WSTAT_DB_MAXW'),187
- 'redis.host' => getenv('WSTAT_REDIS_HOST'),188
- 'redis.port' => getenv('WSTAT_REDIS_PORT'),189
- 'redis.auth' => getenv('WSTAT_REDIS_AUTH'),190
- 'redis.db' => getenv('WSTAT_REDIS_DB'),191
- 'redis.prefix' => getenv('WSTAT_REDIS_PREFIX'),192
- 'collect.geo_driver' => getenv('WSTAT_GEO_DRIVER'),193
- 'collect.geo_xdb' => getenv('WSTAT_GEO_XDB'),194
- 'collect.geo_http_url' => getenv('WSTAT_GEO_URL'),195
- 'collect.geo_http_key' => getenv('WSTAT_GEO_KEY'),196
-];197
-foreach ($envOver as $path => $val) {198
- if ($val === false || $val === '') {199
- continue;200
- }201
- $segments = explode('.', $path);202
- $node = &$cfg;203
- foreach ($segments as $i => $seg) {204
- if ($i === count($segments) - 1) {205
- $node[$seg] = is_bool($node[$seg] ?? null) ? (bool) $val : (is_int($node[$seg] ?? null) ? (int) $val : $val);206
- } else {207
- if (!isset($node[$seg]) || !is_array($node[$seg])) {208
- $node[$seg] = [];209
- }210
- $node = &$node[$seg];211
- }212
- }213
- unset($node);214
-}215
-216
-/* 已安装判定:数据库连接信息齐全即可运行(兼容 installed.php 与环境变量两种部署方式) */217
-$cfg['installed'] = ($cfg['db']['name'] ?? '') !== '' && ($cfg['db']['user'] ?? '') !== '';218
-219
-/* 无 Redis 模式强制开关(最高优先级):WSTAT_NO_REDIS=1 时无视 installed.php 的配置 */220
-if (getenv('WSTAT_NO_REDIS') === '1') {221
- $cfg['redis']['enabled'] = false;222
-}223
-224
-return $cfg;1
+<?php2
+/**3
+ * 网站统计系统 - 全局配置4
+ *5
+ * 三层合并,后者覆盖前者:6
+ * 1) 下方 $defaults 出厂默认值(不含任何真实凭据,可直接对外发布)7
+ * 2) data/installed.php 安装向导写入的实际部署配置(打包发布时不含此文件)8
+ * 3) 环境变量 WSTAT_* 最高优先级,适合容器/K8s 等以环境变量注入的场景9
+ *10
+ * 未安装时本文件依然可用(返回默认值),安装向导依赖此特性在「无数据库」状态下运行。11
+ */12
+declare(strict_types=1);13
+14
+$selfDir = __DIR__; // server/15
+16
+/* ===================== 0) 版本号:唯一事实来源 =====================17
+ * 从 app/version.php 读取(就是一个明文版本号,见该文件头)。18
+ *19
+ * 为什么不是写死在这里,也不从 data/installed.php 读:20
+ * 以前 version 有两个来源 —— 本文件的出厂值 与 installed.php 的安装快照,21
+ * 而快照在「手工覆盖代码 / 目录迁移」下永远不刷新,于是页脚长期显示旧版本、22
+ * 更新检查永远提示有新版本、点升级又被布局守卫拒绝(死循环)。23
+ * 现在版本号只由 app/version.php 提供;app/ 会被升级包覆盖 → 自动跟进,无需人工同步。24
+ * ========================================================================= */25
+$wstatVersion = '0.0.0';26
+$versionFile = $selfDir . '/app/version.php';27
+if (is_file($versionFile)) {28
+ $v = require $versionFile; // 纯数据文件:return '1.0.9';29
+ // 只接受 x.y / x.y.z(可带 -beta.1 这类后缀);写坏了回落 0.0.0,30
+ // 不让页脚与更新检查拿着一个非法版本号去"胡说"。31
+ if (is_string($v) && preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.\-]+)?$/', $v) === 1) {32
+ $wstatVersion = $v;33
+ }34
+}35
+36
+/* ===================== 1) 出厂默认值 ===================== */37
+$defaults = [38
+ // 生效版本号 = app/version.php(唯一事实来源)39
+ 'version' => $wstatVersion,40
+ // 页脚「Powered by Wstats」的品牌名与官网链接**写死在前端**41
+ // (frontend/src/components/PoweredBy.jsx 与安装向导页脚),服务端不再下发品牌:42
+ // 品牌是展示层的事,后端只负责提供版本号。43
+ // 在线更新服务地址(官方:https://wstats-update.kfw.cc/update.php)。44
+ // 协议:带 action=check&version=&php= 查询时返回最新版本、增量包地址与校验值;45
+ // 也兼容旧格式:不带参数直接返回 {"version":"x.y.z"}。46
+ // 空串 = 关闭在线更新(设置页仅显示当前版本)。47
+ // 私有化部署可指向自建服务(见 update-server/README.md)。48
+ // 说明:写成站点根地址(不带 /update.php)通常也能用 —— 站点根的 index.php 会转发到接口,49
+ // 但 index 文件的优先级随 Web 服务器配置而异,固定带 /update.php 最稳妥。50
+ 'update_check_url' => getenv('WSTAT_UPDATE_URL') ?: 'https://wstats-update.kfw.cc/update.php',51
+52
+ // 在线更新(一键升级)行为53
+ 'update' => [54
+ 'enabled' => !((bool) getenv('WSTAT_UPDATE_OFF')), // WSTAT_UPDATE_OFF=1 关闭一键升级(仅保留检查)55
+ 'timeout' => (int) (getenv('WSTAT_UPDATE_TIMEOUT') ?: 30), // 远端请求/下载超时(秒)56
+ 'keep_backup' => (int) (getenv('WSTAT_UPDATE_BACKUP') ?: 3), // 保留最近 N 份升级前备份57
+ // 允许更新的文件前缀白名单(相对项目根;data/、上传目录等运行期数据永不覆盖)58
+ 'allow' => ['app/', 'public/', 'scripts/', 'sql/', 'sdk/', 'config.php', 'README.md', 'LICENSE'],59
+ // 无论如何都不得被更新包删除/覆盖的路径60
+ 'preserve' => ['data/', 'config.php', 'public/install/local.php', '.git/', '.env'],61
+ ],62
+ 'installed' => false, // 由 installed.php 置 true63
+ 'debug' => (bool) (getenv('WSTAT_DEBUG') ?: false),64
+ 'timezone' => getenv('WSTAT_TZ') ?: 'Asia/Shanghai',65
+66
+ // 数据库(写库;明细大表按月分区)67
+ 'db' => [68
+ 'host' => getenv('WSTAT_DB_HOST') ?: '127.0.0.1',69
+ 'port' => (int) (getenv('WSTAT_DB_PORT') ?: 3306),70
+ 'name' => getenv('WSTAT_DB_NAME') ?: '',71
+ 'user' => getenv('WSTAT_DB_USER') ?: '',72
+ 'pass' => getenv('WSTAT_DB_PASS') ?: '',73
+ 'charset' => 'utf8mb4',74
+ // 每秒最多批量写多少事件(限流保护,防止 DB 被打满)75
+ 'max_write_per_sec' => (int) (getenv('WSTAT_DB_MAXW') ?: 5000),76
+ ],77
+78
+ // Redis(实时计数/队列/在线会话)。本库自带纯 PHP RESP 客户端,无需 phpredis 扩展79
+ 'redis' => [80
+ // 无 Redis 模式开关:false 时采集数据直接写入 MySQL(events/sessions),81
+ // 统计全部走数据库聚合,无需部署 Redis 与 worker。安装向导「跳过 Redis」会写入 false。82
+ // 环境变量 WSTAT_NO_REDIS=1 可强制关闭(最高优先级,见文件末尾)。83
+ 'enabled' => true,84
+ 'host' => getenv('WSTAT_REDIS_HOST') ?: '127.0.0.1',85
+ 'port' => (int) (getenv('WSTAT_REDIS_PORT') ?: 6379),86
+ 'auth' => getenv('WSTAT_REDIS_AUTH') ?: '',87
+ 'db' => (int) (getenv('WSTAT_REDIS_DB') ?: 0),88
+ 'prefix' => getenv('WSTAT_REDIS_PREFIX') ?: 'wstat:',89
+ 'timeout' => 2.0,90
+ // 允许在 Redis 不可用时降级(直写 events 表),安装向导会写入检测结果91
+ 'required' => false,92
+ ],93
+94
+ // 会话 / 安全95
+ 'security' => [96
+ 'token_ttl' => 30 * 86400, // 登录 token 30 天97
+ 'session_idle' => 30 * 60, // 会话 30 分钟无活动即结束98
+ 'new_visitor_win'=> 90 * 86400, // 90 天内未见 = 新访客99
+ 'pwd_min' => 8,100
+ 'login_rate' => 10, // 10 分钟内最多登录尝试101
+ // 允许注册(安装后建议按需关闭,只保留管理员创建账号)102
+ 'allow_register' => true,103
+ ],104
+105
+ // 采集服务106
+ 'collect' => [107
+ 'max_body' => 262144, // 单次上报 256KB108
+ 'beacon_method' => 'POST', // 预留109
+ // IP 地理解析驱动:none | http | xdb110
+ // xdb = 本地离线库(随发布包附带,推荐),**IPv4 / IPv6 两个库互相独立降级**:111
+ // 缺 v6 库只影响 IPv6 访客,IPv4 照常解析112
+ // http = 接口型,需自备可用服务,见下方 geo_http_url/key113
+ // none = 不解析(历史行为)114
+ 'geo_driver' => getenv('WSTAT_GEO_DRIVER') ?: 'xdb',115
+ 'geo_xdb' => getenv('WSTAT_GEO_XDB') ?: $selfDir . '/data/ip2region.xdb', // IPv4 库116
+ 'geo_xdb6' => getenv('WSTAT_GEO_XDB6') ?: $selfDir . '/data/ip2region_v6.xdb', // IPv6 库117
+ 'geo_http_url' => getenv('WSTAT_GEO_URL') ?: '', // 形如 {ip} 占位118
+ 'geo_http_key' => getenv('WSTAT_GEO_KEY') ?: '',119
+ // 访客真实 IP 的取值来源(**手动指定,程序不做任何自动判断**;详见 Support\Util::ipTrace):120
+ // remote_addr 直接取 REMOTE_ADDR(TCP 对端,客户端无法伪造;但套 CDN 时是节点地址)121
+ // x_real_ip 取 X-Real-IP 最右侧的合法 IP122
+ // x_forwarded_for 取 X-Forwarded-For 最右侧的合法 IP123
+ // custom 取下方 ip_source_header 指定的请求头(如 CF-Connecting-IP)124
+ // 优先级:管理端「系统设置 → 真实 IP 采集」保存的值(sys_settings.ip_source)> 这里 > remote_addr。125
+ // 设为某个头但该头本次不存在/非法时,会回落 REMOTE_ADDR 并只在诊断里标注,不会写出空 IP。126
+ 'ip_source' => getenv('WSTAT_IP_SOURCE') ?: 'remote_addr',127
+ 'ip_source_header' => getenv('WSTAT_IP_HEADER') ?: '',128
+ 'event_retention'=> 180, // 原始明细保留天数129
+ ],130
+131
+ // 站点验证(文件验证)132
+ 'verify' => [133
+ 'file_prefix' => 'wstat_verify_', // 文件: {domain}/wstat_verify_{token}.txt134
+ 'timeout' => 8,135
+ ],136
+137
+ // CORS138
+ 'cors' => [139
+ 'allow_origin' => '*', // 采集端任意站点跨域;管理端建议收紧为自身域名140
+ 'allow_methods'=> 'GET,POST,PATCH,DELETE,OPTIONS',141
+ 'allow_headers'=> 'Content-Type,Authorization',142
+ 'max_age' => 86400,143
+ ],144
+];145
+146
+/* ===================== 2) 安装器写入的配置 ===================== */147
+$installed = [];148
+$installedFile = $selfDir . '/data/installed.php';149
+if (is_file($installedFile)) {150
+ $loaded = require $installedFile;151
+ if (is_array($loaded)) {152
+ $installed = $loaded;153
+ }154
+}155
+156
+/* 版本号不接受 installed.php 覆盖:157
+ * installed.php 的 version 是「安装当刻的快照」,手工覆盖代码 / 目录迁移都不会刷新它,158
+ * 一旦参与合并就会让页脚长期显示旧版本、更新检查永远提示有新版本(真实故障)。159
+ * 旧版写过的 brand 键也一并忽略 —— 品牌已改为前端写死,服务端不再下发。160
+ * 老库里的这两个键会被安静忽略(不报错、也不需要用户改文件)。 */161
+unset($installed['version'], $installed['brand']);162
+163
+/** 递归合并:$over 覆盖 $base(数组按 key 深入合并,标量直接覆盖) */164
+$merge = static function (array $base, array $over) use (&$merge): array {165
+ foreach ($over as $k => $v) {166
+ if (is_array($v) && isset($base[$k]) && is_array($base[$k])) {167
+ $base[$k] = $merge($base[$k], $v);168
+ } else {169
+ $base[$k] = $v;170
+ }171
+ }172
+ return $base;173
+};174
+175
+$cfg = $merge($defaults, $installed);176
+177
+/* ===================== 3) 环境变量最高优先级 ===================== */178
+$envOver = [179
+ 'debug' => getenv('WSTAT_DEBUG'),180
+ 'timezone' => getenv('WSTAT_TZ'),181
+ 'db.host' => getenv('WSTAT_DB_HOST'),182
+ 'db.port' => getenv('WSTAT_DB_PORT'),183
+ 'db.name' => getenv('WSTAT_DB_NAME'),184
+ 'db.user' => getenv('WSTAT_DB_USER'),185
+ 'db.pass' => getenv('WSTAT_DB_PASS'),186
+ 'db.max_write_per_sec' => getenv('WSTAT_DB_MAXW'),187
+ 'redis.host' => getenv('WSTAT_REDIS_HOST'),188
+ 'redis.port' => getenv('WSTAT_REDIS_PORT'),189
+ 'redis.auth' => getenv('WSTAT_REDIS_AUTH'),190
+ 'redis.db' => getenv('WSTAT_REDIS_DB'),191
+ 'redis.prefix' => getenv('WSTAT_REDIS_PREFIX'),192
+ 'collect.geo_driver' => getenv('WSTAT_GEO_DRIVER'),193
+ 'collect.geo_xdb' => getenv('WSTAT_GEO_XDB'),194
+ 'collect.geo_http_url' => getenv('WSTAT_GEO_URL'),195
+ 'collect.geo_http_key' => getenv('WSTAT_GEO_KEY'),196
+];197
+foreach ($envOver as $path => $val) {198
+ if ($val === false || $val === '') {199
+ continue;200
+ }201
+ $segments = explode('.', $path);202
+ $node = &$cfg;203
+ foreach ($segments as $i => $seg) {204
+ if ($i === count($segments) - 1) {205
+ $node[$seg] = is_bool($node[$seg] ?? null) ? (bool) $val : (is_int($node[$seg] ?? null) ? (int) $val : $val);206
+ } else {207
+ if (!isset($node[$seg]) || !is_array($node[$seg])) {208
+ $node[$seg] = [];209
+ }210
+ $node = &$node[$seg];211
+ }212
+ }213
+ unset($node);214
+}215
+216
+/* 已安装判定:数据库连接信息齐全即可运行(兼容 installed.php 与环境变量两种部署方式) */217
+$cfg['installed'] = ($cfg['db']['name'] ?? '') !== '' && ($cfg['db']['user'] ?? '') !== '';218
+219
+/* 无 Redis 模式强制开关(最高优先级):WSTAT_NO_REDIS=1 时无视 installed.php 的配置 */220
+if (getenv('WSTAT_NO_REDIS') === '1') {221
+ $cfg['redis']['enabled'] = false;222
+}223
+224
+return $cfg;deploy/apache.htaccess.sample+47-47
@@ -1,47 +1,47 @@1
-# WebStats Apache 配置样例2
-# 放置位置:发布包统一布局的 **public/.htaccess**(需开启 mod_rewrite)3
-#4
-# 宝塔 Apache 用户:站点根目录指向 <包根>/public(宝塔:网站目录=包根、运行目录=/public),5
-# 并在站点配置中允许 .htaccess(AllowOverride All)。6
-# 后端 app/ 、data/ 、scripts/ 都在 public 之外,因此无需在 .htaccess 里额外屏蔽它们。7
-8
-# 目录索引必须包含 index.php:/install/ 才能直接打开安装向导9
-DirectoryIndex index.php index.html10
-11
-<IfModule mod_rewrite.c>12
- RewriteEngine On13
- RewriteBase /14
-15
- # 已存在的文件/目录直接返回(静态资源、SDK 等)16
- RewriteCond %{REQUEST_FILENAME} -f [OR]17
- RewriteCond %{REQUEST_FILENAME} -d18
- RewriteRule ^ - [L]19
-20
- # 采集端点21
- RewriteRule ^collect(\.php)?$ collect.php [L,QSA]22
-23
- # 管理端 API24
- RewriteRule ^api/ index.php [L,QSA]25
-26
- # 其余路径交给 index.php:27
- # 未安装 → 服务端 302 跳转 /install/(自动引导安装)28
- # 已安装 → 输出前端产物 index.html(SPA 回退)29
- RewriteRule ^ index.php [L]30
-</IfModule>31
-32
-# 安全:后端目录与运行期数据目录禁止直访(项目被放进 Web 根时尤其重要)33
-<IfModule mod_authz_core.c>34
- <FilesMatch "^(installed\.php|install\.lock|.*\.log)$">35
- Require all denied36
- </FilesMatch>37
-</IfModule>38
-39
-# 静态资源缓存40
-<IfModule mod_expires.c>41
- ExpiresActive On42
- ExpiresByType application/javascript "access plus 7 days"43
- ExpiresByType text/css "access plus 7 days"44
-</IfModule>45
-46
-# 默认字符集47
-AddDefaultCharset UTF-81
+# WebStats Apache 配置样例2
+# 放置位置:发布包统一布局的 **public/.htaccess**(需开启 mod_rewrite)3
+#4
+# 宝塔 Apache 用户:站点根目录指向 <包根>/public(宝塔:网站目录=包根、运行目录=/public),5
+# 并在站点配置中允许 .htaccess(AllowOverride All)。6
+# 后端 app/ 、data/ 、scripts/ 都在 public 之外,因此无需在 .htaccess 里额外屏蔽它们。7
+8
+# 目录索引必须包含 index.php:/install/ 才能直接打开安装向导9
+DirectoryIndex index.php index.html10
+11
+<IfModule mod_rewrite.c>12
+ RewriteEngine On13
+ RewriteBase /14
+15
+ # 已存在的文件/目录直接返回(静态资源、SDK 等)16
+ RewriteCond %{REQUEST_FILENAME} -f [OR]17
+ RewriteCond %{REQUEST_FILENAME} -d18
+ RewriteRule ^ - [L]19
+20
+ # 采集端点21
+ RewriteRule ^collect(\.php)?$ collect.php [L,QSA]22
+23
+ # 管理端 API24
+ RewriteRule ^api/ index.php [L,QSA]25
+26
+ # 其余路径交给 index.php:27
+ # 未安装 → 服务端 302 跳转 /install/(自动引导安装)28
+ # 已安装 → 输出前端产物 index.html(SPA 回退)29
+ RewriteRule ^ index.php [L]30
+</IfModule>31
+32
+# 安全:后端目录与运行期数据目录禁止直访(项目被放进 Web 根时尤其重要)33
+<IfModule mod_authz_core.c>34
+ <FilesMatch "^(installed\.php|install\.lock|.*\.log)$">35
+ Require all denied36
+ </FilesMatch>37
+</IfModule>38
+39
+# 静态资源缓存40
+<IfModule mod_expires.c>41
+ ExpiresActive On42
+ ExpiresByType application/javascript "access plus 7 days"43
+ ExpiresByType text/css "access plus 7 days"44
+</IfModule>45
+46
+# 默认字符集47
+AddDefaultCharset UTF-8deploy/nginx.conf.sample+113-113
@@ -1,113 +1,113 @@1
-# WebStats 生产环境 Nginx 配置样例(宝塔 / 通用)2
-#3
-# 发布包 v1.0.1+ 为**统一布局**,包根 = 站点目录,`public/` 是唯一的 Web 运行目录:4
-#5
-# <包根>/ 例:/www/wwwroot/webstats6
-# app/ 后端代码(含 app/bootstrap.php)7
-# config.php 出厂默认配置8
-# data/ 运行期数据(installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb / verify/ / backup/)9
-# public/ ★ Web 根目录(运行目录)10
-# index.php 入口:管理端 API + 未安装引导 + SPA 回退11
-# collect.php 采集端点12
-# index.html assets/ geo/ sdk/ 前端产物与对外 SDK13
-# install/ 安装向导(安装完成后请删除)14
-# scripts/ worker.php / cron.php / doctor.php15
-# sql/ sdk/ deploy/ docs/16
-#17
-# 关键点:18
-# 1) root 必须指向 **<包根>/public**(宝塔面板里即:网站目录=包根,运行目录=/public);19
-# 2) app/ data/ scripts/ sql/ 都在 Web 根之外 → 天然不可直访,无需额外屏蔽规则;20
-# 3) index.php 同时负责「管理端 API」与「未安装引导 / SPA 回退」,21
-# 所以 location / 要交给 index.php,未安装时会由服务端 302 到 /install/;22
-# 4) 安装完成后建议拒绝访问 /install/(或直接删除 public/install 目录)。23
-24
-server {25
- listen 80;26
- server_name stats.example.com; # 改成你的域名27
-28
- root /www/wwwroot/webstats/public; # ← 包根下的 public29
- index index.php index.html;30
-31
- # ---------- 管理端 SPA + 未安装引导 ----------32
- # 交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html33
- location / {34
- try_files $uri $uri/ /index.php$is_args$args;35
- }36
-37
- # ---------- 管理端 API(含 /api/health 健康检查) ----------38
- location ^~ /api/ {39
- try_files $uri /index.php$is_args$args;40
- }41
-42
- # ---------- 采集端点 ----------43
- # 【重要】采集端点是 POST 请求,必须由 PHP-FPM 执行,这里**不要**再写「精确匹配 + try_files」的静态块:44
- # ✗ location = /collect.php { try_files $uri =404; }45
- # `location =` 是精确匹配,优先级高于正则 location,会把 /collect.php 从 location ~ \.php$ 手里46
- # 抢走;块内没有 fastcgi 处理器 → nginx 按静态文件返回 → POST 得到47
- # 「405 Method Not Allowed」(GET 则变成下载 PHP 源码)。48
- # 正确做法:什么都不写,交给下面的 location ~ \.php$ 处理。49
- # 若确实想显式声明,必须自带 FastCGI(不能只写 try_files):50
- # location = /collect.php {51
- # fastcgi_pass unix:/tmp/php-cgi-82.sock; # 或 127.0.0.1:900052
- # include fastcgi_params;53
- # fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;54
- # }55
- #56
- # 可选:短链形式 /c/{siteKey}(rewrite 到 .php 后会重新匹配 location,安全)57
- location ^~ /c/ {58
- rewrite ^/c/(.*)$ /collect.php?ak=$1 last;59
- }60
-61
- # ---------- 安装向导(Web 根内的 public/install) ----------62
- # 安装完成后强烈建议改为拒绝访问:63
- # location ^~ /install/ { deny all; return 404; }64
- #65
- # 提示:目录结构较特殊(例如包根挂在站点目录之外)时,可在 PHP-FPM 池配置里指定:66
- # env[WSTAT_ROOT] = /www/wwwroot/webstats67
- # 或在安装器目录写 public/install/local.php(安装向导第一步提供「手动指路」表单自动生成)。68
-69
- # ---------- SDK 静态资源 ----------70
- location ^~ /sdk/ {71
- try_files $uri =404;72
- expires 7d;73
- add_header Cache-Control "public";74
- }75
-76
- # ---------- PHP ----------77
- location ~ \.php$ {78
- # 宝塔:include enable-php-82.conf;79
- fastcgi_pass 127.0.0.1:9000; # 或 unix:/tmp/php-cgi-82.sock80
- fastcgi_index index.php;81
- include fastcgi_params;82
- fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;83
- fastcgi_read_timeout 60s;84
- }85
-86
- # ---------- 实时数据不可缓存(面板「数据不更新」的头号原因) ----------87
- # 应用已自带 Cache-Control: no-store,但 CDN / proxy_cache 会无视它,88
- # 若站点前置了缓存,务必把 /api/ 与 /collect.php 排除在缓存之外。89
-90
- # ---------- 旧版布局兼容(仅当从 ≤1.0.0 的包原地升级时可能用到) ----------91
- # 旧包把后端放在 Web 根内的 server/ 目录,需屏蔽直访;统一布局下不需要:92
- # location ^~ /server/ { deny all; return 404; }93
-94
- client_max_body_size 1m;95
- access_log /www/wwwlogs/webstats.log;96
- error_log /www/wwwlogs/webstats.error.log;97
-}98
-99
-# ---------------------------------------------------------------------------100
-# 宝塔面板「防跨站攻击(open_basedir)」说明101
-# ---------------------------------------------------------------------------102
-# 宝塔默认把 open_basedir 限制为「网站目录:/tmp」,**运行目录不影响该限制**。103
-# 统一布局下请这样设置(两步):104
-# 面板 → 网站 → 设置 → 网站目录:105
-# 网站目录 = <包根>(例如 /www/wwwroot/webstats) ← open_basedir 覆盖到它106
-# 运行目录 = /public ← 站点实际 root 指向它107
-# 这样 app/ data/ scripts/ 都在 open_basedir 允许范围内,安装器的自动探测与写入都能正常工作。108
-#109
-# 若目录结构特殊(例如包根在站点目录之外),二选一:110
-# A. 把「网站目录」设为包根、「运行目录」设为 /public(推荐);111
-# B. 在 public/install/ 目录写入 local.php(安装向导第一步可一键生成「手动指路」表单):112
-# <?php return ['root' => '/www/wwwroot/webstats'];113
-# C. 或直接给站点关闭「防跨站攻击」(不推荐)。1
+# WebStats 生产环境 Nginx 配置样例(宝塔 / 通用)2
+#3
+# 发布包 v1.0.1+ 为**统一布局**,包根 = 站点目录,`public/` 是唯一的 Web 运行目录:4
+#5
+# <包根>/ 例:/www/wwwroot/webstats6
+# app/ 后端代码(含 app/bootstrap.php)7
+# config.php 出厂默认配置8
+# data/ 运行期数据(installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb / verify/ / backup/)9
+# public/ ★ Web 根目录(运行目录)10
+# index.php 入口:管理端 API + 未安装引导 + SPA 回退11
+# collect.php 采集端点12
+# index.html assets/ geo/ sdk/ 前端产物与对外 SDK13
+# install/ 安装向导(安装完成后请删除)14
+# scripts/ worker.php / cron.php / doctor.php15
+# sql/ sdk/ deploy/ docs/16
+#17
+# 关键点:18
+# 1) root 必须指向 **<包根>/public**(宝塔面板里即:网站目录=包根,运行目录=/public);19
+# 2) app/ data/ scripts/ sql/ 都在 Web 根之外 → 天然不可直访,无需额外屏蔽规则;20
+# 3) index.php 同时负责「管理端 API」与「未安装引导 / SPA 回退」,21
+# 所以 location / 要交给 index.php,未安装时会由服务端 302 到 /install/;22
+# 4) 安装完成后建议拒绝访问 /install/(或直接删除 public/install 目录)。23
+24
+server {25
+ listen 80;26
+ server_name stats.example.com; # 改成你的域名27
+28
+ root /www/wwwroot/webstats/public; # ← 包根下的 public29
+ index index.php index.html;30
+31
+ # ---------- 管理端 SPA + 未安装引导 ----------32
+ # 交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html33
+ location / {34
+ try_files $uri $uri/ /index.php$is_args$args;35
+ }36
+37
+ # ---------- 管理端 API(含 /api/health 健康检查) ----------38
+ location ^~ /api/ {39
+ try_files $uri /index.php$is_args$args;40
+ }41
+42
+ # ---------- 采集端点 ----------43
+ # 【重要】采集端点是 POST 请求,必须由 PHP-FPM 执行,这里**不要**再写「精确匹配 + try_files」的静态块:44
+ # ✗ location = /collect.php { try_files $uri =404; }45
+ # `location =` 是精确匹配,优先级高于正则 location,会把 /collect.php 从 location ~ \.php$ 手里46
+ # 抢走;块内没有 fastcgi 处理器 → nginx 按静态文件返回 → POST 得到47
+ # 「405 Method Not Allowed」(GET 则变成下载 PHP 源码)。48
+ # 正确做法:什么都不写,交给下面的 location ~ \.php$ 处理。49
+ # 若确实想显式声明,必须自带 FastCGI(不能只写 try_files):50
+ # location = /collect.php {51
+ # fastcgi_pass unix:/tmp/php-cgi-82.sock; # 或 127.0.0.1:900052
+ # include fastcgi_params;53
+ # fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;54
+ # }55
+ #56
+ # 可选:短链形式 /c/{siteKey}(rewrite 到 .php 后会重新匹配 location,安全)57
+ location ^~ /c/ {58
+ rewrite ^/c/(.*)$ /collect.php?ak=$1 last;59
+ }60
+61
+ # ---------- 安装向导(Web 根内的 public/install) ----------62
+ # 安装完成后强烈建议改为拒绝访问:63
+ # location ^~ /install/ { deny all; return 404; }64
+ #65
+ # 提示:目录结构较特殊(例如包根挂在站点目录之外)时,可在 PHP-FPM 池配置里指定:66
+ # env[WSTAT_ROOT] = /www/wwwroot/webstats67
+ # 或在安装器目录写 public/install/local.php(安装向导第一步提供「手动指路」表单自动生成)。68
+69
+ # ---------- SDK 静态资源 ----------70
+ location ^~ /sdk/ {71
+ try_files $uri =404;72
+ expires 7d;73
+ add_header Cache-Control "public";74
+ }75
+76
+ # ---------- PHP ----------77
+ location ~ \.php$ {78
+ # 宝塔:include enable-php-82.conf;79
+ fastcgi_pass 127.0.0.1:9000; # 或 unix:/tmp/php-cgi-82.sock80
+ fastcgi_index index.php;81
+ include fastcgi_params;82
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;83
+ fastcgi_read_timeout 60s;84
+ }85
+86
+ # ---------- 实时数据不可缓存(面板「数据不更新」的头号原因) ----------87
+ # 应用已自带 Cache-Control: no-store,但 CDN / proxy_cache 会无视它,88
+ # 若站点前置了缓存,务必把 /api/ 与 /collect.php 排除在缓存之外。89
+90
+ # ---------- 旧版布局兼容(仅当从 ≤1.0.0 的包原地升级时可能用到) ----------91
+ # 旧包把后端放在 Web 根内的 server/ 目录,需屏蔽直访;统一布局下不需要:92
+ # location ^~ /server/ { deny all; return 404; }93
+94
+ client_max_body_size 1m;95
+ access_log /www/wwwlogs/webstats.log;96
+ error_log /www/wwwlogs/webstats.error.log;97
+}98
+99
+# ---------------------------------------------------------------------------100
+# 宝塔面板「防跨站攻击(open_basedir)」说明101
+# ---------------------------------------------------------------------------102
+# 宝塔默认把 open_basedir 限制为「网站目录:/tmp」,**运行目录不影响该限制**。103
+# 统一布局下请这样设置(两步):104
+# 面板 → 网站 → 设置 → 网站目录:105
+# 网站目录 = <包根>(例如 /www/wwwroot/webstats) ← open_basedir 覆盖到它106
+# 运行目录 = /public ← 站点实际 root 指向它107
+# 这样 app/ data/ scripts/ 都在 open_basedir 允许范围内,安装器的自动探测与写入都能正常工作。108
+#109
+# 若目录结构特殊(例如包根在站点目录之外),二选一:110
+# A. 把「网站目录」设为包根、「运行目录」设为 /public(推荐);111
+# B. 在 public/install/ 目录写入 local.php(安装向导第一步可一键生成「手动指路」表单):112
+# <?php return ['root' => '/www/wwwroot/webstats'];113
+# C. 或直接给站点关闭「防跨站攻击」(不推荐)。docs/deploy.md+494-460
@@ -1,460 +1,494 @@1
-# WebStats 部署与运维手册2
-3
-> 适用于 v1.0.0 及以后的发布包。安装向导支持 **Web 图形化安装** 与 **命令行安装** 两种方式,4
-> 全程不会 DROP/TRUNCATE 任何已有数据,脚本可重复执行。5
-6
-7
-## 一、环境要求8
-9
-| 组件 | 最低要求 | 推荐 | 说明 |10
-| --- | --- | --- | --- |11
-| PHP | 7.4 | 8.1 / 8.2 | 扩展:`pdo`、`pdo_mysql`、`json`、`mbstring`;可选 `openssl`、`curl`、`zip` |12
-| MySQL | 5.7 | 8.0 | 字符集 utf8mb4;`events` 表按 `day` 月分区(5.7 已支持) |13
-| Redis | 3.2 | 6.x / 7.x | **不需要 phpredis 扩展**(内置纯 PHP RESP 客户端);可选,缺失则降级 |14
-| Web 服务器 | — | Nginx / Apache | 站点根目录指向 `<包根>/public` |15
-| Node.js | 18 | 20+ | 仅「自行构建前端」时需要;发布包已内置构建产物 |16
-17
-> **Redis 是可选的**:连接失败时系统自动降级为直写 MySQL,此时「实时访客 / 在线数 / 会话与停留时长」不可用(明细仍完整),18
-> 安装向导与 `scripts/doctor.php` 都会提示。19
-20
-21
-## 二、安装22
-23
-### 2.0 目录布局(先确认这一件事)24
-25
-26
-```27
-<包根>/ 站点目录,例:/www/wwwroot/webstats28
-├── app/ 后端代码(入口引导 app/bootstrap.php)29
-├── config.php 出厂默认配置(部署凭据在 data/installed.php)30
-├── data/ 运行期数据:installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb / verify/ / backup/31
-├── public/ ★ Web 根目录(运行目录)32
-│ ├── index.php 管理端 API 入口 + 未安装引导 + SPA 回退33
-│ ├── collect.php 采集端点34
-│ ├── index.html assets/ geo/ sdk/35
-│ └── install/ 安装向导(安装完成后请删除)36
-├── scripts/ worker.php / cron.php / doctor.php / geo-doctor.php / selfcheck.php / fetch-geo.php37
-└── deploy/ docs/deploy.md README.md RELEASE.md CHANGELOG.md38
-```39
-40
-> 发布包已裁剪:`sdk/`(与 `public/sdk/wstat.js` 重复)、`sql/schema.sql`、`sql/wstat.sql`41
-> (历史参考脚本,含 DROP,仅供源码仓库查阅)、`docs/architecture.md`、`docs/review-feature-audit.html`42
-> (开发/内部文档)、`deploy/nginx.bt.conf.sample`(已废弃布局专用)、`scripts/seed-*.php`43
-> (演示数据造数)以及全部 `selftest-*` / 探针脚本。这些裁剪由构建脚本的「包内容校验」强制保证,见 §9.3。44
-45
-| 面板项 | 填什么 |46
-| --- | --- |47
-| 网站目录(宝塔) | `<包根>`,例 `/www/wwwroot/webstats` |48
-| 运行目录(宝塔) | `/public` |49
-| Nginx `root`(自建/其他面板) | `<包根>/public` |50
-51
-> `app/` `data/` `scripts/` `sql/` 都在 Web 根**之外**,天然不可直访52
-> (`data/installed.php` 含数据库凭据,也不会被下载到),**无需**任何额外屏蔽规则。53
-54
-**安装器仍会自动探测**后端目录(含 `app/bootstrap.php`)与建表脚本位置,下列历史布局照旧可以安装55
-(仅用于从 ≤1.0.0 原地升级的场景,新部署请一律使用上面的统一布局):56
-57
-**`sql/install.sql` 的搜索顺序**(命中即用,可用环境变量 `WSTAT_SQL_FILE` 强制指定):58
-59
-```60
-WSTAT_SQL_FILE → 项目根/sql/ → 项目根/server/sql/ → 安装器目录/sql/61
- → Web根/sql/ → 网站同级目录/sql/ → 上一级目录/sql/62
-```63
-64
-> 发布包已在 `public/install/sql/install.sql` **自带一份**建表脚本65
-66
-### 方式 A:Web 安装向导(推荐)67
-68
-1. 上传发布包到服务器(例如 `/www/wwwroot/webstats`),按上表把「网站目录 / 运行目录」指对;69
-2. **直接访问站点首页** `http://你的域名/` —— **未安装时服务端自动 302 跳转到 `/install/`**,不必记地址70
- (若 Web 服务器直接由 `index.html` 提供首页,前端还会兜底探测 `/api/health` 并跳转);71
-3. 按四步向导操作:72
- 1. **环境检测** —— PHP 版本、扩展、目录写权限、**部署布局**(项目根 / 后端目录 / 建表脚本实际路径)、离线 IP 库;73
- 2. **数据库与 Redis** —— 填写连接信息,向导会实测连接(可勾选「库不存在时自动创建」);74
- 3. **创建管理员** —— 设置登录邮箱与密码,可选开启调试模式;75
- 4. **完成** —— 显示必须执行的后续命令、完整站点接入代码。76
-4. 安装完成后**务必删除 `install` 目录**:77
- ```bash78
- rm -rf /www/wwwroot/webstats/public/install79
- ```80
-81
-> **目录结构特殊时(重要)**:若向导第一步出现「目录结构诊断」页(例如宝塔 `open_basedir` 只允许 Web 根、82
-> 包根在其外部),页面上会逐级列出探测过的路径、标出被 `open_basedir` 拦下的层级,并提供**手动指路**表单:83
-> 填入项目根(含 `app/` 与 `config.php` 的目录)的绝对路径,即生成 `public/install/local.php`,84
-> **无需修改 PHP-FPM / Nginx 配置**。也可手工创建 `public/install/local.php`:85
-> ```php86
-> <?php87
-> return [88
-> 'root' => '/www/wwwroot/webstats', // 统一布局:包根即项目根89
-> // 'server_dir' => '/www/wwwroot/webstats/server', // ≤1.0.0 旧包才是 server 目录90
-> // 'sql_file' => '/www/wwwroot/webstats/sql/install.sql',91
-> ];92
-> ```93
-> 删除该文件即恢复自动探测。94
-95
-> 向导会写入 `data/installed.php`(数据库/Redis 凭据)与 `data/install.lock`(防重复安装)。96
-> 已安装状态下再次访问向导会看到「系统已安装」提示;如需重装:`php public/install/cli.php --unlock`(或删除 lock 文件)。97
-98
-### 方式 B:命令行安装(SSH / 自动化 / 无浏览器场景)99
-100
-```bash101
-cd /www/wwwroot/webstats102
-103
-# 环境自检(含目录布局探测)104
-php public/install/cli.php --check105
-106
-# 目录结构特殊时显式指定项目根(等价于写 local.php)107
-php public/install/cli.php --check --root=/www/wwwroot/webstats108
-109
-# 一键安装(示例)110
-php public/install/cli.php \111
- --db-host=127.0.0.1 --db-port=3306 \112
- --db-name=webstats --db-user=webstats --db-pass='你的数据库密码' --create-db \113
- --redis-host=127.0.0.1 --redis-port=6379 --redis-auth='你的redis密码' --redis-db=0 \114
- [email protected] --admin-pass='Admin@12345'115
-```116
-117
-不带参数执行则为**交互式问答**。其它子命令:118
-119
-| 命令 | 作用 |120
-| --- | --- |121
-| `php public/install/cli.php --check` | 仅环境自检(退出码非 0 表示存在必需项未通过) |122
-| `php public/install/cli.php --status` | 查看安装状态、数据库/Redis 配置、锁文件位置、**部署布局** |123
-| `php public/install/cli.php --unlock` | 解除安装锁(重装 / 迁移前使用) |124
-| `php public/install/cli.php --force ...` | 忽略已安装状态强制重装(会覆盖 `installed.php`) |125
-| `--root=<目录>` / `--sql=<文件>` | 显式指定项目根 / 建表脚本(面板环境常用) |126
-127
-### 方式 C:完全手工(不推荐)128
-129
-```bash130
-# install.sql 内的分区占位标记 __PARTITIONS__ 需替换为真实分区行(参考文件末尾注释或安装器生成结果)131
-mysql -uwebstats -p webstats < sql/install.sql132
-cp data/installed.php.example data/installed.php # 按需手写133
-```134
-135
-136
-## 三、服务器配置137
-138
-### 3.1 Nginx139
-140
-完整样例见 `deploy/nginx.conf.sample`。核心片段:141
-142
-```nginx143
-root /www/wwwroot/webstats/public; # ← 包根下的 public(宝塔:网站目录=包根、运行目录=/public)144
-index index.php index.html; # 必须含 index.php:/install/ 才能直接打开145
-146
-# 非 API 也交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html147
-location / { try_files $uri $uri/ /index.php$is_args$args; }148
-location ^~ /api/ { try_files $uri /index.php$is_args$args; }149
-# app/ data/ scripts/ sql/ 都在 Web 根之外,无需屏蔽;仅从 ≤1.0.0 原地升级时才有 /server/ 需要 deny150
-location ~ \.php$ { # 采集端点 /collect.php 也走这里,不要单独拦截151
- fastcgi_pass 127.0.0.1:9000; # 宝塔已自带该块,无需重复配置152
- include fastcgi_params;153
- fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;154
-}155
-```156
-157
-> **不要把 `/api/` 与 `/collect.php` 交给缓存。** 应用已对全部 JSON 响应发送158
-> `Cache-Control: no-store, no-cache, must-revalidate`,但 CDN、Nginx `proxy_cache` 等中间层159
-> **可能无视源站指令**而自行缓存。若站点前面套了 CDN,或你为 `/api/` 配了 `proxy_cache`,160
-> 请把这两类路径加入**不缓存**名单,否则面板会出现「浏览量 / 访客信息几分钟甚至更久不刷新」的假象161
-> (数据其实早已入库,只是被中间层挡住了)。162
-163
-> **不要写** `location = /collect.php { try_files $uri =404; }`。`location =` 属精确匹配、优先级高于正则,164
-> 会把 `/collect.php` 当静态文件直出:POST 一律 **405 Method Not Allowed**,GET 变成下载 PHP 源码。165
-> 详见 [6.x 故障排查](#六故障排查)。166
-167
-> **未安装自动跳转**:`location /` 交给 `index.php` 后,未安装时访问首页/任意前端路由都会被 **302 到 `/install/`**;168
-> 已安装后同一规则输出 `index.html`(SPA 回退)。若你坚持用 `try_files $uri $uri/ /index.html`,169
-> 跳转由前端兜底完成(探测 `/api/health` 的 503 响应)—— 建议还是按上面写法,服务端跳转更稳。170
-171
-**宝塔面板操作**:网站 → 添加站点(**网站目录填包根**,例如 `/www/wwwroot/webstats`;**运行目录选 `/public`**)172
-→ 设置 → 伪静态粘贴上述规则(或直接选「自定义」)→ PHP 版本选 8.1/8.2 → 保存。173
-174
-**关于宝塔「防跨站攻击 (open_basedir)」**:默认会把 PHP 可访问范围限制为「站点目录 : /tmp」,这是安全设置,175
-**保留即可**(安装器已做越界保护,不会再输出 open_basedir 警告)。注意**运行目录不影响 open_basedir**:176
-站点目录填包根,`app/` `data/` `scripts/` 就都在允许范围内,安装与运行都正常。177
-目录结构确实特殊时,用安装向导第一步的「手动指路」或 `public/install/local.php` 指定路径,**不要**为此关闭防跨站。178
-179
-### 3.2 Apache180
-181
-把 `deploy/apache.htaccess.sample` 复制为 `public/.htaccess`,确保 `mod_rewrite` 开启、`AllowOverride All`。182
-183
-### 3.3 目录权限184
-185
-```bash186
-chown -R www:www /www/wwwroot/webstats/data187
-chmod 755 /www/wwwroot/webstats/data188
-```189
-190
-安装向导需要写 `data/`(生成 `installed.php` 与 `install.lock`,以及在线更新的 `backup/`)。191
-在线更新会覆盖 `app/` `public/` `scripts/` 下的程序文件,因此**整个包根目录都建议给 Web 进程写权限**192
-(宝塔默认 `www:www` 且目录归 `www` 所有时通常已满足);只想手改代码不想自动升级的话,193
-可在 `config.php` 里设 `update.enabled = false`(或环境变量 `WSTAT_UPDATE_OFF=1`)。194
-195
-196
-## 四、必须启动的后台进程(关键)197
-198
-统计链路是:**采集端 → Redis 队列 → worker 落库**。没有常驻 worker,`events` 明细不会入库、199
-`sessions`(会话/停留时长/跳出)与实时数据永远为空。200
-201
-### 4.1 worker 常驻202
-203
-```bash204
-cd /www/wwwroot/webstats205
-nohup php scripts/worker.php >> data/worker.log 2>&1 &206
-```207
-208
-- 宝塔:「软件商店 → 进程守护管理器 / Supervisor」添加进程,启动命令209
- `php /www/wwwroot/webstats/scripts/worker.php`,目录填项目根;210
-- systemd 示例(`/etc/systemd/system/wstat-worker.service`):211
-212
-```ini213
-[Unit]214
-Description=WebStats queue worker215
-After=network.target redis.service mysql.service216
-217
-[Service]218
-Type=simple219
-User=www220
-WorkingDirectory=/www/wwwroot/webstats221
-ExecStart=/usr/bin/php /www/wwwroot/webstats/scripts/worker.php222
-Restart=always223
-RestartSec=5224
-225
-[Install]226
-WantedBy=multi-user.target227
-```228
-229
-> ⚠️ **worker 必须从项目根目录启动,且代码更新后必须重启进程**(PHP 常驻进程不会热加载代码)。230
-> 若机器上存在多个历史版本的 worker 进程,会出现「PV 计数器增长了、但明细/会话没有」的错乱现象:231
-> 旧进程会按旧逻辑吞掉事件。排查方式见第七章。232
-233
-worker 的异常行为约定(便于 supervisor / 日志告警识别):234
-235
-| 日志 | 含义 | 处理 |236
-| --- | --- | --- |237
-| `[worker] Redis unavailable, retry in 3s...` | Redis 连不上(含密码错误/NOAUTH) | 3 秒后自动重连,会自愈;持续刷则修 Redis |238
-| `[worker] reaped closed=N offline=M` | 正常心跳:回收空闲会话 + 清理在线僵尸(仅在有回收时打印) | 无需处理 |239
-| `[worker] Redis error: ...retry in 3s...` | 临时性 Redis 异常(超时/断连) | 会自愈;持续刷则查网络与 Redis 负载 |240
-| `[FATAL] Redis 键类型错误(WRONGTYPE)` + 诊断块 | **键类型被污染**,重试无法自愈 | 按诊断块给出的 RENAME 命令处理;或启动加 `--quarantine` 自动隔离。详见第七章 |241
-242
-### 4.2 定时任务(crontab)243
-244
-```cron245
-* * * * * php /www/wwwroot/webstats/scripts/cron.php session # 回收空闲会话246
-0 * * * * php /www/wwwroot/webstats/scripts/cron.php rollup # 生成每日汇总 site_daily247
-0 3 * * * php /www/wwwroot/webstats/scripts/cron.php partition # 预建未来分区(避免写入超界)248
-0 4 * * * php /www/wwwroot/webstats/scripts/cron.php clean # 清理过期明细(按分区 DROP)249
-*/5 * * * * php /www/wwwroot/webstats/scripts/cron.php alert # 流量异常告警判定(5 分钟一次)250
-0 * * * * php /www/wwwroot/webstats/scripts/cron.php report # 日报推送(按订阅设定的整点发送)251
-```252
-253
-> `alert` 与 `report` 建议至少每 5 分钟 / 每小时各跑一次。两者都是**幂等**的:254
-> `alert` 依 `cooldown_min` 冷却,`report` 依 `last_sent_day` 去重,重复执行不会刷屏。255
-> 未配置任何规则/订阅时,两条命令只做一次空查询即退出,可安全常驻。256
-257
-宝塔:计划任务 → 添加(Shell 脚本,周期如上)。258
-259
-260
-## 五、站点接入(埋点)261
-262
-1. 登录管理端 → 站点管理 → 新建站点 → 填写域名;263
-2. 按提示在站点根目录放置验证文件完成「文件验证」(未验证站点默认拒绝采集);264
-3. 把下面代码粘贴到被统计网站的 `<head>` 中(`data-site-key` 换成后台生成的 key):265
-266
-```html267
-<script async src="https://stats.example.com/sdk/wstat.js"268
- data-site-key="YOUR_SITE_KEY"269
- data-host="https://stats.example.com"></script>270
-```271
-272
-> ⚠️ `src` 与 `data-host` 必须都指向**统计服务地址**,绝不能指向前端开发服务器(如 `localhost:5173`),273
-> 否则线上永远采集不到数据。274
-> 采集端点也支持跨域,无需额外 CORS 配置。275
-276
-277
-## 六、升级与迁移278
-279
-### 6.1 升级280
-281
-面板「系统设置 → 关于与更新」会自动检查;282
-283
-> ⚠️ 升级后请**重启 worker 进程**(PHP 常驻进程不会热加载代码),并刷新面板页面。284
-> 回滚:把 `data/backup/<目录>/` 下的文件按相同相对路径复制回包根即可。285
-286
-#### 方式二:手工升级287
-288
-1. **备份**:数据库 + `data/installed.php`;289
-2. 解压新包,把 `app/` `public/` `scripts/` `sql/` `deploy/` `docs/` `config.php` 覆盖过去290
- (**保留** `data/`;改过 `config.php` 的话先备份再决定是否覆盖);291
- 注意包根 `sdk/` 已不再分发(对外提供的一直是 `public/sdk/wstat.js`),可顺手删掉旧结构里的 `sdk/`;292
-3. 执行增量 SQL(如有):`mysql -u... -p 库名 < sql/upgrade-xxxx.sql`;293
-4. **不用改任何版本号**:生效版本号取自随包覆盖的 `app/version.php`(明文版本号,见第 8.11 节),294
- 覆盖完程序文件就自动跟上了。`data/installed.php` 里的 `'version'` 只是「安装当刻的快照」,295
- 合并配置时被显式忽略 —— v1.0.3 起它既不是判定依据,也不必手工同步296
- (旧文档曾要求自行 `sed` 改它,那一步已删除;改与不改都不影响生效版本);297
-5. **重启 worker 进程**(否则仍跑旧代码);298
-299
-### 6.2 迁移300
-301
-新机器按第二章安装 → 导入数据库备份 → 把 `installed.php` 里的库名/密码改对 → 启动 worker 与 cron。302
-303
-304
-## 七、运维排查305
-306
-| 现象 | 定位与处理 |307
-| --- | --- |308
-| 后台无数据/接口 503 | 未安装或配置缺失:访问 `/install/`(或直接访问首页会自动跳转);或检查 `data/installed.php` |309
-| 访问 `/install/` 报 `open_basedir restriction` 或「无法定位项目根目录」 | 安装器与后端不在同一目录树内。按 2.0 节核对「网站目录 / 运行目录」,或用向导第一步的「手动指路」生成 `public/install/local.php`(等价于 `php public/install/cli.php --check --root=/www/wwwroot/xxx`)。**不要**为此关闭宝塔防跨站 |310
-| 站点有访问但无数据 | ① 站点未验证(采集端返回 403);② 接入代码指向了开发服务器;③ worker 未运行 |311
-| PV 计数器有增长,但明细分页/会话为空 | Redis 队列有旧版 worker 进程在消费旧逻辑:`netstat -ano \| grep 6379` 找连接,`ps aux \| grep worker` 核对路径与启动时间,杀掉旧进程后从项目根重启;PHP 常驻进程不热加载代码 |312
-| 实时/在线数/停留时长长期为空 | Redis 不可用导致降级,或 worker 未运行;执行 `php scripts/doctor.php` 查看链路各环节 |313
-| 采集接口 403 site unverified | 完成站点文件验证;本地联调可临时设环境变量 `WSTAT_ALLOW_UNVERIFIED=1` |314
-| **`POST /collect.php` 返回 405 Method Not Allowed** | nginx 把采集端点当**静态文件**直出了(本系统代码不会返回 405,任何 405 都出在 Web 服务器层)。两种自检:<br>① `curl -I https://统计域名/collect.php` → 若 `Content-Type` 是 `application/octet-stream`/`text/plain` 而非 `application/json`,说明返回的是 PHP 源码,nginx 没交给 PHP-FPM;<br>② 响应头无 `X-Powered-By`、状态 405 且带 `Allow:` 头 → nginx 静态模块拒绝 POST。<br>**根因**:站点配置里存在精确匹配块 `location = /collect.php { try_files $uri =404; }`(`location =` 优先级高于 `location ~ \.php$`,且块内没有 fastcgi 处理器)。<br>**修复**:删除该 `location = /collect.php` 块(交给默认的 `location ~ [^/]\.php(/|$)` 处理);如确需显式声明,块内必须写 `fastcgi_pass` + `SCRIPT_FILENAME`,只写 `try_files` 一律 405。改完 `nginx -t && nginx -s reload`。<br>**备用通道**:`POST /api/collect`(走 index.php,参数与 `/collect.php` 完全一致),用于 `.php` 路径被 CDN/WAF 拦截的环境 |315
-| `GET /collect.php` 下载了文件 / 直接看到 PHP 源码 | 同上:nginx 未把 `.php` 交给 PHP-FPM(PHP 版本为「静态」或 php location 未生效)。宝塔:网站设置 → PHP 版本选 8.x 并保存 |316
-| 地理信息全空 | `data/ip2region.xdb`(IPv4)/ `data/ip2region_v6.xdb`(IPv6)缺失,或 `collect.geo_driver` 配置为 none。两族互相独立:只有 IPv6 访客没地域就是缺 v6 库,跑 `php scripts/fetch-geo.php` 补齐 |317
-| **worker 日志刷 `WRONGTYPE Operation against a key holding the wrong kind of value`** | **某个 Redis 键被写成了别的数据结构**(不是网络抖动,重试永远不会自愈)。<br>**含义**:worker 循环里的 `BRPOP wstat:queue`(或会话化阶段的 `HGETALL ssn:*`)撞上了一个类型不符的键,Redis 直接拒绝执行。<br>**常见根因**:① 同一个 Redis 库被**别的程序/站点共用**且键名冲突(`config.php` 的 `redis.prefix` 为空时键名就是裸 `queue`,最易撞车);② 曾跑过键结构不同的**旧版本**(如哈希队列)后残留;③ 人工用 `redis-cli` / 面板工具往同名键写过值。<br>**定位**:`php scripts/doctor.php` → 第 [3] 节会打印**实际类型 / 期望类型 / 坏键清单**与逐键修复命令(也可以看 worker 自己打印的诊断块,含键名、类型、元素数、内容预览)。手工核对:`redis-cli -n 0 TYPE wstat:queue`(期望 `list`)。<br>**修复**:先备份再重建,不要直接删——<br>`redis-cli -n 0 RENAME wstat:queue wstat:queue:bad_$(date +%Y%m%d%H%M%S)`<br>确认与其它程序无关后也可 `DEL`。随后重启 worker。急用时可直接 `php scripts/worker.php --quarantine`(自动把坏键 RENAME 备份后继续消费)。<br>**已有防护**:采集端遇到该错误会**自动降级直写 MySQL**(不再 500 丢点);worker 遇到队列键类型错误会打印诊断并**快速退出**(不再 3 秒一轮刷屏假死)。<br>**彻底避免**:把 `config.php` 的 `redis.prefix` 改为站点专属前缀(如 `wstat_xxx:`)并保持各环境一致,避免与同库其它应用撞键 |318
-| 采集接口偶发 500 | 看 PHP 错误日志:若含 `Redis error`,多为键类型污染(见上条)或 Redis 连接超时;已实现捕获降级,正常应只记一条 `[wstat] collect 热路径 ... 已降级直写 MySQL` 日志且接口仍返回 200 |319
-| 写入报错 no partition | 分区未覆盖当前日期:执行 `php scripts/cron.php partition`(应加入 crontab) |320
-| 一键自检 | `php scripts/doctor.php`(数据链路)/ `php scripts/selfcheck.php`(组件自检) |321
-322
-日志位置:PHP-FPM 错误日志(宝塔:网站日志)、`data/worker.log`、`data/worker-err.log`(按 §4.1 的重定向路径)。323
-324
-**健康检查接口**:`GET /api/health` —— 已安装返回 `{"code":0,"data":{"installed":true,"version":"..."}}`;325
-未安装返回 `503 + install_url`(前端据此跳转安装向导)。可用于探活与部署流水线校验。326
-327
-328
-## 八、导出 · 下钻 · 告警日报 · 多用户协作329
-330
-### 8.1 数据导出(CSV)331
-332
-管理端「概览」「来源分析」「页面分析」「会话明细」右上角的**导出**按钮;多项时以下拉菜单呈现。333
-334
-| kind | 内容 |335
-| --- | --- |336
-| `daily` | 按日 PV / UV / 独立 IP / 访问次数(流量趋势,即 ① 中的 PV·UV) |337
-| `channels` | 来源渠道构成(① 中的「来源」) |338
-| `referrers` | 外链来源(referrer 主机名) |339
-| `pages` | 页面路径(① 中的「页面」) |340
-| `entries` / `exits` | 入口页 / 退出页 |341
-| `sessions` | 会话明细(支持 q / browser / os / device / source 筛选,① 中的「会话」) |342
-| `geo` | 国家 / 省份 / 城市 |343
-| `devices` / `browsers` / `oses` / `langs` | 环境维度 |344
-345
-- **口径**:一律直查 `events` / `sessions` 现算,不依赖 `site_daily` 预聚合,因此导出数字与页面、346
- 「会话明细」口径一致,rollup 未跑也不会缺行。347
-- **编码**:输出带 UTF-8 BOM,Excel 双击不乱码;`Content-Disposition` 同时提供 ASCII 文件名与348
- `filename*`(中文名),老浏览器与新浏览器都能正确命名。349
-- **上限**:单次导出 5 万行、分组榜单 5000 行(超出截断,属预期保护)。350
-- **权限**:**只读成员(viewer)也可导出**其可见站点的数据。351
-- 直连(脚本/Airflow 等):`GET /api/export/{kind}?site_id=&start=&end=`,需 `Authorization: Bearer <token>`;352
- 管理端前端走 `fetch` + Bearer,**token 不进入 URL**,避免落到访问日志。353
-354
-### 8.2 单页面(URL)下钻355
-356
-- **入口**:页面分析列表中**点击任意一行**即可下钻;也可直接访问 `/#/page?url=<编码后的地址>&match=prefix`。357
-- **内容**:汇总卡(PV/UV/独立 IP/会话数/入口次数/退出次数/平均停留/跳出率)→ PV·UV 按天趋势 → 24h 分时柱图 →358
- 上一页/下一页流向(可继续点击下钻)→ 来源·环境·地域 / 页面自定义事件 / 最近会话。359
-- **匹配口径**:`exact`(精确,默认)或 `prefix`(前缀);两者同时作用于「完整 URL」与「URL 路径」表达式,360
- 兼容 SDK 上报完整 URL 与只存路径两种情形。361
-- **大站点保护**:流向分析与会话侧指标采用采样上限(5 万 PV / 2 万会话),触发时接口返回 `partial=true`,362
- 页面顶部出现黄色提示条,说明这两组数字为采样近似值,其余指标仍为精确值。363
-364
-### 8.3 流量异常告警与日报推送365
-366
-**规则维度**:367
-368
-| 维度 | 取值 |369
-| --- | --- |370
-| 指标 metric | `pv` / `uv` / `visits`(会话数)/ `bounce_rate` / `avg_duration` / `online`(实时在线) |371
-| 窗口 window_min | 统计最近 N 分钟 |372
-| 比较 compare | `ratio_up` / `ratio_down`(涨跌百分比)、`abs_over` / `abs_under`(绝对值高低)、`zero`(掉零) |373
-| 基线 baseline | `prev_day`(昨日同时段)、`prev_window`(上一个等长窗口) |374
-| 阈值 threshold | 百分比或绝对数值(按比较方式解释) |375
-| 防误报 | `min_sample` 基线样本下限(低于则跳过,专治夜间小流量误报);`cooldown_min` 冷却时间(避免刷屏) |376
-| 级别 level | `info` / `warning` / `critical`(体现在推送标题前缀与历史列表) |377
-378
-**推送渠道 7 类**:`webhook`、`feishu`(飞书)、`dingtalk`(钉钉,支持加签)、`wecom`(企业微信)、379
-`serverchan`(Server 酱)、`bark`、`email`。380
-381
-> 所有推送正文均以关键词 **WebStats** 开头,便于通过飞书/钉钉/企微机器人的「自定义关键词」安全设置;382
-> 若机器人已按关键词过滤,请把该关键词设为 `WebStats`。383
->384
-> 渠道密钥在列表接口中以 `******` 掩码返回;编辑时**留空或保持掩码不变**即沿用已存密钥,不会误清空。385
-> 每个渠道都提供「发送测试」,保存前可先验证连通性。386
-387
-**日报推送**:按订阅设定(频率:每日 / 每周指定星期;发送整点 `hour`;站点范围:指定站点或全部)聚合388
-PV/UV、Top 页面、Top 来源等生成正文并推送。`last_sent_day` 做幂等,cron 每小时跑也不会重复发送。389
-390
-**留痕**:每次触发都写入 `alert_logs`(含指标值、基线值、判定结果、各渠道推送状态),391
-管理端「告警历史」可按级别/站点分页筛选。392
-393
-**依赖 cron**(见 4.2):`cron.php alert` 建议每 5 分钟、`cron.php report` 每小时。394
-395
-**权限**:推送渠道 / 告警规则 / 日报订阅都是**按用户**的个人配置;规则绑定站点时要求对该站点396
-至少 **editor**(所有者与协作者可配,只读成员不可)。站点成员被移除后,其针对该站点的规则与订阅397
-会自动停止推送,避免数据继续外泄。398
-399
-### 8.4 站点成员与多用户只读权限400
-401
-**角色三级**(左低右高):402
-403
-| 角色 | 能力 |404
-| --- | --- |405
-| `viewer` 只读 | 查看该站点全部报表、导出 CSV;任何写操作返回 403 |406
-| `editor` 可编辑 | 以上 + 新建/修改/删除该站点的漏斗、配置该站点的告警规则 |407
-| `owner` 所有者 | 以上 + 修改/删除站点、生成与校验验证文件、管理成员 |408
-409
-- **站点所有者天然是 owner**,无需在成员表中插入自己;410
-- **管理入口**:站点管理 → 选中站点 → 「成员」;可添加(按注册邮箱)、改角色、移除;411
-- **被授权用户**登录后站点切换器会出现该站点,当前角色以标签显示(只读=黄色、可编辑/所有者=蓝色);412
-- **越权口径**:无权访问的站点统一返回 **404**(不泄露「该站点是否存在」);权限不足返回 **403**;413
-- **敏感字段**:站点列表接口对非 owner 的条目**清空 `verify_token`**,避免验证令牌外泄;414
-- **不开放「再授 owner」**:成员只能被授予 viewer/editor,以免权限扩散;转移所有权请走站点归属变更。415
-416
-### 8.5 系统设置(管理员):邮件 SMTP / 功能开关 / 注册开关417
-418
-仅**系统管理员**可见(左侧「系统设置」,或 `GET/PATCH /api/settings`)。419
-420
-- **管理员判定**:`users.is_admin=1`。老库未跑增量时自动回退「首个注册账号(最小 id)为管理员」。421
-- **功能开关**(即时生效):422
- - **开放注册**:关闭后登录页隐藏注册入口、注册页禁用提交,直接调 `POST /api/auth/register` 返回 403;423
- - **注册邮箱验证码**(默认开):开启后注册改为两步 —— 先输入**图片验证码**点「获取验证码」,424
- 邮箱收到 6 位验证码(10 分钟有效、60 秒可重发、每邮箱每小时 ≤5 封、每 IP 每小时 ≤20 封),425
- 再凭验证码提交注册。图片验证码由 GD 生成(`GET /api/auth/captcha`),**缺 GD 扩展时该开关等于锁死注册**,426
- 设置页与注册页都会给出红色提示;存储在 `data/verify/`(文件型,单机部署)。427
- - **采集上报**:关闭后全部站点的 SDK 上报被拒(`/api/collect` 返回 403),已有数据仍可查看;428
- - **告警与日报推送**:关闭后 `cron.php alert` / `cron.php report` 直接跳过(规则与订阅保留)。429
-- **邮件发送(SMTP)**:配置后,「告警渠道-邮件」「日报订阅邮件」「发送测试邮件」与**注册/重置邮箱验证码**统一走 SMTP430
- (支持 SSL(465) / STARTTLS(587) / 明文(25),AUTH LOGIN)。**未填 SMTP 服务器时回退 PHP mail()**(需主机 MTA)。431
- SMTP 密码接口掩码回显(`******`),保存时留空或保持掩码即沿用旧值。432
-- **公共开关接口**:`GET /api/auth/public`(无需登录)返回433
- `{registration_enabled, email_verify_enabled, captcha_ready}`,供登录/注册页联动。434
-435
-#### 忘记密码(自助重置)436
-437
-登录页「忘记密码?」→ `/forgot`,也即三条公开接口(均无需登录):438
-439
-| 接口 | 说明 |440
-|---|---|441
-| `GET /api/auth/captcha` | 图片验证码(与注册共用;一次性,校验后即作废) |442
-| `POST /api/auth/reset-code` | `{email, captcha_id, captcha, lang?}` → 发送重置验证码 |443
-| `POST /api/auth/reset-password` | `{email, code, password}` → 重置密码 |444
-445
-- **邮箱必须已注册**(否则 404「该邮箱未注册」),账号被禁用时 403;446
-- 邮箱验证码**恒定启用**,不受「注册邮箱验证码」开关影响 —— 注册可以关闭,已有账号必须能自助找回;447
-- **不看 `registration_enabled`**:注册关闭时重置流程照常可用;448
-- 重置验证码与注册验证码**存储键互不通用**(`mailreset:` / `mailcode:`),注册码不能拿来改密码(反之亦然);449
- 冷却与小时配额(每邮箱 5 封/小时、每 IP 20 封/小时、60 秒重发)两类用途**共用**,防止换用途绕开限流;450
-- 重置成功后**吊销该账号全部登录令牌**(其它设备上的旧会话立即失效),返回 `{ok:true, revoked:N}`;451
-- **缺 GD 扩展时**取码不再强制图片验证码(退化为仅邮件配额限流),`/forgot` 页会给出黄色提示;452
-- 前置限流顺序:图码 → 邮箱存在性 → 发信,前两步失败都不消耗邮件额度。1
+# WebStats 部署与运维手册2
+3
+> 适用于 v1.0.0 及以后的发布包。安装向导支持 **Web 图形化安装** 与 **命令行安装** 两种方式,4
+> 全程不会 DROP/TRUNCATE 任何已有数据,脚本可重复执行。5
+6
+---7
+8
+## 一、环境要求9
+10
+| 组件 | 最低要求 | 推荐 | 说明 |11
+| --- | --- | --- | --- |12
+| PHP | 7.4 | 8.1 / 8.2 | 扩展:`pdo`、`pdo_mysql`、`json`、`mbstring`;可选 `openssl`、`curl`、`zip` |13
+| MySQL | 5.7 | 8.0 | 字符集 utf8mb4;`events` 表按 `day` 月分区(5.7 已支持) |14
+| Redis | 3.2 | 6.x / 7.x | **不需要 phpredis 扩展**(内置纯 PHP RESP 客户端);可选,缺失则降级 |15
+| Web 服务器 | — | Nginx / Apache | 站点根目录指向 `<包根>/public` |16
+| Node.js | 18 | 20+ | 仅「自行构建前端」时需要;发布包已内置构建产物 |17
+18
+> **Redis 是可选的**:连接失败时系统自动降级为直写 MySQL,此时「实时访客 / 在线数 / 会话与停留时长」不可用(明细仍完整),19
+> 安装向导与 `scripts/doctor.php` 都会提示。20
+21
+---22
+23
+## 二、安装24
+25
+### 2.0 目录布局(先确认这一件事)26
+27
+v1.0.1 起发布包:28
+29
+```30
+<包根>/ 站点目录,例:/www/wwwroot/webstats31
+├── app/ 后端代码(入口引导 app/bootstrap.php)32
+├── config.php 出厂默认配置(部署凭据在 data/installed.php)33
+├── data/ 运行期数据:installed.php / install.lock / ip2region.xdb + ip2region_v6.xdb / verify/ / backup/34
+├── public/ ★ Web 根目录(运行目录)35
+│ ├── index.php 管理端 API 入口 + 未安装引导 + SPA 回退36
+│ ├── collect.php 采集端点37
+│ ├── index.html assets/ geo/ sdk/38
+│ └── install/ 安装向导(安装完成后请删除)39
+├── scripts/ worker.php / cron.php / doctor.php / geo-doctor.php / selfcheck.php / fetch-geo.php40
+├── sql/ install.sql(幂等全量建表)+ upgrade-*.sql(增量升级)41
+└── deploy/ docs/deploy.md README.md RELEASE.md CHANGELOG.md42
+```43
+44
+> 发布包已裁剪:`sdk/`(与 `public/sdk/wstat.js` 重复)、`sql/schema.sql`、`sql/wstat.sql`45
+> (历史参考脚本,含 DROP,仅供源码仓库查阅)、`docs/architecture.md`、`docs/review-feature-audit.html`46
+> (开发/内部文档)、`deploy/nginx.bt.conf.sample`(已废弃布局专用)、`scripts/seed-*.php`47
+> (演示数据造数)以及全部 `selftest-*` / 探针脚本。这些裁剪由构建脚本的「包内容校验」强制保证,见 §9.3。48
+49
+| 面板项 | 填什么 |50
+| --- | --- |51
+| 网站目录(宝塔) | `<包根>`,例 `/www/wwwroot/webstats` |52
+| 运行目录(宝塔) | `/public` |53
+| Nginx `root`(自建/其他面板) | `<包根>/public` |54
+55
+> `app/` `data/` `scripts/` `sql/` 都在 Web 根**之外**,天然不可直访56
+> (`data/installed.php` 含数据库凭据,也不会被下载到),**无需**任何额外屏蔽规则。57
+58
+**安装器仍会自动探测**后端目录(含 `app/bootstrap.php`)与建表脚本位置,下列历史布局照旧可以安装59
+(仅用于从 ≤1.0.0 原地升级的场景,新部署请一律使用上面的统一布局):60
+61
+**`sql/install.sql` 的搜索顺序**(命中即用,可用环境变量 `WSTAT_SQL_FILE` 强制指定):62
+63
+```64
+WSTAT_SQL_FILE → 项目根/sql/ → 项目根/server/sql/ → 安装器目录/sql/65
+ → Web根/sql/ → 网站同级目录/sql/ → 上一级目录/sql/66
+```67
+68
+> 发布包已在 `public/install/sql/install.sql` **自带一份**建表脚本,69
+> 因此即使 `sql/` 被放到别处,安装也不会失败。70
+71
+### 方式 A:Web 安装向导(推荐)72
+73
+1. 上传发布包到服务器(例如 `/www/wwwroot/webstats`),按上表把「网站目录 / 运行目录」指对;74
+2. **直接访问站点首页** `http://你的域名/` —— **未安装时服务端自动 302 跳转到 `/install/`**,不必记地址75
+ (若 Web 服务器直接由 `index.html` 提供首页,前端还会兜底探测 `/api/health` 并跳转);76
+3. 按四步向导操作:77
+ 1. **环境检测** —— PHP 版本、扩展、目录写权限、**部署布局**(项目根 / 后端目录 / 建表脚本实际路径)、离线 IP 库;78
+ 2. **数据库与 Redis** —— 填写连接信息,向导会实测连接(可勾选「库不存在时自动创建」);79
+ 3. **创建管理员** —— 设置登录邮箱与密码,可选开启调试模式;80
+ 4. **完成** —— 显示必须执行的后续命令、完整站点接入代码。81
+4. 安装完成后**务必删除 `install` 目录**:82
+ ```bash83
+ rm -rf /www/wwwroot/webstats/public/install84
+ ```85
+86
+> **目录结构特殊时(重要)**:若向导第一步出现「目录结构诊断」页(例如宝塔 `open_basedir` 只允许 Web 根、87
+> 包根在其外部),页面上会逐级列出探测过的路径、标出被 `open_basedir` 拦下的层级,并提供**手动指路**表单:88
+> 填入项目根(含 `app/` 与 `config.php` 的目录)的绝对路径,即生成 `public/install/local.php`,89
+> **无需修改 PHP-FPM / Nginx 配置**。也可手工创建 `public/install/local.php`:90
+> ```php91
+> <?php92
+> return [93
+> 'root' => '/www/wwwroot/webstats', // 统一布局:包根即项目根94
+> // 'server_dir' => '/www/wwwroot/webstats/server', // ≤1.0.0 旧包才是 server 目录95
+> // 'sql_file' => '/www/wwwroot/webstats/sql/install.sql',96
+> ];97
+> ```98
+> 删除该文件即恢复自动探测。99
+100
+> 向导会写入 `data/installed.php`(数据库/Redis 凭据)与 `data/install.lock`(防重复安装)。101
+> 已安装状态下再次访问向导会看到「系统已安装」提示;如需重装:`php public/install/cli.php --unlock`(或删除 lock 文件)。102
+103
+### 方式 B:命令行安装(SSH / 自动化 / 无浏览器场景)104
+105
+```bash106
+cd /www/wwwroot/webstats107
+108
+# 环境自检(含目录布局探测)109
+php public/install/cli.php --check110
+111
+# 目录结构特殊时显式指定项目根(等价于写 local.php)112
+php public/install/cli.php --check --root=/www/wwwroot/webstats113
+114
+# 一键安装(示例)115
+php public/install/cli.php \116
+ --db-host=127.0.0.1 --db-port=3306 \117
+ --db-name=webstats --db-user=webstats --db-pass='你的数据库密码' --create-db \118
+ --redis-host=127.0.0.1 --redis-port=6379 --redis-auth='你的redis密码' --redis-db=0 \119
+ [email protected] --admin-pass='Admin@12345'120
+```121
+122
+不带参数执行则为**交互式问答**。其它子命令:123
+124
+| 命令 | 作用 |125
+| --- | --- |126
+| `php public/install/cli.php --check` | 仅环境自检(退出码非 0 表示存在必需项未通过) |127
+| `php public/install/cli.php --status` | 查看安装状态、数据库/Redis 配置、锁文件位置、**部署布局** |128
+| `php public/install/cli.php --unlock` | 解除安装锁(重装 / 迁移前使用) |129
+| `php public/install/cli.php --force ...` | 忽略已安装状态强制重装(会覆盖 `installed.php`) |130
+| `--root=<目录>` / `--sql=<文件>` | 显式指定项目根 / 建表脚本(面板环境常用) |131
+132
+### 方式 C:完全手工(不推荐)133
+134
+```bash135
+# install.sql 内的分区占位标记 __PARTITIONS__ 需替换为真实分区行(参考文件末尾注释或安装器生成结果)136
+mysql -uwebstats -p webstats < sql/install.sql137
+cp data/installed.php.example data/installed.php # 按需手写138
+```139
+140
+---141
+142
+## 三、服务器配置143
+144
+### 3.1 Nginx145
+146
+完整样例见 `deploy/nginx.conf.sample`。核心片段:147
+148
+```nginx149
+root /www/wwwroot/webstats/public; # ← 包根下的 public(宝塔:网站目录=包根、运行目录=/public)150
+index index.php index.html; # 必须含 index.php:/install/ 才能直接打开151
+152
+# 非 API 也交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html153
+location / { try_files $uri $uri/ /index.php$is_args$args; }154
+location ^~ /api/ { try_files $uri /index.php$is_args$args; }155
+# app/ data/ scripts/ sql/ 都在 Web 根之外,无需屏蔽;仅从 ≤1.0.0 原地升级时才有 /server/ 需要 deny156
+location ~ \.php$ { # 采集端点 /collect.php 也走这里,不要单独拦截157
+ fastcgi_pass 127.0.0.1:9000; # 宝塔已自带该块,无需重复配置158
+ include fastcgi_params;159
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;160
+}161
+```162
+163
+> **不要把 `/api/` 与 `/collect.php` 交给缓存。** 应用已对全部 JSON 响应发送164
+> `Cache-Control: no-store, no-cache, must-revalidate`,但 CDN、Nginx `proxy_cache` 等中间层165
+> **可能无视源站指令**而自行缓存。若站点前面套了 CDN,或你为 `/api/` 配了 `proxy_cache`,166
+> 请把这两类路径加入**不缓存**名单,否则面板会出现「浏览量 / 访客信息几分钟甚至更久不刷新」的假象167
+> (数据其实早已入库,只是被中间层挡住了)。168
+169
+> **不要写** `location = /collect.php { try_files $uri =404; }`。`location =` 属精确匹配、优先级高于正则,170
+> 会把 `/collect.php` 当静态文件直出:POST 一律 **405 Method Not Allowed**,GET 变成下载 PHP 源码。171
+> 详见 [6.x 故障排查](#六故障排查)。172
+173
+> **未安装自动跳转**:`location /` 交给 `index.php` 后,未安装时访问首页/任意前端路由都会被 **302 到 `/install/`**;174
+> 已安装后同一规则输出 `index.html`(SPA 回退)。若你坚持用 `try_files $uri $uri/ /index.html`,175
+> 跳转由前端兜底完成(探测 `/api/health` 的 503 响应)—— 建议还是按上面写法,服务端跳转更稳。176
+177
+**宝塔面板操作**:网站 → 添加站点(**网站目录填包根**,例如 `/www/wwwroot/webstats`;**运行目录选 `/public`**)178
+→ 设置 → 伪静态粘贴上述规则(或直接选「自定义」)→ PHP 版本选 8.1/8.2 → 保存。179
+180
+**关于宝塔「防跨站攻击 (open_basedir)」**:默认会把 PHP 可访问范围限制为「站点目录 : /tmp」,这是安全设置,181
+**保留即可**(安装器已做越界保护,不会再输出 open_basedir 警告)。注意**运行目录不影响 open_basedir**:182
+站点目录填包根,`app/` `data/` `scripts/` 就都在允许范围内,安装与运行都正常。183
+目录结构确实特殊时,用安装向导第一步的「手动指路」或 `public/install/local.php` 指定路径,**不要**为此关闭防跨站。184
+185
+### 3.2 Apache186
+187
+把 `deploy/apache.htaccess.sample` 复制为 `public/.htaccess`,确保 `mod_rewrite` 开启、`AllowOverride All`。188
+189
+### 3.3 目录权限190
+191
+```bash192
+chown -R www:www /www/wwwroot/webstats/data193
+chmod 755 /www/wwwroot/webstats/data194
+```195
+196
+安装向导需要写 `data/`(生成 `installed.php` 与 `install.lock`,以及在线更新的 `backup/`)。197
+在线更新会覆盖 `app/` `public/` `scripts/` 下的程序文件,因此**整个包根目录都建议给 Web 进程写权限**198
+(宝塔默认 `www:www` 且目录归 `www` 所有时通常已满足);只想手改代码不想自动升级的话,199
+可在 `config.php` 里设 `update.enabled = false`(或环境变量 `WSTAT_UPDATE_OFF=1`)。200
+201
+---202
+203
+## 四、必须启动的后台进程(关键)204
+205
+统计链路是:**采集端 → Redis 队列 → worker 落库**。没有常驻 worker,`events` 明细不会入库、206
+`sessions`(会话/停留时长/跳出)与实时数据永远为空。207
+208
+### 4.1 worker 常驻209
+210
+```bash211
+cd /www/wwwroot/webstats212
+nohup php scripts/worker.php >> data/worker.log 2>&1 &213
+```214
+215
+- 宝塔:「软件商店 → 进程守护管理器 / Supervisor」添加进程,启动命令216
+ `php /www/wwwroot/webstats/scripts/worker.php`,目录填项目根;217
+- systemd 示例(`/etc/systemd/system/wstat-worker.service`):218
+219
+```ini220
+[Unit]221
+Description=WebStats queue worker222
+After=network.target redis.service mysql.service223
+224
+[Service]225
+Type=simple226
+User=www227
+WorkingDirectory=/www/wwwroot/webstats228
+ExecStart=/usr/bin/php /www/wwwroot/webstats/scripts/worker.php229
+Restart=always230
+RestartSec=5231
+232
+[Install]233
+WantedBy=multi-user.target234
+```235
+236
+> ⚠️ **worker 必须从项目根目录启动,且代码更新后必须重启进程**(PHP 常驻进程不会热加载代码)。237
+> 若机器上存在多个历史版本的 worker 进程,会出现「PV 计数器增长了、但明细/会话没有」的错乱现象:238
+> 旧进程会按旧逻辑吞掉事件。排查方式见第七章。239
+240
+worker 的异常行为约定(便于 supervisor / 日志告警识别):241
+242
+| 日志 | 含义 | 处理 |243
+| --- | --- | --- |244
+| `[worker] Redis unavailable, retry in 3s...` | Redis 连不上(含密码错误/NOAUTH) | 3 秒后自动重连,会自愈;持续刷则修 Redis |245
+| `[worker] reaped closed=N offline=M` | 正常心跳:回收空闲会话 + 清理在线僵尸(仅在有回收时打印) | 无需处理 |246
+| `[worker] Redis error: ...retry in 3s...` | 临时性 Redis 异常(超时/断连) | 会自愈;持续刷则查网络与 Redis 负载 |247
+| `[FATAL] Redis 键类型错误(WRONGTYPE)` + 诊断块 | **键类型被污染**,重试无法自愈 | 按诊断块给出的 RENAME 命令处理;或启动加 `--quarantine` 自动隔离。详见第七章 |248
+249
+### 4.2 定时任务(crontab)250
+251
+```cron252
+* * * * * php /www/wwwroot/webstats/scripts/cron.php session # 回收空闲会话253
+0 * * * * php /www/wwwroot/webstats/scripts/cron.php rollup # 生成每日汇总 site_daily254
+0 3 * * * php /www/wwwroot/webstats/scripts/cron.php partition # 预建未来分区(避免写入超界)255
+0 4 * * * php /www/wwwroot/webstats/scripts/cron.php clean # 清理过期明细(按分区 DROP)256
+*/5 * * * * php /www/wwwroot/webstats/scripts/cron.php alert # 流量异常告警判定(5 分钟一次)257
+0 * * * * php /www/wwwroot/webstats/scripts/cron.php report # 日报推送(按订阅设定的整点发送)258
+```259
+260
+> `alert` 与 `report` 建议至少每 5 分钟 / 每小时各跑一次。两者都是**幂等**的:261
+> `alert` 依 `cooldown_min` 冷却,`report` 依 `last_sent_day` 去重,重复执行不会刷屏。262
+> 未配置任何规则/订阅时,两条命令只做一次空查询即退出,可安全常驻。263
+264
+宝塔:计划任务 → 添加(Shell 脚本,周期如上)。265
+266
+---267
+268
+## 五、站点接入(埋点)269
+270
+1. 登录管理端 → 站点管理 → 新建站点 → 填写域名;271
+2. 按提示在站点根目录放置验证文件完成「文件验证」(未验证站点默认拒绝采集);272
+3. 把下面代码粘贴到被统计网站的 `<head>` 中(`data-site-key` 换成后台生成的 key):273
+274
+```html275
+<script async src="https://stats.example.com/sdk/wstat.js"276
+ data-site-key="YOUR_SITE_KEY"277
+ data-host="https://stats.example.com"></script>278
+```279
+280
+> ⚠️ `src` 与 `data-host` 必须都指向**统计服务地址**,绝不能指向前端开发服务器(如 `localhost:5173`),281
+> 否则线上永远采集不到数据。282
+> 采集端点也支持跨域,无需额外 CORS 配置。283
+284
+---285
+286
+## 六、升级与迁移287
+288
+### 6.1 升级289
+290
+升级服务地址:**https://wstats-update.kfw.cc/update.php**(面板「系统设置 → 关于与更新」会自动检查;291
+`config.php` 的 `update_check_url` 可改指向自建服务,环境变量 `WSTAT_UPDATE_URL` 可覆盖)。292
+293
+#### 方式一:面板「一键升级」(推荐)294
+295
+系统设置 → 「关于与更新」→ 检查更新 → **一键升级**。服务端会:296
+297
+1. 从更新服务取回**增量包**(只含变化的程序文件 + `update.json` 清单 + 新增的 `sql/upgrade-*.sql`);298
+2. 逐条校验包内每个文件的 sha256,任一条不符即**整体中止**(不会出现半个新版本);299
+3. 把将被覆盖/删除的文件备份到 `data/backup/<新版本>-<时间>/`(含 `rollback.json`);300
+4. 覆盖 `app/` `public/` `scripts/` `sql/` 等程序文件 → 执行新增的增量 SQL → 把本次升级写进 `data/version.json`301
+ (版本号本身**随代码走**:`app/version.php`(明文版本号)被覆盖即自动生效,302
+ 不再需要也不应该去改 `data/installed.php` 的 `version`);303
+5. 旧版本备份默认保留最近 3 份(`config.php` 的 `update.keep_backup`)。304
+305
+**一键升级永不触碰** `data/` 与 `config.php`;若你改过 `config.php`(与基线哈希不一致),306
+新版本的默认配置会落在 `config.php.new`,需要你自己对比合并。307
+308
+> ⚠️ 升级后请**重启 worker 进程**(PHP 常驻进程不会热加载代码),并刷新面板页面。309
+> 回滚:把 `data/backup/<目录>/` 下的文件按相同相对路径复制回包根即可。310
+311
+#### 方式二:手工升级312
+313
+1. **备份**:数据库 + `data/installed.php`;314
+2. 解压新包,把 `app/` `public/` `scripts/` `sql/` `deploy/` `docs/` `config.php` 覆盖过去315
+ (**保留** `data/`;改过 `config.php` 的话先备份再决定是否覆盖);316
+ 注意包根 `sdk/` 已不再分发(对外提供的一直是 `public/sdk/wstat.js`),可顺手删掉旧结构里的 `sdk/`;317
+3. 执行增量 SQL(如有):`mysql -u... -p 库名 < sql/upgrade-xxxx.sql`;318
+4. **不用改任何版本号**:生效版本号取自随包覆盖的 `app/version.php`(明文版本号,见第 8.11 节),319
+ 覆盖完程序文件就自动跟上了。`data/installed.php` 里的 `'version'` 只是「安装当刻的快照」,320
+ 合并配置时被显式忽略 —— v1.0.3 起它既不是判定依据,也不必手工同步321
+ (旧文档曾要求自行 `sed` 改它,那一步已删除;改与不改都不影响生效版本);322
+5. **重启 worker 进程**(否则仍跑旧代码);323
+6. 发布包内的 SDK 已自动混淆,无需手工同步;自建 SDK 时两处副本324
+ (仓库根 `sdk/wstat.js` → `public/sdk/wstat.js`,以及 `frontend/sdk/wstat.js`)保持一致并清 CDN 缓存。325
+326
+### 6.2 迁移327
+328
+新机器按第二章安装 → 导入数据库备份 → 把 `installed.php` 里的库名/密码改对 → 启动 worker 与 cron。329
+330
+---331
+332
+## 七、运维排查333
+334
+| 现象 | 定位与处理 |335
+| --- | --- |336
+| 后台无数据/接口 503 | 未安装或配置缺失:访问 `/install/`(或直接访问首页会自动跳转);或检查 `data/installed.php` |337
+| 访问 `/install/` 报 `open_basedir restriction` 或「无法定位项目根目录」 | 安装器与后端不在同一目录树内。按 2.0 节核对「网站目录 / 运行目录」,或用向导第一步的「手动指路」生成 `public/install/local.php`(等价于 `php public/install/cli.php --check --root=/www/wwwroot/xxx`)。**不要**为此关闭宝塔防跨站 |338
+| 站点有访问但无数据 | ① 站点未验证(采集端返回 403);② 接入代码指向了开发服务器;③ worker 未运行 |339
+| PV 计数器有增长,但明细分页/会话为空 | Redis 队列有旧版 worker 进程在消费旧逻辑:`netstat -ano \| grep 6379` 找连接,`ps aux \| grep worker` 核对路径与启动时间,杀掉旧进程后从项目根重启;PHP 常驻进程不热加载代码 |340
+| 实时/在线数/停留时长长期为空 | Redis 不可用导致降级,或 worker 未运行;执行 `php scripts/doctor.php` 查看链路各环节 |341
+| 采集接口 403 site unverified | 完成站点文件验证;本地联调可临时设环境变量 `WSTAT_ALLOW_UNVERIFIED=1` |342
+| **`POST /collect.php` 返回 405 Method Not Allowed** | nginx 把采集端点当**静态文件**直出了(本系统代码不会返回 405,任何 405 都出在 Web 服务器层)。两种自检:<br>① `curl -I https://统计域名/collect.php` → 若 `Content-Type` 是 `application/octet-stream`/`text/plain` 而非 `application/json`,说明返回的是 PHP 源码,nginx 没交给 PHP-FPM;<br>② 响应头无 `X-Powered-By`、状态 405 且带 `Allow:` 头 → nginx 静态模块拒绝 POST。<br>**根因**:站点配置里存在精确匹配块 `location = /collect.php { try_files $uri =404; }`(`location =` 优先级高于 `location ~ \.php$`,且块内没有 fastcgi 处理器)。<br>**修复**:删除该 `location = /collect.php` 块(交给默认的 `location ~ [^/]\.php(/|$)` 处理);如确需显式声明,块内必须写 `fastcgi_pass` + `SCRIPT_FILENAME`,只写 `try_files` 一律 405。改完 `nginx -t && nginx -s reload`。<br>**备用通道**:`POST /api/collect`(走 index.php,参数与 `/collect.php` 完全一致),用于 `.php` 路径被 CDN/WAF 拦截的环境 |343
+| `GET /collect.php` 下载了文件 / 直接看到 PHP 源码 | 同上:nginx 未把 `.php` 交给 PHP-FPM(PHP 版本为「静态」或 php location 未生效)。宝塔:网站设置 → PHP 版本选 8.x 并保存 |344
+| 地理信息全空 | `data/ip2region.xdb`(IPv4)/ `data/ip2region_v6.xdb`(IPv6)缺失,或 `collect.geo_driver` 配置为 none。两族互相独立:只有 IPv6 访客没地域就是缺 v6 库,跑 `php scripts/fetch-geo.php` 补齐 |345
+| **worker 日志刷 `WRONGTYPE Operation against a key holding the wrong kind of value`** | **某个 Redis 键被写成了别的数据结构**(不是网络抖动,重试永远不会自愈)。<br>**含义**:worker 循环里的 `BRPOP wstat:queue`(或会话化阶段的 `HGETALL ssn:*`)撞上了一个类型不符的键,Redis 直接拒绝执行。<br>**常见根因**:① 同一个 Redis 库被**别的程序/站点共用**且键名冲突(`config.php` 的 `redis.prefix` 为空时键名就是裸 `queue`,最易撞车);② 曾跑过键结构不同的**旧版本**(如哈希队列)后残留;③ 人工用 `redis-cli` / 面板工具往同名键写过值。<br>**定位**:`php scripts/doctor.php` → 第 [3] 节会打印**实际类型 / 期望类型 / 坏键清单**与逐键修复命令(也可以看 worker 自己打印的诊断块,含键名、类型、元素数、内容预览)。手工核对:`redis-cli -n 0 TYPE wstat:queue`(期望 `list`)。<br>**修复**:先备份再重建,不要直接删——<br>`redis-cli -n 0 RENAME wstat:queue wstat:queue:bad_$(date +%Y%m%d%H%M%S)`<br>确认与其它程序无关后也可 `DEL`。随后重启 worker。急用时可直接 `php scripts/worker.php --quarantine`(自动把坏键 RENAME 备份后继续消费)。<br>**已有防护**:采集端遇到该错误会**自动降级直写 MySQL**(不再 500 丢点);worker 遇到队列键类型错误会打印诊断并**快速退出**(不再 3 秒一轮刷屏假死)。<br>**彻底避免**:把 `config.php` 的 `redis.prefix` 改为站点专属前缀(如 `wstat_xxx:`)并保持各环境一致,避免与同库其它应用撞键 |346
+| 采集接口偶发 500 | 看 PHP 错误日志:若含 `Redis error`,多为键类型污染(见上条)或 Redis 连接超时;已实现捕获降级,正常应只记一条 `[wstat] collect 热路径 ... 已降级直写 MySQL` 日志且接口仍返回 200 |347
+| 写入报错 no partition | 分区未覆盖当前日期:执行 `php scripts/cron.php partition`(应加入 crontab) |348
+| 一键自检 | `php scripts/doctor.php`(数据链路)/ `php scripts/selfcheck.php`(组件自检) |349
+350
+日志位置:PHP-FPM 错误日志(宝塔:网站日志)、`data/worker.log`、`data/worker-err.log`(按 §4.1 的重定向路径)。351
+352
+**健康检查接口**:`GET /api/health` —— 已安装返回 `{"code":0,"data":{"installed":true,"version":"..."}}`;353
+未安装返回 `503 + install_url`(前端据此跳转安装向导)。可用于探活与部署流水线校验。354
+355
+---356
+357
+## 八、导出 · 下钻 · 告警日报 · 多用户协作358
+359
+### 8.1 数据导出(CSV)360
+361
+管理端「概览」「来源分析」「页面分析」「会话明细」右上角的**导出**按钮;多项时以下拉菜单呈现。362
+363
+| kind | 内容 |364
+| --- | --- |365
+| `daily` | 按日 PV / UV / 独立 IP / 访问次数(流量趋势,即 ① 中的 PV·UV) |366
+| `channels` | 来源渠道构成(① 中的「来源」) |367
+| `referrers` | 外链来源(referrer 主机名) |368
+| `pages` | 页面路径(① 中的「页面」) |369
+| `entries` / `exits` | 入口页 / 退出页 |370
+| `sessions` | 会话明细(支持 q / browser / os / device / source 筛选,① 中的「会话」) |371
+| `geo` | 国家 / 省份 / 城市 |372
+| `devices` / `browsers` / `oses` / `langs` | 环境维度 |373
+374
+- **口径**:一律直查 `events` / `sessions` 现算,不依赖 `site_daily` 预聚合,因此导出数字与页面、375
+ 「会话明细」口径一致,rollup 未跑也不会缺行。376
+- **编码**:输出带 UTF-8 BOM,Excel 双击不乱码;`Content-Disposition` 同时提供 ASCII 文件名与377
+ `filename*`(中文名),老浏览器与新浏览器都能正确命名。378
+- **上限**:单次导出 5 万行、分组榜单 5000 行(超出截断,属预期保护)。379
+- **权限**:**只读成员(viewer)也可导出**其可见站点的数据。380
+- 直连(脚本/Airflow 等):`GET /api/export/{kind}?site_id=&start=&end=`,需 `Authorization: Bearer <token>`;381
+ 管理端前端走 `fetch` + Bearer,**token 不进入 URL**,避免落到访问日志。382
+383
+### 8.2 单页面(URL)下钻384
+385
+- **入口**:页面分析列表中**点击任意一行**即可下钻;也可直接访问 `/#/page?url=<编码后的地址>&match=prefix`。386
+- **内容**:汇总卡(PV/UV/独立 IP/会话数/入口次数/退出次数/平均停留/跳出率)→ PV·UV 按天趋势 → 24h 分时柱图 →387
+ 上一页/下一页流向(可继续点击下钻)→ 来源·环境·地域 / 页面自定义事件 / 最近会话。388
+- **匹配口径**:`exact`(精确,默认)或 `prefix`(前缀);两者同时作用于「完整 URL」与「URL 路径」表达式,389
+ 兼容 SDK 上报完整 URL 与只存路径两种情形。390
+- **大站点保护**:流向分析与会话侧指标采用采样上限(5 万 PV / 2 万会话),触发时接口返回 `partial=true`,391
+ 页面顶部出现黄色提示条,说明这两组数字为采样近似值,其余指标仍为精确值。392
+393
+### 8.3 流量异常告警与日报推送394
+395
+**规则维度**:396
+397
+| 维度 | 取值 |398
+| --- | --- |399
+| 指标 metric | `pv` / `uv` / `visits`(会话数)/ `bounce_rate` / `avg_duration` / `online`(实时在线) |400
+| 窗口 window_min | 统计最近 N 分钟 |401
+| 比较 compare | `ratio_up` / `ratio_down`(涨跌百分比)、`abs_over` / `abs_under`(绝对值高低)、`zero`(掉零) |402
+| 基线 baseline | `prev_day`(昨日同时段)、`prev_window`(上一个等长窗口) |403
+| 阈值 threshold | 百分比或绝对数值(按比较方式解释) |404
+| 防误报 | `min_sample` 基线样本下限(低于则跳过,专治夜间小流量误报);`cooldown_min` 冷却时间(避免刷屏) |405
+| 级别 level | `info` / `warning` / `critical`(体现在推送标题前缀与历史列表) |406
+407
+**推送渠道 7 类**:`webhook`、`feishu`(飞书)、`dingtalk`(钉钉,支持加签)、`wecom`(企业微信)、408
+`serverchan`(Server 酱)、`bark`、`email`。409
+410
+> 所有推送正文均以关键词 **WebStats** 开头,便于通过飞书/钉钉/企微机器人的「自定义关键词」安全设置;411
+> 若机器人已按关键词过滤,请把该关键词设为 `WebStats`。412
+>413
+> 渠道密钥在列表接口中以 `******` 掩码返回;编辑时**留空或保持掩码不变**即沿用已存密钥,不会误清空。414
+> 每个渠道都提供「发送测试」,保存前可先验证连通性。415
+416
+**日报推送**:按订阅设定(频率:每日 / 每周指定星期;发送整点 `hour`;站点范围:指定站点或全部)聚合417
+PV/UV、Top 页面、Top 来源等生成正文并推送。`last_sent_day` 做幂等,cron 每小时跑也不会重复发送。418
+419
+**留痕**:每次触发都写入 `alert_logs`(含指标值、基线值、判定结果、各渠道推送状态),420
+管理端「告警历史」可按级别/站点分页筛选。421
+422
+**依赖 cron**(见 4.2):`cron.php alert` 建议每 5 分钟、`cron.php report` 每小时。423
+424
+**权限**:推送渠道 / 告警规则 / 日报订阅都是**按用户**的个人配置;规则绑定站点时要求对该站点425
+至少 **editor**(所有者与协作者可配,只读成员不可)。站点成员被移除后,其针对该站点的规则与订阅426
+会自动停止推送,避免数据继续外泄。427
+428
+### 8.4 站点成员与多用户只读权限429
+430
+**角色三级**(左低右高):431
+432
+| 角色 | 能力 |433
+| --- | --- |434
+| `viewer` 只读 | 查看该站点全部报表、导出 CSV;任何写操作返回 403 |435
+| `editor` 可编辑 | 以上 + 新建/修改/删除该站点的漏斗、配置该站点的告警规则 |436
+| `owner` 所有者 | 以上 + 修改/删除站点、生成与校验验证文件、管理成员 |437
+438
+- **站点所有者天然是 owner**,无需在成员表中插入自己;439
+- **管理入口**:站点管理 → 选中站点 → 「成员」;可添加(按注册邮箱)、改角色、移除;440
+- **被授权用户**登录后站点切换器会出现该站点,当前角色以标签显示(只读=黄色、可编辑/所有者=蓝色);441
+- **越权口径**:无权访问的站点统一返回 **404**(不泄露「该站点是否存在」);权限不足返回 **403**;442
+- **敏感字段**:站点列表接口对非 owner 的条目**清空 `verify_token`**,避免验证令牌外泄;443
+- **不开放「再授 owner」**:成员只能被授予 viewer/editor,以免权限扩散;转移所有权请走站点归属变更。444
+445
+### 8.5 系统设置(管理员):邮件 SMTP / 功能开关 / 注册开关446
+447
+仅**系统管理员**可见(左侧「系统设置」,或 `GET/PATCH /api/settings`)。448
+449
+- **管理员判定**:`users.is_admin=1`。老库未跑增量时自动回退「首个注册账号(最小 id)为管理员」。450
+- **功能开关**(即时生效):451
+ - **开放注册**:关闭后登录页隐藏注册入口、注册页禁用提交,直接调 `POST /api/auth/register` 返回 403;452
+ - **注册邮箱验证码**(默认开):开启后注册改为两步 —— 先输入**图片验证码**点「获取验证码」,453
+ 邮箱收到 6 位验证码(10 分钟有效、60 秒可重发、每邮箱每小时 ≤5 封、每 IP 每小时 ≤20 封),454
+ 再凭验证码提交注册。图片验证码由 GD 生成(`GET /api/auth/captcha`),**缺 GD 扩展时该开关等于锁死注册**,455
+ 设置页与注册页都会给出红色提示;存储在 `data/verify/`(文件型,单机部署)。456
+ - **采集上报**:关闭后全部站点的 SDK 上报被拒(`/api/collect` 返回 403),已有数据仍可查看;457
+ - **告警与日报推送**:关闭后 `cron.php alert` / `cron.php report` 直接跳过(规则与订阅保留)。458
+ - **爬虫过滤**(默认开):命中爬虫 / HTTP 客户端 UA 的采集请求静默丢弃(返回 200 而非 403,459
+ 避免爬虫对 4xx 重试放大流量);关闭后爬虫会计入普通访客口径。460
+ - **蜘蛛爬虫统计**(默认开,v1.0.13 新增):关闭后服务端上报端点 `/spider.php` 与采集端461
+ 都不再写 `spider_hits`(见 §8.19)。它与上一条**职责分离、互不影响** ——462
+ 「爬虫过滤」管*爬虫算不算访客*,「蜘蛛统计」管*要不要记蜘蛛账*;463
+ 任一开关都不会让爬虫数据混进 PV/UV/会话。464
+- **事件明细保留天数**(`retention_days`,v1.0.13 新增可保存):留空 = 跟随 `config.php` 的465
+ `collect.event_retention`;非空必须是 **1–3650** 的整数(`cron.php clean` 直接拿它算分区删除边界)。466
+ 非法值返回 422 且**整批不落库**(不会留下「开关已改、天数没改」的半保存状态)。467
+- **保存入口**:「功能开关」卡片自带保存按钮(v1.0.13 前必须滚到下方「接入代码」卡片才有保存按钮,468
+ 容易被当成「改了不保存」)。**开发约定**:`Settings::KEYS` / `DEFAULTS` 与前端 `save()` 里出现的键,469
+ 必须在 `SettingController::update()` 有对应写入分支 —— 只在白名单里登记、忘了写分支时,470
+ 服务端会静默丢弃该键(其余键照常落库 → 提示「保存成功」是真的,刷新后该项变回旧值)。471
+- **邮件发送(SMTP)**:配置后,「告警渠道-邮件」「日报订阅邮件」「发送测试邮件」与**注册/重置邮箱验证码**统一走 SMTP472
+ (支持 SSL(465) / STARTTLS(587) / 明文(25),AUTH LOGIN)。**未填 SMTP 服务器时回退 PHP mail()**(需主机 MTA)。473
+ SMTP 密码接口掩码回显(`******`),保存时留空或保持掩码即沿用旧值。474
+- **公共开关接口**:`GET /api/auth/public`(无需登录)返回475
+ `{registration_enabled, email_verify_enabled, captcha_ready}`,供登录/注册页联动。476
+477
+#### 忘记密码(自助重置)478
+479
+登录页「忘记密码?」→ `/forgot`,也即三条公开接口(均无需登录):480
+481
+| 接口 | 说明 |482
+|---|---|483
+| `GET /api/auth/captcha` | 图片验证码(与注册共用;一次性,校验后即作废) |484
+| `POST /api/auth/reset-code` | `{email, captcha_id, captcha, lang?}` → 发送重置验证码 |485
+| `POST /api/auth/reset-password` | `{email, code, password}` → 重置密码 |486
+487
+- **邮箱必须已注册**(否则 404「该邮箱未注册」),账号被禁用时 403;488
+- 邮箱验证码**恒定启用**,不受「注册邮箱验证码」开关影响 —— 注册可以关闭,已有账号必须能自助找回;489
+- **不看 `registration_enabled`**:注册关闭时重置流程照常可用;490
+- 重置验证码与注册验证码**存储键互不通用**(`mailreset:` / `mailcode:`),注册码不能拿来改密码(反之亦然);491
+ 冷却与小时配额(每邮箱 5 封/小时、每 IP 20 封/小时、60 秒重发)两类用途**共用**,防止换用途绕开限流;492
+- 重置成功后**吊销该账号全部登录令牌**(其它设备上的旧会话立即失效),返回 `{ok:true, revoked:N}`;493
+- **缺 GD 扩展时**取码不再强制图片验证码(退化为仅邮件配额限流),`/forgot` 页会给出黄色提示;494
+- 前置限流顺序:图码 → 邮箱存在性 → 发信,前两步失败都不消耗邮件额度。scripts/cron.php+177-177
@@ -1,177 +1,177 @@1
-<?php2
-/**3
- * 定时维护脚本(建议 crontab 每1分钟~每天执行,见 sql/install.sql 底部注释)4
- *5
- * 用法(在项目根目录下执行;开发副本为 php server/scripts/cron.php …):6
- * php scripts/cron.php session # 回收超时空闲会话 + 清理离线访客7
- * php scripts/cron.php rollup # 重建近3天 site_daily 汇总(幂等)8
- * php scripts/cron.php partition # 预建 events 后续月份分区9
- * php scripts/cron.php clean # 清理过期明细(按保留期删旧分区)10
- * php scripts/cron.php alert # 评估流量异常告警规则并推送(建议每5分钟)11
- * php scripts/cron.php report # 到点发送日报/周报(建议每小时)12
- * php scripts/cron.php session rollup # 可叠加执行13
- */14
-declare(strict_types=1);15
-16
-require dirname(__DIR__) . '/app/bootstrap.php';17
-18
-use Wstat\Support\AlertEngine;19
-use Wstat\Support\Db;20
-use Wstat\Support\Rds;21
-use Wstat\Support\Sessionizer;22
-use Wstat\Support\Settings;23
-24
-$actions = array_slice($argv, 1);25
-if (!$actions) {26
- $actions = ['session'];27
-}28
-29
-foreach ($actions as $act) {30
- if (!method_exists(Cron::class, $act)) {31
- fwrite(STDERR, "[cron] unknown action: $act\n");32
- exit(1);33
- }34
- $t0 = microtime(true);35
- $msg = Cron::$act();36
- fwrite(STDOUT, sprintf("[cron] %-9s %s (%.2fs)\n", $act, $msg, microtime(true) - $t0));37
-}38
-exit(0);39
-40
-final class Cron41
-{42
- /** 会话回收:关闭空闲超时的 ssn hash,并清理在线集合中的离线成员 */43
- public static function session(): string44
- {45
- $r = Rds::get();46
- if ($r === null) {47
- return 'redis unavailable';48
- }49
- [$closed, $offline] = Sessionizer::reapIdle($r);50
- return "closed=$closed offline=$offline";51
- }52
-53
- /** 重建近 3 天 site_daily(幂等:先删近段再回填) */54
- public static function rollup(): string55
- {56
- // UTC 近 3 天为删除窗口(放宽至4天覆盖 +14 时区站点本地日)57
- $cutoff = gmdate('Y-m-d', time() - 4 * 86400);58
- Db::execute('DELETE FROM site_daily WHERE `day` >= ?', [$cutoff]);59
-60
- // 1) 明细聚合(pv/uv/ipc),单条 SQL 覆盖全部站点61
- $n1 = Db::execute(62
- 'INSERT INTO site_daily (site_id, `day`, pv, uv, ipc, updated_at)63
- SELECT site_id, `day`,64
- SUM(type="pageview") pv,65
- COUNT(DISTINCT CASE WHEN type="pageview" THEN visitor_id END) uv,66
- COUNT(DISTINCT CASE WHEN type="pageview" THEN ip END) ipc,67
- UNIX_TIMESTAMP()68
- FROM events WHERE `day` >= ?69
- GROUP BY site_id, `day`',70
- [$cutoff]71
- );72
-73
- // 2) 会话聚合(visits/bounce/duration/new_users),按站点时区逐日回填74
- $sites = Db::select('SELECT id, timezone FROM sites WHERE status=1');75
- foreach ($sites as $s) {76
- $tz = (string) $s['timezone'];77
- $off = \Wstat\Support\Util::tzOffsetSec($tz);78
- $wins = [];79
- for ($i = 3; $i >= 0; $i--) {80
- $d = gmdate('Y-m-d', time() + $off - $i * 86400);81
- $wins[$d] = \Wstat\Support\Util::dateRangeToTs($d, $d, $tz);82
- }83
- foreach ($wins as $d => [$ts0, $ts1]) {84
- $agg = Db::first(85
- 'SELECT COUNT(*) c, COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du,86
- COALESCE(SUM(is_new),0) nw87
- FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<?',88
- [(int) $s['id'], $ts0, $ts1]89
- ) ?: ['c' => 0, 'b' => 0, 'du' => 0, 'nw' => 0];90
- if ((int) $agg['c'] <= 0) {91
- continue;92
- }93
- Db::execute(94
- 'INSERT INTO site_daily (site_id,`day`,visits,bounce,duration,new_users,updated_at)95
- VALUES (?,?,?,?,?,?,UNIX_TIMESTAMP())96
- ON DUPLICATE KEY UPDATE visits=visits+?, bounce=bounce+?, duration=duration+?, new_users=new_users+?',97
- [98
- (int) $s['id'], $d, (int) $agg['c'], (int) $agg['b'], (int) $agg['du'], (int) $agg['nw'],99
- (int) $agg['c'], (int) $agg['b'], (int) $agg['du'], (int) $agg['nw'],100
- ]101
- );102
- }103
- }104
- return 'sites=' . count($sites) . ' events_rows=' . $n1;105
- }106
-107
- /** 预建 events 后续月份分区(当月 + 未来2个月) */108
- public static function partition(): string109
- {110
- $row = Db::first(111
- "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd112
- FROM information_schema.PARTITIONS113
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='events'114
- ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 1"115
- );116
- if ($row === null || !preg_match('/\'(\d{4}-\d{2}-\d{2})\'/', (string) $row['pd'], $m)) {117
- return 'no partition or unparseable';118
- }119
- $maxDate = new DateTimeImmutable($m[1]); // 当前最大分区边界120
- $target = (new DateTimeImmutable('now'))->modify('+2 months');121
- $targetDay = new DateTimeImmutable($target->format('Y-m-01'));122
- $added = 0;123
- while ($maxDate <= $targetDay) {124
- $next = $maxDate->modify('+1 month');125
- $name = 'p' . $next->format('Ym');126
- $bound = $next->format('Y-m-d');127
- Db::execute("ALTER TABLE `events` ADD PARTITION (PARTITION `$name` VALUES LESS THAN (TO_DAYS('$bound')))");128
- $added++;129
- $maxDate = $next;130
- }131
- return "added=$added max_bound={$maxDate->format('Y-m-d')}";132
- }133
-134
- /** 评估流量异常告警规则(命中即推送并留痕) */135
- public static function alert(): string136
- {137
- return AlertEngine::evaluate();138
- }139
-140
- /** 到点发送日报 / 周报(按订阅的本地小时判断,当天幂等) */141
- public static function report(): string142
- {143
- return AlertEngine::runReports();144
- }145
-146
- /** 按保留期清理过期明细(直接 DROP 过期分区,最快) */147
- public static function clean(): string148
- {149
- // 保留天数:系统设置 retention_days 优先(管理员界面可改);空/非法时回退 config.php150
- $cfgDays = (int) wstat_config('collect.event_retention');151
- $s = trim(Settings::get('retention_days'));152
- $retentionDays = ($s !== '' && (int) $s > 0) ? (int) $s : max(1, $cfgDays);153
- $cutoff = (new DateTimeImmutable('now'))->modify('-' . $retentionDays . ' days');154
- $cutoffMonth = $cutoff->format('Y-m-01'); // 早于该月的数据都删155
- $rows = Db::select(156
- "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd157
- FROM information_schema.PARTITIONS158
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='events'159
- ORDER BY PARTITION_ORDINAL_POSITION ASC"160
- );161
- $dropped = 0;162
- foreach ($rows as $r) {163
- $pd = (string) $r['pd'];164
- if ($pd === '' || !preg_match('/\'(\d{4}-\d{2}-\d{2})\'/', $pd, $m)) {165
- continue;166
- }167
- $high = new DateTimeImmutable($m[1]);168
- // 只删“上边界 <= 截止月”的分区,且至少保留2个分区兜底169
- if ($high->format('Y-m-d') <= $cutoffMonth && count($rows) - $dropped > 2) {170
- $pn = (string) $r['pn'];171
- Db::execute("ALTER TABLE `events` DROP PARTITION `$pn`");172
- $dropped++;173
- }174
- }175
- return "dropped=$dropped (retention={$retentionDays}d)";176
- }177
-}1
+<?php2
+/**3
+ * 定时维护脚本(建议 crontab 每1分钟~每天执行,见 sql/install.sql 底部注释)4
+ *5
+ * 用法(在项目根目录下执行;开发副本为 php server/scripts/cron.php …):6
+ * php scripts/cron.php session # 回收超时空闲会话 + 清理离线访客7
+ * php scripts/cron.php rollup # 重建近3天 site_daily 汇总(幂等)8
+ * php scripts/cron.php partition # 预建 events 后续月份分区9
+ * php scripts/cron.php clean # 清理过期明细(按保留期删旧分区)10
+ * php scripts/cron.php alert # 评估流量异常告警规则并推送(建议每5分钟)11
+ * php scripts/cron.php report # 到点发送日报/周报(建议每小时)12
+ * php scripts/cron.php session rollup # 可叠加执行13
+ */14
+declare(strict_types=1);15
+16
+require dirname(__DIR__) . '/app/bootstrap.php';17
+18
+use Wstat\Support\AlertEngine;19
+use Wstat\Support\Db;20
+use Wstat\Support\Rds;21
+use Wstat\Support\Sessionizer;22
+use Wstat\Support\Settings;23
+24
+$actions = array_slice($argv, 1);25
+if (!$actions) {26
+ $actions = ['session'];27
+}28
+29
+foreach ($actions as $act) {30
+ if (!method_exists(Cron::class, $act)) {31
+ fwrite(STDERR, "[cron] unknown action: $act\n");32
+ exit(1);33
+ }34
+ $t0 = microtime(true);35
+ $msg = Cron::$act();36
+ fwrite(STDOUT, sprintf("[cron] %-9s %s (%.2fs)\n", $act, $msg, microtime(true) - $t0));37
+}38
+exit(0);39
+40
+final class Cron41
+{42
+ /** 会话回收:关闭空闲超时的 ssn hash,并清理在线集合中的离线成员 */43
+ public static function session(): string44
+ {45
+ $r = Rds::get();46
+ if ($r === null) {47
+ return 'redis unavailable';48
+ }49
+ [$closed, $offline] = Sessionizer::reapIdle($r);50
+ return "closed=$closed offline=$offline";51
+ }52
+53
+ /** 重建近 3 天 site_daily(幂等:先删近段再回填) */54
+ public static function rollup(): string55
+ {56
+ // UTC 近 3 天为删除窗口(放宽至4天覆盖 +14 时区站点本地日)57
+ $cutoff = gmdate('Y-m-d', time() - 4 * 86400);58
+ Db::execute('DELETE FROM site_daily WHERE `day` >= ?', [$cutoff]);59
+60
+ // 1) 明细聚合(pv/uv/ipc),单条 SQL 覆盖全部站点61
+ $n1 = Db::execute(62
+ 'INSERT INTO site_daily (site_id, `day`, pv, uv, ipc, updated_at)63
+ SELECT site_id, `day`,64
+ SUM(type="pageview") pv,65
+ COUNT(DISTINCT CASE WHEN type="pageview" THEN visitor_id END) uv,66
+ COUNT(DISTINCT CASE WHEN type="pageview" THEN ip END) ipc,67
+ UNIX_TIMESTAMP()68
+ FROM events WHERE `day` >= ?69
+ GROUP BY site_id, `day`',70
+ [$cutoff]71
+ );72
+73
+ // 2) 会话聚合(visits/bounce/duration/new_users),按站点时区逐日回填74
+ $sites = Db::select('SELECT id, timezone FROM sites WHERE status=1');75
+ foreach ($sites as $s) {76
+ $tz = (string) $s['timezone'];77
+ $off = \Wstat\Support\Util::tzOffsetSec($tz);78
+ $wins = [];79
+ for ($i = 3; $i >= 0; $i--) {80
+ $d = gmdate('Y-m-d', time() + $off - $i * 86400);81
+ $wins[$d] = \Wstat\Support\Util::dateRangeToTs($d, $d, $tz);82
+ }83
+ foreach ($wins as $d => [$ts0, $ts1]) {84
+ $agg = Db::first(85
+ 'SELECT COUNT(*) c, COALESCE(SUM(bounce),0) b, COALESCE(SUM(duration),0) du,86
+ COALESCE(SUM(is_new),0) nw87
+ FROM sessions WHERE site_id=? AND start_ts>=? AND start_ts<?',88
+ [(int) $s['id'], $ts0, $ts1]89
+ ) ?: ['c' => 0, 'b' => 0, 'du' => 0, 'nw' => 0];90
+ if ((int) $agg['c'] <= 0) {91
+ continue;92
+ }93
+ Db::execute(94
+ 'INSERT INTO site_daily (site_id,`day`,visits,bounce,duration,new_users,updated_at)95
+ VALUES (?,?,?,?,?,?,UNIX_TIMESTAMP())96
+ ON DUPLICATE KEY UPDATE visits=visits+?, bounce=bounce+?, duration=duration+?, new_users=new_users+?',97
+ [98
+ (int) $s['id'], $d, (int) $agg['c'], (int) $agg['b'], (int) $agg['du'], (int) $agg['nw'],99
+ (int) $agg['c'], (int) $agg['b'], (int) $agg['du'], (int) $agg['nw'],100
+ ]101
+ );102
+ }103
+ }104
+ return 'sites=' . count($sites) . ' events_rows=' . $n1;105
+ }106
+107
+ /** 预建 events 后续月份分区(当月 + 未来2个月) */108
+ public static function partition(): string109
+ {110
+ $row = Db::first(111
+ "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd112
+ FROM information_schema.PARTITIONS113
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='events'114
+ ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 1"115
+ );116
+ if ($row === null || !preg_match('/\'(\d{4}-\d{2}-\d{2})\'/', (string) $row['pd'], $m)) {117
+ return 'no partition or unparseable';118
+ }119
+ $maxDate = new DateTimeImmutable($m[1]); // 当前最大分区边界120
+ $target = (new DateTimeImmutable('now'))->modify('+2 months');121
+ $targetDay = new DateTimeImmutable($target->format('Y-m-01'));122
+ $added = 0;123
+ while ($maxDate <= $targetDay) {124
+ $next = $maxDate->modify('+1 month');125
+ $name = 'p' . $next->format('Ym');126
+ $bound = $next->format('Y-m-d');127
+ Db::execute("ALTER TABLE `events` ADD PARTITION (PARTITION `$name` VALUES LESS THAN (TO_DAYS('$bound')))");128
+ $added++;129
+ $maxDate = $next;130
+ }131
+ return "added=$added max_bound={$maxDate->format('Y-m-d')}";132
+ }133
+134
+ /** 评估流量异常告警规则(命中即推送并留痕) */135
+ public static function alert(): string136
+ {137
+ return AlertEngine::evaluate();138
+ }139
+140
+ /** 到点发送日报 / 周报(按订阅的本地小时判断,当天幂等) */141
+ public static function report(): string142
+ {143
+ return AlertEngine::runReports();144
+ }145
+146
+ /** 按保留期清理过期明细(直接 DROP 过期分区,最快) */147
+ public static function clean(): string148
+ {149
+ // 保留天数:系统设置 retention_days 优先(管理员界面可改);空/非法时回退 config.php150
+ $cfgDays = (int) wstat_config('collect.event_retention');151
+ $s = trim(Settings::get('retention_days'));152
+ $retentionDays = ($s !== '' && (int) $s > 0) ? (int) $s : max(1, $cfgDays);153
+ $cutoff = (new DateTimeImmutable('now'))->modify('-' . $retentionDays . ' days');154
+ $cutoffMonth = $cutoff->format('Y-m-01'); // 早于该月的数据都删155
+ $rows = Db::select(156
+ "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd157
+ FROM information_schema.PARTITIONS158
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='events'159
+ ORDER BY PARTITION_ORDINAL_POSITION ASC"160
+ );161
+ $dropped = 0;162
+ foreach ($rows as $r) {163
+ $pd = (string) $r['pd'];164
+ if ($pd === '' || !preg_match('/\'(\d{4}-\d{2}-\d{2})\'/', $pd, $m)) {165
+ continue;166
+ }167
+ $high = new DateTimeImmutable($m[1]);168
+ // 只删“上边界 <= 截止月”的分区,且至少保留2个分区兜底169
+ if ($high->format('Y-m-d') <= $cutoffMonth && count($rows) - $dropped > 2) {170
+ $pn = (string) $r['pn'];171
+ Db::execute("ALTER TABLE `events` DROP PARTITION `$pn`");172
+ $dropped++;173
+ }174
+ }175
+ return "dropped=$dropped (retention={$retentionDays}d)";176
+ }177
+}scripts/doctor.php+258-258
@@ -1,258 +1,258 @@1
-<?php2
-/**3
- * WebStats 一键诊断(排查“装了统计脚本但没有数据”)4
- * 用法:php scripts/doctor.php (在项目根目录下执行;开发副本为 php server/scripts/doctor.php)5
- */6
-declare(strict_types=1);7
-8
-require dirname(__DIR__) . '/app/bootstrap.php';9
-10
-use Wstat\Support\Db;11
-use Wstat\Support\Rds;12
-use Wstat\Support\RedisGuard;13
-14
-$ok = 0;15
-$warn = 0;16
-$err = 0;17
-18
-function line(string $s): void { fwrite(STDOUT, $s . "\n"); }19
-function mark(bool $good, string $msg): void {20
- global $ok, $warn, $err;21
- if ($good) { $ok++; line(" [OK] $msg"); }22
- else { $warn++; line(" [!!] $msg"); }23
-}24
-25
-line("========== WebStats Doctor ==========");26
-27
-/* 1) PHP 环境 */28
-line("\n[1] PHP 环境");29
-mark(version_compare(PHP_VERSION, '8.0', '>='), 'PHP ' . PHP_VERSION);30
-mark(extension_loaded('pdo_mysql'), 'pdo_mysql');31
-mark(extension_loaded('curl'), 'curl(站点文件验证需要)');32
-33
-/* 2) MySQL */34
-line("\n[2] MySQL");35
-$db = wstat_config('db');36
-try {37
- $pdo = Db::pdo(); // 触发连接38
- mark(true, "已连接 {$db['host']}:{$db['port']}/{$db['name']}");39
- $tables = [];40
- foreach (Db::select('SHOW TABLES') as $r) {41
- $tables[] = (string) array_values($r)[0];42
- }43
- foreach (['sites', 'auth_tokens', 'events', 'sessions', 'site_daily'] as $tb) {44
- mark(in_array($tb, $tables, true), "表 $tb 存在");45
- }46
- // 归因扩展列(来源分析/广告追踪需要)47
- $cols = [];48
- foreach (Db::select('SHOW COLUMNS FROM events') as $r) {49
- $cols[] = (string) $r['Field'];50
- }51
- $need = ['utm_source', 'utm_medium', 'click_source', 'ref_host'];52
- $missing = array_values(array_filter($need, fn ($c) => !in_array($c, $cols, true)));53
- if ($missing) {54
- mark(false, 'events 缺少归因列: ' . implode(',', $missing) . ' → 执行: mysql -uroot -p < sql/upgrade-2026-09-09-sources.sql');55
- } else {56
- mark(true, 'events 归因列齐全(utm_source/utm_medium/click_source/ref_host)');57
- }58
- // 操作日志表(老库需跑增量 SQL;缺表时个人中心「我的操作日志」会明确提示建表)59
- if (!in_array('user_op_logs', $tables, true)) {60
- mark(false, "user_op_logs 缺失 → 在**当前库 {$db['name']}** 执行: "61
- . 'mysql -u<user> -p ' . $db['name'] . ' < sql/upgrade-2026-09-16-profile-users.sql'62
- . '(已建过的话请确认 SQL 是不是执行到了别的库)');63
- } else {64
- mark(true, 'user_op_logs 存在(登录/改密/用户管理操作可留痕)');65
- }66
- // 分区边界67
- $p = Db::first(68
- "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd FROM information_schema.PARTITIONS69
- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='events'70
- ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 1"71
- );72
- if ($p) {73
- mark(true, "events 最新分区 {$p['pn']} 上界 {$p['pd']}(未来需跑 cron partition)");74
- } else {75
- mark(false, 'events 无分区(建表未用 sql/install.sql,或分区占位标记未展开?)');76
- }77
-} catch (\Throwable $e) {78
- mark(false, 'MySQL 连接失败: ' . $e->getMessage() . '(检查 config.php 的 WSTAT_DB_*)');79
- line("\n诊断中止:数据库不可用则无法继续。");80
- exit(1);81
-}82
-83
-/* 3) Redis */84
-line("\n[3] Redis");85
-$r = null;86
-try { $r = Rds::get(); } catch (\Throwable $e) { /* ignore */ }87
-if ($r === null) {88
- // 探明具体原因:直接发 PING 拿原始错误(区分 NOAUTH/WRONGPASS/拒连/超时)89
- $why = '未知';90
- try {91
- $probe = new \Wstat\Support\RedisClient(wstat_config('redis'));92
- $pong = $probe->raw(['PING']);93
- $why = $pong === 'PONG' ? 'PING 通过但应用层判定不可用(异常)' : 'PING 返回异常: ' . var_export($pong, true);94
- } catch (\Throwable $e) {95
- $why = $e->getMessage();96
- }97
- mark(false, 'Redis 不可用 → 采集降级直写 MySQL(可收数据但无实时/队列/会话聚合)。原因: ' . $why98
- . '。若含 NOAUTH 或 WRONGPASS:请把 Redis 密码填入环境变量 WSTAT_REDIS_AUTH(或 config.php 的 redis.auth),重启 php-fpm 与 worker');99
-} else {100
- mark(true, 'Redis 已连接(实时计数/队列在线)');101
- try {102
- // 队列键:先查类型再看长度。类型被污染时 LLEN 会直接抛 WRONGTYPE,103
- // 那正是 worker 报 “WRONGTYPE Operation against a key holding the wrong kind of value” 的根因。104
- $qt = $r->type('queue');105
- $qk = $r->fullKey('queue');106
- if ($qt === 'none') {107
- mark(true, "采集队列为空(key=$qk 不存在,无积压)");108
- } elseif ($qt === 'list') {109
- $qlen = (int) $r->llen('queue');110
- mark($qlen < 500, "采集队列积压 $qlen 条" . ($qlen > 0 ? ' → 队列有数据但 events 表还没有,说明 worker 未消费。请启动: php ' . wstat_rel('scripts/worker.php') . '(supervisor 守护)' : ''));111
- } else {112
- // ⚠️ 必须写 {$qt}:PHP 标识符允许 0x80-0xFF 字节,全角逗号紧跟 $var 时会被113
- // 吃进变量名(这里原本解析成 $qt,期望 → 未定义变量),诊断行会变成「实际==list」。114
- mark(false, "队列键 {$qk} 类型错误:实际={$qt},期望=list");115
- line(' → worker 会持续刷 “WRONGTYPE ... wrong kind of value” 且队列永不消费(不是网络抖动,重试不会自愈)');116
- line(' → 修复(RENAME 会把原数据保留在新键名下,先确认它是不是别的程序在用):');117
- line(' ' . RedisGuard::fixHint($qk));118
- line(' ' . "redis-cli -n " . (int) (wstat_config('redis.db') ?? 0) . " DEL $qk # 确认无用后再删");119
- line(' → 或启动时自动隔离:php ' . wstat_rel('scripts/worker.php') . ' --quarantine');120
- }121
- } catch (\Throwable $e) {122
- mark(false, 'Redis 读取异常: ' . $e->getMessage());123
- }124
-125
- // 全键类型审计:一次性找出所有被写坏类型的键(today:/rt:min:/uv:/ip:/on:/ssn:/live:/v:)126
- try {127
- $a = RedisGuard::audit($r, 200);128
- if (empty($a['bad'])) {129
- mark(true, "键类型审计通过(抽样 {$a['checked']} 个键,类型均正确)");130
- } else {131
- mark(false, '键类型审计发现 ' . count($a['bad']) . " 个异常键(会导致 WRONGTYPE / 实时数缺失):");132
- foreach ($a['bad'] as $k => $info) {133
- line(sprintf(' %-56s 实际=%-7s 期望=%s', $k, $info['type'], $info['expected']));134
- }135
- foreach (array_keys($a['bad']) as $k) {136
- line(' 修复: ' . RedisGuard::fixHint($k));137
- }138
- line(' 根因多为:同一 Redis 库被别的程序/站点共用且键名冲突(redis.prefix 为空时最易发生)、旧版本残留、人工误写。');139
- }140
- } catch (\Throwable $e) {141
- mark(false, '键类型审计失败: ' . $e->getMessage());142
- }143
-}144
-145
-/* 4) 站点与数据 */146
-line("\n[4] 站点 & 数据");147
-$sites = Db::select(148
- 'SELECT s.id,s.name,s.domain,s.status,s.verified_at,s.site_key,149
- (SELECT COUNT(*) FROM events e WHERE e.site_id=s.id AND e.day>=CURDATE()) today_events,150
- (SELECT COUNT(*) FROM sessions ss WHERE ss.site_id=s.id AND ss.start_ts>=UNIX_TIMESTAMP()-7*86400) week_sessions151
- FROM sites s ORDER BY id'152
-);153
-if (!$sites) {154
- mark(false, '还没有任何站点 → 先在「站点管理」创建站点并完成文件验证');155
-}156
-foreach ($sites as $s) {157
- $ver = (int) $s['verified_at'] > 0 ? '已验证' : '未验证';158
- line(" 站点 #{$s['id']} {$s['name']} ({$s['domain']}) [{$ver} / status={$s['status']}] 今日事件(UTC)={$s['today_events']} 近7日会话={$s['week_sessions']}");159
- if ((int) $s['status'] !== 1) {160
- mark(false, "站点 #{$s['id']} 已被停用(status=0),不会接收上报");161
- }162
- if ((int) $s['verified_at'] === 0) {163
- mark(false, "站点 #{$s['id']} 未验证 → 采集端默认拒绝(403)。完成文件验证,或本地联调设置 WSTAT_ALLOW_UNVERIFIED=1");164
- }165
-}166
-167
-/* 4b) 数据链路快照:events 明细 vs 汇总,直接定位“有明细无看板” */168
-line("\n[4b] 数据链路快照(近 7 天,按站点/日期/类型)");169
-foreach ($sites as $s) {170
- $sid = (int) $s['id'];171
- line(" ── 站点 #{$sid} {$s['name']} ──");172
- try {173
- $ev = Db::select(174
- "SELECT `day` d, type, COUNT(*) c FROM events175
- WHERE site_id=? AND `day` >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 7 DAY), '%Y-%m-%d')176
- GROUP BY `day`, type ORDER BY d, type",177
- [$sid]178
- );179
- if (!$ev) {180
- mark(false, 'events 近 7 天无该站点数据(确认上报 ak 是否等于该站点 site_key;或脚本未上报成功)');181
- } else {182
- $hasPv = false;183
- foreach ($ev as $r) {184
- if ($r['type'] === 'pageview') {185
- $hasPv = true;186
- }187
- line(" events {$r['d']} type={$r['type']} rows={$r['c']}");188
- }189
- if (!$hasPv) {190
- mark(false, '近 7 天只有非 pageview 事件(perf/click/scroll…),没有 pageview → 看板 PV 恒为 0。'191
- . '页面加载时的 pageview 上报疑似失败(常见于当时 Redis 假健康导致 500,或 SDK 未在 <head> 正常执行)。'192
- . '修复 Redis 认证后重开页面即可,也可用下方 curl 直接验证采集链路');193
- }194
- }195
- // 站点级采集自测命令(真实 ak,方便复制执行后重跑 doctor 验证)196
- $ak = (string) ($s['site_key'] ?? '');197
- line(' └ 自测命令(把 {统计域名} 换成你的部署域名后执行,会写入 1 条 pageview + 1 个会话):');198
- line(" curl -X POST 'http://{统计域名}/collect.php' -H 'Content-Type: text/plain' -d '{\"v\":1,\"ak\":\"{$ak}\",\"t\":\"pageview\",\"uid\":\"UTest0001\",\"sid\":\"STest0001\",\"url\":\"/\",\"title\":\"t\",\"tz\":480,\"lang\":\"zh-CN\",\"scr\":\"1920x1080\"}'");199
- $sd = Db::first(200
- "SELECT COUNT(*) row7, COALESCE(SUM(pv),0) pv7, COALESCE(SUM(uv),0) uv7,201
- COALESCE(SUM(visits),0) v7202
- FROM site_daily WHERE site_id=? AND `day` >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 7 DAY), '%Y-%m-%d')",203
- [$sid]204
- );205
- line(" site_daily 近7天: 行数={$sd['row7']} pv={$sd['pv7']} uv={$sd['uv7']} visits={$sd['v7']}"206
- . ($sd['row7'] === 0 ? '(空 → 概览接口会按 events 自动回填,或先跑 cron rollup)'207
- : ((int) $sd['pv7'] === 0 && !empty($ev) ? ' ← 汇总疑似 0 值行,概览已加入自动校正' : '')));208
- // sessions 明细快照:有 pageview 但 sessions 空 → 区分“Redis 进行中”与“降级写失败”209
- $ssc = (int) $s['week_sessions'];210
- if ($ssc === 0) {211
- if ($r !== null) {212
- line(' sessions 近7天(已回收): 0 行 ← Redis 模式下会话先驻留内存、空闲 30 分钟才落库;'213
- . '进行中的访问已实时计入概览与“会话列表(LIVE)”顶部,刷新页面即可看到。'214
- . '若 30 分钟后仍无行且列表无 LIVE,请查 php error_log 的 degradedSession/insertIgnore 错误');215
- } else {216
- line(' sessions 近7天: 0 行'217
- . (!empty($ev) ? ' ← events 有 pageview 但无会话行:降级直写应在采集时自动建会话;Redis 模式需 worker+cron。'218
- . '请查看 php error_log 是否含 [wstat] degradedSession failed' : ''));219
- }220
- } else {221
- line(" sessions 近7天: $ssc 行(最近 5 条,旧→新)");222
- $ss = Db::select(223
- 'SELECT session_id,visitor_id,pageviews,duration,bounce,start_ts FROM sessions224
- WHERE site_id=? ORDER BY id DESC LIMIT 5',225
- [$sid]226
- );227
- foreach (array_reverse($ss) as $x) {228
- line(" sess {$x['session_id']} uid={$x['visitor_id']} pv={$x['pageviews']} dur={$x['duration']}s bounce={$x['bounce']} @"229
- . gmdate('m-d H:i', (int) $x['start_ts']));230
- }231
- }232
- } catch (\Throwable $e) {233
- mark(false, '快照查询失败: ' . $e->getMessage());234
- }235
-}236
-if (count($sites) > 1) {237
- line(' ※ 若快照显示某站点有 events 而面板所选站点无 → 请确认顶部站点选择器选中的就是该站点');238
-}239
-line(' ※ 判定规则:看板 PV=site_daily(回填自 events type=pageview);visits/跳出/时长=sessions(需 worker 或降级直写);今日实时=Redis。');240
-241
-/* 5) 常见原因提示 */242
-line("\n[5] 若上面都正常仍无数据,逐项核对");243
-line(" ① 统计脚本能否加载:浏览器直接打开 {你的统计域名}/sdk/wstat.js 应返回 JS(404=没部署到 public/sdk/)");244
-line(" ② 安装代码 data-site-key 与 data-host 是否正确、页面 <head> 已插入");245
-line(" ③ 页面与统计域名协议一致(http/https 混用会被浏览器拦截上报)");246
-line(" ④ Redis 队列积压时 events 表暂无数据 → 必须启动 worker.php(生产用 supervisor 守护)");247
-line(' 若 worker 日志出现 “WRONGTYPE Operation against a key holding the wrong kind of value” → 某个 Redis 键被写成了别的类型(键名冲突/旧版本残留/人工误写),重试不会自愈。先跑本脚本第 [3] 节的「键类型审计」拿到坏键清单,RENAME 备份后重启 worker;详见 docs/deploy.md「七、运维排查」');248
-line(" ⑤ 数据要进概览还需 site_daily:crontab 每5分钟跑 cron.php rollup(缺失天接口会自动回填)");249
-line(" ⑥ 采集接口自测:curl -X POST 'http://{统计域名}/collect.php' -H 'Content-Type: text/plain' -d '{\"v\":1,\"ak\":\"{站点标识}\",\"t\":\"pageview\",\"uid\":\"UTest0001\",\"sid\":\"STest0001\",\"url\":\"/\",\"title\":\"t\",\"tz\":480,\"lang\":\"zh-CN\",\"scr\":\"1920x1080\"}'");250
-line(' 若返回 405 Method Not Allowed → nginx 没把请求交给 PHP,而是当静态文件直出了:删除站点配置里的 location = /collect.php { try_files $uri =404; }(精确匹配会抢在 location ~ \.php$ 之前),详见 docs/deploy.md「七、运维排查」');251
-line(' 备用通道(.php 路径被 WAF/CDN 拦截时用):POST /api/collect,参数与 /collect.php 完全一致');252
-line(" ⑦ 有 PV 但缺 visits/跳出率/时长 → sessions 未生成:Redis 可用时会话先存 Redis,需 worker 持续运行 + cron session 空闲回收");253
-line(" ⑧ 手动立即补数:php " . wstat_rel('scripts/cron.php') . " session rollup (随后刷新概览页)");254
-line(" ⑨ 地域页无数据:跑 php " . wstat_rel('scripts/geo-doctor.php') . " 一键诊断(库文件/驱动/真实 IP/覆盖率)");255
-line(" 最常见两种:库文件缺失或改名(IPv4 需 " . wstat_rel('data/ip2region.xdb') . "、IPv6 需 " . wstat_rel('data/ip2region_v6.xdb') . ");宝塔 Nginx+PHP-FPM 下 REMOTE_ADDR=127.0.0.1 未取代理头");256
-257
-line("\n========== 结果:OK=$ok 注意=$warn ==========");258
-exit($warn > 0 ? 2 : 0);1
+<?php2
+/**3
+ * WebStats 一键诊断(排查“装了统计脚本但没有数据”)4
+ * 用法:php scripts/doctor.php (在项目根目录下执行;开发副本为 php server/scripts/doctor.php)5
+ */6
+declare(strict_types=1);7
+8
+require dirname(__DIR__) . '/app/bootstrap.php';9
+10
+use Wstat\Support\Db;11
+use Wstat\Support\Rds;12
+use Wstat\Support\RedisGuard;13
+14
+$ok = 0;15
+$warn = 0;16
+$err = 0;17
+18
+function line(string $s): void { fwrite(STDOUT, $s . "\n"); }19
+function mark(bool $good, string $msg): void {20
+ global $ok, $warn, $err;21
+ if ($good) { $ok++; line(" [OK] $msg"); }22
+ else { $warn++; line(" [!!] $msg"); }23
+}24
+25
+line("========== WebStats Doctor ==========");26
+27
+/* 1) PHP 环境 */28
+line("\n[1] PHP 环境");29
+mark(version_compare(PHP_VERSION, '8.0', '>='), 'PHP ' . PHP_VERSION);30
+mark(extension_loaded('pdo_mysql'), 'pdo_mysql');31
+mark(extension_loaded('curl'), 'curl(站点文件验证需要)');32
+33
+/* 2) MySQL */34
+line("\n[2] MySQL");35
+$db = wstat_config('db');36
+try {37
+ $pdo = Db::pdo(); // 触发连接38
+ mark(true, "已连接 {$db['host']}:{$db['port']}/{$db['name']}");39
+ $tables = [];40
+ foreach (Db::select('SHOW TABLES') as $r) {41
+ $tables[] = (string) array_values($r)[0];42
+ }43
+ foreach (['sites', 'auth_tokens', 'events', 'sessions', 'site_daily'] as $tb) {44
+ mark(in_array($tb, $tables, true), "表 $tb 存在");45
+ }46
+ // 归因扩展列(来源分析/广告追踪需要)47
+ $cols = [];48
+ foreach (Db::select('SHOW COLUMNS FROM events') as $r) {49
+ $cols[] = (string) $r['Field'];50
+ }51
+ $need = ['utm_source', 'utm_medium', 'click_source', 'ref_host'];52
+ $missing = array_values(array_filter($need, fn ($c) => !in_array($c, $cols, true)));53
+ if ($missing) {54
+ mark(false, 'events 缺少归因列: ' . implode(',', $missing) . ' → 执行: mysql -uroot -p < sql/upgrade-2026-09-09-sources.sql');55
+ } else {56
+ mark(true, 'events 归因列齐全(utm_source/utm_medium/click_source/ref_host)');57
+ }58
+ // 操作日志表(老库需跑增量 SQL;缺表时个人中心「我的操作日志」会明确提示建表)59
+ if (!in_array('user_op_logs', $tables, true)) {60
+ mark(false, "user_op_logs 缺失 → 在**当前库 {$db['name']}** 执行: "61
+ . 'mysql -u<user> -p ' . $db['name'] . ' < sql/upgrade-2026-09-16-profile-users.sql'62
+ . '(已建过的话请确认 SQL 是不是执行到了别的库)');63
+ } else {64
+ mark(true, 'user_op_logs 存在(登录/改密/用户管理操作可留痕)');65
+ }66
+ // 分区边界67
+ $p = Db::first(68
+ "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd FROM information_schema.PARTITIONS69
+ WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='events'70
+ ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 1"71
+ );72
+ if ($p) {73
+ mark(true, "events 最新分区 {$p['pn']} 上界 {$p['pd']}(未来需跑 cron partition)");74
+ } else {75
+ mark(false, 'events 无分区(建表未用 sql/install.sql,或分区占位标记未展开?)');76
+ }77
+} catch (\Throwable $e) {78
+ mark(false, 'MySQL 连接失败: ' . $e->getMessage() . '(检查 config.php 的 WSTAT_DB_*)');79
+ line("\n诊断中止:数据库不可用则无法继续。");80
+ exit(1);81
+}82
+83
+/* 3) Redis */84
+line("\n[3] Redis");85
+$r = null;86
+try { $r = Rds::get(); } catch (\Throwable $e) { /* ignore */ }87
+if ($r === null) {88
+ // 探明具体原因:直接发 PING 拿原始错误(区分 NOAUTH/WRONGPASS/拒连/超时)89
+ $why = '未知';90
+ try {91
+ $probe = new \Wstat\Support\RedisClient(wstat_config('redis'));92
+ $pong = $probe->raw(['PING']);93
+ $why = $pong === 'PONG' ? 'PING 通过但应用层判定不可用(异常)' : 'PING 返回异常: ' . var_export($pong, true);94
+ } catch (\Throwable $e) {95
+ $why = $e->getMessage();96
+ }97
+ mark(false, 'Redis 不可用 → 采集降级直写 MySQL(可收数据但无实时/队列/会话聚合)。原因: ' . $why98
+ . '。若含 NOAUTH 或 WRONGPASS:请把 Redis 密码填入环境变量 WSTAT_REDIS_AUTH(或 config.php 的 redis.auth),重启 php-fpm 与 worker');99
+} else {100
+ mark(true, 'Redis 已连接(实时计数/队列在线)');101
+ try {102
+ // 队列键:先查类型再看长度。类型被污染时 LLEN 会直接抛 WRONGTYPE,103
+ // 那正是 worker 报 “WRONGTYPE Operation against a key holding the wrong kind of value” 的根因。104
+ $qt = $r->type('queue');105
+ $qk = $r->fullKey('queue');106
+ if ($qt === 'none') {107
+ mark(true, "采集队列为空(key=$qk 不存在,无积压)");108
+ } elseif ($qt === 'list') {109
+ $qlen = (int) $r->llen('queue');110
+ mark($qlen < 500, "采集队列积压 $qlen 条" . ($qlen > 0 ? ' → 队列有数据但 events 表还没有,说明 worker 未消费。请启动: php ' . wstat_rel('scripts/worker.php') . '(supervisor 守护)' : ''));111
+ } else {112
+ // ⚠️ 必须写 {$qt}:PHP 标识符允许 0x80-0xFF 字节,全角逗号紧跟 $var 时会被113
+ // 吃进变量名(这里原本解析成 $qt,期望 → 未定义变量),诊断行会变成「实际==list」。114
+ mark(false, "队列键 {$qk} 类型错误:实际={$qt},期望=list");115
+ line(' → worker 会持续刷 “WRONGTYPE ... wrong kind of value” 且队列永不消费(不是网络抖动,重试不会自愈)');116
+ line(' → 修复(RENAME 会把原数据保留在新键名下,先确认它是不是别的程序在用):');117
+ line(' ' . RedisGuard::fixHint($qk));118
+ line(' ' . "redis-cli -n " . (int) (wstat_config('redis.db') ?? 0) . " DEL $qk # 确认无用后再删");119
+ line(' → 或启动时自动隔离:php ' . wstat_rel('scripts/worker.php') . ' --quarantine');120
+ }121
+ } catch (\Throwable $e) {122
+ mark(false, 'Redis 读取异常: ' . $e->getMessage());123
+ }124
+125
+ // 全键类型审计:一次性找出所有被写坏类型的键(today:/rt:min:/uv:/ip:/on:/ssn:/live:/v:)126
+ try {127
+ $a = RedisGuard::audit($r, 200);128
+ if (empty($a['bad'])) {129
+ mark(true, "键类型审计通过(抽样 {$a['checked']} 个键,类型均正确)");130
+ } else {131
+ mark(false, '键类型审计发现 ' . count($a['bad']) . " 个异常键(会导致 WRONGTYPE / 实时数缺失):");132
+ foreach ($a['bad'] as $k => $info) {133
+ line(sprintf(' %-56s 实际=%-7s 期望=%s', $k, $info['type'], $info['expected']));134
+ }135
+ foreach (array_keys($a['bad']) as $k) {136
+ line(' 修复: ' . RedisGuard::fixHint($k));137
+ }138
+ line(' 根因多为:同一 Redis 库被别的程序/站点共用且键名冲突(redis.prefix 为空时最易发生)、旧版本残留、人工误写。');139
+ }140
+ } catch (\Throwable $e) {141
+ mark(false, '键类型审计失败: ' . $e->getMessage());142
+ }143
+}144
+145
+/* 4) 站点与数据 */146
+line("\n[4] 站点 & 数据");147
+$sites = Db::select(148
+ 'SELECT s.id,s.name,s.domain,s.status,s.verified_at,s.site_key,149
+ (SELECT COUNT(*) FROM events e WHERE e.site_id=s.id AND e.day>=CURDATE()) today_events,150
+ (SELECT COUNT(*) FROM sessions ss WHERE ss.site_id=s.id AND ss.start_ts>=UNIX_TIMESTAMP()-7*86400) week_sessions151
+ FROM sites s ORDER BY id'152
+);153
+if (!$sites) {154
+ mark(false, '还没有任何站点 → 先在「站点管理」创建站点并完成文件验证');155
+}156
+foreach ($sites as $s) {157
+ $ver = (int) $s['verified_at'] > 0 ? '已验证' : '未验证';158
+ line(" 站点 #{$s['id']} {$s['name']} ({$s['domain']}) [{$ver} / status={$s['status']}] 今日事件(UTC)={$s['today_events']} 近7日会话={$s['week_sessions']}");159
+ if ((int) $s['status'] !== 1) {160
+ mark(false, "站点 #{$s['id']} 已被停用(status=0),不会接收上报");161
+ }162
+ if ((int) $s['verified_at'] === 0) {163
+ mark(false, "站点 #{$s['id']} 未验证 → 采集端默认拒绝(403)。完成文件验证,或本地联调设置 WSTAT_ALLOW_UNVERIFIED=1");164
+ }165
+}166
+167
+/* 4b) 数据链路快照:events 明细 vs 汇总,直接定位“有明细无看板” */168
+line("\n[4b] 数据链路快照(近 7 天,按站点/日期/类型)");169
+foreach ($sites as $s) {170
+ $sid = (int) $s['id'];171
+ line(" ── 站点 #{$sid} {$s['name']} ──");172
+ try {173
+ $ev = Db::select(174
+ "SELECT `day` d, type, COUNT(*) c FROM events175
+ WHERE site_id=? AND `day` >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 7 DAY), '%Y-%m-%d')176
+ GROUP BY `day`, type ORDER BY d, type",177
+ [$sid]178
+ );179
+ if (!$ev) {180
+ mark(false, 'events 近 7 天无该站点数据(确认上报 ak 是否等于该站点 site_key;或脚本未上报成功)');181
+ } else {182
+ $hasPv = false;183
+ foreach ($ev as $r) {184
+ if ($r['type'] === 'pageview') {185
+ $hasPv = true;186
+ }187
+ line(" events {$r['d']} type={$r['type']} rows={$r['c']}");188
+ }189
+ if (!$hasPv) {190
+ mark(false, '近 7 天只有非 pageview 事件(perf/click/scroll…),没有 pageview → 看板 PV 恒为 0。'191
+ . '页面加载时的 pageview 上报疑似失败(常见于当时 Redis 假健康导致 500,或 SDK 未在 <head> 正常执行)。'192
+ . '修复 Redis 认证后重开页面即可,也可用下方 curl 直接验证采集链路');193
+ }194
+ }195
+ // 站点级采集自测命令(真实 ak,方便复制执行后重跑 doctor 验证)196
+ $ak = (string) ($s['site_key'] ?? '');197
+ line(' └ 自测命令(把 {统计域名} 换成你的部署域名后执行,会写入 1 条 pageview + 1 个会话):');198
+ line(" curl -X POST 'http://{统计域名}/collect.php' -H 'Content-Type: text/plain' -d '{\"v\":1,\"ak\":\"{$ak}\",\"t\":\"pageview\",\"uid\":\"UTest0001\",\"sid\":\"STest0001\",\"url\":\"/\",\"title\":\"t\",\"tz\":480,\"lang\":\"zh-CN\",\"scr\":\"1920x1080\"}'");199
+ $sd = Db::first(200
+ "SELECT COUNT(*) row7, COALESCE(SUM(pv),0) pv7, COALESCE(SUM(uv),0) uv7,201
+ COALESCE(SUM(visits),0) v7202
+ FROM site_daily WHERE site_id=? AND `day` >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 7 DAY), '%Y-%m-%d')",203
+ [$sid]204
+ );205
+ line(" site_daily 近7天: 行数={$sd['row7']} pv={$sd['pv7']} uv={$sd['uv7']} visits={$sd['v7']}"206
+ . ($sd['row7'] === 0 ? '(空 → 概览接口会按 events 自动回填,或先跑 cron rollup)'207
+ : ((int) $sd['pv7'] === 0 && !empty($ev) ? ' ← 汇总疑似 0 值行,概览已加入自动校正' : '')));208
+ // sessions 明细快照:有 pageview 但 sessions 空 → 区分“Redis 进行中”与“降级写失败”209
+ $ssc = (int) $s['week_sessions'];210
+ if ($ssc === 0) {211
+ if ($r !== null) {212
+ line(' sessions 近7天(已回收): 0 行 ← Redis 模式下会话先驻留内存、空闲 30 分钟才落库;'213
+ . '进行中的访问已实时计入概览与“会话列表(LIVE)”顶部,刷新页面即可看到。'214
+ . '若 30 分钟后仍无行且列表无 LIVE,请查 php error_log 的 degradedSession/insertIgnore 错误');215
+ } else {216
+ line(' sessions 近7天: 0 行'217
+ . (!empty($ev) ? ' ← events 有 pageview 但无会话行:降级直写应在采集时自动建会话;Redis 模式需 worker+cron。'218
+ . '请查看 php error_log 是否含 [wstat] degradedSession failed' : ''));219
+ }220
+ } else {221
+ line(" sessions 近7天: $ssc 行(最近 5 条,旧→新)");222
+ $ss = Db::select(223
+ 'SELECT session_id,visitor_id,pageviews,duration,bounce,start_ts FROM sessions224
+ WHERE site_id=? ORDER BY id DESC LIMIT 5',225
+ [$sid]226
+ );227
+ foreach (array_reverse($ss) as $x) {228
+ line(" sess {$x['session_id']} uid={$x['visitor_id']} pv={$x['pageviews']} dur={$x['duration']}s bounce={$x['bounce']} @"229
+ . gmdate('m-d H:i', (int) $x['start_ts']));230
+ }231
+ }232
+ } catch (\Throwable $e) {233
+ mark(false, '快照查询失败: ' . $e->getMessage());234
+ }235
+}236
+if (count($sites) > 1) {237
+ line(' ※ 若快照显示某站点有 events 而面板所选站点无 → 请确认顶部站点选择器选中的就是该站点');238
+}239
+line(' ※ 判定规则:看板 PV=site_daily(回填自 events type=pageview);visits/跳出/时长=sessions(需 worker 或降级直写);今日实时=Redis。');240
+241
+/* 5) 常见原因提示 */242
+line("\n[5] 若上面都正常仍无数据,逐项核对");243
+line(" ① 统计脚本能否加载:浏览器直接打开 {你的统计域名}/sdk/wstat.js 应返回 JS(404=没部署到 public/sdk/)");244
+line(" ② 安装代码 data-site-key 与 data-host 是否正确、页面 <head> 已插入");245
+line(" ③ 页面与统计域名协议一致(http/https 混用会被浏览器拦截上报)");246
+line(" ④ Redis 队列积压时 events 表暂无数据 → 必须启动 worker.php(生产用 supervisor 守护)");247
+line(' 若 worker 日志出现 “WRONGTYPE Operation against a key holding the wrong kind of value” → 某个 Redis 键被写成了别的类型(键名冲突/旧版本残留/人工误写),重试不会自愈。先跑本脚本第 [3] 节的「键类型审计」拿到坏键清单,RENAME 备份后重启 worker;详见 docs/deploy.md「七、运维排查」');248
+line(" ⑤ 数据要进概览还需 site_daily:crontab 每5分钟跑 cron.php rollup(缺失天接口会自动回填)");249
+line(" ⑥ 采集接口自测:curl -X POST 'http://{统计域名}/collect.php' -H 'Content-Type: text/plain' -d '{\"v\":1,\"ak\":\"{站点标识}\",\"t\":\"pageview\",\"uid\":\"UTest0001\",\"sid\":\"STest0001\",\"url\":\"/\",\"title\":\"t\",\"tz\":480,\"lang\":\"zh-CN\",\"scr\":\"1920x1080\"}'");250
+line(' 若返回 405 Method Not Allowed → nginx 没把请求交给 PHP,而是当静态文件直出了:删除站点配置里的 location = /collect.php { try_files $uri =404; }(精确匹配会抢在 location ~ \.php$ 之前),详见 docs/deploy.md「七、运维排查」');251
+line(' 备用通道(.php 路径被 WAF/CDN 拦截时用):POST /api/collect,参数与 /collect.php 完全一致');252
+line(" ⑦ 有 PV 但缺 visits/跳出率/时长 → sessions 未生成:Redis 可用时会话先存 Redis,需 worker 持续运行 + cron session 空闲回收");253
+line(" ⑧ 手动立即补数:php " . wstat_rel('scripts/cron.php') . " session rollup (随后刷新概览页)");254
+line(" ⑨ 地域页无数据:跑 php " . wstat_rel('scripts/geo-doctor.php') . " 一键诊断(库文件/驱动/真实 IP/覆盖率)");255
+line(" 最常见两种:库文件缺失或改名(IPv4 需 " . wstat_rel('data/ip2region.xdb') . "、IPv6 需 " . wstat_rel('data/ip2region_v6.xdb') . ");宝塔 Nginx+PHP-FPM 下 REMOTE_ADDR=127.0.0.1 未取代理头");256
+257
+line("\n========== 结果:OK=$ok 注意=$warn ==========");258
+exit($warn > 0 ? 2 : 0);scripts/fetch-geo.php+113-113
@@ -1,113 +1,113 @@1
-<?php2
-/**3
- * 拉取 ip2region xdb 离线库(**IPv4 + IPv6 双库**)。4
- *5
- * 用法(在项目根目录下执行):6
- * php scripts/fetch-geo.php # 两个库都拉(缺哪个补哪个,已存在则跳过)7
- * php scripts/fetch-geo.php --v4 # 只拉 IPv4 库8
- * php scripts/fetch-geo.php --v6 # 只拉 IPv6 库9
- * php scripts/fetch-geo.php --force # 已存在也重新下载10
- *11
- * 产物:data/ip2region.xdb(IPv4,约 10.6MB)、data/ip2region_v6.xdb(IPv6)12
- * 源:GitHub raw → jsDelivr → Gitee 依次回退;全部失败时提示手动下载。13
- */14
-declare(strict_types=1);15
-16
-$root = dirname(__DIR__);17
-$argvList = array_slice($argv, 1);18
-$only4 = in_array('--v4', $argvList, true);19
-$only6 = in_array('--v6', $argvList, true);20
-$force = in_array('--force', $argvList, true);21
-$want4 = (!$only6) || $only4;22
-$want6 = (!$only4) || $only6;23
-24
-/** 库定义:[族, 目标文件, 官方源文件名, 探针 IP] */25
-$libs = [26
- [27
- 'fam' => 4,28
- 'dest' => $root . '/data/ip2region.xdb',29
- 'src' => 'ip2region_v4.xdb',30
- 'probe' => '114.114.114.114',31
- ],32
- [33
- 'fam' => 6,34
- 'dest' => $root . '/data/ip2region_v6.xdb',35
- 'src' => 'ip2region_v6.xdb',36
- 'probe' => '240e:3b7:3272:d8d0:db09:c067:8d59:539e',37
- ],38
-];39
-40
-require_once $root . '/app/bootstrap.php';41
-42
-$fail = 0;43
-foreach ($libs as $lib) {44
- $fam = (int) $lib['fam'];45
- if (($fam === 4 && !$want4) || ($fam === 6 && !$want6)) {46
- continue;47
- }48
- $dest = (string) $lib['dest'];49
- $tag = 'v' . $fam;50
- fwrite(STDOUT, "\n[geo-{$tag}] 目标文件 {$dest}\n");51
-52
- if (!$force && is_file($dest) && filesize($dest) > 1_000_000) {53
- fwrite(STDOUT, "[geo-{$tag}] 已存在(" . number_format((int) filesize($dest)) . " 字节),跳过下载(--force 可强制重下)\n");54
- } else {55
- $urls = [56
- 'https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/' . $lib['src'],57
- 'https://cdn.jsdelivr.net/gh/lionsoul2014/ip2region@master/data/' . $lib['src'],58
- 'https://gitee.com/lionsoul/ip2region/raw/master/data/' . $lib['src'],59
- ];60
- $saved = false;61
- foreach ($urls as $url) {62
- fwrite(STDOUT, "[geo-{$tag}] downloading {$url}\n");63
- $bin = @file_get_contents($url, false, stream_context_create([64
- 'http' => ['timeout' => 300, 'ignore_errors' => true, 'follow_location' => 1],65
- 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],66
- ]));67
- if (is_string($bin) && strlen($bin) > 1_000_000) {68
- @mkdir(dirname($dest), 0755, true);69
- file_put_contents($dest, $bin);70
- fwrite(STDOUT, '[geo-' . $tag . '] saved ' . number_format(strlen($bin)) . " bytes\n");71
- $saved = true;72
- break;73
- }74
- fwrite(STDERR, "[geo-{$tag}] source unavailable, try next...\n");75
- }76
- if (!$saved) {77
- fwrite(STDERR, "[geo-{$tag}] FAILED. 请手动下载 {$lib['src']} 并保存为 {$dest}\n");78
- $fail++;79
- continue;80
- }81
- }82
-83
- // 下载后自检:库文件声明的 IP 版本是否与槽位一致 + 实际解析探活84
- $st = \Wstat\Support\IpLocator::status();85
- $keyE = $fam === 4 ? 'xdb_exists' : 'xdb6_exists';86
- $keyS = $fam === 4 ? 'xdb_size' : 'xdb6_size';87
- if (empty($st[$keyE])) {88
- fwrite(STDERR, "[geo-{$tag}] 文件不存在或不可用:{$dest}\n");89
- $fail++;90
- continue;91
- }92
- $probe = \Wstat\Support\IpLocator::probe((string) $lib['probe']);93
- $txt = trim(($probe['country'] ?? '') . ' ' . ($probe['province'] ?? '') . ' ' . ($probe['city'] ?? ''));94
- fwrite(95
- STDOUT,96
- '[geo-' . $tag . '] size=' . number_format((int) $st[$keyS]) . ' bytes, driver='97
- . $st['effective'] . ' (v' . $fam . ' 专属: ' . ($fam === 4 ? $st['effective_v4'] : $st['effective_v6']) . ")\n"98
- );99
- if ($txt !== '') {100
- fwrite(STDOUT, "[geo-{$tag}] probe {$lib['probe']} => {$txt} ✅ 库可用\n");101
- } else {102
- fwrite(STDERR, "[geo-{$tag}] 文件已写入但解析探活失败:可能下载被截断、放错了地址族,"103
- . "或驱动被设为 none(见 php scripts/geo-doctor.php)\n");104
- $fail++;105
- }106
-}107
-108
-if ($fail > 0) {109
- fwrite(STDERR, "\n[geo] 有 {$fail} 个库未就绪\n");110
- exit(1);111
-}112
-fwrite(STDOUT, "\n[geo] 全部就绪\n");113
-exit(0);1
+<?php2
+/**3
+ * 拉取 ip2region xdb 离线库(**IPv4 + IPv6 双库**)。4
+ *5
+ * 用法(在项目根目录下执行):6
+ * php scripts/fetch-geo.php # 两个库都拉(缺哪个补哪个,已存在则跳过)7
+ * php scripts/fetch-geo.php --v4 # 只拉 IPv4 库8
+ * php scripts/fetch-geo.php --v6 # 只拉 IPv6 库9
+ * php scripts/fetch-geo.php --force # 已存在也重新下载10
+ *11
+ * 产物:data/ip2region.xdb(IPv4,约 10.6MB)、data/ip2region_v6.xdb(IPv6)12
+ * 源:GitHub raw → jsDelivr → Gitee 依次回退;全部失败时提示手动下载。13
+ */14
+declare(strict_types=1);15
+16
+$root = dirname(__DIR__);17
+$argvList = array_slice($argv, 1);18
+$only4 = in_array('--v4', $argvList, true);19
+$only6 = in_array('--v6', $argvList, true);20
+$force = in_array('--force', $argvList, true);21
+$want4 = (!$only6) || $only4;22
+$want6 = (!$only4) || $only6;23
+24
+/** 库定义:[族, 目标文件, 官方源文件名, 探针 IP] */25
+$libs = [26
+ [27
+ 'fam' => 4,28
+ 'dest' => $root . '/data/ip2region.xdb',29
+ 'src' => 'ip2region_v4.xdb',30
+ 'probe' => '114.114.114.114',31
+ ],32
+ [33
+ 'fam' => 6,34
+ 'dest' => $root . '/data/ip2region_v6.xdb',35
+ 'src' => 'ip2region_v6.xdb',36
+ 'probe' => '240e:3b7:3272:d8d0:db09:c067:8d59:539e',37
+ ],38
+];39
+40
+require_once $root . '/app/bootstrap.php';41
+42
+$fail = 0;43
+foreach ($libs as $lib) {44
+ $fam = (int) $lib['fam'];45
+ if (($fam === 4 && !$want4) || ($fam === 6 && !$want6)) {46
+ continue;47
+ }48
+ $dest = (string) $lib['dest'];49
+ $tag = 'v' . $fam;50
+ fwrite(STDOUT, "\n[geo-{$tag}] 目标文件 {$dest}\n");51
+52
+ if (!$force && is_file($dest) && filesize($dest) > 1_000_000) {53
+ fwrite(STDOUT, "[geo-{$tag}] 已存在(" . number_format((int) filesize($dest)) . " 字节),跳过下载(--force 可强制重下)\n");54
+ } else {55
+ $urls = [56
+ 'https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/' . $lib['src'],57
+ 'https://cdn.jsdelivr.net/gh/lionsoul2014/ip2region@master/data/' . $lib['src'],58
+ 'https://gitee.com/lionsoul/ip2region/raw/master/data/' . $lib['src'],59
+ ];60
+ $saved = false;61
+ foreach ($urls as $url) {62
+ fwrite(STDOUT, "[geo-{$tag}] downloading {$url}\n");63
+ $bin = @file_get_contents($url, false, stream_context_create([64
+ 'http' => ['timeout' => 300, 'ignore_errors' => true, 'follow_location' => 1],65
+ 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],66
+ ]));67
+ if (is_string($bin) && strlen($bin) > 1_000_000) {68
+ @mkdir(dirname($dest), 0755, true);69
+ file_put_contents($dest, $bin);70
+ fwrite(STDOUT, '[geo-' . $tag . '] saved ' . number_format(strlen($bin)) . " bytes\n");71
+ $saved = true;72
+ break;73
+ }74
+ fwrite(STDERR, "[geo-{$tag}] source unavailable, try next...\n");75
+ }76
+ if (!$saved) {77
+ fwrite(STDERR, "[geo-{$tag}] FAILED. 请手动下载 {$lib['src']} 并保存为 {$dest}\n");78
+ $fail++;79
+ continue;80
+ }81
+ }82
+83
+ // 下载后自检:库文件声明的 IP 版本是否与槽位一致 + 实际解析探活84
+ $st = \Wstat\Support\IpLocator::status();85
+ $keyE = $fam === 4 ? 'xdb_exists' : 'xdb6_exists';86
+ $keyS = $fam === 4 ? 'xdb_size' : 'xdb6_size';87
+ if (empty($st[$keyE])) {88
+ fwrite(STDERR, "[geo-{$tag}] 文件不存在或不可用:{$dest}\n");89
+ $fail++;90
+ continue;91
+ }92
+ $probe = \Wstat\Support\IpLocator::probe((string) $lib['probe']);93
+ $txt = trim(($probe['country'] ?? '') . ' ' . ($probe['province'] ?? '') . ' ' . ($probe['city'] ?? ''));94
+ fwrite(95
+ STDOUT,96
+ '[geo-' . $tag . '] size=' . number_format((int) $st[$keyS]) . ' bytes, driver='97
+ . $st['effective'] . ' (v' . $fam . ' 专属: ' . ($fam === 4 ? $st['effective_v4'] : $st['effective_v6']) . ")\n"98
+ );99
+ if ($txt !== '') {100
+ fwrite(STDOUT, "[geo-{$tag}] probe {$lib['probe']} => {$txt} ✅ 库可用\n");101
+ } else {102
+ fwrite(STDERR, "[geo-{$tag}] 文件已写入但解析探活失败:可能下载被截断、放错了地址族,"103
+ . "或驱动被设为 none(见 php scripts/geo-doctor.php)\n");104
+ $fail++;105
+ }106
+}107
+108
+if ($fail > 0) {109
+ fwrite(STDERR, "\n[geo] 有 {$fail} 个库未就绪\n");110
+ exit(1);111
+}112
+fwrite(STDOUT, "\n[geo] 全部就绪\n");113
+exit(0);scripts/geo-doctor.php+225-225
@@ -1,225 +1,225 @@1
-<?php2
-/**3
- * IP 地理归属专项诊断:php scripts/geo-doctor.php [site_id]4
- * (在项目根目录下执行;开发副本为 php server/scripts/geo-doctor.php)5
- *6
- * 逐项检查:配置驱动 → xdb 库文件(存在/大小/可读/目录候选)→ 实际解析探活7
- * → events / sessions 的地理字段覆盖率 → 给出结论与修复建议。8
- */9
-declare(strict_types=1);10
-require dirname(__DIR__) . '/app/bootstrap.php';11
-12
-use Wstat\Support\Db;13
-use Wstat\Support\IpLocator;14
-15
-function gline(string $s = ''): void16
-{17
- echo $s, "\n";18
-}19
-function gok(string $s): void20
-{21
- gline(' [OK] ' . $s);22
-}23
-function gwarn(string $s): void24
-{25
- gline(' [WARN] ' . $s);26
-}27
-function gbad(string $s): void28
-{29
- gline(' [FAIL] ' . $s);30
-}31
-32
-gline('========== IP 地理归属诊断 (geo-doctor) ==========');33
-gline('时间: ' . date('Y-m-d H:i:s'));34
-35
-/* 1) 驱动与库文件 */36
-gline("\n[1] 配置与离线库文件");37
-$st = IpLocator::status();38
-gline(' config collect.geo_driver = ' . $st['configured']);39
-gline(' config collect.geo_xdb = ' . $st['xdb_path'] . ' (IPv4)');40
-gline(' config collect.geo_xdb6 = ' . $st['xdb6_path'] . ' (IPv6)');41
-gline(' 生效驱动 effective = ' . $st['effective']42
- . ' (IPv4: ' . $st['effective_v4'] . ', IPv6: ' . $st['effective_v6'] . ')');43
-gline(' data 目录 = ' . $st['xdb_dir']);44
-45
-/**46
- * 单个库文件的体检:存在 / 体积 / 声明的地址族是否与槽位一致 / 可读。47
- *48
- * 「声明的地址族」这一项专治最隐蔽的一种坏法:把 v6 库改名成 ip2region.xdb(或两个配置项49
- * 指到同一个文件)。那时文件存在、体积也正常,但用 4 字节地址去解析 38 字节的索引项只会50
- * 读到垃圾 —— 这里直接读头部 ipVersion 字段对质,明确报出来而不是静默返回空。51
- *52
- * 体积门槛取 1MB:正常库都是 MB 级,几百 KB 基本是下载被截断的残文件。53
- */54
-$checkLib = static function (int $fam) use ($st): bool {55
- $isV4 = $fam === 4;56
- $tag = $isV4 ? 'IPv4' : 'IPv6';57
- $pathK = $isV4 ? 'xdb_path' : 'xdb6_path';58
- $existsK = $isV4 ? 'xdb_exists' : 'xdb6_exists';59
- $sizeK = $isV4 ? 'xdb_size' : 'xdb6_size';60
- $readK = $isV4 ? 'xdb_readable' : 'xdb6_readable';61
- $name = $isV4 ? 'ip2region.xdb' : 'ip2region_v6.xdb';62
-63
- if (empty($st[$existsK])) {64
- gbad("[{$tag}] 库文件不存在:" . $st[$pathK]);65
- gline(' → 执行 php ' . wstat_rel('scripts/fetch-geo.php') . ' 下载'66
- . '(IPv6 库缺失只影响 IPv6 访客,IPv4 不受影响)');67
- return false;68
- }69
- $size = (int) $st[$sizeK];70
- if ($size <= 1024 * 1024) {71
- gbad("[{$tag}] 库文件存在但体积异常(" . number_format($size) . " 字节,正常为 MB 级)→ 很可能下载失败/被截断,请重新下载");72
- return false;73
- }74
- $fileFam = 0;75
- foreach ((array) $st['xdb_candidates'] as $c) {76
- if (($c['file'] ?? '') === basename((string) $st[$pathK])) {77
- $fileFam = (int) ($c['ip_version'] ?? 0);78
- break;79
- }80
- }81
- if ($fileFam !== 0 && $fileFam !== $fam) {82
- $gbad("[{$tag}] 库文件声明的是 IPv{$fileFam},与所在槽位不符 → 解析会全部落空,请换成 {$name}");83
- return false;84
- }85
- gok("[{$tag}] 库文件就绪,大小 " . number_format($size) . ' 字节(约 ' . round($size / 1048576, 1) . ' MB)');86
- if (empty($st[$readK])) {87
- $gbad("[{$tag}] 库文件不可读(PHP 进程权限不足),请 chmod 644 或调整属主");88
- return false;89
- }90
- return true;91
-};92
-93
-$ok4 = $checkLib(4);94
-$ok6 = $checkLib(6);95
-96
-if ($ok4 && !$ok6) {97
- $gwarn('IPv6 库缺失:IPv6 访客解析不出地域(IPv4 正常)。补齐后 v6 访客即有地域。');98
-} elseif (!$ok4 && $ok6) {99
- $gwarn('IPv4 库缺失:IPv4 访客解析不出地域(IPv6 正常)。');100
-} elseif (!$ok4 && !$ok6) {101
- if (!empty($st['xdb_candidates'])) {102
- $names = array_map(103
- static fn ($c) => $c['file'] . '(' . number_format((int) $c['size']) . ' 字节'104
- . (!empty($c['ip_version']) ? ',声明 IPv' . $c['ip_version'] : '') . ')',105
- (array) $st['xdb_candidates']106
- );107
- gwarn('但同目录发现了其它 xdb 文件:' . implode(', ', $names));108
- gline(' → IPv4 库文件名必须为 ip2region.xdb、IPv6 库为 ip2region_v6.xdb,');109
- gline(' 或在 config 里把 collect.geo_xdb / collect.geo_xdb6 指到实际文件名');110
- } else {111
- gline(' → 官方库下载目录: https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/');112
- gline(' → 或执行: php ' . wstat_rel('scripts/fetch-geo.php'));113
- }114
-}115
-if ($st['configured'] !== 'xdb' && $st['configured'] !== 'http') {116
- gwarn('驱动不是 xdb/http(当前 ' . $st['configured'] . ')→ 采集不会解析地域。改 config 后需重载 PHP-FPM 并重启 worker');117
-}118
-119
-/* 2) 解析探活 */120
-gline("\n[2] 解析探活(固定公网 IP,IPv4 / IPv6 各一组)");121
-$probes = [122
- '114.114.114.114' => $ok4,123
- '223.5.5.5' => $ok4,124
- '8.8.8.8' => $ok4,125
- '101.226.103.106' => $ok4,126
- '240e:3b7:3272:d8d0:db09:c067:8d59:539e' => $ok6,127
- '2400:3200:baba::1' => $ok6,128
- '2606:4700:4700::1111' => $ok6,129
-];130
-$pass = 0;131
-$passV4 = 0;132
-$passV6 = 0;133
-foreach ($probes as $ip => $enabled) {134
- if (!$enabled) {135
- gline(" [SKIP] {$ip} → 该地址族的库未就绪");136
- continue;137
- }138
- $g = IpLocator::probe((string) $ip);139
- $txt = trim(($g['country'] ?? '') . ' ' . ($g['province'] ?? '') . ' ' . ($g['city'] ?? ''));140
- if ($txt !== '') {141
- $pass++;142
- if (IpLocator::family((string) $ip) === 4) {143
- $passV4++;144
- } else {145
- $passV6++;146
- }147
- gok($ip . ' → ' . $txt);148
- } else {149
- gbad($ip . ' → 解析为空');150
- }151
-}152
-if ($pass === 0) {153
- gline(' → 全部解析失败:库文件损坏 / 驱动不可用 / 文件权限问题,按上面 [1] 处理');154
-} elseif ($ok4 && $passV4 === 0) {155
- gline(' → IPv4 全部解析失败:IPv4 库可能损坏或放错文件(见 [1])');156
-} elseif ($ok6 && $passV6 === 0) {157
- gline(' → IPv6 全部解析失败:IPv6 库可能损坏,或那个文件其实不是 v6 库(见 [1])');158
-}159
-160
-/* 3) 数据覆盖率 */161
-gline("\n[3] 数据地理字段覆盖率");162
-$sites = [];163
-try {164
- $sites = Db::select('SELECT id,name,domain FROM sites ORDER BY id');165
-} catch (\Throwable $e) {166
- gbad('站点查询失败: ' . $e->getMessage());167
-}168
-$argSite = isset($argv[1]) ? (int) $argv[1] : 0;169
-if ($argSite > 0) {170
- $sites = array_values(array_filter($sites, fn ($s) => (int) $s['id'] === $argSite));171
-}172
-if (empty($sites)) {173
- gwarn('没有站点记录(或指定 site_id 不存在)');174
-}175
-$sinceDay = gmdate('Y-m-d', time() - 7 * 86400);176
-foreach ($sites as $s) {177
- $sid = (int) $s['id'];178
- gline("\n --- 站点 #{$sid} {$s['name']} ({$s['domain']}) ---");179
- try {180
- $evAll = (int) Db::value('SELECT COUNT(*) FROM events WHERE site_id=?', [$sid]);181
- $evGeo = (int) Db::value("SELECT COUNT(*) FROM events WHERE site_id=? AND country<>''", [$sid]);182
- $ev7 = (int) Db::value('SELECT COUNT(*) FROM events WHERE site_id=? AND `day`>=?', [$sid, $sinceDay]);183
- $evLoop = (int) Db::value(184
- "SELECT COUNT(*) FROM events WHERE site_id=? AND ip IN ('127.0.0.1','::1')",185
- [$sid]186
- );187
- $ssAll = (int) Db::value('SELECT COUNT(*) FROM sessions WHERE site_id=?', [$sid]);188
- $ssGeo = (int) Db::value("SELECT COUNT(*) FROM sessions WHERE site_id=? AND country<>''", [$sid]);189
- $ss7 = (int) Db::value('SELECT COUNT(*) FROM sessions WHERE site_id=? AND start_ts>=?', [$sid, time() - 7 * 86400]);190
-191
- gline(" events 总计 {$evAll},其中有地域 {$evGeo};近 7 天事件 {$ev7};回环 IP {$evLoop}");192
- gline(" sessions 总计 {$ssAll},其中有地域 {$ssGeo};近 7 天会话 {$ss7}");193
-194
- if ($evAll === 0 && $ssAll === 0) {195
- gwarn('该站点还没有任何数据,先跑通采集再看地域');196
- } elseif ($evLoop > 0 && $evLoop === $evAll) {197
- gbad('全部事件的 IP 都是 127.0.0.1 —— 说明采集端取到的是本机反代地址,不是访客地址');198
- gline(' → 站点走 Nginx/PHP-FPM(宝塔典型)时真实 IP 通常就在 REMOTE_ADDR;');199
- gline(' 取不到访客地址时,到「系统设置 → 真实 IP 采集」手动选一个能显示你真实地址的来源');200
- gline(' → 套了 CDN:选 X-Forwarded-For;若该头仍是节点地址,选「自定义头」填 CF-Connecting-IP');201
- gline(' → 修复后新访问才会带正确 IP/地域(历史数据不回溯)');202
- } elseif ($evGeo === 0 && $ssGeo === 0) {203
- if ($pass === 0) {204
- gbad('所有数据都无地域,且解析探活也失败 → 按 [1][2] 修复库/驱动');205
- } elseif ($ev7 === 0) {206
- gwarn('近 7 天没有新事件:现有数据都是启用地理之前采集的,新访问才会带地域');207
- } else {208
- gwarn('近 7 天有新事件但仍无地域:检查采集侧 IP 是否为空/内网(本地或反代未传 X-Forwarded-For)');209
- }210
- } else {211
- gok('地理字段已有数据,地域页应可正常展示');212
- }213
- } catch (\Throwable $e) {214
- gbad('统计失败: ' . $e->getMessage());215
- }216
-}217
-218
-gline("\n[4] 结论速查");219
-gline(' · 库文件路径不对/改名 → IPv4 放 ' . $st['xdb_path'] . '(config collect.geo_xdb);');220
-gline(' IPv6 放 ' . $st['xdb6_path'] . '(config collect.geo_xdb6)');221
-gline(' · 下载中断导致文件损坏 → 重新 php ' . wstat_rel('scripts/fetch-geo.php'));222
-gline(' · 驱动为 none → config 设 collect.geo_driver=xdb,重载 PHP-FPM + 重启 worker');223
-gline(' · 库正常但数据为空 → 老数据不回溯,只有新访问会带地域;反代站点注意真实 IP 头');224
-gline(' · 只有 IPv6 访客没地域 → 缺 ip2region_v6.xdb,跑一次 fetch-geo.php 补齐(不影响 IPv4)');225
-gline('========== 诊断结束 ==========');1
+<?php2
+/**3
+ * IP 地理归属专项诊断:php scripts/geo-doctor.php [site_id]4
+ * (在项目根目录下执行;开发副本为 php server/scripts/geo-doctor.php)5
+ *6
+ * 逐项检查:配置驱动 → xdb 库文件(存在/大小/可读/目录候选)→ 实际解析探活7
+ * → events / sessions 的地理字段覆盖率 → 给出结论与修复建议。8
+ */9
+declare(strict_types=1);10
+require dirname(__DIR__) . '/app/bootstrap.php';11
+12
+use Wstat\Support\Db;13
+use Wstat\Support\IpLocator;14
+15
+function gline(string $s = ''): void16
+{17
+ echo $s, "\n";18
+}19
+function gok(string $s): void20
+{21
+ gline(' [OK] ' . $s);22
+}23
+function gwarn(string $s): void24
+{25
+ gline(' [WARN] ' . $s);26
+}27
+function gbad(string $s): void28
+{29
+ gline(' [FAIL] ' . $s);30
+}31
+32
+gline('========== IP 地理归属诊断 (geo-doctor) ==========');33
+gline('时间: ' . date('Y-m-d H:i:s'));34
+35
+/* 1) 驱动与库文件 */36
+gline("\n[1] 配置与离线库文件");37
+$st = IpLocator::status();38
+gline(' config collect.geo_driver = ' . $st['configured']);39
+gline(' config collect.geo_xdb = ' . $st['xdb_path'] . ' (IPv4)');40
+gline(' config collect.geo_xdb6 = ' . $st['xdb6_path'] . ' (IPv6)');41
+gline(' 生效驱动 effective = ' . $st['effective']42
+ . ' (IPv4: ' . $st['effective_v4'] . ', IPv6: ' . $st['effective_v6'] . ')');43
+gline(' data 目录 = ' . $st['xdb_dir']);44
+45
+/**46
+ * 单个库文件的体检:存在 / 体积 / 声明的地址族是否与槽位一致 / 可读。47
+ *48
+ * 「声明的地址族」这一项专治最隐蔽的一种坏法:把 v6 库改名成 ip2region.xdb(或两个配置项49
+ * 指到同一个文件)。那时文件存在、体积也正常,但用 4 字节地址去解析 38 字节的索引项只会50
+ * 读到垃圾 —— 这里直接读头部 ipVersion 字段对质,明确报出来而不是静默返回空。51
+ *52
+ * 体积门槛取 1MB:正常库都是 MB 级,几百 KB 基本是下载被截断的残文件。53
+ */54
+$checkLib = static function (int $fam) use ($st): bool {55
+ $isV4 = $fam === 4;56
+ $tag = $isV4 ? 'IPv4' : 'IPv6';57
+ $pathK = $isV4 ? 'xdb_path' : 'xdb6_path';58
+ $existsK = $isV4 ? 'xdb_exists' : 'xdb6_exists';59
+ $sizeK = $isV4 ? 'xdb_size' : 'xdb6_size';60
+ $readK = $isV4 ? 'xdb_readable' : 'xdb6_readable';61
+ $name = $isV4 ? 'ip2region.xdb' : 'ip2region_v6.xdb';62
+63
+ if (empty($st[$existsK])) {64
+ gbad("[{$tag}] 库文件不存在:" . $st[$pathK]);65
+ gline(' → 执行 php ' . wstat_rel('scripts/fetch-geo.php') . ' 下载'66
+ . '(IPv6 库缺失只影响 IPv6 访客,IPv4 不受影响)');67
+ return false;68
+ }69
+ $size = (int) $st[$sizeK];70
+ if ($size <= 1024 * 1024) {71
+ gbad("[{$tag}] 库文件存在但体积异常(" . number_format($size) . " 字节,正常为 MB 级)→ 很可能下载失败/被截断,请重新下载");72
+ return false;73
+ }74
+ $fileFam = 0;75
+ foreach ((array) $st['xdb_candidates'] as $c) {76
+ if (($c['file'] ?? '') === basename((string) $st[$pathK])) {77
+ $fileFam = (int) ($c['ip_version'] ?? 0);78
+ break;79
+ }80
+ }81
+ if ($fileFam !== 0 && $fileFam !== $fam) {82
+ $gbad("[{$tag}] 库文件声明的是 IPv{$fileFam},与所在槽位不符 → 解析会全部落空,请换成 {$name}");83
+ return false;84
+ }85
+ gok("[{$tag}] 库文件就绪,大小 " . number_format($size) . ' 字节(约 ' . round($size / 1048576, 1) . ' MB)');86
+ if (empty($st[$readK])) {87
+ $gbad("[{$tag}] 库文件不可读(PHP 进程权限不足),请 chmod 644 或调整属主");88
+ return false;89
+ }90
+ return true;91
+};92
+93
+$ok4 = $checkLib(4);94
+$ok6 = $checkLib(6);95
+96
+if ($ok4 && !$ok6) {97
+ $gwarn('IPv6 库缺失:IPv6 访客解析不出地域(IPv4 正常)。补齐后 v6 访客即有地域。');98
+} elseif (!$ok4 && $ok6) {99
+ $gwarn('IPv4 库缺失:IPv4 访客解析不出地域(IPv6 正常)。');100
+} elseif (!$ok4 && !$ok6) {101
+ if (!empty($st['xdb_candidates'])) {102
+ $names = array_map(103
+ static fn ($c) => $c['file'] . '(' . number_format((int) $c['size']) . ' 字节'104
+ . (!empty($c['ip_version']) ? ',声明 IPv' . $c['ip_version'] : '') . ')',105
+ (array) $st['xdb_candidates']106
+ );107
+ gwarn('但同目录发现了其它 xdb 文件:' . implode(', ', $names));108
+ gline(' → IPv4 库文件名必须为 ip2region.xdb、IPv6 库为 ip2region_v6.xdb,');109
+ gline(' 或在 config 里把 collect.geo_xdb / collect.geo_xdb6 指到实际文件名');110
+ } else {111
+ gline(' → 官方库下载目录: https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/');112
+ gline(' → 或执行: php ' . wstat_rel('scripts/fetch-geo.php'));113
+ }114
+}115
+if ($st['configured'] !== 'xdb' && $st['configured'] !== 'http') {116
+ gwarn('驱动不是 xdb/http(当前 ' . $st['configured'] . ')→ 采集不会解析地域。改 config 后需重载 PHP-FPM 并重启 worker');117
+}118
+119
+/* 2) 解析探活 */120
+gline("\n[2] 解析探活(固定公网 IP,IPv4 / IPv6 各一组)");121
+$probes = [122
+ '114.114.114.114' => $ok4,123
+ '223.5.5.5' => $ok4,124
+ '8.8.8.8' => $ok4,125
+ '101.226.103.106' => $ok4,126
+ '240e:3b7:3272:d8d0:db09:c067:8d59:539e' => $ok6,127
+ '2400:3200:baba::1' => $ok6,128
+ '2606:4700:4700::1111' => $ok6,129
+];130
+$pass = 0;131
+$passV4 = 0;132
+$passV6 = 0;133
+foreach ($probes as $ip => $enabled) {134
+ if (!$enabled) {135
+ gline(" [SKIP] {$ip} → 该地址族的库未就绪");136
+ continue;137
+ }138
+ $g = IpLocator::probe((string) $ip);139
+ $txt = trim(($g['country'] ?? '') . ' ' . ($g['province'] ?? '') . ' ' . ($g['city'] ?? ''));140
+ if ($txt !== '') {141
+ $pass++;142
+ if (IpLocator::family((string) $ip) === 4) {143
+ $passV4++;144
+ } else {145
+ $passV6++;146
+ }147
+ gok($ip . ' → ' . $txt);148
+ } else {149
+ gbad($ip . ' → 解析为空');150
+ }151
+}152
+if ($pass === 0) {153
+ gline(' → 全部解析失败:库文件损坏 / 驱动不可用 / 文件权限问题,按上面 [1] 处理');154
+} elseif ($ok4 && $passV4 === 0) {155
+ gline(' → IPv4 全部解析失败:IPv4 库可能损坏或放错文件(见 [1])');156
+} elseif ($ok6 && $passV6 === 0) {157
+ gline(' → IPv6 全部解析失败:IPv6 库可能损坏,或那个文件其实不是 v6 库(见 [1])');158
+}159
+160
+/* 3) 数据覆盖率 */161
+gline("\n[3] 数据地理字段覆盖率");162
+$sites = [];163
+try {164
+ $sites = Db::select('SELECT id,name,domain FROM sites ORDER BY id');165
+} catch (\Throwable $e) {166
+ gbad('站点查询失败: ' . $e->getMessage());167
+}168
+$argSite = isset($argv[1]) ? (int) $argv[1] : 0;169
+if ($argSite > 0) {170
+ $sites = array_values(array_filter($sites, fn ($s) => (int) $s['id'] === $argSite));171
+}172
+if (empty($sites)) {173
+ gwarn('没有站点记录(或指定 site_id 不存在)');174
+}175
+$sinceDay = gmdate('Y-m-d', time() - 7 * 86400);176
+foreach ($sites as $s) {177
+ $sid = (int) $s['id'];178
+ gline("\n --- 站点 #{$sid} {$s['name']} ({$s['domain']}) ---");179
+ try {180
+ $evAll = (int) Db::value('SELECT COUNT(*) FROM events WHERE site_id=?', [$sid]);181
+ $evGeo = (int) Db::value("SELECT COUNT(*) FROM events WHERE site_id=? AND country<>''", [$sid]);182
+ $ev7 = (int) Db::value('SELECT COUNT(*) FROM events WHERE site_id=? AND `day`>=?', [$sid, $sinceDay]);183
+ $evLoop = (int) Db::value(184
+ "SELECT COUNT(*) FROM events WHERE site_id=? AND ip IN ('127.0.0.1','::1')",185
+ [$sid]186
+ );187
+ $ssAll = (int) Db::value('SELECT COUNT(*) FROM sessions WHERE site_id=?', [$sid]);188
+ $ssGeo = (int) Db::value("SELECT COUNT(*) FROM sessions WHERE site_id=? AND country<>''", [$sid]);189
+ $ss7 = (int) Db::value('SELECT COUNT(*) FROM sessions WHERE site_id=? AND start_ts>=?', [$sid, time() - 7 * 86400]);190
+191
+ gline(" events 总计 {$evAll},其中有地域 {$evGeo};近 7 天事件 {$ev7};回环 IP {$evLoop}");192
+ gline(" sessions 总计 {$ssAll},其中有地域 {$ssGeo};近 7 天会话 {$ss7}");193
+194
+ if ($evAll === 0 && $ssAll === 0) {195
+ gwarn('该站点还没有任何数据,先跑通采集再看地域');196
+ } elseif ($evLoop > 0 && $evLoop === $evAll) {197
+ gbad('全部事件的 IP 都是 127.0.0.1 —— 说明采集端取到的是本机反代地址,不是访客地址');198
+ gline(' → 站点走 Nginx/PHP-FPM(宝塔典型)时真实 IP 通常就在 REMOTE_ADDR;');199
+ gline(' 取不到访客地址时,到「系统设置 → 真实 IP 采集」手动选一个能显示你真实地址的来源');200
+ gline(' → 套了 CDN:选 X-Forwarded-For;若该头仍是节点地址,选「自定义头」填 CF-Connecting-IP');201
+ gline(' → 修复后新访问才会带正确 IP/地域(历史数据不回溯)');202
+ } elseif ($evGeo === 0 && $ssGeo === 0) {203
+ if ($pass === 0) {204
+ gbad('所有数据都无地域,且解析探活也失败 → 按 [1][2] 修复库/驱动');205
+ } elseif ($ev7 === 0) {206
+ gwarn('近 7 天没有新事件:现有数据都是启用地理之前采集的,新访问才会带地域');207
+ } else {208
+ gwarn('近 7 天有新事件但仍无地域:检查采集侧 IP 是否为空/内网(本地或反代未传 X-Forwarded-For)');209
+ }210
+ } else {211
+ gok('地理字段已有数据,地域页应可正常展示');212
+ }213
+ } catch (\Throwable $e) {214
+ gbad('统计失败: ' . $e->getMessage());215
+ }216
+}217
+218
+gline("\n[4] 结论速查");219
+gline(' · 库文件路径不对/改名 → IPv4 放 ' . $st['xdb_path'] . '(config collect.geo_xdb);');220
+gline(' IPv6 放 ' . $st['xdb6_path'] . '(config collect.geo_xdb6)');221
+gline(' · 下载中断导致文件损坏 → 重新 php ' . wstat_rel('scripts/fetch-geo.php'));222
+gline(' · 驱动为 none → config 设 collect.geo_driver=xdb,重载 PHP-FPM + 重启 worker');223
+gline(' · 库正常但数据为空 → 老数据不回溯,只有新访问会带地域;反代站点注意真实 IP 头');224
+gline(' · 只有 IPv6 访客没地域 → 缺 ip2region_v6.xdb,跑一次 fetch-geo.php 补齐(不影响 IPv4)');225
+gline('========== 诊断结束 ==========');scripts/selfcheck.php+282-191
@@ -1,191 +1,282 @@1
-<?php2
-/**3
- * 离线自检脚本(无需 MySQL/Redis,验证纯逻辑模块)4
- * 用法:php scripts/selfcheck.php (在项目根目录下执行;开发副本为 php server/scripts/selfcheck.php)5
- */6
-declare(strict_types=1);7
-8
-require dirname(__DIR__) . '/app/bootstrap.php';9
-10
-use Wstat\Support\RedisClient;11
-use Wstat\Support\Referrer;12
-use Wstat\Support\UaParser;13
-use Wstat\Support\Sessionizer;14
-use Wstat\Support\Util;15
-16
-$pass = 0;17
-$fail = 0;18
-19
-function check(string $name, bool $cond, string $detail = ''): void20
-{21
- global $pass, $fail;22
- if ($cond) {23
- $pass++;24
- echo " PASS $name\n";25
- } else {26
- $fail++;27
- echo " FAIL $name $detail\n";28
- }29
-}30
-31
-/* ============ 1. RESP 协议解析 ============ */32
-function respStream(string $resp)33
-{34
- $fp = fopen('php://temp', 'r+');35
- fwrite($fp, $resp);36
- rewind($fp);37
- return $fp;38
-}39
-40
-echo "\n[1] RESP parser\n";41
-$fp = respStream("+OK\r\n");42
-check('simple string', RedisClient::parseReply($fp) === 'OK');43
-fclose($fp);44
-45
-$fp = respStream(":42\r\n");46
-check('integer', RedisClient::parseReply($fp) === 42);47
-fclose($fp);48
-49
-$fp = respStream("$-1\r\n");50
-check('bulk null', RedisClient::parseReply($fp) === null);51
-fclose($fp);52
-53
-$fp = respStream("$5\r\nhello\r\n");54
-check('bulk string', RedisClient::parseReply($fp) === 'hello');55
-fclose($fp);56
-57
-$fp = respStream("*-1\r\n");58
-check('array null', RedisClient::parseReply($fp) === null);59
-fclose($fp);60
-61
-$fp = respStream("*3\r\n:1\r\n$3\r\nfoo\r\n$-1\r\n");62
-check('array', RedisClient::parseReply($fp) === [1, 'foo', null]);63
-fclose($fp);64
-65
-$fp = respStream("-ERR unknown command\r\n");66
-$err = false;67
-try {68
- RedisClient::parseReply($fp);69
-} catch (RuntimeException $e) {70
- $err = true;71
-}72
-check('error throws', $err);73
-fclose($fp);74
-75
-/* ============ 2. UA 解析 ============ */76
-echo "\n[2] UA parser\n";77
-$ua = UaParser::parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36');78
-check('chrome/win10', $ua['browser'] === 'Chrome' && $ua['os'] === 'Windows 10' && $ua['device'] === 'desktop', json_encode($ua));79
-80
-$ua = UaParser::parse('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1');81
-check('safari/ios-mobile', $ua['browser'] === 'Safari' && $ua['os'] === 'iOS' && $ua['device'] === 'mobile', json_encode($ua));82
-83
-$ua = UaParser::parse('Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Chrome/119.0 Mobile Safari/537.36');84
-check('chrome/android', $ua['browser'] === 'Chrome' && $ua['os'] === 'Android' && $ua['device'] === 'mobile', json_encode($ua));85
-86
-$ua = UaParser::parse('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');87
-check('googlebot', $ua['browser'] === 'GoogleBot' && $ua['device'] === 'bot', json_encode($ua));88
-89
-$ua = UaParser::parse('Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');90
-check('ipad tablet', $ua['os'] === 'iOS' && $ua['device'] === 'tablet', json_encode($ua));91
-92
-$ua = UaParser::parse('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0 Safari/537.36');93
-check('chrome/macos', $ua['browser'] === 'Chrome' && $ua['os'] === 'macOS', json_encode($ua));94
-95
-$ua = UaParser::parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0.0.0');96
-check('edge', $ua['browser'] === 'Edge', json_encode($ua));97
-98
-$ua = UaParser::parse('Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/90.0 Mobile Safari/537.36 MicroMessenger/8.0.0');99
-check('wechat', $ua['browser'] === 'WeChat', json_encode($ua));100
-101
-/* ============ 3. 来源分类 ============ */102
-echo "\n[3] Referrer classify\n";103
-$r = Referrer::classify('', 'example.com');104
-check('direct', $r['type'] === 'direct', json_encode($r));105
-106
-$r = Referrer::classify('', 'example.com', ['source' => 'google', 'medium' => 'cpc', 'campaign' => 'summer']);107
-check('utm', $r['type'] === 'utm' && $r['source'] === 'google' && $r['medium'] === 'cpc', json_encode($r));108
-109
-$r = Referrer::classify('https://example.com/other', 'example.com');110
-check('internal', $r['type'] === 'internal', json_encode($r));111
-112
-$r = Referrer::classify('https://blog.example.com/x', 'example.com');113
-check('subdomain internal', $r['type'] === 'internal', json_encode($r));114
-115
-$r = Referrer::classify('https://www.baidu.com/s?wd=x', 'example.com');116
-check('baidu search', $r['type'] === 'search' && $r['source'] === 'baidu', json_encode($r));117
-118
-$r = Referrer::classify('https://m.weibo.cn/u/123', 'example.com');119
-check('weibo social', $r['type'] === 'social' && $r['source'] === 'weibo', json_encode($r));120
-121
-$r = Referrer::classify('https://news.ycombinator.com/item?id=1', 'example.com');122
-check('hn link', $r['type'] === 'link' && $r['source'] === 'ycombinator', json_encode($r));123
-124
-$r = Referrer::classify('https://www.zhihu.com/question/1', 'example.com');125
-check('zhihu social', $r['type'] === 'social' && $r['source'] === 'zhihu', json_encode($r));126
-127
-/* ============ 4. Util ============ */128
-echo "\n[4] Util\n";129
-check('domain ok', Util::validDomain('www.example.com') && Util::validDomain('127.0.0.1') && Util::validDomain('localhost'));130
-check('domain bad', !Util::validDomain('http://x.com/a') && !Util::validDomain('') && !Util::validDomain('a..b'));131
-check('email', Util::validEmail('[email protected]') && !Util::validEmail('a@'));132
-$tz = Util::tzOffsetSec('Asia/Shanghai');133
-check('tz +8', $tz === 8 * 3600, "got $tz");134
-check('localDay', Util::localDay(1725000000, 480) === gmdate('Y-m-d', 1725000000 + 480 * 60));135
-[$s0, $s1] = Util::dateRangeToTs('2026-09-01', '2026-09-09', 'Asia/Shanghai');136
-check('range', $s1 - $s0 === 9 * 86400, "diff=" . ($s1 - $s0));137
-$k1 = Util::randBase62(10);138
-$k2 = Util::randBase62(10);139
-check('base62', strlen($k1) === 10 && $k1 !== $k2);140
-check('hex', strlen(Util::randHex(16)) === 32);141
-142
-// 访客 IP(**手动指定来源**,1.0.8 起取消一切自动判断):143
-// 严格按指定来源取值;REMOTE_ADDR 忽略任何代理头;XFF / X-Real-IP 取最右;缺失则回落 REMOTE_ADDR。144
-// 注意:这里显式传来源,避免断言依赖管理端已保存的设置值。145
-$savedServer = $_SERVER;146
-$_SERVER = ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', 'HTTP_CF_CONNECTING_IP' => '5.6.7.8'];147
-check('ip remote_addr', Util::ipTrace('remote_addr', '')['ip'] === '203.0.113.9', Util::ipTrace('remote_addr', '')['ip']);148
-check('ip xff right-most', Util::ipTrace('x_forwarded_for', '')['ip'] === '1.2.3.4');149
-check('ip custom cf', Util::ipTrace('custom', 'CF-Connecting-IP')['ip'] === '5.6.7.8');150
-$_SERVER = ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_REAL_IP' => '1.2.3.4, 223.5.5.5'];151
-$ipTrace = Util::ipTrace('x_real_ip', '');152
-check('ip xrealip right-most', $ipTrace['ip'] === '223.5.5.5', $ipTrace['ip']);153
-check('ip spoofable flag', $ipTrace['spoofable'] === true && $ipTrace['remote_private'] === true);154
-$_SERVER = ['REMOTE_ADDR' => '203.0.113.9'];155
-$ipFb = Util::ipTrace('x_forwarded_for', '');156
-check('ip fallback', $ipFb['ip'] === '203.0.113.9' && $ipFb['fallback'] === true);157
-check('ipCandidate count', count(Util::ipCandidates('remote_addr', '')) === 4);158
-check('ipInList cidr', Util::ipInList('10.1.2.3', ['10.0.0.0/8']) && !Util::ipInList('192.168.1.1', ['10.0.0.0/8']));159
-// IPv6 CIDR(原 ip2long 实现对 IPv6 恒 false)160
-check('ipInList ipv6', Util::ipInList('2606:4700:1234::1', ['2606:4700::/32']) && !Util::ipInList('2606:4701::1', ['2606:4700::/32']));161
-check('isCloudflare', Util::isCloudflare('172.71.10.5') && !Util::isCloudflare('1.1.1.1'));162
-$_SERVER = $savedServer;163
-164
-165
-echo "\n==============================\n";166
-echo "RESULT: $pass passed, $fail failed\n";167
-168
-169
-170
-/* ============ 5. Sessionizer(纯逻辑,无需 Redis) ============ */171
-echo "\n[5] Sessionizer\n";172
-$hash = [173
- 'site_id' => '1', 'session_id' => 'SABC123', 'visitor_id' => 'UXYZ789',174
- 'first_ts' => '1700000000', 'last_ts' => '1700000120',175
- 'pv' => '3', 'url' => '/a', 'entry_url' => '/a', 'exit_url' => '/b',176
- 'src_json' => json_encode(['type' => 'search', 'medium' => 'organic']),177
- 'is_new' => '1', 'browser' => 'Chrome', 'os' => 'Windows', 'device' => 'desktop',178
-];179
-$row = \Wstat\Support\Sessionizer::buildRow($hash);180
-check('buildRow pv', $row !== null && (int) $row['pageviews'] === 3 && (int) $row['duration'] === 120);181
-check('buildRow bounce0', (int) ($row['bounce'] ?? 1) === 0, 'pv>1 非跳出');182
-check('buildRow src', ($row['source'] ?? '') === 'search' && ($row['medium'] ?? '') === 'organic');183
-$hash2 = ['first_ts' => '0', 'last_ts' => '0'];184
-check('buildRow null', \Wstat\Support\Sessionizer::buildRow($hash2) === null, '缺时间返回 null');185
-$single = $hash; $single['pv'] = '1';186
-$r1 = \Wstat\Support\Sessionizer::buildRow($single);187
-check('bounce single', (int) ($r1['bounce'] ?? 0) === 1, 'pv=1 跳出');188
-/* ============ 汇总 ============ */189
-echo "\n==============================\n";190
-echo "RESULT: $pass passed, $fail failed\n";191
-exit($fail > 0 ? 1 : 0);1
+<?php2
+/**3
+ * 离线自检脚本(无需 MySQL/Redis,验证纯逻辑模块)4
+ * 用法:php scripts/selfcheck.php (在项目根目录下执行;开发副本为 php server/scripts/selfcheck.php)5
+ */6
+declare(strict_types=1);7
+8
+require dirname(__DIR__) . '/app/bootstrap.php';9
+10
+use Wstat\Support\RedisClient;11
+use Wstat\Support\Referrer;12
+use Wstat\Support\Spider;13
+use Wstat\Support\UaParser;14
+use Wstat\Support\Sessionizer;15
+use Wstat\Support\Util;16
+17
+$pass = 0;18
+$fail = 0;19
+20
+function check(string $name, bool $cond, string $detail = ''): void21
+{22
+ global $pass, $fail;23
+ if ($cond) {24
+ $pass++;25
+ echo " PASS $name\n";26
+ } else {27
+ $fail++;28
+ echo " FAIL $name $detail\n";29
+ }30
+}31
+32
+/* ============ 1. RESP 协议解析 ============ */33
+function respStream(string $resp)34
+{35
+ $fp = fopen('php://temp', 'r+');36
+ fwrite($fp, $resp);37
+ rewind($fp);38
+ return $fp;39
+}40
+41
+echo "\n[1] RESP parser\n";42
+$fp = respStream("+OK\r\n");43
+check('simple string', RedisClient::parseReply($fp) === 'OK');44
+fclose($fp);45
+46
+$fp = respStream(":42\r\n");47
+check('integer', RedisClient::parseReply($fp) === 42);48
+fclose($fp);49
+50
+$fp = respStream("$-1\r\n");51
+check('bulk null', RedisClient::parseReply($fp) === null);52
+fclose($fp);53
+54
+$fp = respStream("$5\r\nhello\r\n");55
+check('bulk string', RedisClient::parseReply($fp) === 'hello');56
+fclose($fp);57
+58
+$fp = respStream("*-1\r\n");59
+check('array null', RedisClient::parseReply($fp) === null);60
+fclose($fp);61
+62
+$fp = respStream("*3\r\n:1\r\n$3\r\nfoo\r\n$-1\r\n");63
+check('array', RedisClient::parseReply($fp) === [1, 'foo', null]);64
+fclose($fp);65
+66
+$fp = respStream("-ERR unknown command\r\n");67
+$err = false;68
+try {69
+ RedisClient::parseReply($fp);70
+} catch (RuntimeException $e) {71
+ $err = true;72
+}73
+check('error throws', $err);74
+fclose($fp);75
+76
+/* ============ 2. UA 解析 ============ */77
+echo "\n[2] UA parser\n";78
+$ua = UaParser::parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36');79
+check('chrome/win10', $ua['browser'] === 'Chrome' && $ua['os'] === 'Windows 10' && $ua['device'] === 'desktop', json_encode($ua));80
+81
+$ua = UaParser::parse('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1');82
+check('safari/ios-mobile', $ua['browser'] === 'Safari' && $ua['os'] === 'iOS' && $ua['device'] === 'mobile', json_encode($ua));83
+84
+$ua = UaParser::parse('Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Chrome/119.0 Mobile Safari/537.36');85
+check('chrome/android', $ua['browser'] === 'Chrome' && $ua['os'] === 'Android' && $ua['device'] === 'mobile', json_encode($ua));86
+87
+$ua = UaParser::parse('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');88
+check('googlebot', $ua['browser'] === 'GoogleBot' && $ua['device'] === 'bot', json_encode($ua));89
+90
+$ua = UaParser::parse('Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');91
+check('ipad tablet', $ua['os'] === 'iOS' && $ua['device'] === 'tablet', json_encode($ua));92
+93
+$ua = UaParser::parse('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0 Safari/537.36');94
+check('chrome/macos', $ua['browser'] === 'Chrome' && $ua['os'] === 'macOS', json_encode($ua));95
+96
+$ua = UaParser::parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0.0.0');97
+check('edge', $ua['browser'] === 'Edge', json_encode($ua));98
+99
+$ua = UaParser::parse('Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/90.0 Mobile Safari/537.36 MicroMessenger/8.0.0');100
+check('wechat', $ua['browser'] === 'WeChat', json_encode($ua));101
+102
+/* ============ 3. 来源分类 ============ */103
+echo "\n[3] Referrer classify\n";104
+$r = Referrer::classify('', 'example.com');105
+check('direct', $r['type'] === 'direct', json_encode($r));106
+107
+$r = Referrer::classify('', 'example.com', ['source' => 'google', 'medium' => 'cpc', 'campaign' => 'summer']);108
+check('utm', $r['type'] === 'utm' && $r['source'] === 'google' && $r['medium'] === 'cpc', json_encode($r));109
+110
+$r = Referrer::classify('https://example.com/other', 'example.com');111
+check('internal', $r['type'] === 'internal', json_encode($r));112
+113
+$r = Referrer::classify('https://blog.example.com/x', 'example.com');114
+check('subdomain internal', $r['type'] === 'internal', json_encode($r));115
+116
+$r = Referrer::classify('https://www.baidu.com/s?wd=x', 'example.com');117
+check('baidu search', $r['type'] === 'search' && $r['source'] === 'baidu', json_encode($r));118
+119
+$r = Referrer::classify('https://m.weibo.cn/u/123', 'example.com');120
+check('weibo social', $r['type'] === 'social' && $r['source'] === 'weibo', json_encode($r));121
+122
+$r = Referrer::classify('https://news.ycombinator.com/item?id=1', 'example.com');123
+check('hn link', $r['type'] === 'link' && $r['source'] === 'ycombinator', json_encode($r));124
+125
+$r = Referrer::classify('https://www.zhihu.com/question/1', 'example.com');126
+check('zhihu social', $r['type'] === 'social' && $r['source'] === 'zhihu', json_encode($r));127
+128
+/* ============ 4. Util ============ */129
+echo "\n[4] Util\n";130
+check('domain ok', Util::validDomain('www.example.com') && Util::validDomain('127.0.0.1') && Util::validDomain('localhost'));131
+check('domain bad', !Util::validDomain('http://x.com/a') && !Util::validDomain('') && !Util::validDomain('a..b'));132
+check('email', Util::validEmail('[email protected]') && !Util::validEmail('a@'));133
+$tz = Util::tzOffsetSec('Asia/Shanghai');134
+check('tz +8', $tz === 8 * 3600, "got $tz");135
+check('localDay', Util::localDay(1725000000, 480) === gmdate('Y-m-d', 1725000000 + 480 * 60));136
+[$s0, $s1] = Util::dateRangeToTs('2026-09-01', '2026-09-09', 'Asia/Shanghai');137
+check('range', $s1 - $s0 === 9 * 86400, "diff=" . ($s1 - $s0));138
+$k1 = Util::randBase62(10);139
+$k2 = Util::randBase62(10);140
+check('base62', strlen($k1) === 10 && $k1 !== $k2);141
+check('hex', strlen(Util::randHex(16)) === 32);142
+143
+// 访客 IP(**手动指定来源**,1.0.8 起取消一切自动判断):144
+// 严格按指定来源取值;REMOTE_ADDR 忽略任何代理头;XFF / X-Real-IP 取最右;缺失则回落 REMOTE_ADDR。145
+// 注意:这里显式传来源,避免断言依赖管理端已保存的设置值。146
+$savedServer = $_SERVER;147
+$_SERVER = ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', 'HTTP_CF_CONNECTING_IP' => '5.6.7.8'];148
+check('ip remote_addr', Util::ipTrace('remote_addr', '')['ip'] === '203.0.113.9', Util::ipTrace('remote_addr', '')['ip']);149
+check('ip xff right-most', Util::ipTrace('x_forwarded_for', '')['ip'] === '1.2.3.4');150
+check('ip custom cf', Util::ipTrace('custom', 'CF-Connecting-IP')['ip'] === '5.6.7.8');151
+$_SERVER = ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_X_REAL_IP' => '1.2.3.4, 223.5.5.5'];152
+$ipTrace = Util::ipTrace('x_real_ip', '');153
+check('ip xrealip right-most', $ipTrace['ip'] === '223.5.5.5', $ipTrace['ip']);154
+check('ip spoofable flag', $ipTrace['spoofable'] === true && $ipTrace['remote_private'] === true);155
+$_SERVER = ['REMOTE_ADDR' => '203.0.113.9'];156
+$ipFb = Util::ipTrace('x_forwarded_for', '');157
+check('ip fallback', $ipFb['ip'] === '203.0.113.9' && $ipFb['fallback'] === true);158
+check('ipCandidate count', count(Util::ipCandidates('remote_addr', '')) === 4);159
+check('ipInList cidr', Util::ipInList('10.1.2.3', ['10.0.0.0/8']) && !Util::ipInList('192.168.1.1', ['10.0.0.0/8']));160
+// IPv6 CIDR(原 ip2long 实现对 IPv6 恒 false)161
+check('ipInList ipv6', Util::ipInList('2606:4700:1234::1', ['2606:4700::/32']) && !Util::ipInList('2606:4701::1', ['2606:4700::/32']));162
+check('isCloudflare', Util::isCloudflare('172.71.10.5') && !Util::isCloudflare('1.1.1.1'));163
+$_SERVER = $savedServer;164
+165
+166
+echo "\n==============================\n";167
+echo "RESULT: $pass passed, $fail failed\n";168
+169
+170
+171
+/* ============ 5. Sessionizer(纯逻辑,无需 Redis) ============ */172
+echo "\n[5] Sessionizer\n";173
+$hash = [174
+ 'site_id' => '1', 'session_id' => 'SABC123', 'visitor_id' => 'UXYZ789',175
+ 'first_ts' => '1700000000', 'last_ts' => '1700000120',176
+ 'pv' => '3', 'url' => '/a', 'entry_url' => '/a', 'exit_url' => '/b',177
+ 'src_json' => json_encode(['type' => 'search', 'medium' => 'organic']),178
+ 'is_new' => '1', 'browser' => 'Chrome', 'os' => 'Windows', 'device' => 'desktop',179
+];180
+$row = \Wstat\Support\Sessionizer::buildRow($hash);181
+check('buildRow pv', $row !== null && (int) $row['pageviews'] === 3 && (int) $row['duration'] === 120);182
+check('buildRow bounce0', (int) ($row['bounce'] ?? 1) === 0, 'pv>1 非跳出');183
+check('buildRow src', ($row['source'] ?? '') === 'search' && ($row['medium'] ?? '') === 'organic');184
+$hash2 = ['first_ts' => '0', 'last_ts' => '0'];185
+check('buildRow null', \Wstat\Support\Sessionizer::buildRow($hash2) === null, '缺时间返回 null');186
+$single = $hash; $single['pv'] = '1';187
+$r1 = \Wstat\Support\Sessionizer::buildRow($single);188
+check('bounce single', (int) ($r1['bounce'] ?? 0) === 1, 'pv=1 跳出');189
+/* ============ 6. Spider(爬虫识别与归一化,纯函数) ============ */190
+echo "\n[6] Spider\n";191
+$d = Spider::detect('curl/8.0.1');192
+check('detect curl', $d['bot'] === true && $d['name'] === 'curl' && $d['kind'] === 'tool', json_encode($d));193
+$d = Spider::detect('Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)');194
+check('detect baidu', $d['bot'] === true && $d['name'] === 'Baiduspider' && $d['kind'] === 'search');195
+// 规则表顺序敏感:图片抓取器的片段必须在主 UA 之前命中,且归到同一产品名(否则会被拆成两只爬虫)196
+$d = Spider::detect('Mozilla/5.0 (compatible; Baiduspider-image/1.0; +http://www.baidu.com/search/spider.html)');197
+check('detect 顺序敏感(image → 同一产品名)', $d['name'] === 'Baiduspider', json_encode($d));198
+$d = Spider::detect('Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)');199
+check('detect GPTBot 归 AI 类', $d['name'] === 'GPTBot' && $d['kind'] === 'ai', json_encode($d));200
+201
+// 真实访客 UA:一律不得计入蜘蛛(这是「爬虫过滤」不误伤访客的前提)202
+$visitorUas = [203
+ '',204
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',205
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',206
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',207
+ 'Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.44',208
+];209
+$bad = [];210
+foreach ($visitorUas as $i => $visitorUa) {211
+ $d = Spider::detect($visitorUa);212
+ if ($d['bot'] !== false || $d['name'] !== '' || $d['kind'] !== '') {213
+ $bad[] = '#' . $i;214
+ }215
+}216
+check('真实访客 UA 一律不记账', $bad === [], implode(',', $bad));217
+218
+// 规则表未覆盖但确实是 bot → 兜底命名,绝不因为「不认识」而丢数据219
+$d = Spider::detect('foobar/1.0');220
+check(221
+ '未覆盖的 bot 兜底为「其它爬虫」',222
+ $d['bot'] === true && $d['name'] === Spider::FALLBACK_NAME && $d['fallback'] === true,223
+ json_encode($d)224
+);225
+226
+// **核心不变量**:detect() 的 bot 判定必须与采集端过滤所用的 UaParser::isBot() 完全一致。227
+// 若将来有人在规则表里塞宽泛片段('ecosia' / 'yahoo.' 之类)并让 Spider 自己判定「是不是爬虫」,228
+// 就会把真实访客算成爬虫 —— 开着「爬虫过滤」时那是**直接丢访客数据**。229
+$probeUas = array_merge($visitorUas, [230
+ 'curl/8.0.1',231
+ 'python-requests/2.31.0',232
+ 'Go-http-client/1.1',233
+ 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',234
+ 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',235
+ 'Mozilla/5.0 (compatible; EcosiaBot/1.0)',236
+ 'Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)',237
+ 'Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)',238
+ 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',239
+]);240
+$mismatch = [];241
+foreach ($probeUas as $probeUa) {242
+ if (Spider::detect($probeUa)['bot'] !== UaParser::isBot($probeUa)) {243
+ $mismatch[] = ($probeUa === '' ? '(空 UA)' : $probeUa);244
+ }245
+}246
+check('bot 判定与 UaParser::isBot 完全一致', $mismatch === [], implode(' | ', $mismatch));247
+248
+// 名称 → 类别反查(报表按已落库的名称还原类别,必须与写入时一致)249
+check(250
+ 'kindOfName 反查',251
+ Spider::kindOfName('Baiduspider') === Spider::KIND_SEARCH252
+ && Spider::kindOfName(Spider::FALLBACK_NAME) === Spider::KIND_OTHER253
+ && Spider::kindOfName('不存在的爬虫') === Spider::KIND_OTHER254
+);255
+256
+// 规则表卫生:类别必须是已知常量(防手滑写出 'serach' —— 前端会拿不到标签、报表归类为空)257
+// 注意 RULES 是 private **const**(不是属性),要用 ReflectionClassConstant 才能取到。258
+try {259
+ $rc = new ReflectionClassConstant(Spider::class, 'RULES');260
+ $rules = (array) $rc->getValue();261
+ $known = array_keys(Spider::KIND_LABELS);262
+ $badKind = [];263
+ $emptyName = [];264
+ foreach ($rules as $needle => [$nm, $kd]) {265
+ if (!in_array($kd, $known, true)) {266
+ $badKind[] = $needle . '=>' . $kd;267
+ }268
+ if ($nm === '') {269
+ $emptyName[] = (string) $needle;270
+ }271
+ }272
+ check('规则表类别均为已知常量', $badKind === [], implode(',', $badKind));273
+ check('规则表名称为空?', $emptyName === [], implode(',', $emptyName));274
+ check('规则表规模', count($rules) >= 80, '当前 ' . count($rules) . ' 条');275
+} catch (Throwable $e) {276
+ check('规则表可反射读取', false, $e->getMessage());277
+}278
+279
+/* ============ 汇总 ============ */280
+echo "\n==============================\n";281
+echo "RESULT: $pass passed, $fail failed\n";282
+exit($fail > 0 ? 1 : 0);scripts/worker.php+318-318
@@ -1,318 +1,318 @@1
-<?php2
-/**3
- * 消费 Worker(常驻进程)4
- *5
- * 用法:php scripts/worker.php (建议 supervisor / nohup 守护;在项目根目录下执行)6
- * 可选:php scripts/worker.php --once (单轮拉取即退出,便于测试)7
- * 可选:php scripts/worker.php --quarantine8
- * (队列键被写成别的类型时,自动 RENAME 备份该键后继续消费)9
- * 开发副本(本目录在 server/ 下)路径为 php server/scripts/worker.php。10
- *11
- * 职责:从 Redis 队列批量消费原始事件 →12
- * 1. 会话化:打开会话维护在 Redis(ssn:{sid}),空闲超时即落库 sessions13
- * 2. 事件明细攒批写入 events(每批 100 行,降低写放大)14
- *15
- * 注意:Redis 返回 WRONGTYPE 表示某个键的数据结构被污染(键名冲突/旧版本残留/人工误写),16
- * 属永久故障而非网络抖动。此时不进入 3 秒重试循环,而是打印诊断(键名/实际类型/修复命令)17
- * 后退出,避免“日志一直刷、队列永不消费”的假死状态。18
- */19
-declare(strict_types=1);20
-21
-require dirname(__DIR__) . '/app/bootstrap.php';22
-23
-use Wstat\Support\Db;24
-use Wstat\Support\IpLocator;25
-use Wstat\Support\Rds;26
-use Wstat\Support\RedisClient;27
-use Wstat\Support\RedisGuard;28
-use Wstat\Support\Sessionizer;29
-30
-$once = in_array('--once', $argv, true);31
-// 队列键被写成别的类型时,自动 RENAME 备份坏键后继续消费(默认不自动改数据)32
-$quarantine = in_array('--quarantine', $argv, true);33
-34
-/* ---------- 单实例守卫 ----------35
- * 同一个队列只允许一个「常驻」消费者。并发消费会互相覆盖数据:36
- * 续接会话是「HGETALL 读 → pv+1 → HMSET 写」的非原子流程,两个 worker 同时处理37
- * 同一会话的两条 pageview 时会双双读到旧 pv,写回同一个值 → 会话 pv/停留时长少计;38
- * reapIdle 也可能在另一个 worker 正写入时把会话提前关闭。39
- * flock 由内核在进程退出(含崩溃/kill)时自动释放,不会留下陈旧锁。40
- * --once 是单轮诊断模式,不参与守卫(允许在有常驻 worker 时临时执行)。41
- */42
-$lockFp = null;43
-if (!$once) {44
- $lockFile = dirname(__DIR__) . '/data/worker.lock';45
- $lockFp = @fopen($lockFile, 'c');46
- if ($lockFp === false || !flock($lockFp, LOCK_EX | LOCK_NB)) {47
- fwrite(48
- STDERR,49
- "[worker] 已有另一个 worker 正在消费同一队列(锁文件 {$lockFile}),本次启动直接退出。\n"50
- . " 并发消费者会在会话哈希上产生「读改写」竞争,导致 pv / 停留时长少计,请只保留一个常驻 worker。\n"51
- . " 确认是陈旧进程时可先结束它,或用 php " . wstat_rel('scripts/doctor.php') . " 查看队列状况。\n"52
- );53
- exit(0);54
- }55
- ftruncate($lockFp, 0);56
- fwrite($lockFp, 'pid=' . getmypid() . ' started=' . date('c') . "\n");57
- fflush($lockFp);58
-}59
-60
-$idle = (int) wstat_config('security.session_idle');61
-$newWin = (int) wstat_config('security.new_visitor_win');62
-63
-$flushEvents = function (array &$rows) {64
- if ($rows) {65
- // 过滤掉未迁移的列,保证 events 结构升级期间不中断66
- $clean = [];67
- foreach ($rows as $row) {68
- $clean[] = Db::filterColumns('events', $row);69
- }70
- Db::insertBatch('events', $clean, 100);71
- $rows = [];72
- }73
-};74
-75
-$rows = [];76
-$closed = 0;77
-$processed = 0;78
-$idleLoops = 0;79
-$started = microtime(true);80
-81
-/**82
- * 会话化:仅 pageview 驱动;perf/event/click 挂靠现有会话。83
- * 任何 Redis 异常向上抛,由主循环统一分类(Redis 键类型污染不能被吞掉)。84
- */85
-$sessionize = function (RedisClient $r, array $raw, int $now, int &$closed) use ($idle, $newWin): void {86
- $type = (string) ($raw['type'] ?? '');87
- $sessionId = (string) ($raw['session_id'] ?? '');88
- $visitorId = (string) ($raw['visitor_id'] ?? '');89
- if ($sessionId === '') {90
- return;91
- }92
- if ($type === 'pageview') {93
- $hash = $r->hgetall(Sessionizer::HASH_KEY . $sessionId);94
- if ($hash && (int) ($hash['last_ts'] ?? 0) > 0 && $now - (int) $hash['last_ts'] > $idle) {95
- // 会话超时:关闭旧会话,开启新会话96
- if (Sessionizer::close($r, $sessionId)) {97
- $closed++;98
- }99
- $hash = [];100
- }101
- if (!$hash) {102
- $isNew = 0;103
- if ($visitorId !== '') {104
- // SET NX EX:不存在则写入并视为新访客105
- $set = $r->setnx('v:' . $visitorId, '1', $newWin);106
- $isNew = $set ? 1 : 0;107
- }108
- $r->hmset(Sessionizer::HASH_KEY . $sessionId, [109
- 'site_id' => (string) ($raw['site_id'] ?? ''),110
- 'session_id' => $sessionId,111
- 'visitor_id' => $visitorId,112
- 'end_user' => (string) ($raw['end_user'] ?? ''),113
- 'first_ts' => (string) $now,114
- 'last_ts' => (string) $now,115
- 'pv' => '1',116
- 'url' => (string) ($raw['url'] ?? ''),117
- 'title' => (string) ($raw['title'] ?? ''),118
- 'entry_url' => (string) ($raw['url'] ?? ''),119
- 'exit_url' => (string) ($raw['url'] ?? ''),120
- 'src_json' => json_encode([121
- 'type' => $raw['source'] ?? 'direct',122
- 'medium' => $raw['medium'] ?? '',123
- 'campaign' => $raw['campaign'] ?? '',124
- 'content' => $raw['content'] ?? '',125
- 'term' => $raw['term'] ?? '',126
- 'click_id' => $raw['click_id'] ?? '',127
- ], JSON_UNESCAPED_UNICODE),128
- 'is_new' => (string) $isNew,129
- 'browser' => (string) ($raw['browser'] ?? ''),130
- 'os' => (string) ($raw['os'] ?? ''),131
- 'device' => (string) ($raw['device'] ?? ''),132
- 'screen' => (string) ($raw['screen'] ?? ''),133
- 'lang' => (string) ($raw['lang'] ?? ''),134
- 'ip' => (string) ($raw['ip'] ?? ''),135
- 'country' => (string) ($raw['country'] ?? ''),136
- 'province' => (string) ($raw['province'] ?? ''),137
- 'city' => (string) ($raw['city'] ?? ''),138
- ]);139
- } else {140
- // 续接会话(老会话缺 IP 时顺带补齐,便于列表/详情展示;end_user 首个携带值优先)141
- $r->hmset(Sessionizer::HASH_KEY . $sessionId, [142
- 'last_ts' => (string) $now,143
- 'pv' => (string) ((int) ($hash['pv'] ?? 1) + 1),144
- 'url' => (string) ($raw['url'] ?? ''),145
- 'title' => (string) ($raw['title'] ?? ''),146
- 'exit_url' => (string) ($raw['url'] ?? ''),147
- 'ip' => (string) ($hash['ip'] ?? $raw['ip'] ?? ''),148
- 'end_user' => ((string) ($hash['end_user'] ?? '') !== '')149
- ? (string) $hash['end_user']150
- : (string) ($raw['end_user'] ?? ''),151
- ]);152
- }153
- } elseif ($type !== 'hb' && $r->exists(Sessionizer::HASH_KEY . $sessionId)) {154
- // 行为/性能事件:刷新会话最后活跃时间155
- $r->hmset(Sessionizer::HASH_KEY . $sessionId, ['last_ts' => (string) $now]);156
- }157
-};158
-159
-/* ---------- 顶层兜底:未被内层捕获的异常统一分类,绝不让全局 JSON 异常处理器杀死常驻进程 ---------- */160
-while (true) {161
-try {162
-while (true) {163
- $r = Rds::get();164
- if ($r === null) {165
- // Redis 不可用(含认证缺失/错误):不退出崩溃循环,等待恢复后自动重连166
- fwrite(STDERR, '[worker] Redis unavailable, retry in 3s...' . "\n");167
- if ($once) {168
- exit(1);169
- }170
- sleep(3);171
- continue;172
- }173
- try {174
- $got = $r->brpop('queue', 2);175
- } catch (\Throwable $e) {176
- // WRONGTYPE(键类型被污染)属永久故障:重试不会自愈,必须明确报错而不是 3 秒一轮刷屏。177
- if (RedisGuard::isWrongType($e)) {178
- // 先用全新连接复核:长连接流错位也会产生伪 WRONGTYPE,且旧连接上连 TYPE 都读不准。179
- // 新连接复核 queue=list/none → 判定为连接异常,重连后继续消费(数据无损)。180
- Rds::reset();181
- $r2 = Rds::get();182
- $real = ($r2 !== null) ? $r2->type('queue') : '?';183
- if ($real === 'list' || $real === 'none') {184
- fwrite(STDERR, '[worker] brpop WRONGTYPE 但新连接复核 queue=' . $real . '(疑似连接流错位,非真污染),重连后继续消费。' . "\n");185
- if ($once) {186
- exit(1);187
- }188
- continue;189
- }190
- fwrite(STDERR, '[worker] 新连接复核 queue=' . $real . ',确认键类型污染:' . "\n");191
- fwrite(STDERR, RedisGuard::describe(RedisGuard::diagnose($r2 ?? $r, 'queue')));192
- if ($quarantine) {193
- $bak = RedisGuard::quarantine($r2 ?? $r, 'queue');194
- if ($bak !== null) {195
- fwrite(STDERR, "[worker] 已将坏键备份为 {$bak},队列重建后继续消费。\n");196
- Rds::reset();197
- continue;198
- }199
- fwrite(STDERR, "[worker] 隔离坏键失败(键可能已被删除或无权重命名),请手工处理后重启。\n");200
- }201
- exit(1);202
- }203
- fwrite(STDERR, '[worker] Redis error: ' . $e->getMessage() . ', retry in 3s...' . "\n");204
- Rds::reset();205
- if ($once) {206
- exit(1);207
- }208
- sleep(3);209
- continue;210
- }211
-212
- if ($got === null) {213
- $flushEvents($rows);214
- if ($once) {215
- break;216
- }217
- // 看门狗:连接流错位时 brpop/消费「看起来正常」但会话化会静默失败,218
- // 空闲时 PING 自检,回复不是 +PONG 立即重连(每 3 个空闲轮询查一次,开销可忽略)。219
- if (($idleLoops % 3) === 0 && !$r->ping()) {220
- fwrite(STDERR, '[worker] ping watchdog: Redis 连接流异常(疑似错位),重连后继续。' . "\n");221
- Rds::reset();222
- continue;223
- }224
- // 队列空闲时定期兜底回收空闲会话(不依赖 crontab 也能落库 sessions)225
- if (++$idleLoops >= 15) {226
- $idleLoops = 0;227
- try {228
- [$c, $o] = Sessionizer::reapIdle($r);229
- if ($c > 0 || $o > 0) {230
- fwrite(STDOUT, "[worker] reaped closed=$c offline=$o\n");231
- }232
- } catch (\Throwable $e) {233
- if (RedisGuard::isWrongType($e)) {234
- // ssn:/on: 等键被污染:reap 属兜底逻辑,不退出,但必须把坏键清单报出来235
- fwrite(STDERR, RedisGuard::describeAudit(RedisGuard::audit($r), 'Redis 键类型错误(会话回收阶段)'));236
- } else {237
- fwrite(STDERR, '[worker] reap error: ' . $e->getMessage() . "\n");238
- }239
- }240
- }241
- continue;242
- }243
-244
- // 兼容两种载荷形态:当前 brpop 返回 JSON 字符串;历史版本可能已解码为数组245
- // (直接对数组做 (string) 强转会触发 "Array to string conversion" 且事件被丢弃)246
- $val = $got[1];247
- if (is_array($val)) {248
- $raw = $val;249
- } else {250
- $raw = json_decode((string) $val, true);251
- if (!is_array($raw)) {252
- fwrite(STDERR, '[worker] skip invalid payload: ' . substr((string) $val, 0, 200) . "\n");253
- continue;254
- }255
- }256
- $processed++;257
-258
- // ---- IP 归属补齐(老队列载荷采集时未解析;已带 geo 的新载荷跳过) ----259
- $raw = IpLocator::enrich($raw);260
-261
- /* ---------- 会话化(仅 pageview 驱动;perf/event/click 挂靠现有会话) ---------- */262
- $type = (string) ($raw['type'] ?? '');263
- $now = (int) ($raw['ts'] ?? time());264
- try {265
- $sessionize($r, $raw, $now, $closed);266
- } catch (\Throwable $e) {267
- if (RedisGuard::isWrongType($e)) {268
- // ssn:/v:/live: 等键被污染:无法定位单个键 → 直接审计出坏键清单269
- fwrite(STDERR, RedisGuard::describeAudit(RedisGuard::audit($r), 'Redis 键类型错误(会话化阶段)'));270
- fwrite(STDERR, '[worker] 提示:' . $e->getMessage() . "\n");271
- exit(1);272
- }273
- // 其它 Redis 异常:本次事件仍落库,不中断消费274
- fwrite(STDERR, '[worker] sessionize error: ' . $e->getMessage() . "\n");275
- }276
-277
- /* ---------- 攒批落库(hb 不落库) ---------- */278
- if ($type !== 'hb') {279
- $rows[] = $raw;280
- }281
- if (count($rows) >= 100) {282
- $flushEvents($rows);283
- }284
-285
- /* 心跳:定期汇报 */286
- if ($processed % 500 === 0) {287
- $sec = max(0.001, microtime(true) - $started);288
- fwrite(STDOUT, sprintf("[worker] processed=%d closed=%d rate=%.0f/s\n", $processed, $closed, $processed / $sec));289
- }290
-291
- if ($once) {292
- $flushEvents($rows);293
- break;294
- }295
-}296
-break; // 内层 while 正常退出(--once)297
-} catch (\Throwable $e) {298
- if (RedisGuard::isWrongType($e)) {299
- // 键类型污染属永久故障:顶层兜底也要打印坏键清单后退出,重试不会自愈300
- $r2 = Rds::get();301
- if ($r2 !== null) {302
- fwrite(STDERR, RedisGuard::describeAudit(RedisGuard::audit($r2), 'Redis 键类型错误(顶层兜底捕获)'));303
- }304
- fwrite(STDERR, '[worker] ' . $e->getMessage() . "\n");305
- exit(1);306
- }307
- // 其它异常(含 DB 抖动):未落库批次保留在 $rows,重连后重试308
- fwrite(STDERR, '[worker] top-level error: ' . $e->getMessage() . ', retry in 3s...' . "\n");309
- Rds::reset();310
- if ($once) {311
- exit(1);312
- }313
- sleep(3);314
-}315
-}316
-317
-fwrite(STDOUT, "[worker] done. processed={$processed} closed={$closed}\n");318
-exit(0);1
+<?php2
+/**3
+ * 消费 Worker(常驻进程)4
+ *5
+ * 用法:php scripts/worker.php (建议 supervisor / nohup 守护;在项目根目录下执行)6
+ * 可选:php scripts/worker.php --once (单轮拉取即退出,便于测试)7
+ * 可选:php scripts/worker.php --quarantine8
+ * (队列键被写成别的类型时,自动 RENAME 备份该键后继续消费)9
+ * 开发副本(本目录在 server/ 下)路径为 php server/scripts/worker.php。10
+ *11
+ * 职责:从 Redis 队列批量消费原始事件 →12
+ * 1. 会话化:打开会话维护在 Redis(ssn:{sid}),空闲超时即落库 sessions13
+ * 2. 事件明细攒批写入 events(每批 100 行,降低写放大)14
+ *15
+ * 注意:Redis 返回 WRONGTYPE 表示某个键的数据结构被污染(键名冲突/旧版本残留/人工误写),16
+ * 属永久故障而非网络抖动。此时不进入 3 秒重试循环,而是打印诊断(键名/实际类型/修复命令)17
+ * 后退出,避免“日志一直刷、队列永不消费”的假死状态。18
+ */19
+declare(strict_types=1);20
+21
+require dirname(__DIR__) . '/app/bootstrap.php';22
+23
+use Wstat\Support\Db;24
+use Wstat\Support\IpLocator;本次改动内容较多,仅显示前 5000 行差异。