「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界

「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界-知集
「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 32 分钟9,516 字

摘要:给站点加了一条「文章过期提示」:发布超过 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 天前」的提示。

这个选择本身没有对错,但它必须被写进断言,而不是靠感觉:

发布时间$passedN=7 时是否提示用途
6 天前6否下边界(差一天)
7 天前7是临界点
8 天前8是上边界

有意思的是:只测「8 天前应该提示」,这个功能就算有 off-by-one 也测不出来(因为 6 天、7 天、8 天都满足 >= 7 里的一部分)。必须两端都测,才能把边界夹住。

教训:凡是「超过 N」「小于 M」「不早于 X」这类措辞,都要在写代码时立刻翻译成「哪个值算、哪个值不算」,并把它写成两条断言。含混的语义留着不管,迟早会在某个临界点上被人发现——而那通常是在生产环境。

六、验证这类功能,最容易踩的两个方法论坑

坑现象正解
同进程读到旧配置「写完配置再读」拿到旧值 → 误判「配置没生效」很多配置有每请求一次的静态缓存。写完必须另起请求或另起进程复核
测完没还原文/配置下一次测试从一个被污染的状态开始,结果飘忽测前快照配置、测后比对还原;造的文章测试后删除

第二条在「配置驱动」的功能上尤其重要——这次一共动了 5 个配置项(开关、天数、文案、两个颜色)。如果测完不还原,下一次回归就会在一个「上次留下的奇怪状态」里跑,失败原因完全不可解释。

顺带一个部署层面的教训:改动要按同一个清单同步到所有环境。这次就出现过「线上已经部署、测试环境漏了文件」的情况——表现是同一套断言在一个环境全绿、另一个环境全红。核对方式很简单:以代码仓库的提交内容为准,逐个环境比对文件清单,而不是凭记忆。

附:同系列文章

本文属于「zibll 子主题开发实录」系列,同系列其它篇目见文末导航。


📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容