码桶

发现社区成员的开源项目

geekssl / 公开

版本历史

8 次提交

提交详情

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-0
CHANGELOG.md+151-145
Dockerfile+52-0
LICENSE+21-0
README.md+352-181
RELEASE.md+42-42
THIRD-PARTY-NOTICES.md+26-0
config.php+224-224
deploy/apache.htaccess.sample+47-47
deploy/nginx.conf.sample+113-113
docker-compose.yml+127-0
docker/cron-loop.sh+46-0
docker/entrypoint.sh+116-0
docs/deploy.md+494-460
scripts/cron.php+177-177
scripts/doctor.php+258-258
scripts/fetch-geo.php+113-113
scripts/geo-doctor.php+225-225
scripts/selfcheck.php+282-191
scripts/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.9
17-
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.8
32-
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.7
45-
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.6
58-
59-- 新增**外部代码注入**:系统设置里可粘贴第三方统计(百度统计、Google Analytics 等)或在线客服代码,
60- 保存后按原样注入本站每个页面 `</head>` 之前,用于统计本站自身访问、挂载客服窗口(仅管理员可配置;
61- 与「站点接入代码模板」相互独立——后者是给被统计站点站长粘贴的模板,本站不渲染)
62-- 新增**每个页面独立标题**:浏览器标签页标题按路由显示「页面名 · 应用名」(跟随中英文切换),
63- 移动看板 `/m` 的标题跟随当前 Tab
64- 对确属 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.5
71-
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.4
86-
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.3
100-
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.2
120-
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.1
128-
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.0MB
137-
138-## 1.0.0
139-
140-- 首个正式版本:PV/UV/IP/会话/停留时长/跳出率、来源、地域、终端环境、页面下钻
141-- 实时概览、数据大屏(时间筛选 / 刷新倒计时 / 全屏)
142-- 漏斗与转化目标、广告追踪、热力图、访客旅程、受众分析
143-- 开放 API(PAT 令牌)、CSV 导出、流量异常告警与日报推送
144-- 多用户协作(所有者 / 编辑者 / 只读)、系统设置、注册邮箱验证码与忘记密码
145-- 宝塔/通用 Nginx、Apache 部署样例,Web 安装向导与 CLI 安装
1+# 更新日志
2+
3+格式约定:`## 版本号` 开头的小节,其下的列表行即为该版本的更新条目。
4+
5+## 1.0.11
6+
7+- **新增:搜索引擎来路关键词**
8+- **新增:IP 追踪溯源**
9+- **SDK v1.5:来源保真(301/302 跳板场景)**
10+
11+## 1.0.10
12+
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.9
23+
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.8
38+
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.7
51+
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.6
64+
65+- 新增**外部代码注入**:系统设置里可粘贴第三方统计(百度统计、Google Analytics 等)或在线客服代码,
66+ 保存后按原样注入本站每个页面 `</head>` 之前,用于统计本站自身访问、挂载客服窗口(仅管理员可配置;
67+ 与「站点接入代码模板」相互独立——后者是给被统计站点站长粘贴的模板,本站不渲染)
68+- 新增**每个页面独立标题**:浏览器标签页标题按路由显示「页面名 · 应用名」(跟随中英文切换),
69+ 移动看板 `/m` 的标题跟随当前 Tab
70+ 对确属 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.5
77+
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.4
92+
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.3
106+
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.2
126+
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.1
134+
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.0MB
143+
144+## 1.0.0
145+
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 # 对外提供的采集 SDK
50-│ └── install/ # 安装向导(Web 四步向导 + CLI + 自带 install.sql),装完请删除
51-├── app/ # 后端代码(无 Composer 依赖)
52-│ ├── bootstrap.php # 自动加载 / 全局异常 / 未安装守卫
53-│ ├── Http/ # Router / Request
54-│ ├── Support/ # Db / RedisClient(RESP) / Rds / Auth / Util / Settings / Updater …
55-│ └── Controllers/ # Auth / Site / Stats / Collect / Setting / Alert / Export / Open
56-├── 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.md
63-```
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-```bash
78-chown -R www:www /www/wwwroot/webstats/data
79-```
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-```bash
91-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-```bash
108-cd /www/wwwroot/webstats
109-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_daily
113-# 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-```nginx
125-root {包根}/public; # ← 关键:根目录指向包根下的 public
126-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; # 管理 API
133-}
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 # 对外提供的采集 SDK
52+│ └── install/ # 安装向导(Web 四步向导 + CLI + 自带 install.sql),装完请删除
53+├── app/ # 后端代码(无 Composer 依赖)
54+│ ├── bootstrap.php # 自动加载 / 全局异常 / 未安装守卫
55+│ ├── Http/ # Router / Request
56+│ ├── Support/ # Db / RedisClient(RESP) / Rds / Auth / Util / Settings / Updater …
57+│ └── Controllers/ # Auth / Site / Stats / Collect / Setting / Alert / Export / Open
58+├── 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.md
69+```
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+```bash
88+chown -R www:www /www/wwwroot/webstats/data
89+```
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+```bash
102+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+```bash
119+cd /www/wwwroot/webstats
120+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_daily
124+# 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+```bash
134+cd frontend
135+npm install
136+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+```nginx
146+root {包根}/public; # ← 关键:根目录指向包根下的 public
147+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; # 管理 API
154+}
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+```bash
182+cp .env.example .env # 改 WSTAT_DB_PASS / MYSQL_ROOT_PASSWORD / 管理员账号与口令
183+docker compose up -d --build # 首次构建;若已拉取 ghcr 镜像可用 docker compose up -d --no-build
184+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+```bash
202+docker network create wstats
203+
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:latest
212+
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.php
217+```
218+
219+> `data` 卷必须在 web / worker / cron 之间共享:安装配置(`installed.php`)、站点验证文件、IP 库都在里面。
220+
221+#### 首次自动安装
222+
223+`WSTAT_AUTO_INSTALL=1` 且 `data/install.lock` 不存在时,容器入口脚本会先等 MySQL 就绪,再执行:
224+
225+```bash
226+php public/install/cli.php --no-interactive --create-db --db-* --redis-* --admin-email --admin-pass
227+```
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+```bash
236+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+```bash
263+# 备份数据库(结构与数据)
264+docker compose exec db sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' > webstats-$(date +%F).sql
265+
266+# 源码方式升级 / 镜像方式升级(worker、cron 会一并重建 = 重启 worker,避免跑旧代码)
267+git pull && docker compose up -d --build
268+docker compose pull && docker compose up -d
269+
270+# 日志与状态
271+docker compose logs -f worker cron
272+docker compose ps
273+```
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+```bash
281+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`)。一律取所选头**最右侧**的合法 IP
330+ (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.10
2-
3-- 构建时间:2026-09-16 15:48:54
4-- 构建环境:PHP 8.2.20
5-- 安装方式:访问 https://你的域名/install/ 或执行 php public/install/cli.php
6-
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.10
2+
3+- 构建时间:2026-09-16 15:48:54
4+- 构建环境:PHP 8.2.20
5+- 安装方式:访问 https://你的域名/install/ 或执行 php public/install/cli.php
6+
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-<?php
2-/**
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 置 true
63- '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, // 单次上报 256KB
108- 'beacon_method' => 'POST', // 预留
109- // IP 地理解析驱动:none | http | xdb
110- // xdb = 本地离线库(随发布包附带,推荐),**IPv4 / IPv6 两个库互相独立降级**:
111- // 缺 v6 库只影响 IPv6 访客,IPv4 照常解析
112- // http = 接口型,需自备可用服务,见下方 geo_http_url/key
113- // 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 最右侧的合法 IP
122- // x_forwarded_for 取 X-Forwarded-For 最右侧的合法 IP
123- // 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}.txt
134- 'timeout' => 8,
135- ],
136-
137- // CORS
138- '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+<?php
2+/**
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 置 true
63+ '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, // 单次上报 256KB
108+ 'beacon_method' => 'POST', // 预留
109+ // IP 地理解析驱动:none | http | xdb
110+ // xdb = 本地离线库(随发布包附带,推荐),**IPv4 / IPv6 两个库互相独立降级**:
111+ // 缺 v6 库只影响 IPv6 访客,IPv4 照常解析
112+ // http = 接口型,需自备可用服务,见下方 geo_http_url/key
113+ // 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 最右侧的合法 IP
122+ // x_forwarded_for 取 X-Forwarded-For 最右侧的合法 IP
123+ // 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}.txt
134+ 'timeout' => 8,
135+ ],
136+
137+ // CORS
138+ '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.html
10-
11-<IfModule mod_rewrite.c>
12- RewriteEngine On
13- RewriteBase /
14-
15- # 已存在的文件/目录直接返回(静态资源、SDK 等)
16- RewriteCond %{REQUEST_FILENAME} -f [OR]
17- RewriteCond %{REQUEST_FILENAME} -d
18- RewriteRule ^ - [L]
19-
20- # 采集端点
21- RewriteRule ^collect(\.php)?$ collect.php [L,QSA]
22-
23- # 管理端 API
24- 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 denied
36- </FilesMatch>
37-</IfModule>
38-
39-# 静态资源缓存
40-<IfModule mod_expires.c>
41- ExpiresActive On
42- ExpiresByType application/javascript "access plus 7 days"
43- ExpiresByType text/css "access plus 7 days"
44-</IfModule>
45-
46-# 默认字符集
47-AddDefaultCharset UTF-8
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.html
10+
11+<IfModule mod_rewrite.c>
12+ RewriteEngine On
13+ RewriteBase /
14+
15+ # 已存在的文件/目录直接返回(静态资源、SDK 等)
16+ RewriteCond %{REQUEST_FILENAME} -f [OR]
17+ RewriteCond %{REQUEST_FILENAME} -d
18+ RewriteRule ^ - [L]
19+
20+ # 采集端点
21+ RewriteRule ^collect(\.php)?$ collect.php [L,QSA]
22+
23+ # 管理端 API
24+ 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 denied
36+ </FilesMatch>
37+</IfModule>
38+
39+# 静态资源缓存
40+<IfModule mod_expires.c>
41+ ExpiresActive On
42+ ExpiresByType application/javascript "access plus 7 days"
43+ ExpiresByType text/css "access plus 7 days"
44+</IfModule>
45+
46+# 默认字符集
47+AddDefaultCharset UTF-8
deploy/nginx.conf.sample+113-113
@@ -1,113 +1,113 @@
1-# WebStats 生产环境 Nginx 配置样例(宝塔 / 通用)
2-#
3-# 发布包 v1.0.1+ 为**统一布局**,包根 = 站点目录,`public/` 是唯一的 Web 运行目录:
4-#
5-# <包根>/ 例:/www/wwwroot/webstats
6-# 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/ 前端产物与对外 SDK
13-# install/ 安装向导(安装完成后请删除)
14-# scripts/ worker.php / cron.php / doctor.php
15-# 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; # ← 包根下的 public
29- index index.php index.html;
30-
31- # ---------- 管理端 SPA + 未安装引导 ----------
32- # 交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html
33- 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:9000
52- # 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/webstats
67- # 或在安装器目录写 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.sock
80- 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/webstats
6+# 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/ 前端产物与对外 SDK
13+# install/ 安装向导(安装完成后请删除)
14+# scripts/ worker.php / cron.php / doctor.php
15+# 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; # ← 包根下的 public
29+ index index.php index.html;
30+
31+ # ---------- 管理端 SPA + 未安装引导 ----------
32+ # 交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html
33+ 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:9000
52+ # 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/webstats
67+ # 或在安装器目录写 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.sock
80+ 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/webstats
28-├── 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.php
37-└── deploy/ docs/deploy.md README.md RELEASE.md CHANGELOG.md
38-```
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- ```bash
78- rm -rf /www/wwwroot/webstats/public/install
79- ```
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-> ```php
86-> <?php
87-> 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-```bash
101-cd /www/wwwroot/webstats
102-
103-# 环境自检(含目录布局探测)
104-php public/install/cli.php --check
105-
106-# 目录结构特殊时显式指定项目根(等价于写 local.php)
107-php public/install/cli.php --check --root=/www/wwwroot/webstats
108-
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-```bash
130-# install.sql 内的分区占位标记 __PARTITIONS__ 需替换为真实分区行(参考文件末尾注释或安装器生成结果)
131-mysql -uwebstats -p webstats < sql/install.sql
132-cp data/installed.php.example data/installed.php # 按需手写
133-```
134-
135-
136-## 三、服务器配置
137-
138-### 3.1 Nginx
139-
140-完整样例见 `deploy/nginx.conf.sample`。核心片段:
141-
142-```nginx
143-root /www/wwwroot/webstats/public; # ← 包根下的 public(宝塔:网站目录=包根、运行目录=/public)
144-index index.php index.html; # 必须含 index.php:/install/ 才能直接打开
145-
146-# 非 API 也交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html
147-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/ 需要 deny
150-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 Apache
180-
181-把 `deploy/apache.htaccess.sample` 复制为 `public/.htaccess`,确保 `mod_rewrite` 开启、`AllowOverride All`。
182-
183-### 3.3 目录权限
184-
185-```bash
186-chown -R www:www /www/wwwroot/webstats/data
187-chmod 755 /www/wwwroot/webstats/data
188-```
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-```bash
204-cd /www/wwwroot/webstats
205-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-```ini
213-[Unit]
214-Description=WebStats queue worker
215-After=network.target redis.service mysql.service
216-
217-[Service]
218-Type=simple
219-User=www
220-WorkingDirectory=/www/wwwroot/webstats
221-ExecStart=/usr/bin/php /www/wwwroot/webstats/scripts/worker.php
222-Restart=always
223-RestartSec=5
224-
225-[Install]
226-WantedBy=multi-user.target
227-```
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-```cron
245-* * * * * php /www/wwwroot/webstats/scripts/cron.php session # 回收空闲会话
246-0 * * * * php /www/wwwroot/webstats/scripts/cron.php rollup # 生成每日汇总 site_daily
247-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-```html
267-<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)**:配置后,「告警渠道-邮件」「日报订阅邮件」「发送测试邮件」与**注册/重置邮箱验证码**统一走 SMTP
430- (支持 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/webstats
31+├── 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.php
40+├── sql/ install.sql(幂等全量建表)+ upgrade-*.sql(增量升级)
41+└── deploy/ docs/deploy.md README.md RELEASE.md CHANGELOG.md
42+```
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+ ```bash
83+ rm -rf /www/wwwroot/webstats/public/install
84+ ```
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+> ```php
91+> <?php
92+> 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+```bash
106+cd /www/wwwroot/webstats
107+
108+# 环境自检(含目录布局探测)
109+php public/install/cli.php --check
110+
111+# 目录结构特殊时显式指定项目根(等价于写 local.php)
112+php public/install/cli.php --check --root=/www/wwwroot/webstats
113+
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+```bash
135+# install.sql 内的分区占位标记 __PARTITIONS__ 需替换为真实分区行(参考文件末尾注释或安装器生成结果)
136+mysql -uwebstats -p webstats < sql/install.sql
137+cp data/installed.php.example data/installed.php # 按需手写
138+```
139+
140+---
141+
142+## 三、服务器配置
143+
144+### 3.1 Nginx
145+
146+完整样例见 `deploy/nginx.conf.sample`。核心片段:
147+
148+```nginx
149+root /www/wwwroot/webstats/public; # ← 包根下的 public(宝塔:网站目录=包根、运行目录=/public)
150+index index.php index.html; # 必须含 index.php:/install/ 才能直接打开
151+
152+# 非 API 也交给 index.php:未安装 → 302 /install/;已安装 → 输出 index.html
153+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/ 需要 deny
156+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 Apache
186+
187+把 `deploy/apache.htaccess.sample` 复制为 `public/.htaccess`,确保 `mod_rewrite` 开启、`AllowOverride All`。
188+
189+### 3.3 目录权限
190+
191+```bash
192+chown -R www:www /www/wwwroot/webstats/data
193+chmod 755 /www/wwwroot/webstats/data
194+```
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+```bash
211+cd /www/wwwroot/webstats
212+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+```ini
220+[Unit]
221+Description=WebStats queue worker
222+After=network.target redis.service mysql.service
223+
224+[Service]
225+Type=simple
226+User=www
227+WorkingDirectory=/www/wwwroot/webstats
228+ExecStart=/usr/bin/php /www/wwwroot/webstats/scripts/worker.php
229+Restart=always
230+RestartSec=5
231+
232+[Install]
233+WantedBy=multi-user.target
234+```
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+```cron
252+* * * * * php /www/wwwroot/webstats/scripts/cron.php session # 回收空闲会话
253+0 * * * * php /www/wwwroot/webstats/scripts/cron.php rollup # 生成每日汇总 site_daily
254+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+```html
275+<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)**:配置后,「告警渠道-邮件」「日报订阅邮件」「发送测试邮件」与**注册/重置邮箱验证码**统一走 SMTP
472+ (支持 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-<?php
2-/**
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 Cron
41-{
42- /** 会话回收:关闭空闲超时的 ssn hash,并清理在线集合中的离线成员 */
43- public static function session(): string
44- {
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(): string
55- {
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) nw
87- 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(): string
109- {
110- $row = Db::first(
111- "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd
112- FROM information_schema.PARTITIONS
113- 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(): string
136- {
137- return AlertEngine::evaluate();
138- }
139-
140- /** 到点发送日报 / 周报(按订阅的本地小时判断,当天幂等) */
141- public static function report(): string
142- {
143- return AlertEngine::runReports();
144- }
145-
146- /** 按保留期清理过期明细(直接 DROP 过期分区,最快) */
147- public static function clean(): string
148- {
149- // 保留天数:系统设置 retention_days 优先(管理员界面可改);空/非法时回退 config.php
150- $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 pd
157- FROM information_schema.PARTITIONS
158- 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+<?php
2+/**
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 Cron
41+{
42+ /** 会话回收:关闭空闲超时的 ssn hash,并清理在线集合中的离线成员 */
43+ public static function session(): string
44+ {
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(): string
55+ {
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) nw
87+ 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(): string
109+ {
110+ $row = Db::first(
111+ "SELECT PARTITION_NAME pn, PARTITION_DESCRIPTION pd
112+ FROM information_schema.PARTITIONS
113+ 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(): string
136+ {
137+ return AlertEngine::evaluate();
138+ }
139+
140+ /** 到点发送日报 / 周报(按订阅的本地小时判断,当天幂等) */
141+ public static function report(): string
142+ {
143+ return AlertEngine::runReports();
144+ }
145+
146+ /** 按保留期清理过期明细(直接 DROP 过期分区,最快) */
147+ public static function clean(): string
148+ {
149+ // 保留天数:系统设置 retention_days 优先(管理员界面可改);空/非法时回退 config.php
150+ $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 pd
157+ FROM information_schema.PARTITIONS
158+ 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-<?php
2-/**
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.PARTITIONS
69- 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(可收数据但无实时/队列/会话聚合)。原因: ' . $why
98- . '。若含 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_sessions
151- 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 events
175- 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) v7
202- 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 sessions
224- 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+<?php
2+/**
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.PARTITIONS
69+ 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(可收数据但无实时/队列/会话聚合)。原因: ' . $why
98+ . '。若含 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_sessions
151+ 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 events
175+ 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) v7
202+ 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 sessions
224+ 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-<?php
2-/**
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+<?php
2+/**
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-<?php
2-/**
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 = ''): void
16-{
17- echo $s, "\n";
18-}
19-function gok(string $s): void
20-{
21- gline(' [OK] ' . $s);
22-}
23-function gwarn(string $s): void
24-{
25- gline(' [WARN] ' . $s);
26-}
27-function gbad(string $s): void
28-{
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+<?php
2+/**
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 = ''): void
16+{
17+ echo $s, "\n";
18+}
19+function gok(string $s): void
20+{
21+ gline(' [OK] ' . $s);
22+}
23+function gwarn(string $s): void
24+{
25+ gline(' [WARN] ' . $s);
26+}
27+function gbad(string $s): void
28+{
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-<?php
2-/**
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 = ''): void
20-{
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+<?php
2+/**
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 = ''): void
21+{
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_SEARCH
252+ && Spider::kindOfName(Spider::FALLBACK_NAME) === Spider::KIND_OTHER
253+ && Spider::kindOfName('不存在的爬虫') === Spider::KIND_OTHER
254+);
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-<?php
2-/**
3- * 消费 Worker(常驻进程)
4- *
5- * 用法:php scripts/worker.php (建议 supervisor / nohup 守护;在项目根目录下执行)
6- * 可选:php scripts/worker.php --once (单轮拉取即退出,便于测试)
7- * 可选:php scripts/worker.php --quarantine
8- * (队列键被写成别的类型时,自动 RENAME 备份该键后继续消费)
9- * 开发副本(本目录在 server/ 下)路径为 php server/scripts/worker.php。
10- *
11- * 职责:从 Redis 队列批量消费原始事件 →
12- * 1. 会话化:打开会话维护在 Redis(ssn:{sid}),空闲超时即落库 sessions
13- * 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+<?php
2+/**
3+ * 消费 Worker(常驻进程)
4+ *
5+ * 用法:php scripts/worker.php (建议 supervisor / nohup 守护;在项目根目录下执行)
6+ * 可选:php scripts/worker.php --once (单轮拉取即退出,便于测试)
7+ * 可选:php scripts/worker.php --quarantine
8+ * (队列键被写成别的类型时,自动 RENAME 备份该键后继续消费)
9+ * 开发副本(本目录在 server/ 下)路径为 php server/scripts/worker.php。
10+ *
11+ * 职责:从 Redis 队列批量消费原始事件 →
12+ * 1. 会话化:打开会话维护在 Redis(ssn:{sid}),空闲超时即落库 sessions
13+ * 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 行差异。