第六期验证了注册表驱动的可扩展,第七期补上了「本地常驻」这种不出站的数据角色。第八期要回答一个更「软」的问题------怎么做一个游戏化功能,才能不制造焦虑? 答案是把它当成一道数据建模题来解。本文记录「情绪闯关挑战盒」的落地过程。
关键词:派生而非存储 · 跳过一等公民 · 单一事实源 · 能力探测降级 · 前后端错峰上线 目标读者:前端 / 后端 / 产品
一、为什么「连续打卡天数」是危险的设计
大多数习惯类产品靠 streak(连续天数)留人:断一天,火焰熄灭,一切归零。它对「要不要继续用」的激励极强,但对情绪类产品,制造的是另一种东西------愧疚。
用户状态不好、连着几天没打开,回来看到「你已中断 3 天」,第一反应不是重新开始,而是「我已经失败了」,然后卸载。
挑战盒立了三条反直觉的规矩:
| 常规游戏化 | 挑战盒 |
|---|---|
| 连续天数、断卡惩罚 | 累计次数 + 徽章,永不归零 |
| 完成率、击败 X% 用户 | 无排行榜、无百分比 |
| 「你已离开 X 天」召回 | 回来永远只显示「欢迎回来」 |
这三条不是贴在文案里的承诺,而要落到数据模型上------于是有了本期最核心的决定:进度与徽章是派生数据,一个字段都不存。
二、派生而非存储:徽章进度不落库
如果给徽章建一张「成就表」,存 badge_id / earned / progress / unlocked_at,会立刻冒出三个问题:前后端各维护一份进度、需要额外的同步与冲突仲裁、以及最致命的------进度是「连续天数」这类不可派生状态的温床。
挑战盒反着来。em_challenge 域只存一样东西:
js
// emotion-monster/utils/storage.js
function defaultChallenge() {
return { records: [], swapCount: 0 };
// records: 打卡/跳过记录;swapCount: "换一个"计数(纯本地)
}
徽章与统计全部从 records 实时算出。前端 calc.challengeSummary 是纯函数,输入 records、输出 weekCount / totalCount / byStar / 徽章列表;判定规则本身就是数据,不是代码分支:
js
// emotion-monster/utils/constants.js ------ CHALLENGE_BADGES
{ id: 'emotion_container', name: '情绪容纳者', icon: 'wave', series: 'allow',
rule: { kind: 'doneStar', star: 2, min: 10 } } // 二星任务累计完成 10 次
doneTotal / doneStar / doneTask / skipTotal / swapCount / reunion 六种规则覆盖全部徽章,判定逻辑十几行。
好处是结构性的:
- 不存在「进度字段过期」------删一条记录,进度自动回退,无需维护缓存一致性;
- 不存在「前后端进度不一致」------两边用同一份 records、同一套规则;
- 不存在「连续天数」------records 里根本没有 streak 字段,从数据模型层面消灭断卡焦虑。
代价只是每次进觉察册做一次内存聚合。用户一年最多几千条记录,这个量级可以忽略。
三、跳过是一等公民:status 只有两种
传统打卡系统里,未完成是「缺失」,跳过是「失败」。挑战盒把两者合并成同一条记录的两种取值:
js
// emotion-monster/utils/adapters/challenge-local.js ------ create 的落库
status: p.status, // 'done' | 'skip'
「跳过」不是不做记录,而是写入一条 status: 'skip' 的记录。两个后果:
- 当日状态是「休息」而不是「空缺」。首页今日状态三态(已完成 / 今天休息 / 还没打开),休息与完成视觉权重相同------休息不是空白。
- 休息本身可被统计、被奖励 。徽章「休息知晓者」的条件就是
skip ≥ 15:
「十五次,你诚实地对自己说了'今天不行'。听懂自己的'不想做',也是一种觉察。」
这是本期最想强调的一点:接纳不是口头禅,而是把「不想做」建模成合法数据并给予正向反馈。 用户不做的权利,在数据层和交互层同时被尊重。
跳过还必须零成本:无二次确认弹窗、不扣进度、不弹挽留。交互上的「跳过无确认」,对应数据上的「跳过不惩罚」------同一条规矩的两面。
四、徽章判定:前后端同构 + 单一事实源
徽章这件事上,前后端的分工又有微妙不同。
进度判定 必须在两端各算一遍:游客态走本地 calc.challengeSummary,登录态走服务端聚合。但展示字段 (名称、图标、文案、分组)若各存一份,就是前后端双份文案漂移------本次开发真实踩过:后端没同步升级时,前端拿到的徽章缺 icon 字段,图标全渲染成了默认的「返回箭头」。
结论是展示字段收口到前端单一事实源:
- 后端
summary只返回进度 :[{id, earned, progress}],id是前后端约定的关联键; - 前端用
CHALLENGE_BADGES字典按id补齐name / emoji / icon / series / desc。
java
// mood-backend/.../service/ChallengeService.java ------ buildBadges 只算进度,不碰文案
addBadge(out, "emotion_container", countStar(done, 2) >= 10, countStar(done, 2), 10);
js
// emotion-monster/utils/adapters/remote-adapter.js ------ 云端进度 + 前端字典补齐展示
const def = CHALLENGE_BADGES.find((d) => d.id === b.id);
const merged = def
? Object.assign({}, b, { name: def.name, emoji: def.emoji, icon: def.icon, series: def.series, desc: def.desc })
: b;
「算法两端同构、文案一端维护」------对第五期「同构纪律」的一次细化:同构的是计算,不是展示。
五、能力探测与降级:新模块如何与旧后端错峰共存
小程序要审核、用户要手动更新,前端上线天然滞后于后端;反过来,后端也可能因故障回滚。于是出现经典组合:新前端 + 旧后端。
对挑战盒这是致命的:新前端把 module:'challenge' 推给旧后端的 /sync/push,旧分发逻辑不认识 challenge,会掉进 else 兜底------当成 mood 记录写进去,产生脏数据。
两层防御:
后端给 challenge 加独立分支,绝不复用兜底:
java
// mood-backend/.../service/SyncService.java ------ push 的分发
} else if ("challenge".equalsIgnoreCase(change.module())) {
challengeService.applyChallenge(userId, change.action(), change.record(), clientId);
} else {
applyMood(userId, change, clientId); // 兜底只留给 mood
}
前端 写之前先「能力探测」:调一次 GET /challenges/today,返回 404/405 说明后端未就绪,就把记录降级落本地、标记待同步,不推 /sync/push;探测结果缓存,后端恢复后走正常路径。
js
// emotion-monster/utils/adapters/remote-adapter.js ------ ensureChallengeSupported
return http.get('/challenges/today', { date: DateUtil.today() })
.then(() => { this._challengeSupported = true; return true; })
.catch((e) => {
if (e && (e.statusCode === 404 || e.statusCode === 405)) {
this._challengeSupported = false; // 后端未就绪:降级本地,绝不推云
return false;
}
return true; // 网络错误:走正常推送,失败入离线队列
});
能力探测把一个潜在的脏数据事故,降级成了一次本地降级。
六、Schema v2 → v3:第七期那个坑,再来一遍
新增域要改 migrate(),但真正的坑在 initStore() 的首次启动判空------第七期已经总结过。挑战盒照搬这套经验,这次几乎没有新坑:
js
// emotion-monster/utils/storage.js ------ migrate
if (from < 3) {
db.challenge = db.challenge || defaultChallenge();
}
challenge 域独立加载、缺失即回退默认,不参与核心结构判空;dirty 与 flush() 补上 challenge(否则置脏后 flush 早退、数据永远停在内存);backupGuest() 快照带上 challenge(合并失败可回滚)。第七期交的学费,这期直接变成了 checklist。
七、验证清单
- 老用户(v1/v2)升级后 challenge 域补齐、无 INIT_FAILED,历史数据不丢;
- 游客态抽盒、完成、跳过全可用;断网写入进离线队列,联网重放;
- 后端未就绪(404)时写 challenge 降级本地、不推云、不产生 mood 脏数据;
- 同日多次打卡幂等;「换一个」计数仅本地、不上云;
- 徽章进度本地/云端一致(同一份 rules、同一份 records);
reduceMotion下无缩放动画;日记默认剥离不上云。
小结
三条工程原则:
- 派生优于存储。能算出来的就别存------它自动解决一致性、过期、迁移三件套;
- 把「不做什么」也建模。skip 是数据不是失败,接纳才有落点,而不是停在文案;
- 新能力上线,先想怎么降级。能力探测不是锦上添花,是保护旧数据的底线。