摘要:给站点加了一条「文章过期提示」:发布超过 N 天的文章,正文顶部出现一条「部分内容可能已过时」的提示条(可关闭)。功能不到 100 行,但有四个边界决定了它是「好用」还是「添乱」——后台填 1 天、代码却按 7 天算;颜色值原样拼进 style 属性;提示条跑进了 RSS 和编辑预览;以及”刚好满 N 天”到底算不算过期。本文逐一还原,并附一份零依赖交付代码与可直接照抄的断言表。
适合阅读:做过「配置驱动的前台输出」的开发者,尤其是那些会把用户填的值拼进 HTML 属性的场景。
一、需求很简单,难点全在边界
技术站点的老文章是「沉默的负债」:两年前写的配置步骤、API 用法、价格,早就不适用了,但搜索引擎还在往那儿导流量。加一条「本文已发布 N 天」的提示,成本几乎为零,却能明显减少「照着旧文章操作踩坑」的读者。
实现只需要一个正文过滤器:
// 优先级 5:必须早于那些「重排正文」的模块
add_filter('the_content', 'my_expire_notice', 5);
function my_expire_notice($content) {
if (!is_singular('post') || is_feed() || is_preview()) { return $content; }
$post = get_post();
$days = 180;
$passed = (int) floor((current_time('timestamp') - strtotime($post->post_date)) / DAY_IN_SECONDS);
if ($passed < $days) { return $content; }
return $notice . $content; // 提示条拼在正文最前面
}
看起来没什么好写的。但它在下面四个地方,每一个都能让结果和你预期的不同。
二、边界①:「我填了 1 天,它却按 7 天算」
这是这个功能上线后遇到的第一个真实问题。现象是:所有文章(包括当天刚发的)都出现了过期提示条。
排查发现配置里 article_expire_days = 1。但后台字段明明写了范围 7–3650,怎么会存进一个 1?——存量数据:这个键在这套配置体系之前就存在过,旧值一直是 1,改造后没有清理。
两层保护,缺一不可
| 层次 | 做法 | 作用 |
|---|---|---|
| 后台字段 | min = 7 | 阻止**新**输入越界 |
| 代码读取 | 读取时夹紧到同一区间 | 兜住**存量脏数据**与程序化写入 |
关键认知:表单约束只对新输入生效。数据库里的旧值、别的代码 update_option() 写进去的值、从备份恢复的值——统统绕过表单。所以「读的时候再夹一次」不是冗余,是必需的。
// 读取时夹紧:下限与后台字段的 min 保持一致
$days = max(7, min(3650, (int) $raw));
// 🧨 反例:早期版本写的是 max(1, ..) —— 与后台的 min=7 冲突,
// 脏数据 1 被「合法化」成 1 天 → 所有文章立刻过期
$days = max(1, (int) $raw); // ✗
教训:「后台限制」和「代码约束」必须是同一个数字,并且都得有。两者写不一致时,代码那一层就成了脏数据的放大器——它会把一个非法值「夹」成一个仍然不合理、但看起来合法的值。
三、边界②:颜色值会被拼进 HTML 属性
提示条支持自定义背景色和文字色。很自然的写法是:
$notice = '<div class="notice" style="background:' . $bg . ';color:' . $color . ';">';
问题在于:$bg 是用户填的。只要它能闭合引号,就能逃逸出 style 属性,往页面里塞任意标签或事件处理器。
修法:白名单 + 静默回落
/** 只放行 #RGB / #RRGGBB / #RRGGBBAA,其余回落默认 */
function zdw_ex_color($value, $default) {
$value = trim((string) $value);
return preg_match('/^#[0-9a-fA-F]{3,8}$/', $value) ? $value : $default;
}
// 使用处:
$bg = zdw_ex_color($raw_bg, '#fff3cd');
$notice = '<div style="background:' . esc_attr($bg) . ';">';
注意这不是「校验失败就报错」,而是静默回落到默认色——用户填错了颜色不该看到一个报错页面,他应该看到默认色。
断言要验「不透出」,不只是验「回落」
验证这类白名单时,只断言「出现了默认色」是不够的。因为一个「回落了但同时也把原始串输出到了别处」的实现,同样能通过那条断言。完整的断言是两条:
输入: expression(alert(1))
断言 A(回落) :页面里应出现默认色 #fff3cd
断言 B(不透出):页面里【不应出现】字符串 expression(alert(1)) ← 这条最关键
四、边界③:提示条跑进了 RSS 和编辑预览
the_content 过滤器不只是「文章页渲染正文」时才触发——RSS/Atom feed 输出、后台的编辑器预览、某些 REST 响应都会经过它。如果只判断「是不是文章」,提示条就会出现在这些地方。
后果很好笑但很真实:订阅者的阅读器里,每篇文章开头都多一条带「关闭」按钮的提示条——而那个按钮在阅读器里点了没反应。
if (!is_singular('post') || is_feed() || is_preview()) {
return $content; // 只进文章正文
}
🧨 一个更隐蔽的陷阱:in_the_loop()
按「最佳实践」,你可能会想再加一个主循环判断:
if (!in_the_loop()) { return $content; } // ✗ 会把正常文章也挡掉
结果是提示条永远不出现,且不报任何错。原因很反直觉:不少主题(含本项目的父主题)渲染文章正文时,并不在主循环里——the_content 的触发点不在 the_loop() 之内,in_the_loop() 恒为 false。这个判断看起来更「安全」,实际是把整个功能关掉了。
教训:
in_the_loop()的语义是「当前是否处于主循环内」,它不是「当前是不是文章正文」。加判断前先确认它的真实语义,尤其是当主题用自定义模板(而非标准循环)渲染内容时。
五、边界④:「刚好满 N 天」算不算过期?
当前判定是:
$passed = (int) floor((now - post_date) / 86400);
if ($passed < $days) { return $content; } // 不足 N 天,不提示
把它翻译成人话:第 N 天当天就提示,第 N-1 天不提示。所以 N=7 时,一篇「6 天前」发的文章不提示,「7 天前」的提示。
这个选择本身没有对错,但它必须被写进断言,而不是靠感觉:
| 发布时间 | $passed | N=7 时是否提示 | 用途 |
|---|---|---|---|
| 6 天前 | 6 | 否 | 下边界(差一天) |
| 7 天前 | 7 | 是 | 临界点 |
| 8 天前 | 8 | 是 | 上边界 |
有意思的是:只测「8 天前应该提示」,这个功能就算有 off-by-one 也测不出来(因为 6 天、7 天、8 天都满足 >= 7 里的一部分)。必须两端都测,才能把边界夹住。
教训:凡是「超过 N」「小于 M」「不早于 X」这类措辞,都要在写代码时立刻翻译成「哪个值算、哪个值不算」,并把它写成两条断言。含混的语义留着不管,迟早会在某个临界点上被人发现——而那通常是在生产环境。
六、验证这类功能,最容易踩的两个方法论坑
| 坑 | 现象 | 正解 |
|---|---|---|
| 同进程读到旧配置 | 「写完配置再读」拿到旧值 → 误判「配置没生效」 | 很多配置有每请求一次的静态缓存。写完必须另起请求或另起进程复核 |
| 测完没还原文/配置 | 下一次测试从一个被污染的状态开始,结果飘忽 | 测前快照配置、测后比对还原;造的文章测试后删除 |
第二条在「配置驱动」的功能上尤其重要——这次一共动了 5 个配置项(开关、天数、文案、两个颜色)。如果测完不还原,下一次回归就会在一个「上次留下的奇怪状态」里跑,失败原因完全不可解释。
顺带一个部署层面的教训:改动要按同一个清单同步到所有环境。这次就出现过「线上已经部署、测试环境漏了文件」的情况——表现是同一套断言在一个环境全绿、另一个环境全红。核对方式很简单:以代码仓库的提交内容为准,逐个环境比对文件清单,而不是凭记忆。
附:同系列文章
本文属于「zibll 子主题开发实录」系列,同系列其它篇目见文末导航。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界 (本文)
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容