本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/待办事宜/PHP8.5兼容性与安装链路审计报告.md。
FlintHub — 安装链路审计 + PHP 8.4/8.5 兼容性报告(含实施计划)
| 项 | 值 |
|---|---|
| 审计日期 | 2026-09-15(当日增补:PHP 8.4 专项 + 真机实测 + 全部修复完成并打包) |
| 审计范围 | 安装链路(install.php + Schema::bootstrap/seedDefaults);全库 PHP 8.4 / 8.5 兼容性(app/ plugins/ cli/ lang/ + 根目录,共 568 个 PHP 文件) |
| 本机环境 | Nginx 1.15.11 + PHP 8.0.2(另装 7.3.4 / 8.2.9);另下载便携版 PHP 8.4.25 / 8.5.10 用于真机验证 |
| 目标环境 | 手机端 WSToolbox 内置 PHP(实测 8.4+);IIS + 腾讯云函数(每 5 分钟触发 cron_trigger.php) |
| 结论 | 安装链路可跑通;站点在 8.4.25 / 8.5.10 上 19 条路由全部正常;全部 11 处兼容性问题已修复,全库 568 文件编译期告警归零 |
| 交付 | update/3/(V1.0.3) —— 7 个源码文件 + version.php + 改动说明.md |
一、结论摘要
安装链路
| 检查项 | 结论 | 依据 |
|---|---|---|
Schema::bootstrap() 建库骨架 | ✅ 通过 | 实测:1.6s 完成,新装库表结构与生产库逐表一致 |
Schema::seedDefaults() 默认数据 | ✅ 通过 | settings 12 行、categories 1 行、level_config 11 行 |
| 幂等性(重复 bootstrap) | ✅ 通过 | 第二次执行新建 0 条目、无报错 |
data/ 目录 web 防护 | ✅ 通过 | .htaccess + web.config 均已生成 |
| P-2 索引在新装路径生效 | ✅ 通过 | idx_reply_index_status 已建 |
| 管理员账号创建 | ✅ 代码审查通过 | 未实跑(需真实 data 目录,避免污染) |
| 协议勾选 | ✅ 代码正确,未发现缺陷 | Alpine 绑定结构正确 + 已确认打包版 Alpine 支持 checkbox |
| 协议勾选的历史成因 | ⚠️ 已定位设计脆弱点 | 见 §2.4 |
PHP 8.4(你手机服务器当前命中)
| 严重度 | 数量 | 说明 | 状态 |
|---|---|---|---|
| HIGH | 1 | session_set_save_handler() 传「独立回调」—— 每次请求必触发 | ✅ 已修(§3.3 / H-2) |
| HIGH | 1 | 隐式可空参数(ViewCounter.php)—— 每次请求必触发 2 条,经实测严重度由 MED 升为 HIGH | ✅ 已修(§3.3 / M-4) |
PHP 8.4 只引入两类与本项目相关的弃用:
session_set_save_handler()独立回调 与 隐式可空参数。其余告警项(
ord()/curl_close/imagedestroy/finfo_close)均为 8.5 才引入,8.4 下不会触发。两项都是「每请求级」日志刷屏,现已全部消除。
PHP 8.5
| 严重度 | 数量 | 说明 | 状态 |
|---|---|---|---|
| HIGH | 1 | ord() 传多字节字符串 —— 中文标题必然触发(已实测复现) | ✅ 已修(§3.2 / H-1) |
| MED | 7 | 弃用函数:curl_close ×2、imagedestroy ×4、finfo_close ×1 | ✅ 已修(§3.4) |
| LOW | 1 | gc_collect_cycles() 返回值语义变更(实测无告警) | ✅ 无需处理 |
本报告结论已用真实 PHP 8.4.25 / 8.5.10 便携版实测复核(见 §3.7),不是纯静态分析。
唯一的真实生产运行时证据来自你的手机服务器。
二、第一部分:安装链路审计
2.1 链路全貌
访问 install.php
│
├─ ① 基础防护:error_reporting / 常量定义 / 载入 version.php
├─ ② 已安装判定:config.php 含 SPLITDB_DATA_PATH 或 data/meta/business.sqlite 存在
│ └─ 命中 → renderAlreadyInstalled()(HTTP 410)并 exit
├─ ③ Session 加固:HttpOnly / SameSite=Lax / use_strict_mode / Secure(按 HTTPS 判断)
│ └─ 生成 install_csrf 存入 session
│
├─ ④ 分步向导(Alpine 控制,5 步)
│ 1 欢迎 → 2 许可协议(勾选) → 3 环境检测 → 4 管理员 → 5 安装中
│
└─ ⑤ POST action=install
├─ CSRF 校验(hash_equals,先 is_string 防护)
├─ 环境复检 envAllOk()
├─ 协议复检 $_POST['agree_license'] === '1'
├─ 字段校验(用户名 ≥2 / 邮箱格式 / 密码 ≥6)
└─ runInstall()
├─ flock install.lock(非阻塞,防并发双装)
├─ session_write_close()(避免 config.php 的 session ini_set 告警)
├─ writeConfig() → 生成 config.php(MAIL_PASS_KEY 随机 32 字节)
├─ Schema::bootstrap() ← 实测通过
├─ Schema::seedDefaults() ← 实测通过
├─ 创建管理员(users,role=admin,level=11,points=9999)
├─ 分配确定性 seed 头像
└─ 原子写 protected/.install_done(tmp + rename)2.2 核心链路实测结果
以临时 dataPath 跑真实代码(不触碰站点 data/),PHP 8.0.2:
【1】Schema::bootstrap() 完成,耗时 1625 ms,新建 9 个目录条目
【2】目录树:meta / meta/task_queue / meta/search / bucket/active/2026Q3
/ bucket/archive / extern / lock / log / runtime 全部 ✓
【3】meta 库:business.sqlite(23 表) / main_index.sqlite(2 表)
/ sessions.sqlite(1 表) / global_id.sqlite(id_generator) 全部 ✓
【4】队列库:queue_0 / queue_1 / queue_2 全部 ✓
【5】默认数据:settings 12 行 / categories 1 行 / level_config 11 行 ✓
【6】reply_index 索引:idx_reply_index_pid, idx_reply_index_uid,
idx_reply_index_status ← P-2 新装路径生效 ✓
【7】data 防护文件:.htaccess ✓ web.config ✓
【8】幂等复跑:新建 0 条目,无报错 ✓关键交叉验证 —— 新装库与生产库逐表比对:
| 库 | 新装 | 生产 | 结果 |
|---|---|---|---|
meta/business.sqlite | 23 表 | 23 表 | 表名与数量完全一致 |
meta/main_index.sqlite | reply_index, topic_index | 同 | 一致 |
meta/global_id.sqlite | id_generator | 同 | 一致 |
→ 全新安装产出的库结构与现网一致,安装链路核心无缺陷。
2.3 协议勾选专项核查
先说结论:当前代码的勾选逻辑是正确的,没有发现缺陷。
实现方式(install.php:586-599):
...协议全文...
我已阅读并同意上述《许可协议》(MIT License)
下一步 →
逐环节验证:
| 环节 | 验证结果 |
|---|---|
| 脚本加载顺序 | fhstate.js 带 defer(解析后执行),内联 installer() 在解析期即定义 → Alpine 启动时函数已就绪 ✅ |
x-data="installer()" | 内联函数声明在顶层 → 挂在 window,Alpine 可解析 ✅ |
x-model 支持 checkbox | 打包版内确认存在 t.type==="checkbox" 与 ["checkbox","radio"].includes(t.type)?"change":"input" ✅ |
x-cloak 移除 | 打包版内确认 p("cloak",t=>queueMicrotask(()=>t.removeAttribute(...))) ✅ |
:disabled 简写 | 打包版内确认 x-bind 实现存在 ✅ |
| HTML 结构 | 实测渲染 HTML 标签闭合正确、无嵌套错误 ✅ |
| 服务端复检 | $_POST['agree_license'] === '1' 兜底校验存在 ✅ |
未完成的一步(需说明):本应再用真实浏览器点击验证,但沙箱拦截了 Chromium 下载
(agent-browser install 写入 ...\chrome-153.0.8010.36\locales\et_NEUTER.pak 被拒),
故未做真人点击实测。这一环建议你自己 5 秒确认:
http://127.0.0.1/.workbuddy-ai/tmp/install-test/install.php(该极小测试目录仅含 install.php / version.php / LICENSE / fhstate.js,
不依赖站点已安装状态,经你的 Nginx 已返回 200。)
2.4 发现的问题(安装链路)
问题 1|⚠️ 设计脆弱点:整个向导强依赖 Alpine,无降级
[x-cloak]{display:none!important} 配合 5 个步骤卡片全部带 x-cloak。
**若 assets/lib/fhstate.js 加载失败(404 / JS 被禁 / CDN 拦截 / 路径错误),
所有步骤卡片将永久 display:none** —— 页面只剩步骤指示条,
用户既看不到协议也无法勾选,更无法进入下一步。
这高度符合「协议不能勾选」的历史反馈特征:用户看到的不是"勾选框点不动",
而是"协议步骤没有内容/点了没反应"。
当前状态:install.php 与 assets/lib/fhstate.js 均与 flinthub1.0.0/ 发行版
逐字节一致,说明修复(若曾修复)已包含在发行版内。但**无 <noscript> 兜底、
无"Alpine 未启动"检测**这一脆弱性依然存在。
建议:加一段极轻量的兜底 —— 在向导末尾脚本里检测 window.Alpine,
若不存在则移除所有 x-cloak 并给出提示;或补 <noscript> 提示块。
(属加固项,非缺陷修复。)
问题 2|ℹ️ 服务端协议校验形同虚设(低危)
步骤 4 的表单里硬编码了 <input type="hidden" name="agree_license" value="1">,
而服务端只校验该字段等于 '1'。因此直接 POST 即可绕过协议勾选,
前端勾选框实际只起提示作用。协议为 MIT,风险可忽略,如需要可改为
"步骤 4 提交时由前端把 agreed 一并提交"再服务端校验。
问题 3|ℹ️ 无"安装前置自检"独立入口
环境检测只在向导第 3 步与 POST 时执行。若环境不达标,用户要先点完前两步才知道。
非缺陷,属体验优化空间。
三、第二部分:PHP 8.4 / 8.5 兼容性
3.1 扫描方法
- 取 PHP 官方
migration84.deprecated+migration85.incompatible+migration85.deprecated全量条目,逐条转为可扫描规则; - 对
app/plugins/cli/lang/+ 根目录共 556 个 PHP 文件做正则扫描,
剔除注释行、字符串内反引号、已可空参数等误报;
- 对无法正则判定的项(解构非数组、浮点转 int、printf 精度、字符串自增)
做定向人工复核;
- 补充:首轮 8.5 扫描漏掉了 8.4 的
session_set_save_handler(),
后经用户手机服务器线上报错暴露,遂扩写为 scan_php81_85.py(覆盖 8.0–8.5)重扫全库,
补齐本节 §3.3。
3.2 HIGH(8.5)— 必修(1 处)
H-1 app/Helpers/CoverTemplate.php:155 — ord() 传多字节字符串
PHP 8.5 变更:ord() 传入非单字节字符串已弃用(官方注释:这通常是 bug 的征兆)。
触发条件(必然触发):
// CoverTemplate.php:118
$chars = \preg_split('//u', $title, -1, PREG_SPLIT_NO_EMPTY); // 按「字符」切分
// CoverTemplate.php:126-131
foreach ($chars as $ch) { $u = self::charUnits($ch); ... }
// CoverTemplate.php:155
private static function charUnits(string $ch): float {
$o = \ord($ch); // ← 中文"中"是 3 字节,ord() 在此收到多字节字符串preg_split('//u', ...) 按 UTF-8 字符切分,中文/全角字符是 2–4 字节。
即:任何含中文的标题在生成封面图时都会触发 E_DEPRECATED。
对一个中文论坛而言,这是最常见路径,且日志会被刷屏。
✅ 真机实测(PHP 8.5.10)—— 已确认触发:
实际报错原文:
Deprecated: ord(): Providing a string that is not one byte long is deprecated.
Use ord($str[0]) instead in app/Helpers/CoverTemplate.php on line 155实测告警条数(与非 ASCII 字符数完全成正比):
| 测试标题 | 8.5.10 告警数 | 8.4.25 告警数 |
|---|---|---|
| 「PHP 8.5 兼容性改造实战:从告警刷屏到零告警」 | 17 | 0 |
| 「你好世界」 | 4 | 0 |
| 「FlintHub 1.0 发布:纯 SQLite 分片论坛(性能提升 300%)」 | 10 | 0 |
| 「Hello FlintHub Release Notes」(纯 ASCII) | 0 | 0 |
| 6 个模板 × 中文标题 | 各 9 | 各 0 |
结论:8.5 下必触发、8.4 下不触发,与版本矩阵完全一致;纯 ASCII 标题不触发。
修复方向(语义等价,零行为变更):
private static function charUnits(string $ch): float
{
if (\strlen($ch) > 1) {
return 1.0; // 多字节 = 中文/全角,直接返回 1 单位
}
$o = \ord($ch); // 此时必为单字节,安全
return ($o < 0x80) ? (\preg_match('/[MWmw@#%&]/', $ch) ? 0.9 : 0.5) : 1.0;
}依据:
\strlen($ch) === 1时ord()必为单字节;>= 0x80的单字节分支原样保留,且原代码对多字节字符也是返回
1.0,故行为完全一致。
3.3 PHP 8.4 专项 — 线上实测命中项
本节由真实运行时报错驱动(用户手机服务器,PHP 8.4+),优先级高于静态扫描结论。
H-2 ✅ 已修 app/init.php:41 — session_set_save_handler() 传「独立回调」
PHP 8.4 变更:session_set_save_handler() 若传入「独立回调」(第 1~8 个参数为 callable),
必须改为传入一个实现 SessionHandlerInterface 的对象。
线上原始报错(用户提供):
Deprecated: session_set_save_handler(): Providing individual callbacks instead of
an object implementing SessionHandlerInterface is deprecated
in /storage/emulated/0/Android/data/com.xiaojiangi.wstoolbox/files/WSToolbox/flinthub/app/init.php on line 41原实现:6 个闭包(open / close / read / write / destroy / gc)直接传给
session_set_save_handler()。该写法每次请求都会触发一次 E_DEPRECATED ——
对论坛而言等于每个页面请求都往日志写一行,日志会被迅速刷满。
修复方案(已落地):
| 文件 | 变更 |
|---|---|
app/Core/SessionHandler.php | 新增。实现 \SessionHandlerInterface,6 个方法逐一对应原闭包 |
app/init.php:41 | 6 个闭包 → session_set_save_handler(new \app\Core\SessionHandler()); |
关键设计决策(防行为漂移):
- 刻意不实现
SessionUpdateTimestampHandlerInterface(validateId/updateTimestamp)。
原写法只传了 6 个回调,未提供第 7/8 个;PHP 在 session.use_strict_mode=1 下走的是
「read() 返回空串即判定为新 ID」的默认路径。若补上 validateId,会改变会话固定攻击防护的判定路径,
属于隐性行为变更,故明确不补。
- 失败返回值与原实现逐一对齐:
read→''、write→false、destroy→false、gc→0。 - TTL 保持 1800 秒(
expires = time() + 1800),与原闭包完全一致。 - 沿用
Database::getSessionConnection()(独立 PDO 连接,与业务连接物理隔离),
避免 SplitDB 的 2014 游标冲突。
实测验证(本机 PHP 8.0.2 + 真实 sessions.sqlite):
| 验证项 | 结果 |
|---|---|
实现 SessionHandlerInterface | ✅ |
未实现 SessionUpdateTimestampHandlerInterface | ✅(保持原行为) |
| 6 个接口方法全部实现 | ✅ open, close, read, write, destroy, gc |
| 返回类型与接口兼容 | ✅ 全部一致 |
read(不存在) → '' | ✅ |
write → read 回读一致 | ✅ |
| UPSERT 覆盖写 | ✅ |
destroy → read → '' | ✅ |
gc() 返回 int | ✅ |
php -l(8.0.2 / 8.2.9) | ✅ 无语法错误 |
| HTTP 端到端:主页 63,863 字节、0 条告警 | ✅ |
| 跨请求会话持久化:请求 1 与请求 2 的 CSRF token 完全一致 | ✅ 证明落盘 + 读回均正常 |
| 数据库侧佐证 | ✅ sessions 表记录 expires - now = 1800s |
关于 PHP 7.3.4:
php -l在 7.3.4 下报联合类型string|false语法错误 —— 这是预期的。项目本就要求 PHP 8.0+(
install.php:74有版本门禁),且app/SplitDB/CountingPDO.php:32早已使用
int|false。7.3 不在支持范围内,无需处理。
✅ 真机复核(PHP 8.4.25 / 8.5.10)—— 复现 + 修复双验证
用便携版 PHP 跑同一脚本,分别注册「旧闭包写法」与「新对象写法」:
| PHP 版本 | 旧写法(6 个闭包) | 新写法(对象) | 判定 |
|---|---|---|---|
| 8.0.2(对照) | 0 条(该版本尚未引入此弃用) | 0 条 | 无法区分 |
| 8.4.25 | 1 条 ✗ | 0 条 ✓ | ✅ 复现 + 修复有效 |
| 8.5.10 | 1 条 ✗ | 0 条 ✓ | ✅ 复现 + 修复有效 |
8.4.25 / 8.5.10 上旧写法输出的告警与你手机上那条逐字一致:
Deprecated: session_set_save_handler(): Providing individual callbacks instead of
an object implementing SessionHandlerInterface is deprecated这证明:① 你手机服务器的报错被准确复现;② 修复后该告警在 8.4 与 8.5 上均彻底消失。
M-4 ⚠️ 严重度由 MED 上调为 HIGH 隐式可空参数(2 处)
PHP 8.4 起弃用,8.5 仍弃用 —— 类型前缺 ? 会发 E_DEPRECATED。
| 文件:行 | 现状 | 应改为 |
|---|---|---|
app/SplitDB/ViewCounter.php:30 | public static function inc(int $topicId, PDO $mainDb = null): void | ?PDO $mainDb = null |
app/SplitDB/ViewCounter.php:57 | public static function flush(PDO $mainDb = null): int | ?PDO $mainDb = null |
佐证:两处 docblock 已写明 @param PDO|null $mainDb,即意图本就是可空,
只是语法漏了 ?。修复纯属补全,零行为变更。
✅ 真机实测:为什么它是 HIGH 而不是 MED
静态扫描时按「命中 2 处」记为 MED,但实测发现它每次请求都会触发 2 条:
[15-Sep-2026 11:44:51 Asia/Shanghai] PHP Deprecated: app\SplitDB\ViewCounter::inc(): Implicitly marking
parameter $mainDb as nullable is deprecated, the explicit nullable type must be used instead
in ...\app\SplitDB\ViewCounter.php on line 30
[15-Sep-2026 11:44:51 Asia/Shanghai] PHP Deprecated: app\SplitDB\ViewCounter::flush(): Implicitly marking
parameter $mainDb as nullable is deprecated, the explicit nullable type must be used instead
in ...\app\SplitDB\ViewCounter.php on line 57触发链(关键在 index.php:285):
// index.php:280 —— 每次请求都会注册这个 shutdown 钩子
register_shutdown_function(function () {
\app\Helpers\Settings::flushCounters();
try {
\app\SplitDB\ViewCounter::flush(); // ← index.php:285 无条件调用
} catch (\Throwable $e) { ... }
});ViewCounter::flush() 是无条件调用的,因此类文件 ViewCounter.php 每次请求都会被编译,
两个隐式可空参数就在编译期各发一条告警 → 每请求固定 2 条。
⚠️ 修正一处早先的判断:先前曾推测「未启用 APCu 时只有
inc()一条路径会命中」——这是错的。隐式可空参数是编译期告警,只要
ViewCounter.php被加载就必然触发,与 APCu 是否启用、是否真的浏览主题都无关。实测已证实每请求 2 条。
实测计数:19 条路由的冒烟测试中,8.4.25 与 8.5.10 均在 protected/error.log 中
留下 各 21 次 inc() + 21 次 flush() = 42 行(对应约 21 次请求)。
结论:这与 H-2 属于同一性质的问题(每请求级日志刷屏),
在 8.4 和 8.5 上都会触发。只改 2 个字符即可彻底消除,性价比最高,
建议与 H-2 同批发布。
⚠️ 顺带发现:日志被应用改道
config.php:85 有 ini_set('error_log', __DIR__ . '/protected/error.log');
——应用的告警不会写到 php.ini 指定的日志,而是全部落到
protected/error.log。排查线上问题时请直接看这个文件(手机端路径同理)。
3.4 MED — 建议修(7 处)
M-1 弃用函数:curl_close()(2 处)
PHP 8.5 起 curl_close() 已弃用(CurlHandle 对象自动释放)。
| 文件:行 | 代码 |
|---|---|
app/Controllers/ApiController.php:371 | \curl_close($ch); |
app/Helpers/Plugin.php:201 | curl_close($ch); |
修复:直接删除该行。注意:删除前确认该句柄不再被引用(两个位置都是请求内局部句柄,安全)。
M-2 弃用函数:imagedestroy()(4 处)
PHP 8.5 起已弃用(GdImage 对象自动释放)。
| 文件:行 | 代码 |
|---|---|
app/Controllers/ApiController.php:500 | imagedestroy($img); |
app/Helpers/Upload.php:220 | if ($thumb === false) { \imagedestroy($src); return; } |
app/Helpers/Upload.php:229 | \imagedestroy($src); |
app/Helpers/Upload.php:240 | \imagedestroy($thumb); |
修复:删除调用即可(对象在作用域结束/引用清零时自动释放)。
Upload.php:220 需保留 return;,仅删 \imagedestroy($src);。
M-3 弃用函数:finfo_close()(1 处)
| 文件:行 | 代码 |
|---|---|
app/Helpers/Upload.php:58 | \finfo_close($finfo); |
修复:删除该行。上下文是 $finfo 局部变量,finfo_file() 已调用完毕。
M-5 gc_collect_cycles() 返回值语义变更(1 处,LOW)
| 文件:行 | 代码 | 影响 |
|---|---|---|
cli/migrate_notifications_builtin.php:86 | gc_collect_cycles(); | 无影响 —— 裸调用未使用返回值 |
修复:无需改动,登记备查即可。
3.5 已确认「清白」的项(无需处理)
以下变更点已逐项扫描,本项目未命中:
| 变更点 | 核查结果 |
|---|---|
session_start() options 更严格(8.5) | 全站仅 2 处调用(app/init.php:115、install.php:48),均为无参调用 ✅ |
session_set_save_handler() 独立回调(8.4) | ~~原命中~~ → 已修,见 §3.3 H-2 ✅ |
PDO::FETCH_CLASS 构造函数参数 CUFA 语义 | 全库无 FETCH_CLASS / fetchObject / FETCH_INTO / FETCH_LAZY ✅ |
PDOStatement::setFetchMode() fetch 期间调用 | 全库无此调用 ✅ |
| PDO fetch 常量值变更 | 未使用 FETCH_GROUP/UNIQUE/CLASSTYPE/PROPS_LATE/SERIALIZE ✅ |
SplFileObject::fwrite() length 默认值变更 | 全库无 SplFileObject / ->fwrite() ✅ |
PDO::sqliteCreateFunction/Collation/Aggregate | 全库无调用 ✅ |
class_alias("array"/"callable") | 无 class_alias 调用 ✅ |
disable_classes INI 被移除 | 代码中无引用 ✅ |
setlocale() 整数 0 | 无 setlocale 调用 ✅ |
zend_extension=opcache 告警 | 代码中无该配置(但需检查服务器 php.ini,见 §4.3) |
非规范类型转换 (boolean)/(integer)/(double)/(binary) | 全库无 ✅ |
case X; 分号结尾 | 全库 0 处 ✅ |
| 反引号运算符(shell_exec 别名) | 初筛 11 处全部为误报(注释 / SQL 反引号 / preg 正则内),实际 0 处 ✅ |
__sleep() / __wakeup() / __debugInfo() | 全库无 ✅ |
readdir(null) / rewinddir(null) / closedir(null) | 全库无 ✅ |
$http_response_header | 全库无 ✅ |
ReflectionProperty::setAccessible() | 全库无 ✅ |
SplObjectStorage / ArrayObject / ArrayIterator | 全库无 ✅ |
DATE_RFC7231 | 全库无 ✅ |
report_memleaks / register_argc_argv | 代码中无引用 ✅ |
SimpleXMLElement::xpath() | 全库无 ✅ |
spl_autoload_call 注销加载器 | 全库无 ✅ |
chr() 越界 | 仅有 ord()(见 H-1),无 chr() ✅ |
| 解构非数组值告警 | 全库 14 处解构均来自函数返回的数组(explode / config / 自定义方法) ✅ |
| 字符串自增弃用 | 所有 $x++ 均为整数计数器($i / $count / $hops) ✅ |
| printf 系列精度处理变更 | 全库无 %f / %F 浮点格式符 ✅ |
| 浮点数转 int 告警 | 无静态可判定的可疑转换(需运行时观察) ⚠️ |
$_SESSION 键含管道符 `\ | ` |
| mbstring Unicode 17.0 | 54 个文件使用 mb_* 函数,属数据表更新,正常业务无影响 ✅ |
3.6 风险总览
| 编号 | 严重度 | 版本 | 文件 | 行 | 问题 | 触发频率 | 修复成本 | 状态 |
|---|---|---|---|---|---|---|---|---|
| H-2 | HIGH | 8.4+ | app/init.php + app/Core/SessionHandler.php | 41 | session_set_save_handler() 独立回调 | 每请求 1 条 | 新增 1 类 + 改 1 行 | ✅ 已修 |
| M-4 | HIGH ⬆ | 8.4+ | app/SplitDB/ViewCounter.php | 30 / 57 | 隐式可空参数 | 每请求 2 条 | 加 2 个 ? | ✅ 已修 |
| H-1 | HIGH | 8.5 | app/Helpers/CoverTemplate.php | 155 | ord() 收多字节字符串 | 每次生成封面(中文标题) | 3 行 | ✅ 已修 |
| M-1 | MED | 8.5 | app/Controllers/ApiController.php | 371 | curl_close() 弃用 | 每次远程拉图 | 删 1 行 | ✅ 已修 |
| M-1 | MED | 8.5 | app/Helpers/Plugin.php | 201 | curl_close() 弃用 | 每次插件远程请求 | 删 1 行 | ✅ 已修 |
| M-2 | MED | 8.5 | app/Controllers/ApiController.php | 500 | imagedestroy() 弃用 | 每次图片处理 | 删 1 行 | ✅ 已修 |
| M-2 | MED | 8.5 | app/Helpers/Upload.php | 220 / 229 / 240 | imagedestroy() 弃用 | 每次上传含缩略图 | 删 3 处 | ✅ 已修 |
| M-3 | MED | 8.5 | app/Helpers/Upload.php | 58 | finfo_close() 弃用 | 每次上传 | 删 1 行 | ✅ 已修 |
| M-5 | LOW | 8.5 | cli/migrate_notifications_builtin.php | 86 | gc_collect_cycles 语义 | — | 无需改 | ✅ 实测无告警 |
合计:7 个文件(1 新增 + 6 修改)、11 个代码位置,全部为「删除调用」或「补 1 个字符」级别,无逻辑重构。
全部 ✅ 已修复,已打包为 update/3/(V1.0.3)。
修复后复核:全库 568 个 PHP 文件在 8.4.25 与 8.5.10 下编译期告警归零;
19 条路由冒烟后
protected/error.log为空(修复前为 42 行)。详见 §3.7。
3.7 真机实测记录(PHP 8.4.25 / 8.5.10)
为把结论从「静态分析」升级为「实测」,本轮下载了 PHP 官方便携版
8.4.25 与 8.5.10(Windows NTS x64)并配置
pdo_sqlite/mbstring/
fileinfo/curl/gd/openssl,做了四组验证。
7.1 全库编译期扫描(568 个 PHP 文件)
用 php -l 对 app/ cli/ plugins/ lang/ update/ 待办事宜/ + 根目录共 568 个 PHP 文件
逐一做编译检查(脚本:.workbuddy-ai/tmp/scan_compile_84_85.py):
| PHP 版本 | 有编译期告警的文件 | 告警数 |
|---|---|---|
| 8.4.25 | 1 个(app/SplitDB/ViewCounter.php) | 2 |
| 8.5.10 | 1 个(同上) | 2 |
✅ 这是权威结论:全库除
ViewCounter.php外,再无任何编译期弃用(包括此前静态扫描怀疑的各类隐式可空参数)。比正则扫描可靠得多。
7.2 站点路由冒烟(19 条路由)
用 PHP 内置服务器 + 模拟 nginx try_files 的路由脚本
(.workbuddy-ai/tmp/router.php)跑真实路由:
| 路由 | 8.4.25 | 8.5.10 |
|---|---|---|
/、/index.php、/forum、/login、/register、/forgot-password | 200 | 200 |
/blog、/tags、/thread/1、/blog/1、/tag/1、/forum/category/1 | 200 | 200 |
/search、/message、/notifications、/profile、/post/new、/admin | 302(未登录跳转,正确) | 302 |
/assets/css/modern.css | 200 | 200 |
两个版本结果完全一致,页面字节数一致(首页 63,863 字节)。
⚠️ 但日志并非干净 —— 见 7.3。
7.3 运行期告警实测
| 项 | PHP 8.4.25 | PHP 8.5.10 | 报错原文 |
|---|---|---|---|
H-2 旧写法 session_set_save_handler() | 1 条 ✗ | 1 条 ✗ | Providing individual callbacks instead of an object implementing SessionHandlerInterface is deprecated |
| H-2 新写法(对象) | 0 条 ✓ | 0 条 ✓ | — |
M-4 隐式可空参数(ViewCounter) | 2 条 / 请求 ✗ | 2 条 / 请求 ✗ | Implicitly marking parameter $mainDb as nullable is deprecated... |
H-1 ord() 多字节(中文标题) | 0 ✓ | 17 / 4 / 10 条(随非 ASCII 字符数) ✗ | ord(): Providing a string that is not one byte long is deprecated. Use ord($str[0]) instead |
M-1 curl_close() | 0 ✓ | 1 条 ✗ | Function curl_close() is deprecated since 8.5, as it has no effect since PHP 8.0 |
M-2 imagedestroy() | 0 ✓ | 1 条 ✗ | Function imagedestroy() is deprecated since 8.5, as it has no effect since PHP 8.0 |
M-3 finfo_close() | 0 ✓ | 1 条 ✗ | Function finfo_close() is deprecated since 8.5, as finfo objects are freed automatically |
M-5 gc_collect_cycles() | 0 ✓ | 0 条 ✓ | — |
结论:版本矩阵 100% 得到实测验证 —— 8.4 只中 H-2 与 M-4,其余为 8.5 专属。
7.4 关键运维发现
- 告警不写在
php.ini指定的日志里 ——config.php:85会
ini_set('error_log', __DIR__ . '/protected/error.log')。
排查线上问题请直接看 protected/error.log(手机端同理)。
- 实测 19 条路由后,
protected/error.log中新增 42 行 M-4 告警,
证明「每请求 2 条」的判断成立。
- 日志中另有 15 条
Template not found: home/index_default (/),
均为 9-14 的历史记录,本轮冒烟未复现,且 app/Views/home/index_default.php
文件存在 —— 属已解决的旧问题,与 PHP 版本无关。
四、第三部分:实施结果
4.0 全部完成 ✅
2026-09-15 已按用户指令一次性完成全部 11 处修复,并打包为 update/3/(V1.0.3)。
| 编号 | 项 | 文件 | 变更 | 状态 |
|---|---|---|---|---|
| H-2 | session_set_save_handler() 独立回调 | 新增 app/Core/SessionHandler.php;改 app/init.php:41 | 6 闭包 → 传对象 | ✅ |
| M-4 | 隐式可空参数 | app/SplitDB/ViewCounter.php:30 / 57 | PDO $x = null → ?PDO $x = null | ✅ |
| H-1 | ord() 多字节 | app/Helpers/CoverTemplate.php:155 | 前置 strlen($ch) > 1 判断 | ✅ |
| M-1 | curl_close() ×2 | ApiController.php:371、Plugin.php:201 | 删调用 | ✅ |
| M-2 | imagedestroy() ×4 | ApiController.php:500、Upload.php:220/229/240 | 删调用(220 保留 return;) | ✅ |
| M-3 | finfo_close() ×1 | Upload.php:58 | 删调用 | ✅ |
交付物:update/3/ —— 7 个源码文件 + version.php(V1.0.3)+ 改动说明.md(9 个文件)。
不涉及 CSS/JS(sw.js 无需递增)、不涉及数据库结构、不涉及配置项。
4.1 修复后复核结果
| 复核项 | 结果 |
|---|---|
| 全库编译期扫描(568 个 PHP 文件 × 8.4.25 / 8.5.10) | 告警归零 ✅(修复前:1 文件 / 2 条) |
| 19 条路由冒烟(8.4.25 / 8.5.10) | 全部正常;首页均 63,863 字节,与修复前一致 ✅ |
protected/error.log(冒烟后) | 空 ✅(修复前 42 行) |
| H-1 中文标题告警(8.5.10) | 17 条 → 0 条 ✅ |
| H-1 输出一致性(30 个 SVG) | 逐字节一致,内容哈希 5b377287…b2772 ✅ |
| M-4 反射签名 | 两处 allowsNull=true,类型 ?PDO ✅ |
| 功能回归(缩略图 / finfo / 插件 HTTP / 拉图 / captcha / 封面预览) | 全部正常、0 告警 ✅ |
| 语法检查(8.0.2 / 8.4.25 / 8.5.10) | 7 个文件全通过 ✅ |
4.2 验证方法(可复跑)
| 验证项 | 方法 |
|---|---|
| H-2 | ① .workbuddy-ai/tmp/test_session_handler.php 接口 + 功能双向验证;② HTTP 端到端:主页 63,863 字节 / 0 告警;③ 跨请求会话持久化:两次请求 CSRF token 一致;④ 查 sessions 表确认 expires - now = 1800s;⑤ 8.4.25 / 8.5.10 真机:旧写法 1 条告警 → 新写法 0 条(test_84_85_session.php) |
| M-4 | ① 反射:(new ReflectionMethod('app\SplitDB\ViewCounter','inc'))->getParameters()[1]->allowsNull() 应为 true;② 权威验证:php84/php85 -l app/SplitDB/ViewCounter.php 不再输出任何 Deprecated;③ 冒烟 19 条路由后确认 protected/error.log 无新增 |
| H-1 | 造含中文的标题调用 CoverTemplate::generateCover();在 8.5.10 下确认告警数由 17 → 0;比对生成 SVG 与改前逐字节一致(test_h1_ord.php + gen_svg.php) |
| M-1/2/3 | 触发远程拉图(ApiController)、附件上传含缩略图(Upload)、插件远程请求(Plugin)、/api/captcha、/api/cover-preview;在 8.5.10 下确认无 Deprecated(test_med_85.php + test_med_functional.php) |
| 全阶段 | php -l 语法检查;已具备 8.4.25 / 8.5.10 便携版,可直接做真机冒烟(见 §3.7) |
4.3 后续可选:安装链路加固(阶段 D,未做)
| 项 | 内容 | 涉及文件 | 预估 |
|---|---|---|---|
| 阶段 D | Alpine 未启动兜底 + <noscript> 提示(防「协议勾选框看不见/点不动」历史问题复发) | install.php | 约 15 行 |
本次未做(不在用户指令范围内)。属锦上添花,建议单独评估后再定。
4.4 上线前你需自行确认的环境项(代码之外)
先确认手机服务器到底是哪个版本(决定上面哪些项真的会命中):
| 版本 | 会命中的项 | 建议动作 |
|---|---|---|
| 8.4.x | H-2(已修)+ M-4 | 补发 H-2 + M-4 即可 |
| 8.5.x | H-2(已修)+ M-4 + H-1 + M-1 + M-2 + M-3 | 建议一次性全修 |
查版本的方法:手机端建一个 pv.php,内容 <?php echo PHP_VERSION;,浏览器访问即可。
或在 WSToolbox 的 PHP 设置页看版本号。
看日志的正确位置:不是
php.ini里error_log指向的文件,而是站点目录下的
protected/error.log(config.php:85会改写error_log)。
其余环境项:
php.ini是否含zend_extension=opcache.so/php_opcache.dll
→ PHP 8.5 起 opcache 始终内置,该行会触发告警,应删除。(8.4 无此问题)
php.ini是否含disable_classes→ 8.5 已移除该指令,应删除。php.ini的register_argc_argv→ 建议显式设0(非 CLI 从查询串推导 argc/argv 已弃用)。display_errors/error_reporting→ 建议生产关闭E_DEPRECATED显示,
避免升级后日志/页面被弃用告警污染(但不应掩盖 H-1 / M-4,仍应修)。
- 服务器 PHP 是否已装
pdo_sqlite/mbstring/fileinfo/gd或imagick/curl/openssl
→ 与 install.php 的环境检测项一致。
- ~~是否启用 APCu~~ → 已证实与 M-4 无关(编译期告警,无条件触发),无需确认。
4.5 红线约束(沿用既有约定)
- 本次只动上述 7 个文件;H-2 例外地新增了
app/Core/SessionHandler.php
(新增独立类,未触碰 app/Core/ 既有任何文件);
- 不碰
app/Controllers/Admin/、其它插件; - 所有 SQL 走
prepare()+ 参数绑定; - 输出统一
$this->e()/htmlspecialchars(); - 所有文件 UTF-8 无 BOM + LF;
- 打包放
update/3/,版本文件末位数字 = 目录号;若涉及 CSS/JS 需同步递增sw.js版本。
(H-2 与 M-4 均不涉及任何 CSS/JS,故若只发这两项,sw.js 无需递增。)
4.6 回滚方案
| 变更 | 回滚方式 |
|---|---|
| 任意文件 | 用上一版文件覆盖即可,无 schema 变更、无数据迁移 |
| H-2 | 恢复 app/init.php 旧版(6 个闭包版)即可;app/Core/SessionHandler.php 可留着不影响(无引用即无副作用)。sessions 表结构与数据格式未变,新旧实现可互相读取已存会话 → 回滚不会导致任何在线用户掉线 |
| 阶段 D(未做) | — |
本次全部改动均为「消除告警」性质,不涉及业务逻辑、SQL、模板与静态资源,回滚后行为与升级前完全一致。
五、附录
5.1 审计产物
| 文件 | 说明 |
|---|---|
.workbuddy-ai/tmp/scan_php85.py | PHP 8.5 兼容性扫描器(可复跑,已修正误报) |
.workbuddy-ai/tmp/scan_php81_85.py | PHP 8.0–8.5 扩面扫描器(H-2 即由此发现) |
.workbuddy-ai/tmp/php85_findings.json | 8.5 扫描明细 |
.workbuddy-ai/tmp/php81_85_findings.json | 8.0–8.5 扫描明细 |
.workbuddy-ai/tmp/audit_nullable.py | 隐式可空参数精确审计(跨行签名,已修正误报) |
.workbuddy-ai/tmp/php85_extra.json | 补充项明细 |
.workbuddy-ai/tmp/test_install_core.php | 安装核心链路验证脚本(可复跑) |
.workbuddy-ai/tmp/test_session_handler.php | H-2 验证脚本(接口一致性 + 功能 + 清理,可复跑) |
.workbuddy-ai/tmp/php84/、php85/ | PHP 8.4.25 / 8.5.10 便携版(含自建 php.ini),真机实测环境 |
.workbuddy-ai/tmp/scan_compile_84_85.py | 全库编译期弃用扫描器(调 php -l,最权威的静态手段) |
.workbuddy-ai/tmp/test_84_85_session.php | H-2 新旧写法对比测试(8.4/8.5 复现 + 修复验证) |
.workbuddy-ai/tmp/test_h1_ord.php | H-1 中文标题 ord() 告警测试(8.5 触发 / 8.4 不触发) |
.workbuddy-ai/tmp/test_med_85.php | M-1~M-5 逐项告警测试(版本矩阵验证) |
.workbuddy-ai/tmp/router.php + crawl.sh | 模拟 nginx try_files 的内置服务器路由 + 19 路由冒烟脚本 |
.workbuddy-ai/tmp/gen_svg.php + svg_before/ svg_after/ | H-1 输出一致性验证(30 个 SVG 逐字节比对) |
.workbuddy-ai/tmp/test_med_functional.php | M-1/2/3 功能回归(缩略图 / finfo / 插件 HTTP / 拉图) |
.workbuddy-ai/tmp/diff/update3.diff | update/3 的完整改动 diff(218 行,含全部 7 个文件) |
.workbuddy-ai/backup/20260915_errorlog/ | 清空前备份的 protected/error.log(57 行) |
.workbuddy-ai/tmp/install-core-data/ | 测试生成的临时库(可人工检查后删除) |
.workbuddy-ai/tmp/install-test/ | 极小安装向导测试目录(4 文件,可经 Nginx 访问) |
.workbuddy-ai/tmp/install-e2e/ | ⚠️ 未使用的完整项目副本(待清理,见 §5.2) |
.workbuddy-ai/tmp/home_check.html、ck.txt | H-2 端到端验证的响应快照与 cookie jar(可删) |
.workbuddy-ai/tmp/php84.zip、php85.zip | PHP 便携版安装包(各约 35MB,可删) |
交付物(源码侧):
| 文件 | 变更 |
|---|---|
app/Core/SessionHandler.php | 新增(H-2) |
app/init.php | 修改第 41 行(H-2) |
app/SplitDB/ViewCounter.php | 修改第 30 / 57 行(M-4) |
app/Helpers/CoverTemplate.php | 修改第 155 行(H-1) |
app/Controllers/ApiController.php | 删 2 处(M-1 / M-2) |
app/Helpers/Plugin.php | 删 1 处(M-1) |
app/Helpers/Upload.php | 删 4 处(M-2 / M-3) |
发布包:update/3/(V1.0.3)—— 上述 7 个文件 + version.php + 改动说明.md;
另有 update/增量补丁1.0.3.zip(同内容压缩包,便于上传)。
5.2 遗留事项
- 待清理目录:
.workbuddy-ai/tmp/install-e2e/(一次未采用的整项目复制尝试留下)、
.workbuddy-ai/tmp/install-core-data/(测试库)、.workbuddy-ai/tmp/php84、php85
(便携版 + zip,约 140MB)。等你确认后我再删(保留着方便后续复测)。
- 未完成的实测:协议勾选的真人点击验证(Chromium 下载被沙箱拦截)。
可自行访问 http://127.0.0.1/.workbuddy-ai/tmp/install-test/install.php 确认。
- ~~8.4 / 8.5 运行时实测缺失~~ → 已解决:本轮下载了 8.4.25 / 8.5.10 便携版,
全部结论已实测复核(见 §3.7)。
- ~~M-4 尚未修~~ → 已修,见 §4.0。
- ~~H-2 / M-4 尚未打包~~ → 已打包为
update/3/(V1.0.3),含全部 7 个源码文件。 - 是否要同步
flinthub1.0.0/发布目录:该目录是发布快照,
本次修复未同步进去(保持快照纯净)。若要做下一个发行版,需另行同步。
protected/error.log已清空:原 57 行(含 15 条 9-14 的Template not found历史记录)
已备份到 .workbuddy-ai/backup/20260915_errorlog/error.log.before-truncate,当前为空。
app/Helpers/Plugin.php使用 CRLF 换行 —— 既有状态,非本次引入
(flinthub1.0.0 与 update/1 中该文件同样为 CRLF)。本次刻意保持原样以免产生整文件 diff。
全库共 15 个文件含 CRLF,若要统一为 LF 建议单独做一次格式化提交。
- 阶段 D(安装链路加固)未做 —— 不在本次指令范围内,见 §4.3。