关键词:微信小程序、本地优先(Local First)、Spring Boot 3、MongoDB、增量同步、游客模式、双 Token、系统部署
项目
mood_log包含两个工程:emotion-monster(微信小程序前端)+mood-backend(Java 后端)。本文以一个个人开发者视角的完整周期,拆解它的设计取舍与落地细节。
一、项目背景与目标
市面上的情绪 / 生理期记录工具大多"重云端、轻隐私":打开就要注册,数据默认躺在别人的服务器上。本项目反其道而行------本地优先(Local First) 是贯穿始终的核心价值观:
- 隐私可控:所有数据默认存在用户设备本地,离线可用,打开即用,不强制注册。
- 零门槛:未登录也能完整记录;想备份换机时,再登录把数据合并上云。
- 轻量可维护:个人开发者维护,技术栈必须简单、依赖少、可独立部署。
产品目标很朴素:让用户"今天花 10 秒打一个情绪卡 + 生理期卡",再通过统计与可视化,帮助ta看见自己的身心节律。技术目标则要更硬核一些:本地优先架构下的离线写入、增量同步、幂等合并、游客一键上云。
二、整体架构设计
系统采用前后端分离 + 本地优先的两段式结构:
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────┐
│ emotion-monster (小程序) │ ─────────────────────▶ │ mood-backend (Spring Boot) │
│ │ /api/v1/** │ 8200 │
│ storage.js (L4 本地引擎) │ │ Spring Security + JWT │
│ data-adapter (门面) │ │ MongoDB (mood_log) │
│ ├─ LocalAdapter (本地) │ │ calculator / service / ... │
│ └─ RemoteAdapter (云端) │ ◀───────────────────── │ │
└──────────────────────────────┘ 增量 pull / push └─────────────────────────────┘
核心抽象是 Api 门面 (utils/data-adapter.js):页面层只调用 Api.request(module, action, payload),至于数据最终落在本地还是云端,由适配器决定。
javascript
init(force) {
if (this.adapter && !force) return;
const logged = !!storage.getSession();
this.adapter = logged ? new RemoteAdapter() : new LocalAdapter();
}
- 游客态 :走
LocalAdapter,所有读写直接操作wx.Storage上的内存镜像。 - 登录态 :走
RemoteAdapter,写操作先落本地、后台推送云端;读操作镜像回本地缓存,页面无感。
这种"门面 + 适配器"的模式,让页面代码完全不感知存储位置,是本地优先能成立的关键。
三、关键技术决策及理由
3.1 为什么用原生小程序而非框架
项目选择原生 WXML/WXSS/JS,不引入 Taro/uni-app。理由:
- 零
npm install、零构建,微信开发者工具导入即跑,部署心智负担最低; - 个人开发者维护,减少"框架升级踩坑"的隐性成本;
- 需要的是稳定可交付,而不是跨端复用。
代价是组件复用靠手写,但这对功能规模来说是可接受的。
3.2 为什么后端选 Spring Boot 3 + MongoDB
| 维度 | 选择 | 理由 |
|---|---|---|
| 语言/框架 | Java 17 + Spring Boot 3.3 | 与算法/类型系统契合,Spring Security 鉴权开箱即用 |
| 数据库 | MongoDB | 文档模型天然适配"每个用户一份变长记录集",schema 演进成本低 |
| 鉴权 | JWT(jjwt 0.12.6, HS256)+ 双 Token | 无状态、适合小程序与未来 Web 管理端共享 |
| 文档 | springdoc-openapi | 自动生成 /v3/api-docs,前端类型可代码生成 |
MongoDB 的"无表结构"特性尤其重要:本地存储引擎(storage.js)也是一套 JSON 结构,前端与后端的数据形态高度一致,同步算法得以在两端复用同一套语义。
3.3 为什么"本地优先"而非"云优先"
云优先意味着断网即废、注册即门槛、隐私即妥协。本地优先把可用性 和隐私前置:
- 写操作永远先成功(落本地),再异步上云;
- 网络异常进入离线队列
pendingOps,恢复后重放; - 即使后端宕机,用户的核心记录体验不受影响。
它带来的唯一复杂度是"同步",我们用增量 push/pull + clientId 幂等 + updatedAt 冲突仲裁来化解(见第五节)。
3.4 为什么游客与正式用户共享同一套记录体验
设计原则叫"进得来、用得起、走得掉"------游客与正式用户唯一差异在"数据是否上云":
- 游客身份 = 本地
deviceId(wb-+ 随机串); - 登录即用
openid关联合后端用户,触发本地数据与云端按clientId + updatedAt合并; - 合并前自动备份到
em_guest_backup,失败可回滚,绝不丢数据。
这样"登录"不是一个门槛动作,而是一次可选的"备份升级"。
四、核心功能实现思路
4.1 L4 本地存储引擎(storage.js)
这是整个前端最关键的模块,能力包括:分域多 key 读写、结构校验、版本迁移、写锁串行、防抖持久化、事件广播、内存镜像。
几个值得说的设计点:
- 内存镜像 :启动时把四个核心域(
meta/mood/period/settings)读入内存,页面读写只操作内存,避免频繁wx.Storage同步 IO。 - 写锁 + 防抖 :所有写操作进入串行队列
writeQueue,并以 500ms 防抖批量落盘(flush),既保证并发安全又减少 IO。
javascript
function lockWrite(task) {
const run = writeQueue.then(task, task);
writeQueue = run.catch(() => {});
return run;
}
- 版本迁移与自恢复 :
schemaVersion驱动migrate();一旦结构损坏,转存em_corrupted_backup后空库启动,不白屏。
4.2 静默登录 + 游客降级(app.js)
登录流程刻意做到"无弹窗、不打断":
- 启动时优先判断本地会话是否可复用(
canReuseSession():refreshToken 剩余 > 7 天直接复用,不发新请求); - 不可复用时才
wx.login()取 code,静默调/auth/wx-login; - 任何失败一律降级为游客态,本地数据完全不受影响。
4.3 双 Token 与单飞续期(http.js)
后端签发 accessToken(2h)+ refreshToken(30d,哈希落库 + 轮换)。前端封装统一请求层:
- 业务请求 401(token 过期)→ 单飞(single-flight) 调
/auth/refresh→ 重放原请求; - 单飞用模块级
refreshTask变量保证并发 401 只刷新一次,避免"刷新风暴"。
javascript
function refreshAccessToken() {
if (refreshTask) return refreshTask; // 并发复用同一 Promise
refreshTask = new Promise(/* ... */);
return refreshTask;
}
4.4 增量同步与冲突仲裁
- push :本地每次写操作后节流 2s 合并推送,断网进
pendingOps队列,指数退避重试; - pull :登录/启动/回前台携带
since=pullCursor增量拉取; - 幂等 :
(userId, clientId)唯一索引,重复触发不产生重复数据; - 冲突 :
updatedAt新者胜,相同则服务端优先;删除用软删墓碑(deleted=true,保留 90 天)。
4.5 统计算法前后端同源
后端 calculator/ 包(连续打卡 StreakCalculator、周期预测 PeriodPredictor、情绪统计 MoodStatsCalculator、关联分析 PeriodCorrelationAnalyzer)由前端 utils/calc.js 移植而来,保证本地计算与云端聚合口径完全一致------这是"本地优先"能给出可信统计的前提。
4.6 首页"情绪增强"模块(零后端改动)
一个很好的例子是首页增强模块:新增"快速因子标签栏 / 今日小滋养短句 / 情绪预警提示卡"三块功能,后端零改动、首页零新增网络请求 ,完全复用已请求的 5 个并发数据。它还把"情绪 × 生理周期相位"的联动提示前置到首页 ,并严格遵守合规红线:只提示、不诊断,固定尾行"⚠️ 只做提示,不做诊断。"。这种"纯前端、纯函数、可独立验证"的模块设计(utils/mood-tips.js)值得推广。
五、开发与测试流程
5.1 设计先行
项目最大的特点是有系列化、可实施的详细设计文档(v4/v5/v6 演进、首页增强、管理端方案、以及两份"诊断报告")。每次大的架构演进都先写文档再写代码:本地优先 → 叠加账号体系 → 登录与同步 → 性能诊断与优化。文档里甚至包含"落地改造清单"和"实施优先级",把"想清楚"作为第一道工序。
5.2 静态代码审计驱动优化
没有专职测试团队,团队用静态代码审计 + 增长建模 做性能预判。例如 refresh_tokens-数据增长诊断报告 通过逐行追代码,预言了集合会单调递增、垃圾率 >99%,并给出定量模型与上线检查清单。这种"在写之前就把坑画出来"的做法,比事后救火高效得多。
5.3 纯函数可测性
核心规则(预警判定 buildAlert、滋养句选取 pickNourish、连续打卡、周期预测)都被抽成无副作用的纯函数 ,不依赖 wx.*,可脱离小程序环境直接跑用例矩阵。首页增强文档里就附了 10 条 buildAlert 用例(T1--T10)作为验收基准。
5.4 接口契约前移
后端集成 springdoc,/v3/api-docs 可直接用 openapi-typescript 生成前端 DTO 类型,把契约错误消灭在编译期。
六、部署方案与环境配置
后端部署在单机 Linux,整体链路:小程序 → Nginx(HTTPS) → Spring Boot(8200, 仅回环) → MongoDB(27017)。
6.1 服务托管(systemd)
deploy/mood-backend.service 用 systemd 托管,做了基础加固:NoNewPrivileges、PrivateTmp、ProtectSystem=full,JVM 参数按规格收敛(-Xmx256m、G1GC),凭据通过 EnvironmentFile(chmod 600)注入,优先级高于 jar 内配置,便于轮换而无需重新打包。
6.2 反向代理(Nginx)
nginx-mood.conf 承担:HTTP→HTTPS 全量跳转、TLS≥1.2+现代 cipher 套件、Strict-Transport-Security、gzip、单 IP 限流(limit_req)、文件上传大小限制。后端只监听 127.0.0.1:8200,外网一律经 Nginx 进入,缩小攻击面。
6.3 滚动发布与自动回滚(rollout.sh)
每次发版一个脚本搞定:备份旧 jar → 替换 → systemctl restart → 健康检查 GET /api/v1/health → 超时则回滚。还保留最近 5 个备份,失败可一键还原。
bash
until curl -fsS "$HEALTH_URL" >/dev/null 2>&1; do
if [ "$(date +%s)" -ge "$DEADLINE" ]; then
# 回滚到上一个可用版本
fi
sleep 2
done
6.4 配置与密钥
真实凭据放 application-local.yml(gitignore 忽略),生产优先用环境变量(MONGODB_URI / JWT_SECRET / WECHAT_APP_ID / WECHAT_APP_SECRET)。文档特别提示:密码含 @ 需 URL 编码为 %40;JWT_SECRET 必须 ≥32 字节并定期轮换。
七、遇到的挑战及解决方案
7.1 refresh_tokens 集合失控增长
问题 :诊断发现,小程序 onLaunch 无条件静默登录,每次冷启动签发一条新 refreshToken,旧记录永不回收;deleteByExpiresAtBefore 已定义却 0 处调用,无 TTL 索引、无定时任务。建模估算典型公测场景年增约 186 万条、~740MB,垃圾率 >99%。
解决方案(分阶段落地):
- 客户端会话复用 :
canReuseSession()判断 refreshToken 剩余 >7 天直接复用,消除 90%+ 的签发请求(改动约 10 行)。 - TTL 索引 :给
expiresAt加expireAfterSeconds=604800,MongoDB 自动回收过期记录。 (userId, deviceId)唯一索引 + upsert :每用户每设备恒定 1 条记录,集合规模从O(启动次数)降为O(用户数 × 设备数)。- CAS 原子刷新:用"删除返回值"做乐观锁,杜绝并发重复创建孤儿 token。
- 兜底定时清理 + 监控 (
TokenCleanupTask,每日低峰输出validRate等指标日志)。
上线踩坑点 :第二步引入唯一索引后,若存量已有
(userId, deviceId)重复记录,Spring Data 启动时建索引会抛DuplicateKeyException导致启动失败。文档给出"预检 → 清理重复 → 复检"的发布前检查清单,必须执行。
7.2 deviceId 不稳定击穿容量优化
诊断 deviceId-稳定性对刷新链路的影响分析 发现:虽然 deviceId 变化不会打断刷新(refresh 靠 tokenHash 定位、JWT 中 deviceId 只是透传 claim),但会让"每设备 1 条"的优化失效,并有小概率触发唯一索引冲突导致掉登录。
根因 :storage.js 在 meta.deviceId 缺失兜底时,只写入内存变量、未回写并持久化,导致同一设备每次冷启动都生成新 deviceId。
修复 :兜底生成后必须 enqueueWrite('meta') 再 flushNow()------注意顺序,因为 flush() 以 dirty 标记为开关,直接调 flushNow() 时若 dirty.meta 仍为 false 会直接返回不落盘。同时后端 refresh 不再写回 deviceId(语义上续期不应改变设备归属),彻底消除索引冲突。
7.3 弱网与冲突
- 断网写 :先落本地 + 进
pendingOps,恢复后批量重放,指数退避最多 5 次。 - 合并冲突 :
updatedAt新者胜;合并前备份em_guest_backup,任一步失败可安全重试,不进入半合并状态。 - 管理端并发写 :引入乐观锁
expectedUpdatedAt,冲突返回SYNC_CONFLICT(50001),提示用户刷新后重试,避免静默覆盖。
八、性能优化措施
- 本地存储引擎:内存镜像 + 写锁串行 + 500ms 防抖,把高频小写聚合成低频落盘;万条记录冷启动目标 <100ms。
- 会话复用:避免每次冷启动签发 token,把刷新集合增长压降 ~98%。
- 增量同步 :
push节流合并、pull携带since游标,只传变化量,减少带宽与计算。 - 统计走服务端聚合 :复杂聚合(趋势、分布、热力、留存)由后端
calculator计算,前端只做视图映射,杜绝"前端拉明细再统计"的崩溃风险。 - 索引治理 :补齐跨用户检索索引(
{recordDate:-1,deleted:1}等)、聚合强制带日期区间、预聚合表(daily_stats)应对大数据量,管理端读可选走从节点。 - Nginx 层 :gzip、连接复用(
keepalive)、单 IP 限流,缓解接口被刷。 - 可观测 :
TokenCleanupTask输出容量指标(total/validRate/active24h),traceId贯穿前后端日志,问题可主动发现而非事后救火。
九、经验总结与最佳实践
9.1 把"想清楚"前置,用文档驱动开发
这个项目最值得借鉴的不是某段代码,而是设计文档即实施清单的习惯:每次大的演进都先产出一份带"落地改造清单 / 优先级 / 检查项"的文档,甚至包含定量增长模型。它让一个人的开发也能保持架构一致性,降低返工。
9.2 本地优先是价值观,不是技术噱头
本地优先带来的离线可用、隐私可控、零门槛体验是产品差异点,但它要求你认真设计同步、幂等、冲突仲裁、合并回滚。一旦偷懒,就会在"集合膨胀""设备号漂移"等地方付出代价。本项目的诊断报告证明:这些代价是可以被提前建模并消除的。
9.3 用"最小改动"验证最大收益
很多优化只需十几行:会话复用(前端 10 行)、TTL 索引(实体 1 行)、deviceId 兜底回写(几行)。先做收益最高、风险最低的改动,再逐步上结构性优化,是个人项目的务实节奏。
9.4 一致性要靠"协议"而非"默契"
前端本地、前端云端、后端、未来的管理端,四方对"软删墓碑 / clientId 幂等 / updatedAt 仲裁"必须口径一致。管理端方案据此规定:只软删、必更新 updatedAt、clientId 不变、source=admin、乐观锁------守住这五条,小程序无需任何改造即可感知后台改动。
9.5 安全与隐私是默认项,不是补丁
- 密码 BCrypt;
openid/session_key不下发、不落库; - 所有查询强制按
CurrentUser.userId隔离,拒绝客户端注入 userId; - 全局异常不泄漏堆栈;日志敏感数据脱敏;
- 合规上定位"工具 > 效率/生活服务"类目,文案避免诊断/医疗建议,隐私指引与
wx.requirePrivacyAuthorize完整接入。
9.6 部署要"一键可回滚"
rollout.sh 把备份、重启、健康检查、回滚串成一条命令,配合 systemd 自恢复(Restart=on-failure)。对个人开发者而言,能在 2 分钟内把坏版本退回去,比追求零停机更重要。
十、写在最后
情绪小恐龙 不是一个追求技术炫酷的项目,它更像一个"一个人在约束下把事情做对"的样本:本地优先的产品理念、文档先行的开发纪律、用静态审计补位测试、用最小改动拿最大收益、把安全与隐私当默认值。
对同样在独立做小程序的开发者,我的建议只有一句:先想清楚数据怎么存、怎么同步、怎么回滚,再写第一行页面代码------因为后面所有功能,都会建立在这套地基之上。
项目已在 Gitee 开源,有兴趣的朋友可以私信联系获取地址