摘要:站点的「隐藏成就」勋章功能上线几个月,用户敲出秘技后弹窗提示成就已解锁——直到把勋章数据翻开对账才发现:勋章从来没有真正发放出去过。本文完整复盘这个潜伏 bug 的发现、溯源、修复与验证:父主题勋章引擎的注册表机制、「flag 先置位」如何掩盖失败、以及两个极难察觉的存储细节。
适合阅读:zibll 二开站长、给父主题「打补丁」的插件/子主题开发者。
一、发现:弹窗说「恭喜」,账上没有勋章
起因是一次例行核对。子主题的站点彩蛋功能运行了几个月,逻辑很直白:用户敲出 Konami 秘技 → 前端上报 → 服务端授勋 → 弹窗告知。抽检一个用户的勋章列表时,却发现勋章「彩蛋猎人」不在账上——但防重旗标(user_meta)显示这个用户早就「授过」了。
第一反应是缓存。清了对象缓存再看:还是没有。第二反应是存储位置:zibll 的用户 meta 走中央存储优化,换用父主题自己的封装 zib_get_user_meta() 再读——依然没有。这就不是缓存问题了:授予动作本身就没成功过,而且不止一个用户。
二、溯源:父主题勋章引擎的「注册表」机制
翻父主题源码,授勋入口是 zib_add_user_medal($uid, $medal_name, $remarks),核心逻辑两步:
// ① 不能重复添加
if (isset($medal[$medal_name])) { return false; }
// ② 不能添加「不存在」的勋章
$medal_args = zib_get_single_medal_args($medal_name);
if (!$medal_args) { return false; }
关键在②:zib_get_single_medal_args() 从勋章清册里按名字查。清册怎么构建的?
// zib_get_medal_args():读后台配置 → 过 filter → 构建索引
foreach ($cat['items'] as $item) {
if ($item['name'] && $item['icon'] && $item['get_type']) { // ← 三者齐全才收
$item_args[$item['name']] = $item;
}
}
三个条件缺一不可:name、icon、get_type。任何一个为空,这条勋章会被静默丢弃——不进清册,于是 zib_add_user_medal 在第②步永远返回 false。整个链路没有任何报错、任何日志。
再对照我们的注册代码:为了实现「隐藏成就」(触发条件不公示、只能由代码授予),注册时刻意没传 get_type——直觉上「没有自动触发事件就不该填触发类型」。就是这一个字段,让所有隐藏成就根本没进过清册。
三、v2 为什么错了三层还没被发现
| 层 | 错误 | 后果 |
|---|---|---|
| ① 注册 | 隐藏成就不传 get_type | 被清册丢弃 → 授勋必失败 |
| ② 顺序 | 先置 flag,再调授勋 | 授勋失败后旗标已落库 → 重试被自己挡住 |
| ③ 反馈 | 接口只看 flag 判「已授过」 | 失败被「你已经找到过」掩盖,用户和管理员都以为正常 |
三层错误互相掩护:第①层让授勋必然失败,第②层让失败不可重试,第③层让失败不可感知。更麻烦的是用户侧体验完全正常——弹窗照样弹、文案照样对,只有对账才能发现。这也是潜伏几个月的原因:这类「成功假象」型 bug 不报错、不崩页面,传统测试全绿。
四、修复:一个哨兵值解决「隐藏」与「可授」的矛盾
难点在于约束冲突:勋章要进清册就必须有 get_type(父主题写死的条件),但隐藏成就恰恰不能有真实的触发类型(否则父主题的自动授予引擎会按类型自动发,违背「仅代码授予」)。解法是哨兵值:
'get_type' => 'zach_hidden' // 父主题引擎永远不会有此类型的事件
// → 能进清册(授勋不再静默失败)
// → 引擎永远匹配不到 → 仍然只能由代码授予
同时修正授予顺序与失败回滚:
if (get_user_meta($uid, $flag, true)) { return false; } // 幂等
update_user_meta($uid, $flag, 1);
$ok = zib_add_user_medal($uid, $name, $remark);
if (!$ok) {
delete_user_meta($uid, $flag); // 🧨 失败回滚旗标,允许下次重试
return false;
}
五、顺带踩出的存储细节:raw 读不到的勋章
排查期间还有一个容易误判的点:zibll 对用户 meta 做了中央存储优化,medal_details 这类键不在 user_meta 表里,而是进了统一 option 存储。验证勋章是否到账,必须用父主题封装 zib_get_user_meta();直接 get_user_meta($uid, 'medal_details') 会读到空——你会把「读错了地方」误判成「没发出去」。测试脚本两次在这上面打转。
| 读法 | 结果 | 结论 |
|---|---|---|
| get_user_meta($uid, ‘medal_details’) | 空 | ❌ 读错存储,误判未发放 |
| zib_get_user_meta($uid, ‘medal_details’) | 勋章数组 | ✅ 唯一正确读法 |
六、验证:端到端实测代替「看起来对」
修复后用一次性测试用户跑完整链路(服务端断言,非肉眼):
| 断言 | 结果 |
|---|---|
| 首次授勋返回 true | 通过 |
| 二次授勋返回 false(幂等) | 通过 |
| 勋章入账(zib 封装读取,含时间与备注) | 通过 |
| 勋章进清册(get_type=zach_hidden 哨兵) | 通过 |
| 清册里缺失图标/类型的条目被丢弃(回归确认) | 通过 |
另有两条 E2E 教训值得记录:① nonce 绑定会话——CLI 里用 wp_set_current_user() 生成的 nonce,配 HTTP Cookie 调用必 403,必须「带 Cookie 抓页面 → 从页面提取 nonce → 同会话回放」;② CLI 环境缺后台函数——wp_delete_user、wp_set_post_thumbnail 都是后台函数,测试脚本要么 require wp-admin/includes/,要么用 update_post_meta 等价替代。
八、踩坑清单
- 父主题勋章清册 name && icon && get_type 三全才收,缺一静默丢弃;
- 授勋失败无日志无报错,只有对账能发现;
- 防重旗标要在授勋成功后才算数,失败必须回滚;
- 验证入账用
zib_get_user_meta(),raw 读不到中央存储; - nonce 绑定会话,跨上下文生成的 nonce 配 Cookie 必 403;
- CLI 缺后台函数:
wp_delete_user/wp_set_post_thumbnail都要替代方案。
一句话总结:凡是「给父主题引擎投递数据」的扩展,先读引擎的准入条件再动手;授勋类操作要「先授后记、失败回滚」,并把「已授予」的判断建立在账本而不是旗标上。
2026-10-04 更新:本文交付代码覆盖「彩蛋猎人」一枚的注册与授勋。后续把勋章体系补完整时(夜猫子自动成就 + 圣诞礼遇限时三选一活动),又踩了三个”不报错、不留日志、页面看着正常”的静默坑:服务器时区让 date() 判定恒 false、自由文本框 10-4 被严格正则静默拒判、AJAX 授勋后消息角标不会自己变——修法与新交付代码见《勋章活动的三个静默失败:时区、正则、角标》。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统









暂无评论内容