摘要:给站点做了三枚成就勋章,用了三种完全不同的获取方式——秘技(全站任意页面输入「上上下下左右左右BA」触发)、限时认领(节日窗口内横幅三选一)、自动授予(深夜活跃即得,用户无感知)。三者的交互形态差得很远,但都落到同一条服务端通道上。本文把三种模型各自的「最容易做错的一处」拆开讲:秘技的判定为什么必须在服务端、认领的 pick 为什么是不可信输入、以及自动授予里那个跨零点 + 站点时区的组合拳。附零依赖交付代码。
适合阅读:做过成就/勋章/活动系统,或任何「同一个业务结果、多种触发入口」的开发者。
一、三个模块,三种触发模型
需求来自运营:想让用户有「被记住」的感觉。于是设计了三枚勋章,刻意用了三种不同的获取方式——因为玩法差异本身就是奖励的一部分。
| 模型 | 触发者 | 用户感知 | 典型场景 |
|---|---|---|---|
| ① 秘技 | 用户输入一串按键 | 有(探索感) | Konami Code「彩蛋猎人」 |
| ② 限时认领 | 用户点按钮 | 有(活动感) | 节日横幅「三选一」 |
| ③ 自动授予 | 系统(时段判定) | 无 | 深夜活跃「夜猫子」 |
看似三件不相干的事,但它们的后端部分几乎一样:都是「给某个用户,幂等地授予一枚隐藏型勋章」。所以架构上只需要一条通道:
/**
* 幂等授予:先查旗标 → 再发勋章 → 最后置旗标
*/
function zdw_medal_award_once($uid, $medal_key, $flag) {
if (get_user_meta($uid, $flag, true)) { return false; } // 已发过
if (function_exists('zib_add_user_medal')) {
if (!zib_add_user_medal($uid, $medal_key)) { return false; }
}
update_user_meta($uid, $flag, 1);
return true;
}
这里有一个顺序问题,值得单独说:幂等旗标必须在发勋章成功之后才置位。如果反过来(先置旗标、再发勋章),一旦发勋章失败,用户就被永久锁在门外——系统认为「已经发过」,而用户手里什么都没有。这个 bug 不会报错,只会让极少数用户在某个偶然时刻永远拿不到勋章。
一条通用原则:幂等标记的意义是「这件事已经完成了」,不是「这件事尝试过了」。两者差一个「成功」的判断,但后果差一个用户。
二、模型①:秘技的判定必须在服务端
「上上下下左右左右BA」这种秘技,前端实现只有十几行:监听键盘、维护一个匹配指针、命中就发个请求。
var seq = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
var pos = 0;
document.addEventListener('keydown', function (e) {
pos = (e.key === seq[pos]) ? pos + 1 : (e.key === seq[0] ? 1 : 0);
if (pos < seq.length) { return; }
pos = 0;
report(); // 上报
});
关键:前端只「上报」,不「决定」
天真的做法是前端判中以后直接 ajax → 发勋章。但那样一来,**打开控制台手动调一下端点,就能给自己刷勋章**——成就系统的意义立刻归零。
正确分工是:前端只负责「报告我按对了序列」,服务端独立判断「该不该发」。服务端不看你是不是真的按了键(那没法验证),它只看业务规则:登录了吗?nonce 对吗?之前发过吗?
// 服务端:幂等授予,返回是否"本次新发"
$uid = get_current_user_id();
$new = zdw_medal_award_once($uid, 'zhiji_medal_egg', 'zdw_medal_egg_flag');
wp_send_json(array('ok' => true, 'won' => $new)); // won=false 表示早就有了
页面上的 won 字段只用来决定「要不要放一次特效」,不参与授予决策。这样即使有人伪造上报请求,也只是白白点一下——最多拿到他本来就能拿的那一枚。
教训:任何「用户做了个动作就能拿到奖励」的功能,都要分清「哪些是体验、哪些是规则」。体验可以放前端(越顺滑越好),规则必须留在服务端(越严格越好)。把两者混在一起,就是给自己留了一个可刷的后门。
三、模型②:认领的 pick 是不可信输入
节日活动是「三选一」:雪人、圣诞袜、圣诞树,每人只能领一枚——三枚都送就没有稀缺感了,运营特意强调。
前端渲染三个按钮,点击时带上 pick 参数。服务端如果这样写,就会出问题:
// ✗ 危险:直接把用户传来的值当作勋章 key
$pick = $_REQUEST['pick'];
zib_add_user_medal($uid, $pick);
因为 pick 来自客户端。用户可以传任何字符串:你名单外的勋章、别人的勋章 key、甚至是构造出来让发奖逻辑出错的怪值。正确做法是先过白名单,且**不给”默认兜底”**:
$allow = array('snowman', 'sock', 'tree');
$pick = sanitize_key($_REQUEST['pick'] ?? '');
if (!in_array($pick, $allow, true)) {
wp_send_json(array('ok' => false, 'msg' => '请选择一枚勋章'), 400);
}
// 只有命中白名单才继续
注意这里没有写「不在名单里就默认发第一枚」。那种「友好兜底」会让越权请求看起来像是成功了,既掩盖了攻击,也破坏了「用户选了哪枚」这个业务事实。
另外「每人限领一枚」的幂等,就复用第一节那条通道:三枚共用一个旗标——领过任意一枚,其余两枚自动锁定。这正是「三选一」这个业务约束在数据层的表达。
四、模型③:自动授予的两个时间陷阱
「夜猫子」勋章不需要用户做任何事:只要在设定的深夜时段内访问站点,登录会员就自动获得。它挂在 template_redirect 上——这个钩子**只在真正的前台页面请求**时触发,不会在 admin-ajax、REST、cron 里跑(否则后台点一下也会被授予,语义就乱了)。
自动授予看起来简单,但时间判定有两个必踩的坑。
陷阱一:跨零点
「深夜」通常是 00:00 – 05:00,也可能被运营配成 22:00 – 05:00。后者跨过了零点,而下面这个”直觉写法”在跨零点时会恒为假:
// ✗ 跨零点失效:start=22:00, end=05:00 时永远进不去
if ($t >= $s && $t <= $e) { /* 在窗口内 */ }
// ✓ 先判断是否跨零点
if ($s <= $e) {
return $t >= $s && $t <= $e; // 同一天内
}
return $t >= $s || $t <= $e; // 跨零点:落在两端任一区间即在窗口内
陷阱二:服务器时区
第二个坑更隐蔽。取「现在几点」时,很多人顺手写 date('H:i')——而 date() 用的是服务器时钟。服务器常年跑 UTC,站点时区是东八区,两者差 8 小时。于是你在后台把深夜窗口设成 00:00–05:00,实际生效的却是 08:00–13:00——用户在上班时间拿到了”夜猫子”。
// ✗ 服务器时区(UTC):与站点设置差 8 小时
$now = date('H:i');
// ✓ 站点时区:current_time() 已应用站点时区设置
$now = current_time('H:i');
教训:凡是要和「用户看到的钟」对齐的时间判断,都必须用站点时区的取值函数。这类 bug 的特点是:白天测不出来,深夜才复现,而且换个时区的服务器又会变——所以它总在”上线一阵子之后”才被人发现。
五、共同底座:隐藏型勋章只认代码授予
三枚勋章有一个共同点:它们的类型都是隐藏型。父主题有一套「自动成就引擎」(评论数、登录天数、消费金额之类的条件达成即授予),而隐藏型勋章不会被这套引擎产生——只能由代码显式授予。
| 要点 | 说明 |
|---|---|
| 先注册,再授予 | 勋章要在父主题的清册里注册过(含类型、名称、图标),否则授予会被静默丢弃 |
| 类型必须是隐藏型 | 这样自动引擎不会”顺手”发给所有人,活动才有意义 |
| 授予要走父主题函数 | 子主题不要自己写 meta —— 父主题的勋章存储、缓存、清册刷新都在那个函数里 |
这也是三个玩法完全不同的模块,最终能共用一条通道的原因:它们唯一的差别只在「什么时候触发」,触发之后的流程完全一致。
六、这类功能怎么验证
| 断言 | 做法 | 能发现 |
|---|---|---|
| 时段判定(纯函数传值) | 把判定抽成接受时间参数的纯函数,直接传 23:59 / 00:30 / 12:00 | 跨零点、边界时刻(不用真的等到深夜) |
| 幂等连测两次 | 同一动作连做两次:第一次 won=true,第二次必须 false 且勋章数不变 | 重复授予 / 旗标顺序错 |
| 白名单 | 传一个不在名单里的 pick → 应 400 拒绝且勋章数不变 | 越权领取 |
| 未登录 | 不带登录态请求上报端点 → 应 401 | 鉴权缺失 |
| 开关关闭 | 关掉模块后端点与前台输出应一并消失 | 开关没接上 |
第一条是这篇文章里最省时间的一条经验:把时间判定抽成「接受时间参数」的纯函数,验证成本从「等到凌晨」降到「传一个字符串」。而且它能覆盖边界——23:59、00:00、05:00、05:01 各测一遍,比真等一夜可靠得多。
附:同系列文章
本文属于「zibll 子主题开发实录」系列,同系列其它篇目见文末导航。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容