摘要:给站点做了一期勋章活动:限时日期窗内,会员在前台横幅里三选一领一枚圣诞勋章;另有一枚「夜猫子」在深夜时段访问即自动授予。功能上线当天,用户接连报了三个「不生效」:后台设置了时段却不触发、开始日填了横幅却不出现、领取成功后消息角标纹丝不动。三个问题同一种性格——不报错、不留日志、页面看起来一切正常。本文逐一还原根因(服务器时区、自由文本正则、服务端渲染角标),给出修法与 49 项单测。
适合阅读:在 WordPress / zibll 主题上做过(或打算做)限时活动、成就勋章、AJAX 授勋的开发者。文末附零依赖交付代码。
一、先说设计:三选一,每人限领一枚
勋章活动有两个入口,语义完全不同,实现也必须分开:
| 入口 | 语义 | 关键约束 |
|---|---|---|
| 圣诞礼遇(横幅) | 限时公开活动,用户主动领取 | 日期窗口内才出现;三选一;幂等(flag 锁定) |
| 夜猫子(自动) | 深夜活跃即授予,用户无感知 | 时段窗口内首次前台访问;幂等;零打扰 |
「三选一」是活动运营的要求:三枚都送 = 没有稀缺感。落到实现上就是两件事:① 前端横幅渲染三枚按钮,点击时带上 pick 参数;② 服务端把 pick 当不可信输入——必须命中白名单(snowman / sock / tree)才发,且只发那一枚。整场活动共用一个幂等旗标 user_meta,领过任意一枚后其余两枚自动锁定。
两条通道最终走同一条授勋管路:先注册进父主题清册,再调 zib_add_user_medal()。这条管路的两个坑(缺 get_type 被清册丢弃、flag 先置位堵死重试)我在《zibll 隐藏成就体系:潜伏 bug 实录》里已经写过,本文不重复,只在第五章验证修复。
二、坑①:date() 是服务器的钟,不是用户的钟
夜猫子的判定逻辑一眼看上去无懈可击:
// 用户设置:23:10 ~ 05:00(跨午夜窗)
$time = $time ?: date('H:i'); // ← 问题在这一行
return in_night_window($start, $end, $time);
上线当晚 23:25,用户设好 23:10~05:00,访问前台——什么都没发生。在服务器上跑一组对照,差异一目了然:
| 取时方式 | 实测输出 | 含义 |
|---|---|---|
date('H:i') | 15:34 | 服务器时钟(UTC) |
current_time('H:i') | 23:34 | 站点时区(gmt_offset=8,Asia/Shanghai) |
服务器在 UTC,站点配置了 +8 区:date() 拿到的是 15:34,永远落不进 23:10~05:00,判定恒 false,且没有任何报错。WordPress 里凡「用户感知的时间」(今天几号、现在几点、活动窗口、定时发布)都必须用 current_time();date() 只适合与 UTC 相关的内部计算。顺带一提,同一份代码在两台服务器上行为不同(一台 UTC 一台东八),这也是「测试环境好的、线上不好」的经典来源。
修法一行:date('H:i') → current_time('H:i')。但真正值得留下的是这条纪律:写时间判断时先问「这个时间是给谁看的」。
三、坑②:用户一定会填「10-4」
圣诞礼遇的开始日,后台是一个自由文本框,提示格式 MM-DD。用户填的是 10-4——完全合理,人类就是这么写日期的。而我的窗口校验是:
// 旧写法:格式不合格一律判「不在窗口」
if (!preg_match('/^d{2}-d{2}$/', ) || !preg_match('/^d{2}-d{2}$/', )) {
return false; // 10-4 → false → 横幅永不出现,无任何报错
}
10-4 不满足 ^d{2}-d{2}$(月份只有一位),于是窗口恒关闭,横幅静默不渲染。用户视角是「我设了开始日,前台没反应」;开发者视角是「格式错误按关闭处理,符合设计」——两个视角都对,凑在一起就是个无法排查的 bug。
3.1 修法:入口归一化,而不是出口拒判
自由文本框的职责边界应该是:能解析就解析,解析不了才关闭。加一个归一化函数,同时把 10/4、带空格等常见写法一并兼容:
function zdw_ma_norm_md($v) {
$v = trim((string) $v);
if (preg_match('/^(d{1,2})s*[-/]s*(d{1,2})$/', $v, $m)) {
$mo = (int) $m[1]; $d = (int) $m[2];
if ($mo >= 1 && $mo <= 12 && $d >= 1 && $d <= 31) {
return sprintf('%02d-%02d', $mo, $d); // 10-4 / 10/4 → 10-04
}
}
return ''; // 真正解析不了才返回空(调用方按「未配置」处理)
}
同一个思路顺手补了时间框:9:05 → 09:05。这不再是「宽容」,而是承认输入的现实:后台字段是给人填的,不是给正则填的。
四、坑③:领取成功后,消息角标纹丝不动
第三个问题最隐蔽:AJAX 领取返回「恭喜获得圣诞雪人」,父主题也确实发了条站内信(user_add_medal 钩子 → zib_user_add_medal_new_msg),但头部铃铛的角标数字不变,要手动刷新页面才出现。
4.1 为什么不会自己变
因为角标是服务端渲染的——页面输出时查了一次未读数,之后这份 HTML 就「冻」在那里。AJAX 请求只发生了授勋和发信,浏览器里的 DOM 没有任何人去动它。这不是 bug,是渲染模型:服务端渲染的东西,客户端必须有人负责同步。
4.2 按父主题自己的口径去改
同步不是随手 +1。翻了父主题的消息系统,有三个必须对齐的事实:
| 事实 | 出处/含义 | 对实现的影响 |
|---|---|---|
勋章消息归 system 分类 | 父主题 zib_get_msg_cat():medal → system 组 | 要同时更新 all 与 system 两个角标 |
角标结构 <badge msg-cat="…">N</badge> | 父主题 message.js 就按这个选择器更新 | 沿用同一口径,别发明新 class |
| 计数为 0 时不渲染 badge 节点 | $count ? … : ''; | 找不到节点 = 原数为 0,要在消息图标处新建节点 |
落地的 bumpBadge() 十几行:有节点则自增,无节点则新建(all 角标要带父主题的 top class)。只在本次真正授勋时调用——返回 repeat(之前已领过)说明消息早发过,角标不能再加一次。对齐宿主的既有口径,永远好过引入自己的平行实现。
五、幂等的另一半:失败不置旗标
上一篇文章写过 v2 的潜伏 bug:先置旗标再授勋,授勋一旦失败,用户被自己的旗标永久挡在门外。本次交付代码把顺序反过来,并用单测把这个性质钉死:
$ok = zib_add_user_medal($uid, $name, $remarks);
if (!$ok) {
return array('repeat' => false, 'granted' => array()); // ← 不置旗标
}
update_user_meta($uid, $flag, 1); // 只有真正授出才锁定
对应两条断言(49 项单测中的两条):「授勋失败不置旗标」「修复环境后重试可成功」。幂等锁的语义应该是「这件事做成了」,而不是「这件事尝试过」。
六、顺手一修:勋章弹窗「偏小还向后移」
用户还报了一个观感问题:勋章弹窗里我们的自绘 PNG 比父主题勋章小一圈。原因是父主题按方形 SVG 设计了这套 UI:图标高 120px,衬托在 305px 宽的虚线背景框里;竖版 PNG(如圣诞三枚 540×720)在 120px 高度下只有 90px 宽,视觉占比骤减,「小 + 像是缩在后面」都源于此。
修法是把弹窗与消息中心共用的 .medal-single-card .medal-icon 提到 180px、背景框同步上移保持居中。两个工程细节:① 弹窗和消息中心的勋章卡片是同一套 class(父主题两处同构 HTML),一条规则全覆盖;② 子主题样式必须确认加载顺序在父主题 main.css 之后(同特异性时后者生效),抓首页 HTML 看 link 顺序即可,实测我们 core.css 恰好排在最后。
附:三个坑的共性,与一条检查清单
把三个坑放在一起看,性格完全一致——都能开启、看着在运行、只是不生效:
| 坑 | 失效机制 | 为什么无报错 | 一步检查法 |
|---|---|---|---|
| 时区 | date() 读服务器钟,判定恒 false | 比较结果只是 false,不算错误 | 服务器打印 date() 与 current_time() 对照 |
| 正则 | 一位数日期不匹配,窗口恒关闭 | 格式错误按「未配置」处理是设计行为 | 把后台填的原始值直接喂给判定函数打印结果 |
| 角标 | 服务端渲染的 DOM 无人同步 | 服务端确实发了信,错的是客户端没人接 | 抓包看响应里有没有发信证据,再看 DOM 有没有对应节点 |
共同根因一句话:「配置了」到「生效了」之间隔着一次取值、一次解析、一次渲染,每一环都可能静默断开。以后写任何「设置了却没生效」类的功能,先做三件事:把取到的原始值打印出来、把解析结果打印出来、把渲染出来的关键节点抓出来——三步之内,静默失败无所遁形。本文的 49 项单测与交付代码,就是把这三步固化成可重跑的资产。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容