开发错误记录
00 - 开发错误记录
每次新开发任务前先读本文档,避免重复踩坑;按需去README.md索引过往开发文档。
每次开发收尾阶段,都应主动维护本文档及开发文档,用于项目的可持续性。
E01 · Widget 调用必须在 header.php 之后
错误现象:页面导航栏缺少部分菜单项(如"我的动态""网站维护"消失,只剩"首页"和最后一个页面)。
根因:Widget\Contents\Page\Rows::alloc() 是共享实例,迭代器一旦消耗不会自动重置。在 header.php 之前调用并遍历该 widget,会导致 header.php 拿到已消耗的实例,无法正确构建导航。
规则:模板文件顶部只做文件系统操作(glob、file_get_contents 等),所有 Widget::alloc() 调用必须放在 $this->need('header.php') 之后。
E03 · <nav> 标签会被 pico.css 附加导航样式
错误现象:面包屑导航渲染后看起来像一个完整的导航栏(字体、间距与顶部 site-nav 相同)。
根因:pico.css 对 <nav> 标签有全局样式规则,凡是 <nav> 都会被当作导航组件处理。
规则:非导航功能的元素(面包屑、提示文字等)使用 <p> 或 <div>,不要用 <nav>。
E04 · 服务端环境问题不要在代码层面绕行
错误现象:时区显示 UTC 而非 CST,在 PHP 代码中用 DateTimeZone('Asia/Shanghai') 修复,方向错误,后被回滚。
根因:问题根因是服务器 php.ini 未配置 date.timezone,属于环境问题,不应在业务代码中用 DateTimeZone 绕行。
规则:遇到时区/编码/扩展缺失等问题,先确认是环境配置问题还是代码问题,环境问题直接改配置文件(如 php.ini),不要在代码里打补丁。
E05 · Typecho 模板名来自 docblock 第一行,非 Template Name 注释
错误现象:误以为 Typecho 读取 Template Name: xxx 作为后台下拉显示的模板名。
根因:Plugin::parseInfo() 只解析 docblock 中第一段非 @ 标签文字作为 description(即模板名),Template Name: 行位于 @package 之后,实际上被忽略。
规则:自定义模板的显示名称 = docblock 第一行文字(@package custom 之前的描述行)。Template Name: 注释无实际作用,可省略或仅作说明用途保留。
E06 · pico.css <ul> 默认样式导致自定义列表出现 bullet point
错误现象:自定义下拉菜单(<ul> + <li>)每项前面出现黑色方块或圆点,即使已设 list-style: none。
根因:pico.css 对 <ul> 默认施加 padding-left,且部分情况下 list-style 的继承不够彻底。只在 <ul> 上设 list-style: none 不足以完全清除标记,还需同时清零 padding: 0,并在 <li> 上也显式设 list-style: none。
规则:自定义 <ul> 组件必须同时设置:
ul.custom { list-style: none; padding: 0; margin: 0; }
ul.custom li { list-style: none; padding: 0; margin: 0; }E07 · 下拉浮层不要用 CSS :hover 或 <details>
错误现象:
:hover方案:鼠标从触发按钮向下移动时面板闪烁消失(经过 1–2px 缝隙即触发 hover 解除)<details>方案:点击展开后导航栏整体高度暴增——浏览器计算<details>高度时包含了position: absolute子内容的自然高度,不遵循"绝对定位不贡献父高"的 CSS 规则
规则:浮层/下拉菜单一律用 click 切换 + 点击外部关闭 方案:
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
});
document.addEventListener('click', () => dropdown.classList.remove('open'));子菜单默认 display: none,JS 切换 .open class 展开,配合 position: absolute 浮层,完全脱离文档流。
E08 · theme.css 仅在 customize 配色方案下加载
错误现象:在 theme.css 中添加的全局样式规则(如 body > main { padding-top } 间距调整)在深色模式或其他配色方案下完全不生效。
根因:header.php 中 theme.css 的加载受条件保护:
<?php if ($this->options->colorSchema == 'customize'): ?>
<link rel="stylesheet" href="...theme.css">
<?php endif; ?>非 customize 方案下该文件根本不请求,所有规则对用户不可见。
规则:全局通用的样式覆盖(body 级别、layout 调整、spacing 修改等)必须写在 header.php 内联 <style> 块中,该块对所有配色方案始终加载。theme.css 只放与 customize 主题色直接相关的变量覆盖(--pico-primary 等)。
E09 · pico.css 对 <a> 颜色处理:--pico-color 在 <a> 作用域内被重写为主色
错误现象:
- 浮层下拉内
<a>设color: inherit→ 继承到导航栏的--pico-primary-inverse(白色),深/亮色模式均不可见 - 改为
color: var(--pico-color) !important后文字变蓝(主色),仍非预期的正文灰
根因:pico 全局规则 :where(a:not([role=button])) { --pico-color: var(--pico-primary); } 将 --pico-color 在每个 <a> 作用域内重写为主色。color: var(--pico-color) 写在 <a> 上 = 蓝色;color: inherit 则顺着 DOM 继承到导航栏白色。
规则:在 <a> 的父容器(<ul>/<div>)上设 color: var(--pico-color)(此时解析为正确文本色),子 <a> 用 color: inherit !important 继承,绕过 pico 的变量重写:
.nav-sub-menu { color: var(--pico-color); }
.nav-sub-menu li a { color: inherit !important; }E12 · iOS Safari 点击输入框自动缩放页面
错误现象:在 iOS Safari 中点击 <input>、<select> 或 <textarea> 后,页面自动放大,导致布局溢出或内容显示不全。
根因:iOS Safari 内置行为:当表单元素的 font-size 小于 16px 时,浏览器认为文字过小,点击聚焦时自动缩放视口以提升可读性。只要任意表单元素的计算字号(含继承)低于 16px 即会触发,与 viewport meta 设置无关(除非使用 maximum-scale=1,但该方式会完全禁止用户手动缩放,损害可访问性,不推荐)。
规则:在 theme.css 中已添加全局媒体查询,移动端强制表单元素字号 ≥ 16px:
@media (max-width: 768px) {
input, select, textarea {
font-size: 1rem !important;
}
}此规则已写入 theme.css,所有页面自动受益。新增页面无需单独处理。
E13 · dirname(__DIR__, N) 回溯层级算错导致共享模块路径偏移
错误现象:封面缓存重构为共享模块 usr/themes/classic-22/inc/cover-cache.php 后,轻小说查询页所有封面变为 📚 占位符(novel-cover.php 返回 500);EPUB 转换器每次转换都重新下载封面,缓存始终未命中(静默性能退化)。
根因:在 cover-cache.php 内用 dirname(__DIR__, 3) 计算项目根目录,少回了一级:
__DIR__ = {ROOT}/usr/themes/classic-22/inc
dirname(…, 3) = {ROOT}/usr ❌
dirname(…, 4) = {ROOT} ✅规则:被 require 的共享模块不要自己用 __DIR__ 推算项目根——更稳的做法是让调用方(本身就在根目录的文件)显式传入路径常量;非要用时必须逐级数清楚并在注释中写明推导(inc → classic-22 → themes → usr → {ROOT},共 4 级)。
E14 · 删除 inline JS 函数时误删相邻函数的闭合花括号
错误现象:轻小说查询页加载后,点击「查询」按钮完全无反应(Network 无任何请求发出),页面其他交互也全部失灵。后端 API 经 curl 验证完全正常。
根因:移除 toggleIntro() 函数时,edit_file 的 old_string 范围不精确,误将相邻 loadRating() 函数的闭合 } 一并吞掉,导致整个 <script> 块 parse error,所有顶层函数和事件绑定全部失效。PHP php -l 无法发现此问题,因为错误在 inline JS 内。
规则:
- 删除 JS 函数时,
old_string必须从function xxx(一直精确包到结尾},不要切半个函数边界。 - 改完 inline JS 后提取
<script>块做一次语法校验(node --check),或用浏览器 DevTools Console 实测——这是唯一零误报的检查手段。
E15 · 多接口聚合缓存的"部分成功即写入"陷阱
错误现象:个人动态页 Steam 模块某天上午有内容,当天稍后变为完全空白(「无法获取 Steam 状态,请稍后刷新重试」)。刷新无效,且会持续 6 小时(短缓存有效期);短缓存过期后仍然空白(fallback 文件也已被污染)。
根因:getSteamData() 聚合三个子接口的数据后,用 OR 判断是否有数据:
$player = fetchSteamPlayerSummary(); // 渲染硬依赖,为 null 则直接 return 错误提示
$games = fetchSteamRecentGames();
$badges = fetchSteamBadges();
$hasData = $player !== null || !empty($games); // ❌ OR 聚合
if ($hasData) {
_activity_write_cache($cacheFile, $data); // 污染短缓存
_activity_write_cache($fallbackFile, $data); // 连兜底也污染
}当 fetchSteamPlayerSummary() 临时失败而 GetRecentlyPlayedGames 成功时,$hasData = true 但 player = null,写入的坏数据会同时污染短缓存和 fallback。
规则:
- 写缓存的判据必须是"渲染层硬依赖字段":
$hasData = $player !== null才写,可选字段缺失可降级但不阻止写入。 - 短缓存和 fallback 用不同写入条件:短缓存接受主字段成功的半成品(避免重复请求);fallback 要求所有非可选字段均成功。Steam 示例:
$hasData = $player !== null(写短缓存)、$hasGoodFallback = $player !== null && $badges !== null(写 fallback)。 - 修完代码后必须手动删除已被污染的缓存和 fallback 文件,否则修复不会立即生效。
E16 · 根目录自定义 API 端点调 Typecho 登录态,必须先 \Widget\Init::alloc()
错误现象:已登录的管理员访问自建 API(如 /novel-rating-override-api.php)时,\Widget\User::alloc()->hasLogin() 恒为 false,接口返回 401。
根因:Typecho 的登录态以 Cookie 形式存在,Cookie 名带前缀 md5(siteUrl)(见 var/Typecho/Cookie.php::setPrefix)。这个前缀的设置由 \Widget\Init::alloc() 在初始化阶段完成(读 Widget_Options 的 siteUrl 后调 Cookie::setPrefix)。自定义 API 若只 require config.inc.php,只加载了常量 + autoload,没执行 Init,Cookie::$prefix 是空的 → Cookie::get('__typecho_uid') 找不到浏览器里那个带前缀的 Cookie → hasLogin() 直接返回 false。
规则:任何需要读 Typecho 登录态、配置、插件状态的根目录 PHP 端点,require config.inc.php 之后必须紧跟 \Widget\Init::alloc();只读 SQLite 或代理外部 API 的端点无需初始化 Init。
require_once __DIR__ . '/config.inc.php';
\Widget\Init::alloc(); // ← 关键
$user = \Widget\User::alloc();
if (!$user->hasLogin()) { /* 401 */ }E17 · pico.css 对 input/select/textarea 的 margin-bottom 不会应用于 div 自定义控件
错误现象:搜索表单在手机端,"连载状态"选择器与"标签"选择器之间的间距,明显小于其他表单字段之间的间距(目测约 0.4rem vs 1.4rem)。
根因:pico.css 有全局规则:
input:not([type=checkbox],[type=radio]), select, textarea {
margin-bottom: var(--pico-spacing); /* 1rem */
}这条规则只覆盖原生表单元素。.status-toggle(连载状态切换器)是 <div>,不在选择器范围内,margin-bottom = 0。结果:
- 其他字段(书名/作者
<input>、文库<select>):1rem margin-bottom + 0.4rem flex/grid gap = 1.4rem 视觉间距 - 连载状态(
.status-toggle <div>):0 + 0.4rem gap = 0.4rem 视觉间距,差了整整 1rem
注意:同一文件早已有同类补丁(margin-top),因为 label > :where(input,select,textarea) { margin-top: 0.25rem } 也不覆盖 div,已手动补齐。但 margin-bottom 那条规则被遗漏。
规则:用 <div> 仿造表单控件时(toggle、自定义 select 等),检查 pico 对原生 input/select/textarea 施加的所有 margin/padding 规则,逐一手动补到 div 上:
.custom-control {
margin-top: calc(var(--pico-spacing) * 0.25); /* 对应 label > :where(input,select) */
margin-bottom: var(--pico-spacing); /* 对应 input:not(...),select,textarea */
}E18 · 根目录自定义端点使用共享模块前仍需显式加载配置
错误现象:轻小说详情页请求 /novel-cover.php?id=40&size=l,线上 novel-rating-api.php 明明返回 bangumi_id=2265,但封面接口仍返回 200×296 的 wenku8 小图,响应头为 max-age=300(说明走了大图失败后的短缓存降级)。
根因:novel-cover.php 只 require cover-cache.php,没有先加载 config.inc.php。结果 bangumi-client.php 看不到 BANGUMI_API_BASE_URLS / BANGUMI_IMAGE_HOST_MAP / BANGUMI_WEB_BASE_URL,仍使用默认 api.bgm.tv 和 lain.bgm.tv。这些域名在当前环境直连超时,Bangumi 大图获取失败后透明降级为小图。
规则:根目录自定义端点只要依赖站点配置常量,就必须在加载共享模块前显式 require config.inc.php。不需要登录态时无需 \Widget\Init::alloc(),但配置文件仍要加载。
if (file_exists(__DIR__ . '/config.inc.php')) {
require_once __DIR__ . '/config.inc.php';
}
require_once __DIR__ . '/usr/themes/classic-22/inc/cover-cache.php';补充:Bangumi 图片域名对 PHP stream 可能超时,封面大图下载优先使用 PHP cURL,并基于图片 path 尝试多个镜像候选,失败再回退 stream,避免单个 CDN 抖动导致误降级小图。
E19 · images.large 不等于真实大图,*_l.jpg 必须校验像素尺寸
错误现象:轻小说详情页 bookid=1165 的线上 novel-rating-api.php 返回 bangumi_id=27298,但 /novel-cover.php?id=1165&size=l 返回的仍是 200×280 小图,并且响应头是 max-age=31536000, immutable。
根因:封面代理只按 {aid}_l.jpg 文件存在和字节数判断缓存有效;但历史缓存里已经有被写成 {aid}_l.jpg 的小图。进一步验证发现 Bangumi 条目 27298 的 images.large 源图本身也是 200×280,所以清掉文件后如果不校验尺寸,还会再次把“小图 large”缓存成长期大图。
规则:
- 大图缓存命中不能只看文件存在,必须用
getimagesize()校验像素尺寸;当前默认阈值为300×420。 - 下载到 Bangumi
images.large后也要用getimagesizefromstring()校验;尺寸不达标时不要写{aid}_l.jpg。 - 对“Bangumi large 实际过小”的条目写
{aid}_l.miss.json负缓存(默认 7 天),避免每次详情页都重复请求 Bangumi。 - 人工校准
bangumi_id或清零时必须同时清理{aid}_l.jpg和{aid}_l.miss.json,让新 ID 有机会重新抓图。 服务器上历史假大图用一次性命令清理:
php -r '$dir=__DIR__."/usr/covers/"; foreach (glob($dir."*_l.jpg") ?: [] as $f) { $i=@getimagesize($f); if (!$i || $i[0] < 300 || $i[1] < 420) { echo "delete {$f} ".($i ? "{$i[0]}x{$i[1]}" : "invalid").PHP_EOL; @unlink($f); } }'
E20 · file_get_contents 的 $http_response_header 是局部魔术变量,多 base fallback 易跨轮污染
错误现象:bangumiRequest() 多 API base 顺序 fallback 时,某个 base 网络彻底失败(file_get_contents 返回 false)后,下一轮可能读到上一轮残留的响应头,导致状态码判断错乱。
根因:$http_response_header 是 PHP 在 file_get_contents() 成功时写入当前函数作用域的魔术变量;请求返回 false 时不更新,旧值会保留到下一轮循环。
规则:
- 循环内每轮显式重置
$httpCode与响应头局部变量。 - 仅在本轮
$raw !== false后读取响应头,并立即用局部变量捕获再解析,杜绝跨轮复用。 - PHP 8.4+ 优先用
http_get_last_response_headers()(8.5 起对魔术变量有 deprecation 告警),旧版本回退$http_response_header。
E21 · 大图缓存写盘前必须校验字节,否则坏内容被仅验尺寸的读路径反复命中
错误现象:Bangumi 图片镜像偶发返回 HTML 错误页/截断内容时,可能被写入 {aid}_l.jpg,之后读缓存只验尺寸不验内容,导致坏图长期命中。
根因:fetchAndCacheCoverLarge() 写盘前未对下载字节做魔数+尺寸校验;读路径 isValidLargeCoverFile() 仅用 getimagesize() 验尺寸。
规则:写 {aid}_l.jpg 前必须过 isValidLargeCoverBytes()(JPEG 魔数 FF D8 FF + 像素尺寸双校验),不通过直接返回 null,绝不落盘。
E22 · 外部 URL 下载即使 host 白名单也要校验解析 IP(SSRF 纵深防御)
错误现象:downloadWenku8Txt() 仅校验 scheme=https + host=固定 wenku8 域名,未校验解析到的 IP,理论上存在 DNS rebinding 指向内网的风险。
根因:host 白名单无法防御「域名解析到私有 IP」的攻击(DNS rebinding / 被污染的解析结果)。
规则:发起外部下载前,用 hostResolvesToPublicOnly() 解析 host 的所有 A/AAAA 记录,任一落在私有/回环/链路本地/保留段即拒绝。纯函数 isPublicIpAddress() 用 FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE 判断,放在 inc/net-helpers.php。
E23 · Steam 短缓存判据要兼顾「不锁死」与「不打爆接口」
错误现象:原 getSteamData() 短缓存判据仅 player !== null,badges 偶发失败时 badges=null 被写入短缓存,导致接下来整个 6 小时即使 badges 恢复仍显示 0 徽章。
根因:短缓存写入判据过松(只看 player),把半成品 badges 写进了短缓存。
规则(见 inc/steam-cache-decision.php 的 steamCacheWriteDecision()):
player失败 → 不写任何缓存,读 fallback(渲染端硬依赖 player)。player+badges均成功 → 写完整短缓存 + 写 fallback(fallback 永远是完整数据)。player成功但badges失败 → 仍写短缓存但剔除 badges(留待下次刷新重试),但不写 fallback。- 不能简单「player+badges 都成功才写短缓存」:那样 badges 持续故障时会每次请求都打三个 Steam 接口,更易触发限流。
games=[]可接受(用户近 2 周没玩),不纳入判据。
E24 · 已知标题命中率的分母口径要统一为「去重后唯一标题数」
错误现象:epub-builder.php 的 splitByKnownStructure() 命中率计算分母语义不清,重复候选可能导致阈值判断偏松/偏紧。
根因:命中统计 $hitKeys 与候选集 $candidates 口径不一致;候选可能含重复标题。
规则:命中率 = 命中的唯一 key 数 / 去重后唯一标题数($keyMap/$titleMap 已按 key 去重,与命中统计同口径)。阈值常量统一为 EpubBuilder::KNOWN_TITLE_MIN_HIT_RATE = 0.6;紧凑化与命中率抽为公开纯函数 compactTitleKey() / titleHitRate()(后者分母为 0 返回 0.0 防除零)。
E25 · 删除模块时先检查共享 helper,接口异常必须保持 JSON 协议
错误现象:移除 GitHub 动态后,Steam 动态显示“网络错误,请刷新重试”。线上activity-api.php?module=steam 实际返回 HTTP 200,但正文是Call to undefined function timeAgo() 的 HTML 错误页。
根因:
timeAgo()原本位于 GitHub 代码段中,但 Steam 离线状态也用它格式化lastlogoff;删除 GitHub 时只按代码位置判断归属,遗漏跨模块引用。- 前端直接调用
response.json(),PHP Fatal Error 产生的 HTML 解析失败后进入fetch().catch(),把服务端执行异常误报为网络断开。 - Steam fallback 只兜底上游数据请求,无法兜底 PHP 渲染阶段的代码错误。
规则:
- 删除函数前先用
rg 'functionName\('做全仓引用扫描;共享 helper 应放到独立文件并单测。 - 根目录 JSON API 用
try/catch (Throwable)包住配置加载、数据获取和渲染,异常时清空输出缓冲并返回 HTTP 500 JSON。 - 前端先读取响应文本,再尝试 JSON 解析;HTTP/协议异常与真正的 fetch 网络失败使用不同提示。
- fallback 的边界要写清楚:数据缓存只能提供数据,不能替代渲染异常处理。
E26 · PHP 8.5 会在编译期弃用响应头魔术变量直接引用
错误现象:仅 require bangumi-client.php,尚未调用 Bangumi 网络函数,PHP 8.5 就输出 The predefined locally scoped ... variable is deprecated。动态 API 开启 display_errors 时,警告会混入 JSON 的 HTML 字段。
根因:源码中即使把旧响应头魔术变量放在 function_exists('http_get_last_response_headers') 的 else 分支,PHP 8.5 仍会在编译函数时检查到直接变量引用并发出弃用提示,运行时分支无法阻止。
规则:
- PHP 8.4+ 优先调用
http_get_last_response_headers()。 - 兼容旧 PHP 时通过
get_defined_vars()['http_response_header']读取本地魔术值,不在源码中直接引用该变量。 - 根目录 JSON 端点验证时至少运行一次
display_errors=1,确认警告不会进入响应正文。
单元测试体系
本项目已引入 PHPUnit(composer.json dev 依赖,phpunit.xml,测试在 tests/)。vendor/ 与 composer.lock 已在 .gitignore,服务器无需部署测试依赖。
- 安装:
composer install - 运行:
vendor/bin/phpunit - 只测纯函数(URL 改写、参数解析、IP 校验、命中率、缓存决策、封面字节校验等),不依赖 Typecho 运行时/网络/文件系统。涉及网络(
bangumiRequest)、exec(opencode)、登录态的逻辑不做单测,靠php -l和人工评审。 - 现有测试:
ActivityPageContractTest、ActivityTimeAgoTest、BangumiClientTest、CoverValidationTest、NovelApiParamsTest、Wenku8SsrfTest、EpubChapterMatchTest、SteamCacheDecisionTest。