开发错误记录

文档编号 00

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 内。

规则:

  1. 删除 JS 函数时,old_string 必须从 function xxx( 一直精确包到结尾 },不要切半个函数边界。
  2. 改完 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。

规则:

  1. 写缓存的判据必须是"渲染层硬依赖字段":$hasData = $player !== null 才写,可选字段缺失可降级但不阻止写入。
  2. 短缓存和 fallback 用不同写入条件:短缓存接受主字段成功的半成品(避免重复请求);fallback 要求所有非可选字段均成功。Steam 示例:$hasData = $player !== null(写短缓存)、$hasGoodFallback = $player !== null && $badges !== null(写 fallback)。
  3. 修完代码后必须手动删除已被污染的缓存和 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”缓存成长期大图。

规则:

  1. 大图缓存命中不能只看文件存在,必须用 getimagesize() 校验像素尺寸;当前默认阈值为 300×420。
  2. 下载到 Bangumi images.large 后也要用 getimagesizefromstring() 校验;尺寸不达标时不要写 {aid}_l.jpg。
  3. 对“Bangumi large 实际过小”的条目写 {aid}_l.miss.json 负缓存(默认 7 天),避免每次详情页都重复请求 Bangumi。
  4. 人工校准 bangumi_id 或清零时必须同时清理 {aid}_l.jpg 和 {aid}_l.miss.json,让新 ID 有机会重新抓图。
  5. 服务器上历史假大图用一次性命令清理:

    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 时不更新,旧值会保留到下一轮循环。

规则:

  1. 循环内每轮显式重置 $httpCode 与响应头局部变量。
  2. 仅在本轮 $raw !== false 后读取响应头,并立即用局部变量捕获再解析,杜绝跨轮复用。
  3. 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()):

  1. player 失败 → 不写任何缓存,读 fallback(渲染端硬依赖 player)。
  2. player + badges 均成功 → 写完整短缓存 + 写 fallback(fallback 永远是完整数据)。
  3. player 成功但 badges 失败 → 仍写短缓存但剔除 badges(留待下次刷新重试),但不写 fallback。
  4. 不能简单「player+badges 都成功才写短缓存」:那样 badges 持续故障时会每次请求都打三个 Steam 接口,更易触发限流。
  5. 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 错误页。

根因:

  1. timeAgo() 原本位于 GitHub 代码段中,但 Steam 离线状态也用它格式化 lastlogoff;删除 GitHub 时只按代码位置判断归属,遗漏跨模块引用。
  2. 前端直接调用 response.json(),PHP Fatal Error 产生的 HTML 解析失败后进入
    fetch().catch(),把服务端执行异常误报为网络断开。
  3. Steam fallback 只兜底上游数据请求,无法兜底 PHP 渲染阶段的代码错误。

规则:

  1. 删除函数前先用 rg 'functionName\(' 做全仓引用扫描;共享 helper 应放到独立文件并单测。
  2. 根目录 JSON API 用 try/catch (Throwable) 包住配置加载、数据获取和渲染,异常时清空输出缓冲并返回 HTTP 500 JSON。
  3. 前端先读取响应文本,再尝试 JSON 解析;HTTP/协议异常与真正的 fetch 网络失败使用不同提示。
  4. 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 仍会在编译函数时检查到直接变量引用并发出弃用提示,运行时分支无法阻止。

规则:

  1. PHP 8.4+ 优先调用 http_get_last_response_headers()。
  2. 兼容旧 PHP 时通过 get_defined_vars()['http_response_header'] 读取本地魔术值,不在源码中直接引用该变量。
  3. 根目录 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。