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

第六期验证了注册表驱动的可扩展,第七期补上了「本地常驻」这种不出站的数据角色。第八期要回答一个更「软」的问题------怎么做一个游戏化功能,才能不制造焦虑? 答案是把它当成一道数据建模题来解。本文记录「情绪闯关挑战盒」的落地过程。

关键词:派生而非存储 · 跳过一等公民 · 单一事实源 · 能力探测降级 · 前后端错峰上线 目标读者:前端 / 后端 / 产品


一、为什么「连续打卡天数」是危险的设计

大多数习惯类产品靠 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' 的记录。两个后果:

  1. 当日状态是「休息」而不是「空缺」。首页今日状态三态(已完成 / 今天休息 / 还没打开),休息与完成视觉权重相同------休息不是空白。
  2. 休息本身可被统计、被奖励 。徽章「休息知晓者」的条件就是 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。

七、验证清单

  1. 老用户(v1/v2)升级后 challenge 域补齐、无 INIT_FAILED,历史数据不丢;
  2. 游客态抽盒、完成、跳过全可用;断网写入进离线队列,联网重放;
  3. 后端未就绪(404)时写 challenge 降级本地、不推云、不产生 mood 脏数据;
  4. 同日多次打卡幂等;「换一个」计数仅本地、不上云;
  5. 徽章进度本地/云端一致(同一份 rules、同一份 records);
  6. reduceMotion 下无缩放动画;日记默认剥离不上云。

小结

三条工程原则:

  1. 派生优于存储。能算出来的就别存------它自动解决一致性、过期、迁移三件套;
  2. 把「不做什么」也建模。skip 是数据不是失败,接纳才有落点,而不是停在文案;
  3. 新能力上线,先想怎么降级。能力探测不是锦上添花,是保护旧数据的底线。

相关推荐
java资料站2 小时前
七、Spring AI Alibaba · Skills
java·后端·spring
code2cat2 小时前
Java进阶篇之AtomicReferenceFieldUpdater:用不可变快照替换整份状态
java·开发语言·cas
geats人山人海2 小时前
现代c++2.4 虚继承
java·开发语言·c++
灯澜忆梦3 小时前
【面向对象编程c++】| 数组与函数
java·数据结构·c++
我命由我123453 小时前
Android 广播 - 组件通信
android·java·开发语言·android studio·android jetpack·android-studio·android runtime
Wang's Blog3 小时前
Java 项目实战: 交友项目 - 安装 Nacos 注册中心与 Dubbo 监控中心
java·dubbo·交友
李福春3 小时前
斗破Google ADK Java苍穹-第2境斗者-凝旋筑基-Java环境
java·开发语言·gemini·llmagent·深圳同盟·腾讯云架构师技术同盟·adk java
霸道流氓气质3 小时前
LLM API密钥管理与轮换策略:Spring Vault动态密钥、熔断限流协同与多Provider路由的 Java 生产级实现
java·后端·spring