第八期 · 情绪闯关挑战盒:一个「无惩罚」成就系统的数据建模

发布时间:2026/10/14 18:55:11
第八期 · 情绪闯关挑战盒:一个「无惩罚」成就系统的数据建模
第六期验证了注册表驱动的可扩展第七期补上了「本地常驻」这种不出站的数据角色。第八期要回答一个更「软」的问题——怎么做一个游戏化功能才能不制造焦虑答案是把它当成一道数据建模题来解。本文记录「情绪闯关挑战盒」的落地过程。关键词派生而非存储 · 跳过一等公民 · 单一事实源 · 能力探测降级 · 前后端错峰上线目标读者前端 / 后端 / 产品一、为什么「连续打卡天数」是危险的设计大多数习惯类产品靠 streak连续天数留人断一天火焰熄灭一切归零。它对「要不要继续用」的激励极强但对情绪类产品制造的是另一种东西——愧疚。用户状态不好、连着几天没打开回来看到「你已中断 3 天」第一反应不是重新开始而是「我已经失败了」然后卸载。挑战盒立了三条反直觉的规矩常规游戏化挑战盒连续天数、断卡惩罚累计次数 徽章永不归零完成率、击败 X% 用户无排行榜、无百分比「你已离开 X 天」召回回来永远只显示「欢迎回来」这三条不是贴在文案里的承诺而要落到数据模型上——于是有了本期最核心的决定进度与徽章是派生数据一个字段都不存。二、派生而非存储徽章进度不落库如果给徽章建一张「成就表」存badge_id / earned / progress / unlocked_at会立刻冒出三个问题前后端各维护一份进度、需要额外的同步与冲突仲裁、以及最致命的——进度是「连续天数」这类不可派生状态的温床。挑战盒反着来。em_challenge域只存一样东西// emotion-monster/utils/storage.jsfunctiondefaultChallenge(){return{records:[],swapCount:0};// records: 打卡/跳过记录swapCount: 换一个计数纯本地}徽章与统计全部从records实时算出。前端calc.challengeSummary是纯函数输入 records、输出 weekCount / totalCount / byStar / 徽章列表判定规则本身就是数据不是代码分支// 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 只有两种传统打卡系统里未完成是「缺失」跳过是「失败」。挑战盒把两者合并成同一条记录的两种取值// 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。// mood-backend/.../service/ChallengeService.java —— buildBadges 只算进度不碰文案addBadge(out,emotion_container,countStar(done,2)10,countStar(done,2),10);// emotion-monster/utils/adapters/remote-adapter.js —— 云端进度 前端字典补齐展示constdefCHALLENGE_BADGES.find((d)d.idb.id);constmergeddef?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 加独立分支绝不复用兜底// mood-backend/.../service/SyncService.java —— push 的分发}elseif(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探测结果缓存后端恢复后走正常路径。// emotion-monster/utils/adapters/remote-adapter.js —— ensureChallengeSupportedreturnhttp.get(/challenges/today,{date:DateUtil.today()}).then((){this._challengeSupportedtrue;returntrue;}).catch((e){if(e(e.statusCode404||e.statusCode405)){this._challengeSupportedfalse;// 后端未就绪降级本地绝不推云returnfalse;}returntrue;// 网络错误走正常推送失败入离线队列});能力探测把一个潜在的脏数据事故降级成了一次本地降级。六、Schema v2 → v3第七期那个坑再来一遍新增域要改migrate()但真正的坑在initStore()的首次启动判空——第七期已经总结过。挑战盒照搬这套经验这次几乎没有新坑// emotion-monster/utils/storage.js —— migrateif(from3){db.challengedb.challenge||defaultChallenge();}challenge域独立加载、缺失即回退默认不参与核心结构判空dirty与flush()补上 challenge否则置脏后 flush 早退、数据永远停在内存backupGuest()快照带上 challenge合并失败可回滚。第七期交的学费这期直接变成了 checklist。七、验证清单老用户v1/v2升级后 challenge 域补齐、无 INIT_FAILED历史数据不丢游客态抽盒、完成、跳过全可用断网写入进离线队列联网重放后端未就绪404时写 challenge 降级本地、不推云、不产生 mood 脏数据同日多次打卡幂等「换一个」计数仅本地、不上云徽章进度本地/云端一致同一份 rules、同一份 recordsreduceMotion下无缩放动画日记默认剥离不上云。小结三条工程原则派生优于存储。能算出来的就别存——它自动解决一致性、过期、迁移三件套把「不做什么」也建模。skip 是数据不是失败接纳才有落点而不是停在文案新能力上线先想怎么降级。能力探测不是锦上添花是保护旧数据的底线。