一款专为情侣两人设计的亲密互动 App。把日常的小事攒成关系里的仪式感。
仓库是 monorepo,含两个独立项目:
couple-buzz-app/— Expo / React Native 移动端(iOS 主)couple-buzz-server/— Node.js + Express + SQLite 后端
App 底部 6 个 tab:拍拍 · 废话区 · 每日 · 信箱 · 约定 · 数据。
底部导航是自定义的灵动岛风格 PillTabBar:胶囊状按钮、
onPressIn派发瞬切(不等抬手)、上方一段 fade-up 渐变让屏幕内容自然没入工具栏;选中态粉色填充 + spring 弹性放大;4 个 tab(拍拍 / 废话区 / 每日 / 信箱)顶角有未读红点。
- 实时摸一摸:双方在线时按住屏幕同步,主页心跳动画 + 持续 haptic
- 同时在线感知:两人同时打开 App 时主页提示「你们正在同时想着对方 💓」;重连后服务端会主动下发权威 presence 快照,避免残留状态
- 顶部状态条:连续天数 🔥 / 置顶纪念日倒数 / 双方在线指示
- 50+ 表情一键发:4 类网格(表达爱意 / 心情 / 日常 / 找你),上滑出下滑收
- APNs 推送 + Haptic:每个动作推送对方手机 + 触感反馈
- 在线静音:对方在 App 前台时跳过 emoji 推送,红点完全由 socket 驱动,避免双端同时在线时锁屏被刷屏
- 聊天式时间线 + 连发折叠:按日聚合;5 分钟内同一表情连发屏幕内自动折叠成
表情 ×NN,新一下落地时计数弹跳一次;锁屏推送同步合并 —— server 端bursts.ts跟踪 5 min 滚动窗口,第二条起带×N文案 + 稳定collapseId,APNs collapse-id 把旧通知替换掉,5 个 💋 spam 在锁屏只显示 1 条 banner,数字滚到 ×5(v1.3.1) - 上拉翻页:列表顶端再拖动一次触发
loadOlder(before_id cursor + has_more),SectionList 用maintainVisibleContentPosition让 prepend 旧消息时可视区不跳;单次拖动只触发一次,要再加载下一页必须重新拖动(v1.3.2) - 未读分界线 + 水滴入场:上次离开位置自动画一条「以下是新消息」分界线,新消息以水滴 spring 入场动画落入列表
- 长按标题自定义:长按页面标题(默认「香宝聚集地 💕」)弹 prompt 改文案,30 字上限、whitespace-only 视为清空,按账号 per-user 持久到服务端(v1.2.20)
- 时区感知:每条记录按双方各自所在时区分别显示时间
- 桌面 badge 真实未读数:iOS 图标右上角显示对方发的未读消息数(一次性 mark-read 防客户端越权)
- 早安/晚安打卡:按本地时区窗口(早 4-13 点 / 晚 18-4 点)开放;双方都打卡后展示当日互动 recap
- 每日问答:1000+ 题题库,双方都作答后才互相揭晓;按北京时间 07:00滚动新题,距下次刷新倒计时合并到屏幕底部
- 一次性 👍 / 👎 互评 + 评价后推送通知对方
- 自己已答对方未答时显示「⏰ 快答!」催答按钮(30s cooldown)
- 每日快照:每天一张前置自拍,按月日历查看
- 同样支持一次性 👍 / 👎 互评 + 「⏰ 拍照!」催拍按钮(5s cooldown)
- 上传 atomic(写 tmp → 校验 → rename),防绕过 client 覆盖已有照片
v1.1 重构:原本平铺的 MailboxCard / TimeCapsuleCard 全部下沉,主屏只剩三张入口卡 + 底部「写信 ✉️」灵动岛 pill。点入口卡以全屏
pageSheet模态弹出对应子界面。这套结构让信箱真正像一个「桌子」:写信入口永远停在底部、收件 / 整理 / 留言三件事各回各家。
主屏入口(自带未读 🚩 红旗):
- 📬 收件箱 — 已送达的半日达 + 已开启的择日达
- 🗑️ 废件箱 — 从收件箱删除的信件可以在这里恢复(v1.1 前叫「垃圾篓」)
- 📝 小贴吧 — 双方共享的便利贴墙(v1.1 新增)
- 📷 快照日历 — 按月查看双方每日自拍:7 列网格 + Polaroid 缩略图 + 月份切换;两人都拍当天双张错位叠放(我的在上 +2° / ta 的在下 -2°),today cell 粉边框;右下角「ta / 我」segmented toggle 单视角切换;点 cell 全屏预览,点空白处收起(v1.2.21 / v1.3.0–v1.3.2)
底部固定的 ✉️ pill 进入统一写信流程(半日达 / 择日达分支由封信后选择)。底部还有 📤 发件箱入口(OutboxScreen),列出自己已寄出但还没送达的半日达 / 择日达,按上下滑动浏览,已送达自动从列表里消失;新寄出的灵动岛 toast + 信箱 tab 红点立刻点亮,无需等推送回环。
5 个阶段:write → sealing → kind → capsuleDetails → sending
- 写:奶油色信纸(
#FAF6E8)+ 棕墨字(#3D2A19);正式信件版式:致 [对方/自己] · 正文 · 落款 [自己] · 字数计数;iOS 键盘上方 inline accessory 「完成」按钮一键收键盘;草稿 400ms debounce 自动落 AsyncStorage,关掉重开还在 - 封:SealAnimation(信纸 → 信封 → 火漆印)~1.3s,作者本人也看不到自己写的内容(
my_sealed服务端标志,writing/sealing 阶段不返回 my_message;客户端草稿 setter 在 sealing 之后失活,避免 stale closure 把 sealing 阶段的 UI 草稿写入) - 选:📮 半日达 / 💌 择日达,二选一卡片;两类信件 1000 字上限对齐(半日达 v1.3.7 从 500 提到 1000,前端字数计数与服务端 reject 阈值统一);半日达单场可多发(同一窗口写多少封都行,到点一起送达),不再是「一场一封」的限制
- 择日达详情:年/月/日 + 时/分五段下拉选择器(不再是日历点击,6 年窗口,月日联动 clamp);双时区即时预览——同时显示「我(北京时间):04-27 20:34」与「ta 那边收到时:04-28 04:34」,让对方拿到的也是整点钟整分;可见性切换 🪞 给自己 / 💕 给对方
- 寄:信件缩小 + 微微旋转 + 沿 Y 轴落入信箱图标(~520ms)+ 信箱小弹跳(~180ms),左右随机偏置增加变化
- 中央卡居中 snap,scale 1 / 邻居 0.93 / 远端 0.86 体现景深,所有卡保持 opacity 1 全部展示;卡片层叠 75% / 露出 25%
- 中央卡 tap 直接快速预览(fade + scale up,~250ms);邻居 tap 自动滚到居中
- 滑动 haptic + 标题栏渐变 + 未读 pill + 邮戳时间戳(双时区邮戳 = 寄出方 TZ + 收件方 TZ)
- 右划删除:仅中央卡可触发,飞出阈值 38% 屏宽,触发后调用 trash API + 顶部弹出灵动岛风格 toast
- 未读小旗子:服务端 letter list 与本地
INBOX_LAST_SEENcursor 比对,新到的飞旗子;打开 InboxScreen 即刷新 cursor 到 now - 长信完整可读:信纸用「definite-height 根 + flex 链」布局,header / footer / Dynamic Type 怎么变 ScrollView 都拿得到正确剩余空间;底部「还有更多」渐隐条 + scrollIndicator + 「拖动阅读 · 轻点空白处收起」提示文案三处 affordance 让用户知道可以滚(v1.3.4 / v1.3.6)
- 长信不再牵动收件箱:letter overlay 包在自己的
transparentModal(=iOSoverFullScreen)里,盖住底下的 pageSheet InboxScreen,pageSheet 的 swipe-to-dismiss recognizer 收不到 touch,长信 ScrollView 滑到边界只是自己 bounce 回弹,不会把整个信箱 modal 拖走(v1.3.8) - 排序保障:
(arrivedAt ASC, writtenAt ASC)复合排序 + scroll-to-bottom 初始化,同 session 多封信按写信时间 tiebreak,新解锁的 capsule 也能落到顶层(v1.2.17 / v1.2.18)
- 单条「恢复」/「彻底删除」+ 选择模式批量「全部恢复」/「全部删除」
- 「彻底删除(purge)」服务端永久隐藏:archive、capsules、open endpoint 三处统一拦截,无法恢复
- 主信箱页下拉刷新一并刷新收件箱 + 废件箱
双方共享的木质便利贴墙。把那种「想发又不值得发一条单独消息」的小事写出来,贴一张,过几天对方贴一张回应——比聊天更轻、比朋友圈更近。
- 木色背景
#D4B68C+ 奶油便利贴#FFFBE6;自己的字粉色#A0144A,对方的字蓝色#0F4F8A - 掉落入场动画:scale 1.35→1 + translateY -32→0 + opacity 0→1,spring
- 跟帖 = 订书钉串联的便利贴堆:每个跟帖一张独立纸,纵向重叠 14pt + 圆钉装饰(pin head + highlight 高光)+ 纸面随机 ±1°-±5° 微旋转,新纸在最上层
- 双击便利贴 → 跟个帖:300ms 内同 sticky 二次轻点直接弹出回复编辑器
- 撕下来过场动画 + 永久删除:长按 → 二次确认 → scale 缩 + 随机 ±22° 翻转 + Y 轴上飘 + 淡出(~320ms)→ 调 DELETE 接口级联清掉所有 block 和 seen 记录
- 草稿持久化(temp):每人每对最多一张未发布的 sticky;编辑器 1200ms debounce 自动保存到服务端
status='temp',关掉再开还在;类似地,跟帖也有 temp 阶段 - 背景点击收起 + 底部「收起」pill + 标题贴顶;点击空白区域收回编辑器,连点保留草稿
- 未读旗子:每张 sticky 维护
last_seen_block_id,对方有新 block 则首张纸右上角亮一颗「未读」灵动岛 - socket 实时推送:服务端
sticky_posted/sticky_appendedAPNs 推送 +sticky_updatesocket 广播;信箱 tab 红点和入口卡红旗实时刷新
所有「过去的约定」和「未来的约定」集中地。
- 多纪念日管理:增删改、置顶倒数(首页显示)、支持每年重复
- 年/月/日三段下拉选择(不再用日历点击,方便选 20 年前的纪念日)
- 已过去的不重复纪念日自动显示「已经 N 天啦!」
- 「添加纪念日」改用 App 级灵动岛 pill 入口
- 每条纪念日带「编辑」按钮,复用同一 picker 表单,提交分支 POST vs PUT 切换;以前只有「删了重建」一条路(v1.3.7)
- 心愿清单:一起完成的 bucket list,按分类管理(旅行 / 美食 / 活动 / 其他)
- 「添加心愿」也是灵动岛 pill;输入框不自动 focus,避免一进页面就弹键盘
- 创建者区分:每条心愿左侧 4px 彩条 + 右侧 chip,自己粉色(
COLORS.kiss)/ 对方浅蓝(#7AB8D6) - 完成时二次确认 + 屏幕烟花动画庆祝 + 推送带具体心愿名给对方
- 双方 ID 卡片:左右并列展示
- 互动统计:总互动数、双方对比、最爱表情 Top 5、按月趋势
- 恋爱周报:本周互动量、与上周对比、连续天数、温度评分(互动 + 问答 + 打卡 + 连续天数四项加权)
- 昵称 + 备注 配对行:双方昵称、彼此私密备注左右镜像并排,编辑后居中弹出灵动岛保存 pill
- 昵称 + 时区设置:时区像 ID 一样并列显示,点选即自动保存
- 多设备列表(DeviceListCard):列出当前账号所有登录中的设备(设备名 / 机型 / 系统 / App 版本 / 上次活跃时间);任一为「主设备」,本机标注「本机」徽章;可一键设主设备 / 强制下线某台 / 自我下线;强制下线即时生效(被踢端下次请求 401,access token 也会被 token_version 拒绝),不用等 token 自然过期
- 解除绑定:双侧均可发起;解绑后双方所有 couple-scoped 数据(History / 信箱 / 小贴吧 / 心愿 / 纪念日 / 每日问答…)保留 90 天,期间重新绑定整套数据自动复活,过期才硬删
- iOS WidgetKit 小组件:桌面卡片 Swift 框架已搭好(
couple-buzz-app/targets/widget/,预留partnerLastEmoji / partnerLastActionTime / streak / partnerName4 个 App Group UserDefaults key) - OTA 热更新:纯 JS 改动通过 EAS Updates 秒推到手机,无需重 build
- JWT 双 token + 多设备会话:access 15 分钟 / refresh 90 天,refresh token 自动轮换 + 单事务化轮换 + 并发刷新加锁;每条 refresh token 绑定一个
session_id(设备名 / 机型 / 系统 / App 版本 / 上次活跃 /is_primary/revoked都在refresh_tokens行内),auth 中间件每次请求都过isSessionActive(),强制下线 / 主设备切换即时生效;token_version 字段保留作全账号即时吊销开关 - 弱网 refresh grace 5 min:rotation grace window 从 10 s 拉到 300 s 后地铁 / 电梯 / 弱 Wi-Fi 上 refresh 响应丢包不会被踢;客户端 bootstrap 遇 generic AuthError 等 2.5s 再 retry 一次 getStatus 才决定清账号;revoked session 仍立即返回
code:session_revoked强制下线,安全语义不变(v1.2.19) - 重登多设备智能合并:以 APNs device_token 作「同一物理设备」唯一可信信号 ——
/login和PUT /device-token检测到 token 已绑定到本用户旧 session 时自动 revoke 那个旧 session 并把device_name + is_primary继承过来;两台同名 iPhone(APNs token 不同)依旧并存。客户端登录带上storage.getApnsTokenCache()缓存值(device-scoped, 不参与 clearAll)(v1.2.17) - 多设备 APNs 推送:
device_tokens表(apns_tokenPK +user_id+session_id)替代users.device_token单值字段,一个用户在 N 台设备登录就 N 行;pushToUser自动 fan-out 到所有设备,APNs 410 失效 token 自动清理;与 sessions 关联的 token 在DELETE /sessions/:sid时一并删 - 跨端状态同步上服务端:每日已读(
daily_seen_date / pa / ps)/ 收件箱已读(inbox_last_seen)/ 发件箱已读(outbox_last_seen)/ 写信草稿(write_letter_draft)以前都只在 AsyncStorage,换设备 / 重装就丢;现在全部存到users表对应字段,多设备状态实时一致;写入路径加 only-advance 守卫,旧请求乱序到达不会把游标 roll back - pair_id 关系实体 + 90 天数据 TTL:
couples表把每段「关系」抽象成稳定 10 字符pair_id(用户 a < b lex 排序保证唯一),所有 couple-scoped 数据(actions / mailbox / capsules / sticky / important_dates / daily_answers / ritual / inbox_actions)都打pair_id标签;解绑写ended_at,90 天内重绑同一对人 →ended_at清空,全套数据复活;TTL 调度器 03:00 UTC 每日扫一次过期 couple 硬删 - APNs badge 全推送覆盖 + 每推送 +1:
pushToUser在 caller 没传 badge 时兜底取真实跨功能未读数(History + 信箱 + 已解锁 capsule + 小贴吧 partner block 累加,floor=1);scheduler.broadcastPush也按用户 fan-out 走同一管道;并额外维护users.unack_push_count计数列,每次 push 自增 1,最终 badge = max(1, 跨功能未读数, 该计数),让date_new / ritual_* / weather_* / urge_* / weekly_report / bucket_* / snap_*这些没有自己未读游标的推送类型也能让图标数字 +1;前台POST /api/badge-ackreset 计数(v1.2.6 起累计修复,v1.2.18 起 +1 计数) - 限流分层:注册 / 配对 / 认证 / 普通 API 各自独立的速率限制
- Socket 触摸限速:服务端 5 events / 1s 的滚动窗口拦截畸形/恶意客户端,省电省 APNs 配额
- 多设备并发 socket:presence 用
Map<userId, Set<socketId>>维护,手机 + iPad 同时在线不再误报对方掉线,touch_end 仅在最后一个 touching 设备离开时广播 - Presence 防闪 / 防残留:3s debounce 才广播
presence_both避免快速重连闪烁;disconnect 留 1.5s grace + stale-closure guard(旧 closure 检测到 presence 已被新 session 覆盖直接 bail);on-connect 给孤身 socket 主动补一次presence_single治愈历史残留状态 - Capsule 解锁推送防重复 + 分钟级精度:
time_capsules.notified_at列 + 调度器分钟级 dedup key,进程重启 / 时钟漂移 / 多次扫描都不会让对方收到第二遍开信通知;scheduler 去掉 5 min 节流改成每分钟扫一次(v1.2.17);GET /api/capsules加 auto-open sweep,到点的 capsule 在 recipient 拉列表时直接opened_at=unlock_at,对齐半日达「session reveal 自动揭晓」语义,且避免 race 把刚自动 open 的 capsule 再次标成新到达 - socket daily_update 转发:v1.3.7 修复
services/socket.ts漏接daily_update的 bug —— 之前 DailyQuestionCard / DailySnapCard 的subscribe('daily_update')永远收不到,要手动下拉刷新才能看到对方答题 / 拍照;现在双方同在「每日」tab 时一方提交对方屏幕立刻刷新 - 响应式适配:爱心心跳 / 触摸圆环 / 写信 pill / 卡片高度 / 信纸字号 / iPad 信件宽度等 7 处按屏幕尺寸动态计算,从 iPhone SE 到 iPad mini 都不变形
- 健康检查:
GET /health - 下拉刷新:每日 / 信箱 / 数据三 tab 都支持
- 加密备份:每日 03:00 GPG 公钥加密 SQLite,私钥离线保管,详见
couple-buzz-server/docs/BACKUP.md - 灵动岛风格 in-app toast:屏幕顶部胶囊形提示,slide + spring 进出(
IslandToast.tsx) - 顶部 scroll-bound fade:每日 / 信箱 / 约定 / 数据 4 屏列表顶部按
scrollY渐隐,与底部 PillTabBar 形成对称的内容淡入效果 - App 级 Toolbar Slot:
ToolbarSlotContext把屏幕的「写信 / 添加纪念日 / 保存备注」灵动岛 pill 提升到 App overlay 层,不会被屏幕内的渐变遮罩压住
- deep-link 路由:
react-navigationlinking 配置 +couplebuzz://scheme,cold-launch 用getInitialURL(),warm-tap 用addNotificationResponseReceivedListener订阅;点击通知后稳定跳到对应 tab,告别手写 nav-queue - iOS 锁屏合并:相同对话方向的通知合并展示,避免连续摸一摸刷屏
- APNs payload 兼容:服务端把所有自定义字段包在
body顶级 key 下,解决 expo-notifications 从userInfo['body']取数据的兼容性问题 - 桌面图标 badge 全覆盖:以前只有废话区表情 / 反应 / 摸一摸三种推送会带
aps.badge,sticky / 信箱 / 每日 / 快照 / 心愿 / 纪念日 / 仪式 / capsule / 催答催拍 / 解除配对 / 周报 / 天气等 ~25 种推送都把字段省掉,APNs 规范是「缺 badge → 不动图标」,叠加客户端进前台清 0,桌面红圈一直显示不出来;v1.2.6 起pushToUser兜底取真实跨功能未读数(floor=1),所有推送类型都会刷新红圈 - 小贴吧实时联动:
sticky_posted/sticky_appended走 APNs,sticky_update走 socket,信箱 tab 的红点 + 入口卡 🚩 同时刷新;点击通知 deep-link 直接进信箱
- React Native 0.81 · React 19 · Expo SDK 54 · TypeScript
- React Navigation v7(material-top-tabs + 自定义 PillTabBar,6 tab 底部导航 + 顶部 swipe)
- Socket.IO Client(实时触碰 + presence + sticky_update)
- Expo Notifications / Haptics / ImagePicker / Updates / FileSystem / LinearGradient
- 内置
AnimatedAPI +PanResponder实现 wallet cascade、信封开合、右划手势、PillTabBar spring、便利贴掉落 / 撕下来、写信寄出动画(未引入 reanimated,所有动画 OTA 可推) @bacons/apple-targets(iOS Widget 原生构建)- EAS Build (internal IPA) + EAS Updates (OTA)
- Node.js 20 · Express 4 · TypeScript
- SQLite(
better-sqlite3WAL 模式,启动时自动迁移 schema;表分组:用户 / 关系couples/ 设备 tokendevice_tokens/ 互动actions/ 信箱mailbox+time_capsules+inbox_actions/ 每日daily_answers+daily_snaps+daily_reactions+rituals/ 小贴吧sticky_notes+sticky_blocks+sticky_seen/ 心愿bucket_items/ 纪念日important_dates) - Socket.IO Server(一次性 ticket 30s TTL 鉴权,多设备 socket Set,重连权威 presence 快照,touch 5/1s 滚动窗口限速)
@parse/node-apn(APNs HTTP/2 推送 + 多设备 fan-out + 失效 token 自动清理 + payload 包body兼容 expo-notifications + badge 全覆盖兜底)- Multer(图片上传 5MB + image MIME 白名单 + atomic tmp/rename 防覆写)
- JWT + scrypt 密码哈希 + refresh token 哈希存储 + 并发轮换锁 + 事务化轮换 + per-session 设备元信息 + 强制下线即时生效
- node-cron 调度(mailbox reveal / capsule unlock / 周报 / couple TTL 03:00 UTC 硬删,capsule 推送
notified_at列持久 dedup) - Jest + supertest(15 个 suite / 366 个测试用例 —— 含 api.test + regression_h_bugs / ml_bugs / coverage_gaps + 11 个版本回归 suite v1.2.17 → v1.3.8)
- express-rate-limit
- GPG 加密备份 + cron + 离线私钥
| 维度 | 实现 |
|---|---|
| 密码 | scrypt + crypto.timingSafeEqual(防时序攻击) |
| Token | JWT 双 token + token_version 即时吊销 + refresh token 哈希存储 + 自动轮换 + 并发刷新锁 + 单条事务化轮换 |
| 多设备会话 | per-session is_primary / revoked / device_* 元数据;强制下线立刻吊销该 session(auth 中间件每请求过 isSessionActive);DELETE /sessions/:sid 同时清掉绑定的 device_token;自我下线返回新登录入口 |
| pair_id 关系隔离 | couples 表唯一 pair_id(user_a < user_b lex 排序);所有 couple-scoped 数据贴 pair_id 标签;解绑写 ended_at 进入 90 天 grace;中间换过别的对象再换回来不会读到错误关系的旧数据;TTL 后硬删 |
| Cross-pair lookup 防御纵深 | getMailboxMessageById / getCapsuleById 签名加 pairId 参数,row 自身 pair_id != null && != ctx.pairId 时直接 undefined;3 处调用点(validateInboxRef×2 + inbox/trash unopened check)全部传 ctx.pairId(v1.3.7) |
| 图片访问 | HMAC 签名 URL(1h TTL + timing-safe verify + 路径正则严格) |
| WebSocket | 一次性 ticket 30s TTL + origin 白名单 + 多设备 Set 维护 + touch 5/1s 滚动窗口限速 |
| SQL 注入 | 全部 prepared statements 参数化绑定,零字符串拼接 |
| 输入校验 | typeof + length 上限 + YYYY-MM-DD 严格格式 + week/month 范围校验 + daily-reaction 一次性服务端锁 + sticky content 长度校验 + history_title trim/30 字 |
| 文件上传 | atomic tmp+rename + DB pre-check + 5MB + MIME 白名单 |
| 路径穿越 | regex 严格 + HMAC + 文件名/路径不取自 client 输入 |
| 随机源 | crypto.randomInt 生成用户 ID / 配对码 |
| 限流 | 注册 / 配对 / 认证 / API 分层 + socket touch 限速 |
| Badge 计数 | 服务端真实未读数 + clamp 防客户端越权推进读位指针 |
| Badge 全覆盖 | pushToUser 在 caller 不传 badge 时兜底取跨功能未读总数(floor=1),所有推送类型都会刷新桌面图标;scheduler.broadcastPush 也按用户 fan-out 走同一管道,避免直调 sendPush 绕过 |
| 跨端状态同步 | daily_seen / inbox_seen / outbox_seen / letter_draft 全部上 server 字段,only-advance SQL 守卫拒绝旧客户端 roll back 已读游标 |
| 快照反窥探 | 对方拍照后自己未拍时不下发其照片 URL,避免「先看对方再决定自己拍什么」的偷看;daily_snaps 接口按对称性裁掉敏感字段 |
| 时间胶囊 | self 可见性服务端校验(防 partner 猜 id 越权读取) |
| 半日达内容封存 | writing/sealing 阶段服务端不返回作者自己的 my_message,加 my_sealed 标志;客户端草稿 setter 在 sealing 后失活,避免 stale closure 把 UI 草稿覆写 sealed 内容 |
| Inbox 软删除 | inbox_actions 表 per-recipient 状态机(trashed / purged);archive、capsules、open endpoint 三处统一拦截 |
| Purge 防绕过 | 彻底删除后即使直接调 POST /capsules/:id/open 也返 404,杜绝从历史 id 拿回内容 |
| 推送隐私 | scheduler 不给 self-vis 胶囊推送 partner,防止泄露用户私密信件存在 |
| Capsule 推送防重 | notified_at 列 + scheduler 分钟级 dedup key,重启 / 时钟漂移不会重发开信通知 |
| Sticky 多写竞态 | 撕贴 vs 跟帖并发:服务端在 commit 前校验 sticky 仍存在,撕贴成功后 cascade 删除 blocks + seen 记录 |
| Presence 残留 | disconnect 1.5s grace + stale-closure guard(旧 closure 通过身份 token 比对识别已被新 session 覆盖)+ on-connect 给孤身 socket 补 presence_single |
| 数据备份 | GPG 公钥加密(AES-256),私钥离线 U 盘 + 密码管理器 passphrase |
v1.3.8(2026-05-27,Latest)
长信滑到边界牵动收件箱整体 — 改用 nested Modal 隔离手势
- 根因:InboxScreen 是
presentationStyle="pageSheet"原生 Modal,自带 swipe-to-dismiss 识别器。EnvelopeOpenAnimation之前以wrapInModal={false}渲染成 sibling absoluteFill,没法屏蔽 iOS native 那个 pageSheet recognizer,长信 ScrollView 滑到边界把整个 InboxScreen modal 带走 - 修复:InboxScreen 移除
wrapInModal={false}回到默认行为 —— letter overlay 包在自己的<Modal transparent>(=iOSoverFullScreen)里,无 swipe-to-dismiss + 完全盖住底下 pageSheet 吞掉所有 touch,ScrollView 自包含,bounce 回弹不会牵动 pageSheet - 顺手把
wrapInModaldocstring 改成「DO NOT pass false from inside a pageSheet Modal」+ 引用 v1.3.8 根因防下次踩坑 regression_v1_3_8.test.ts6 条 + 全量 15 suites / 366 tests passed
4 项审计修复
- P0 半日达 501–1000 字写不出去 —— server 上限 500 → 1000 与 capsule 对齐,删掉前端那段已无意义的 client-side 500 字守卫
- P1
daily_updatesocket 事件客户端没转发 ——services/socket.ts加一行socket.on('daily_update', d => emit(...)),与已有 7 个事件同模式,DailyQuestionCard / DailySnapCard 的 subscribe 终于生效 - P1 编辑纪念日功能后端有路由前端没调 ——
api.updateDate(id, title, date, recurring)+ AnniversaryWishScreen 加editingDatestate / handleStartEdit / handleSaveDate / 复用 picker,提交分支 POST vs PUT;每条纪念日加「编辑」按钮 - P2 Cross-pair lookup 防御纵深 ——
MailboxMessage / TimeCapsuleinterface 加 pair_id(nullable 兼容 legacy);getMailboxMessageById / getCapsuleById签名加 pairId 参数;3 处调用点全部传 ctx.pairId regression_v1_3_7.test.ts13 条 + 全量 14 suites / 360 tests passed
长信看不到全貌 — 砍掉 200pt 硬编码 reserve 改 flex 链
- 根因:
contentScroll.maxHeight = LETTER_MAX_H - 200在 iPhone SE 1st (568pt) / Dynamic Type Large+ / 长备注名换行三种边缘情况下都是错的,ScrollView 视口要么窄到 7-8 行要么直接溢出 letterCenter - 修复:布局改成「definite-height 根 + flex 链」—— letterCenter / letterContainer 用 height (definite);letterPress 去掉 minHeight/maxHeight 改
flex:1;contentWrap 加flex:1 + minHeight:0(RN column-flex shrink 必填);contentScroll 改flex:1砍掉 flexShrink + maxHeight - 权衡:短信卡片现在固定 LETTER_MAX_H 高度有留白,视觉上更「信件感」且和长信尺寸一致
- 347 tests passed
fadeOpacity inputRange 双分支避免重复边界
overflowAmount在 (0.5, 30] 时原写法生成inputRange=[0, 0, X]有重复首边界,RN 文档说「允许相等但行为 implementation-defined」;按overflowAmount > 30分两条等价分支,inputRange 严格递增更耐 RN 版本升级
长信加 3 处滚动 affordance — indicator / 底部渐隐 / 拖动文案
- v1.3.3 改对了 layout 但用户不知道可以滚 —— 把发现性补齐:
showsVerticalScrollIndicator重新开启(iOS 触摸期间可见,平时不打扰)- 底部「还有更多」渐隐条(LinearGradient transparent → white),仅
contentHeight > scrollViewHeight时挂载,Animated 跟 scrollY 联动距底 30pt 内淡出,pointerEvents="none"不抢手势 - tapHint 文案改成「拖动阅读 · 轻点空白处收起」
- useEffect 在 (visible, content) 切换时重置 scrollY + measurements 防换信带着上一封的 scroll 位置
regression_v1_3_4.test.ts+13 / 344 tests passed
长信无法滚动 + 撕下来 500
- #1 信纸读取无法滚动:
contentScroll只有 flexShrink 没显式 maxHeight,RN 0.81 在 flex column 中 ScrollView 撑成 content 高度,父卡片 clip 在 maxHeight 但 ScrollView 内部感知不到 overflow → 不滚。临时改 maxHeight = LETTER_MAX_H - 200 让长信能滚(v1.3.6 进一步改成 flex 链);envelopeWrap的 pointerEventsnone→box-none首次解开模式下也能滚 - #2 撕下来 500:
deleteSticky()还在按 v1.2.0 之前 5-placeholder OR 子句传参,但 pair_id 重构后stmtGetStickyForCouple只接受 2 个占位符 (id, pair_id),better-sqlite3 抛 RangeError → 500。改函数签名(stickyId, pairId),路由层传ctx.pairId regression_v1_3_3.test.ts+12 / 331 tests passed
废话区翻页改成单次拉动触发 + 快照日历 ta|我 toggle
- HistoryScreen 翻页修复:之前用户拉到顶部 onScroll 持续触发 loadOlder 直到
has_more=false把对话一路刷到第一条。改成 interaction state machine(idle / dragging / momentum + per-interaction fired latch):每次手指拖动至多触发一次 loadOlder,要下一页必须重新拖动 - SnapCalendarScreen ta|我 segmented toggle:右下角与「收起」pill 同一行,每格只渲染当前模式那一侧的照片(PolaroidThumb 改成单图 + 底部 accent 条:粉色=我 / 蓝色=ta),预览 overlay 同样过滤;反窥探在服务端不变
regression_v1_3_2.test.ts+21 / 319 tests passed
表情连发推送合并 + 废话区上拉翻页 + 快照日历点空白收起
- #1 同表情连发 push 合并(锁屏只显示 1 条 ×N):新建
server/src/bursts.ts在内存里跟踪(recipient, sender, action_type)burst,5 min 滚动窗口跟 v1.2.20 客户端对齐;trackBurst每次返回当前 count + 稳定的collapseId(整段 burst 共用)。/api/action调 trackBurst,count > 1时构造 "{name} … ×N" 作 bodyOverride,始终带 collapseId;APNs collapse-id 把锁屏的旧通知替换掉,5 个 💋 spam 在锁屏只出 1 条 banner 数字滚到 ×5 - #2 废话区上拉翻页(loadOlder):
stmtGetHistoryBeforeSQL(WHERE a.id < ?);GET /api/history加before_id查询参数 +has_more响应字段;客户端api.getHistory(limit, beforeId?)+ HistoryScreen state 重心从 sections 改成rawActions,sections 用 useMemo 派生,mergeRaw 按 id 去重合并;加onScroll(距顶 < 80pt 触发)+onListContentSizeChange(latestId 真变大 + 用户在底部才 scrollToEnd 避免 prepend 跳屏);SectionList 用maintainVisibleContentPosition让 prepend 旧消息时可视区不动 - #3 快照日历点空白收起:ScrollView 套
<Pressable onPress={onClose}>,cell 自己的 TouchableOpacity 优先消费 onPress,scroll 手势归 ScrollView;点 grid 区空白(cell 间隙 / padding / 空月份)收起 regression_v1_3_1.test.ts+21 / 298 tests passed
快照反窥探 4 个完整场景测试
- 补 v1.2.21 验收:服务端反窥探机制现在由 4 个测试从双方视角全覆盖(之前只有 1 个单边测试)—— 1) 只 ta 拍 → 我 my_photo=null & partner_photo=null + ta 自己只看到 my_photo;2) 只我拍 → 镜像 case;3) 双方都拍 → 双方都看见两张(both_snapped=true);4) 跨日不解锁 → 我拍 day-A 不会泄露 ta 的 day-B 照片(per-day check)
- 277 tests passed
信箱新增快照日历 + 全清「废话区反应」功能 + 死代码清理
- 反应功能完全移除:服务端
POST /api/reaction整条路由删 +dbOps.addReaction/getReaction/updateReaction/getHistoryReactions+ 对应 SQL prepared statements 全删;/api/historyresponsereactions:{}空对象保留一发兜底旧 OTA bundle;push.ts删reaction模板;客户端api.sendReaction / ReactionResponse / ReactionPicker.tsx整文件删;ActionRecord删onLongPress + reactionsprops;actions.reply_to列保留(SQLite drop-column 需重建表,老 orphan 行被reply_to IS NULLfilter 隔离)。注意:daily-reaction(每日问答 / 快照的 👍👎)是独立 endpoint,照常工作 - 信箱新增「📷 快照日历」入口:
SnapCalendarScreen.tsx(pageSheet modal,模仿 InboxScreen/StickyWall 同款架构)—— 月份切换器 + 7 列日历网格 + Polaroid 缩略图 cell + 全屏预览 overlay + 收起 pill;today cell 粉边框;网格 cell 单 polaroid / 双 polaroid 错位叠放(两人都拍时我的在上 +2° / ta 的在下 -2°);MailboxScreen 在「📝 小贴吧」下方加 entry card,ref + onRefresh 联动 reload;复用现有GET /api/snaps?month=YYYY-MM(反窥探:caller 没拍那天 partner_photo 不下发) - 死代码清理:客户端
api.openCapsule(v1.2.17 后改 auto-open sweep 替代)+api.logout(被revokeSessionGroup替代);服务端dbOps.pairUsers / unpairUsers(被pairCouple / unpairCouple取代)+dbOps.getSession / getRituals / getAllPairedUserTokens / saveSnap+stmtGetRituals / stmtGetAllPairedTokensSQL 全清 regression_v1_2_21.test.ts+24 / 273 tests passed
表情连发去重(×NN bounce)+ 长按标题自定义跟随账号
- 同表情连发去重(屏幕内):HistoryScreen 新增
collapseBursts()—— 同 user_id + 同 action_type + 距上一条 ≤ 5 min 折叠成一个 burst,leader 的 id 作 React key 保持稳定,displayCreatedAt / latestId 滚到最新成员;在groupByDate内部 per-day 调用防止跨日误并;injectUnreadDivider改用(latestId ?? id)比 boundaryId - ActionRecord ×NN 徽章:
count > 1时 emoji 后渲染 ×NN,prevCountRef + Animated.sequence,只有 count 严格变大才弹跳,历史回放 "kiss ×3" 加载时不会无端跳 - 长按标题自定义:
users.history_title新列(TEXT NOT NULL DEFAULT ''),空串=用默认;PUT /api/profile加 optionalhistory_title字段:trim / 30 字上限 / non-string 400 / whitespace-only 视为清空 / undefined 不动现有值(老客户端兼容);GET /api/status带字段;客户端 storageHISTORY_TITLE_KEY+ App.tsx 三处 caching 全部同步;HistoryScreen 标题onLongPress → Alert.prompt → updateProfile,DEFAULT_HISTORY_TITLE ='香宝聚集地 💕' - 27 个新回归测试(228 → 255)
弱网误踢 + 登录 ID autofill
- 弱网误踢回登录页:服务端
/api/auth/refreshrotation grace window 10 s → 300 s(地铁 / 电梯 / 弱 Wi-Fi 一个 round-trip 常超 10 s);revoked session 仍在 grace 检查之后立即返回code:session_revoked,强制下线保证不变。客户端 App.tsx bootstrap 在 generic AuthError 上等 2.5s 再 retry 一次 getStatus 才决定是否清账号;抽 applyStatus / fallbackToCachedOrWaiting 让首试和 retry 路径复用 - 重新登录时 ID 不能 autofill:SetupScreen 登录表单
loginId加textContentType="username" + autoComplete="username",loginPassword加textContentType="password" + autoComplete="password",iOS QuickType 栏现在能在 ID 字段建议保存的凭证 - 11 个新回归测试(217 → 228)
4 项打磨:每推送 +1 角标 / 登录后时区生效 / tab 红点 race / 收件箱排序
- APNs badge 每次 +1:
users.unack_push_count新列,pushToUser每次 INCREMENT,最终badge = max(1, 跨功能未读数, 该计数);POST /api/badge-ackreset 计数;mark-read 也 reset - 登录后时区生效:App.tsx bootstrap / waiting-state poll / handleRegistered 都拉
status.timezone + partner_timezone + partner_remark存进 AsyncStorage,确保首屏 WriteLetterScreen 预览 / InboxScreen 邮戳 / MailboxScreen 下次送达提示用真实 tz 不再 fallbackAsia/Shanghai;SettingsScreen.loadStatus 也同步进 storage 让跨设备改完 tz 另一台 focus 即生效 - 信箱 tab 红点二次加固:
GET /api/capsulesauto-open sweep 改用autoOpenCapsule把opened_at写成unlock_at而非CURRENT_TIMESTAMP,避免 race 把刚自动 open 的 capsule 再次标成新到达;语义也更对(「收于」邮戳 = 信件实际送达时刻) - 收件箱排序明确化:3 个 e2e sort 测试 —— 单纯按到达 / 同 session 多封信按写信时间 tiebreak / 混合 mailbox + 新解锁 capsule 也能让 capsule 落顶层
- 14 个新回归测试(203 → 217)
3 bug + 2 优化:择日达对方收不到 / tab 红点污染 / 重登多设备 / 分钟级解锁 / 收件箱排序
- BUG1 择日达对方收不到:
GET /api/capsules加 auto-open sweep —— 到点的 capsule 在 recipient 拉列表时直接 open,对齐半日达「session reveal 自动揭晓」语义;修了「openCapsule endpoint 客户端从未调用 + InboxScreen 直接过滤掉opened_at=null的 capsule」造成的死锁 - BUG2 写信后信箱 tab 红点:App.tsx 去掉
hasFreshOutboxItems + subscribeOutboxChanged对 tab dot 的驱动,tab 红点只由 partner→me 信号(sticky + inbox 未读)驱动;发件箱 🚩 入口卡红旗由 MailboxScreen 内部状态维持不受影响 - BUG3 重登后「新的本机」:用 APNs device_token 作「同物理设备」唯一可信信号,
/login和PUT /device-token检测到 token 已绑定到本用户旧 session 时自动 revoke 那个旧 session 并把 device_name + is_primary 继承过来;两台同名 iPhone(APNs token 不同)依旧并存;客户端 storagegetApnsTokenCache / setApnsTokenCache(device-scoped,不参与 clearAll) - REQ1 择日达分钟精度:scheduler 去掉
utcMin % 5 === 0节流,capsule unlock 每分钟扫一次;notified_at + fireOnce分钟桶仍保证不会重复推送 - REQ2 收件箱排序:LetterCard 加
writtenAt字段;排序改(arrivedAt ASC, writtenAt ASC)—— 同 session 揭晓的多封信中,后写的落屏幕底部 = 开信箱第一眼看到 - 22 个新回归测试 + 全部 181 个老测试通过(203/203)
README docs 同步
- 测试数从 stale 的 130 同步成实际的 181(技术栈描述 + 跑测试说明 两处)
- 项目结构
components/列表移除已删的MailboxCard / TimeCapsuleCard,补上DailyQuestionCard / DailySnapCard / RitualButton / TouchArea / ActionRecord / ReactionPicker - 项目结构
utils/列表把不存在的outboxFresh改成实际的outboxEvents,补上device / timezone - v1.2.14 changelog 把 "L8 删除 350 行" 修正为 "~950 行"(实际 433+516=949)
- Roadmap backfill v1.2.7—v1.2.11 五个版本(之前从 v1.2.6 直接跳到 v1.2.12,缺了 sessions 多设备打磨那批)
4 处验收清单测试覆盖补齐
GET /health加 supertest + 静态检查(生产 index.ts 注册的契约{status:'ok',timestamp}必须对得上)- L5 客户端
SetupScreen.tsx静态检查password.length < 6+ Alert 文案「密码至少6位」+ placeholder「至少6位」 - H3 客户端
DailyQuestionCard/DailySnapCard静态检查subscribe('daily_update', ...)+ 各自的 kind/target 过滤分支 + load() 真的调到(防 no-op stub regression) - POST
/api/snaps显式 happy-path 测试(前一版只在 H3 emit 测试里隐式覆盖,新加一条独立断言"L1 multer wrapper 没把正常上传也变 400") - 181 / 181 测试全过(171 → 181,+10)
16 项 M / L 级审计修复
- M1
/api/dates未配对早返回字段名nearest→pinned,与 paired 路径一致 - M2
/api/profilepartner_remark提交前 trim:whitespace-only 清空备注、超长按 trim 后判 - M3 HistoryScreen 30s 兜底轮询加 per-focus
staleflag — 切 tab 时 in-flight fetch 不再 setState 出 RN warning - M4
time_capsules/bucket_itemsUPDATE 加AND pair_id = ?防御层(既有路由 pre-check 之外的二道防线) - M5 WriteLetterScreen
pendingSaveRef— 关→快速重开模态时先 await 上一次 in-flight 草稿 PUT 再 GET,避免读到 stale draft - M6 AsyncStorage getter 全部走
safeGet()wrapper,存储底层抛错时返回 null 而不是 unhandled rejection 卡死 loading - M7
push.tsINVALID_TOKEN 清理移除break— 单 token 时行为不变,未来改 batch 时每个失效 token 都被清 - M8 socket client
subscribeunsubscribe 后if (set.size === 0) delete listeners[event]— listeners 对象不再线性 grow - L1
/api/snapsmulter error 用 wrapper 转 400 JSON,避免客户端把 multer 500 误判为网络问题死循环重传 - L2 App.tsx bootstrap
getHistory(1)加 cancelled flag,rapid 退出登录时已发出 fetch 不再写 ref - L3
/api/ws-ticket加if (!user.partner_id) return 400,未配对时不再发空 ticket - L4
startScheduler检查SCHEDULER_DISABLED=1环境变量,pm2 cluster 模式时可指定单进程跑调度,防多进程重复推送 - L5
/registerpassword 最小 4 位 → 6 位,客户端 SetupScreen 同步 placeholder + alert - L6
push.ts加_resetAPNsForTesting()export — 测试隔离更干净 - L7
notification.ts新增getNotificationPermissionStatus()暴露 granted/denied/undetermined,Settings UI 后续可加"通知权限被禁用"提示 - L8 删除 v1.1 重构后未再被 import 的
MailboxCard.tsx/TimeCapsuleCard.tsx死代码(共 ~950 行) - 24 个新回归测试(171 / 171 全过;上一版 147)
4 项 HIGH 级审计修复
- H1 HistoryScreen 用
normalizeIso替代+ 'Z'拼 SQLite 时间戳:Hermes 解析'2026-05-10 12:34:56Z'不稳,跨时区情侣可能看到错位时间或所有消息挤进同一日期分组 - H2 App.tsx
coldStartConsumedRef在 appState 离开 'ready' 时复位:解决"强制下线 → 重新登录" 同进程内点通知不再跳转到对应 tab 的问题 - H3 服务端 3 个 endpoint 新增
emit('daily_update', ...)(/daily-question/answer//snaps//daily-reaction),DailyQuestionCard / DailySnapCard 精确订阅:双方同在「每日」tab 时一方答完题对方屏幕立刻刷新 - H4 socket 客户端
connect_errorhandler 入口捕获myInstance,await 后比对socket === myInstance守卫:AppState 后台→前台触发 disconnect+connect 时不再把旧 handler 的新 ticket 串到新 socket - 10 个新回归测试(137 → 147)
「半日达」更名 + 信箱底部下次送达预告
- 全仓库
次日达→半日达:4 个 APNs 推送模板(mailbox_open / mailbox_written / mailbox_countdown_15min / mailbox_reveal)+ App 全部 UI 文案(写信选项卡 / 收件箱空态 / 废件箱 / 发件箱 / Mailbox 入口卡两条副标题 / 字数超限 Alert / 老 MailboxCard 标题与列表 chip)+ README 描述文 + 内部注释,无遗漏 - 信箱页
写信 ✉️pill 下方新增小字「下个半日达将于 北京时间 05-10 20:00 寄达」,与每日页「下次更新于 ...」同款 typography(fontSize 12 /COLORS.textLight/ 居中),按 Settings 里设置的 tz 渲染 - 新增
useNextHalfDayRevealAt()countdown hook:对齐到下一个 0:00 / 12:00 UTC 边界(半日达 reveal 节奏),minute-tick 重渲染;和useNextDailyRefreshAt共用同一思路 - 137 个接口测试全过(之前 README 标 130,已同步徽章)
APNs badge 全推送覆盖
pushToUser在 caller 没传 badge 时兜底取真实跨功能未读数(History + 信箱 + 已解锁 capsule + 小贴吧 partner block 累加,floor=1,确保至少亮红圈)scheduler.broadcastPush改为按用户 fan-out 走pushToUser(之前直接调sendPush绕过 badge 兜底,定时广播全部漏掉 badge)mailbox.created_at(SQLite 默认'YYYY-MM-DD HH:MM:SS')vsinbox_last_seen(ISO 带TZ)lex 比较坑用datetime()归一化(<T否则 marker 永远显得更晚,未读永远算 0)- 测试 6 处 mockPush 断言补 badge arg;总数 130 个接口测试 pass
多设备 + 跨端同步
- 多设备会话管理:
refresh_tokens拓展 per-session 元数据(device_name / device_model / device_os / app_version / last_active / is_primary / revoked);新增/api/sessionsGET / POST/sessions/:sid/primary/ DELETE/sessions/:sid;auth 中间件每请求过isSessionActive让强制下线即时生效;DeviceListCard 在 Settings tab 列出所有设备 + 主设备徽章 + 本机徽章 + 一键切主设备 / 强制下线 - 多设备 APNs 推送:
device_tokens表(apns_tokenPK +user_id+session_id+updated_at)替代users.device_token单值;pushToUserfan-out 到一个用户的全部 token;session 注销时同步删 token;APNs 410 失效自动 evict - 跨端状态全部上服务端:
daily_seen_date / pa / ps、inbox_last_seen、outbox_last_seen、write_letter_draft改成users表字段,多设备状态实时一致;only-advance UPDATE 守卫旧客户端不会 roll back 已读游标 - 上传可靠性:snap 上传 401 主动续期;超大 multer payload catch 兜底;429 不再误判登出(区分限流 vs 真鉴权失败)
8 项稳定性修复
- 历史按用户 tz 分组(之前混用 UTC,跨时区双方按日聚合错位)
- streak 跨 tz 计算(连续天数在用户夜里跨日时不再丢一天)
- snap 上传 race(同一秒两次上传 atomic rename 抢占 → 改为 DB pre-check + tmp 文件唯一名)
- scheduler 并发推送(
Promise.allSettled替代串行 await,单个慢 token 不再卡掉整波广播) - 删冗余轮询(HistoryScreen + App.tsx 两份 10s poll,合并为单 socket 驱动)
- API 超时(默认 30s 超时 + AbortController,挂机进程不会无限 pending)
- 401 主动续期(access token 过期请求触发一次 refresh 再重发,避免对用户表现为"一直转圈")
- 上传 catch 兜底(multer 抛异常时返回 400 而不是 unhandled crash)
- 快照反窥探:对方拍了今天的快照、自己还没拍时,服务端不返回对方的照片 URL(避免"先看对方再决定自己拍什么")
- 新表情:
praise_you(蒸蚌)+ 天气分类 5 个(晴 / 多云 / 雨 / 雷 / 雪)+haha(嘻嘻)+sad(难过)
7 项屏幕响应式适配:爱心心跳尺寸 / 触摸圆环半径 / 写信 pill 宽度 / 卡片高度 / 信纸字号上限 / iPad 信件最大宽度 / 顶部状态条间距,从 iPhone SE 到 iPad mini 全覆盖。
- 时区一致性(每日刷新点 / 收件箱「写于」「收于」/ 早晚安同日合算 / 跨 tz 校验)
- 邮戳精度(双时区按各自 tz 分别格式化)
- 标题渐变贴边(每日 / 信箱 / 约定 / 数据顶部渐隐贴住屏幕边)
- 早晚安同日规则测试
pair_id 关系实体 + 90 天数据 TTL
- 新增
couples表,每段「关系」抽象为稳定 10 字符pair_id(user_a_id < user_b_idlex 排序保证唯一) - 所有 couple-scoped 数据(
actions / mailbox / time_capsules / sticky_notes / important_dates / daily_answers / rituals / inbox_actions)打pair_id标签 - 解绑写
ended_at进入 90 天 grace 期,期间重绑同一对人 →ended_at清空,全套数据自动复活;中间换过别的对象再换回来不会读到错误关系的旧数据 - TTL 调度器 03:00 UTC 每日扫一次过期 couple,按 pair_id 级联硬删
- pair / unpair 整体事务化 + backfill 脚本一次性 migrate 历史数据 + 6 个新测试
信箱大改造
- 半日达单场可多发(不再「一场一封」)
- 写信成功 toast + 信箱 tab 红点直接点亮 + 发件箱排序倒置
- 新增 OutboxScreen 上下滑动浏览未送达;废掉曾试过的右划撤回(手势冲突 + 用户预期不一致)
- 时区一致性 + 每日刷新加日期去秒 + 快照评价加标签 + 快照反应内联
- 跟帖后不再自动续开编辑器(写完一条对方再开会被强制再写一条的反人性)
- 空草稿不持久化到服务端 temp(避免脏数据残留)
- 单条跟帖可独立撕(之前必须撕整张 sticky)
安全 / 竞态打磨
- Socket 触摸 DoS — 客户端可以无限循环 emit
touch_start,服务端原地放大成 APNs 推送 + 心跳广播。新增 5/1s 滚动窗口限速 + silent drop。 - 客户端 socket 死循环 — auto-reconnect 在网络抖动时进入「连上即断」自激振荡,每秒新建几十条连接。改为指数退避 + 短窗口熔断。
- Capsule 推送重复 — 调度器进程重启或被 cron 多次拉起时,
unlock_at <= now AND opened_at IS NULL会再次命中已通知的胶囊。新增notified_at列 + 内存 minute-bucket dedup key。 - Refresh token 轮换非事务 — 旧 hash 删除与新 hash 写入分别为两条 SQL,崩在中间会让用户彻底登不回来。合并到单事务。
- Presence 闪烁 — 网络抖动时短间隔 disconnect/reconnect 触发
presence_single→presence_both来回闪。checkBothOnline()加 3s debounce。 - 跟帖 vs 撕贴竞态 — 一方正在写跟帖、另一方撕走整张 sticky,commit 时若 sticky 已不存在会留下孤儿 block。在 commit 前校验 sticky 存在 + 撕贴 cascade 删除。
- AppState 切换定时器泄漏 — DailyQuestionCard / DailySnapCard / 触感震动定时器在切换 tab / 切到后台时未清理;统一加 cleanup。
- Presence stale-closure — 旧 disconnect 闭包在 presence 被新 session 覆盖后还会广播,把刚回来的人误标成离线。closure 内通过身份 token 比对识别 presence 是否仍属于自己。
- 写信草稿覆写 sealing 内容 — 用户高速点「寄出」时,草稿 setter 还会再触发一次,把 sealing 阶段的 UI 草稿(已封存内容)回写到状态。setter 在 sealing 之后失活。
信箱大重构
- 平铺卡片 → 三入口(收件箱 / 废件箱 / 小贴吧)+ 写信 pill;写信下沉成统一流程
- 「垃圾篓」改名「废件箱」
- 收件箱重构:点击空白收起 + 底部「收起」pill + 标题贴顶 + 卡片倒序按时间 + 旧贴 relayout / 旋转 ±1°-±5° / 时间戳浮在最上
- 写信流程 5 阶段:write → sealing → kind → capsuleDetails → sending;封信 + 寄出过场动画
- 择日达分钟级日期时间选择(六年窗口 + 月日联动 clamp + 双时区即时预览)
- 写信键盘可收回 + KeyboardAvoidingView 防遮挡 + 草稿持久化 + 正式信件版式(致 / 落款 / 双时区邮戳)
小贴吧(信箱 tab 共享便利贴墙)
- 木色背景 + 奶油便利贴 + 自己粉 / 对方蓝
- 掉落入场动画 / 撕下来过场动画 / 永久删除
- 跟帖独立成订书钉串联的便利贴堆 + 老贴一并 relayout
- 跟帖纸 z-order 翻转 + 圆钉替代订书钉
- 双击便利贴跟个帖 + 编辑器 tap-外保留草稿
- 标题边缘真正贴住的柔化渐隐 + 选中态加辨识 + 「不看了」pill
- 冷启动误重登修复 + 收件箱未读小旗子 + 重连给孤身 socket 同步 presence_single
v1.0.2 修复清单(Release notes)
经仓库全量审计验证的 7 个真实漏洞:
- 徽章数被反应(reaction)污染 —
stmtCountUnreadActions/stmtLatestPartnerActionId没过滤reply_to,反应行 id > 顶层 id 时markReadclamp 不到,徽章可能卡在 ≥1 永远清不掉。两条 SQL 加reply_to IS NULL。 - 登录后用户名丢失 —
/api/login不返回name,重装登录的用户在 MailboxCard / InboxScreen / TimeCapsuleCard / HistoryScreen 全部显示「我」。服务端响应增加name,客户端登录写入。 - 收件箱「未读」红标错闪 —
seenBeforeOpenRef异步加载与load()拉信件并行,AsyncStorage 解析慢时所有卡片瞬间打红标。改为先await getInboxLastSeen()再触发load()。 - 多设备 socket presence 误报下线 —
presence.sockets是Map<userId, socketId>,结构上无法表达多设备。重构为Map<userId, Set<socketId>>。 - 未开启的择日达扔进垃圾篓后永远消失 —
/api/inbox/trash加守卫:未开启的胶囊不能被扔。 - 接收方触感震动可能停不下来 — 新增 AppState 监听:切到非 active 时主动清空所有定时器和动画。
- 倒计时 1Hz 无条件重渲染 — DailyQuestionCard / DailySnapCard 改为 cooldown 内才 tick,过期自停。
- HIGH —
POST /capsules/:id/open不检查inbox_actions状态:彻底删除一封 capsule 后仍可通过此 endpoint 直接拿回内容。已加 status 检查(outgoing partner-vis 豁免)。 - MEDIUM —
EnvelopeOpenAnimation缺动画取消机制:快速 open/close/open 切换时旧动画 callback 仍触发 setState,造成闪烁。已加cancelledflag。 - LOW-MEDIUM —
IslandToastunmount 时 hideTimer 未清理:可能 setState on unmounted component。已加 useEffect cleanup。 - LOW — scheduler 给 self-vis 胶囊推送 partner:partner 收到 fake 推送但 app 内看不到对应胶囊。已在循环里跳过。
PoopHub/
├── couple-buzz-app/ # Expo / RN 移动端
│ ├── App.tsx # 入口 + 6 tab 路由 + PillTabBar + linking + push/socket 生命周期
│ ├── app.config.ts # Expo + EAS 配置
│ ├── eas.json # EAS Build profile
│ ├── src/
│ │ ├── screens/ # Home / History / Us / Mailbox / WriteLetter / Inbox / Outbox / Trash / StickyWall / SnapCalendar / AnniversaryWish / Settings / Setup
│ │ ├── components/ # DailyQuestionCard / DailySnapCard / RitualButton / TouchArea / BucketListCard / SealAnimation / EnvelopeOpenAnimation / IslandToast / SpringPressable / StickyNote / FireworksOverlay / DeviceListCard / WeeklyReportCard / StatsCard / ActionRecord(ReactionPicker 在 v1.2.21 整文件移除)
│ │ ├── services/ # api(含 sessions / sync / outbox / letter-draft / dates PUT / history before_id 翻页)/ socket(含 daily_update 转发)/ notification
│ │ ├── utils/ # storage(含 HISTORY_TITLE / APNs_TOKEN_CACHE keys)/ countdown / postmark / inboxUnread / outboxEvents / toolbarSlot / device / timezone
│ │ └── constants.ts # 颜色 / 表情配置
│ └── targets/widget/ # iOS WidgetKit Swift 框架(expo-target.config.js + index.swift,数据桥未接通)
│
└── couple-buzz-server/
├── src/
│ ├── index.ts # Express 入口 + 中间件 + 限流
│ ├── routes.ts # REST API(~70 路由:含 sticky-wall 11 个接口 + inbox trash/restore/purge + sessions 五件套(list/primary/name/group/single)+ sync 三组游标 + outbox + letter-draft + dates PUT/pin/delete + history before_id 翻页 + badge-ack + logout-all + capsules auto-open sweep)
│ ├── socket.ts # WebSocket touch / presence / action / sticky / daily_update(多设备 Set + 重连快照 + 5/1s 限速 + stale-closure guard)
│ ├── auth.ts # JWT / scrypt / HMAC 图片签名 / refresh 并发锁 + 事务轮换 + per-session 元数据 + isSessionActive + 5min refresh grace
│ ├── bursts.ts # 锁屏推送合并 ×N collapse-id(5min 滚动窗口 per (recipient, sender, action_type))— v1.3.1 新增
│ ├── db.ts # SQLite schema + 全部 SQL 操作(couples / device_tokens / sticky_* / inbox_actions / time_capsules.notified_at / users.{daily,inbox,outbox,letter}_seen + users.history_title + users.unack_push_count / refresh_tokens.session_id+device_*+is_primary+revoked / actions.reply_to 列保留但 reactions 功能已删)
│ ├── push.ts # APNs + 推送模板(payload 包 body + badge 全覆盖兜底 + 多设备 fan-out + 410 自动 evict + PUSH_MESSAGES export 让 routes 复用模板组合 body)
│ ├── scheduler.ts # 信箱 reveal / 胶囊解锁(分钟级精度,去掉 5min 节流)/ 周报 / couple TTL 03:00 硬删(capsule 解锁 dedup key)
│ └── questions.ts # 1000+ 每日问答题库
├── docs/BACKUP.md # GPG 加密备份完整运维指南
├── scripts/backup.sh # GPG 加密备份脚本
├── scripts/secure-existing-backups.sh # 一次性处理历史明文备份
└── data/ # 运行时 DB 与 snap 上传目录(gitignore)
- Node.js 20.x
- Xcode + iOS 模拟器(或 Expo Go / EAS Development Build)
- Apple Developer 账号(启用 APNs 推送时需要)
cd couple-buzz-server
npm install
cp .env.example .env # 填 JWT_SECRET、APN_* 等
npm run dev # ts-node + nodemon服务默认监听 127.0.0.1:3000。首次启动自动建表 + 跑 schema migration,上传图片放 data/snaps/。
cd couple-buzz-app
npm install
npm run ios # 或 npm start 扫码首次登录两端配对:A 注册后获得 6 位 ID(去掉容易混淆的字符),B 注册后在配对页输入 A 的 ID 即可绑定。
cd couple-buzz-server
JWT_SECRET=test-secret npm test
# 15 suites / 366 passed| Key | 说明 |
|---|---|
PORT |
HTTP 端口,默认 3000 |
HOST |
绑定地址,默认 127.0.0.1 |
JWT_SECRET |
JWT 签名密钥(必填,未设置启动会主动报错) |
APN_KEY_ID |
Apple APNs Key ID |
APN_TEAM_ID |
Apple Team ID |
APN_KEY_PATH |
.p8 私钥路径,默认 ./certs/AuthKey.p8 |
APN_BUNDLE_ID |
iOS Bundle ID |
APN_PRODUCTION |
生产 APNs 网关开关;Ad Hoc 安装的 IPA 需要 true |
| Key | 说明 |
|---|---|
API_URL |
后端地址。留空 / 删除 会兜底到 app.config.ts 默认值(生产 URL) |
本地调试连本地服务时,建议用
API_URL=http://192.168.x.x:3000 npm startinline 注入,不要修改.env文件 —— 避免脏数据被 EAS Update 烤进生产 bundle。
- 自托管 Node.js 进程(pm2 守护),SQLite 单机数据库
- 反向代理(nginx / Caddy)终止 HTTPS
scripts/backup.sh配合 cron 每天 03:00 跑 GPG 加密备份(保留最近 30 份),私钥离线保管- 异地备份:Mac 端 launchd 23:00 用 rsync 拉到 iCloud Drive(私钥可解密)
详细备份运维流程见 couple-buzz-server/docs/BACKUP.md。
- 首次 / native 改动:
eas build --profile preview --platform ios,出 internal distribution IPA,Ad Hoc provisioning profile 直接安装 - 后续纯 JS 改动:
npm run ota:preview(仓库脚本 inline 注入生产API_URL)
OTA 推送后手机端冷启动两次生效。
v1.3.8(2026-05-27,Latest)
- 长信滑到边界牵动收件箱整体 — letter overlay 改 transparent Modal(=
overFullScreen)盖住 pageSheet InboxScreen,吞掉 swipe-to-dismiss 手势;regression_v1_3_8+6 / 366 全过
- 4 项审计修复 — 半日达字数 500→1000 /
daily_updatesocket 转发 / 纪念日编辑 API + UI / cross-pair lookup pairId 防御层;+13 测试 / 360 全过
- 长信完整可读 — flex 链替代硬编码 200pt reserve(iPhone SE / Dynamic Type / 长备注全部覆盖)+ 滚动 affordance 3 件套(indicator / 底部渐隐 / 「拖动阅读」copy)+ fadeOpacity 双分支避免 inputRange 重复边界 + envelopeWrap pointerEvents 改 box-none + deleteSticky 5-arg → 2-arg pair_id 适配;+30 测试
- 表情连发推送合并 —
bursts.ts5min 滚动窗口 + APNs collapse-id,锁屏 5 个 💋 spam 只显示 1 条 ×5 banner - 废话区上拉翻页 —
before_idcursor +has_more+maintainVisibleContentPosition+ 单次拖动至多触发一次 loadOlder - 快照日历 ta|我 toggle + 点空白收起 + 反窥探 4 场景测试
- +63 测试
- 信箱新增「📷 快照日历」入口 — pageSheet modal + 7 列网格 + Polaroid 缩略图 + ta/我 单视角
- 「废话区反应」功能完全移除 —— server route + dbOps + push 模板 + ReactionPicker.tsx + ActionRecord onLongPress 全删;daily-reaction 独立保留
- 8 处死代码清理 —— openCapsule / api.logout / pairUsers / unpairUsers / getSession / getRituals / getAllPairedUserTokens / saveSnap
- 表情连发屏幕内 ×NN 去重 + bounce — 5min 滚动窗口 collapseBursts
- 长按 HistoryScreen 标题自定义 —
users.history_title列 + PUT /profile 字段 + 跨设备同步
- 弱网误踢修复 — refresh grace 10s→300s + bootstrap 2.5s retry
- 登录 ID autofill — TextInput
textContentType+autoComplete
- 每推送 +1 角标 —
users.unack_push_count列 +pushToUserINCREMENT +POST /api/badge-ack - 登录后时区生效 — bootstrap / waiting poll / handleRegistered 全部 cache tz 到 storage
- 信箱 tab 红点二次加固 — auto-open sweep
opened_at = unlock_at避免 race - 收件箱排序 —
(arrivedAt ASC, writtenAt ASC)复合 + scroll-to-bottom + 3 个 e2e 测试
- 择日达对方收不到修复 —
GET /api/capsulesauto-open sweep + 客户端 InboxScreen 过滤掉opened_at=nullcapsule 死锁修复 - 写信后信箱 tab 红点污染修复 — 去掉 outbox 触发 tab dot
- 重登多设备「新的本机」修复 — APNs device_token 作物理设备身份;自动 revoke + 继承 device_name + is_primary
- 择日达分钟级解锁 — scheduler 去掉 5min 节流
- 收件箱排序 —
(arrivedAt, writtenAt)同 session 后写的落底部 = 开信箱第一眼
- README docs 同步 — 测试数 stale 130→181 / 项目结构 components+utils 实际化 / v1.2.14 行数 350→950 修正 / backfill v1.2.7—v1.2.11
- 验收清单测试补齐 —
/health/ SetupScreen 客户端密码校验 / DailyCard 客户端 subscribe / 快照上传 happy path 显式断言 + 10 个新测试
- 16 项 M / L 级审计修复 —
/api/dates字段一致性 / partner_remark trim / HistoryScreen 轮询 stale flag / pair_id 防御层 / 草稿 PUT 串行化 / AsyncStorage 容错 / socket listeners 自动收尾 / multer 错误 400 化 / scheduler env gate / 密码 ≥ 6 位 / 删死代码 350 行 / 24 个新回归测试
- 4 项 HIGH 级审计修复 — HistoryScreen normalizeIso / coldStartRef 复位 / 每日 socket 实时同步 / socket connect_error 实例守卫 + 10 个新回归测试
- 「半日达」更名 — 全仓库 26 处
次日达→半日达(4 个推送模板 + App 全部 UI 文案 + README + 注释) - 信箱底部下次送达预告 — 写信 pill 下方"下个半日达将于 ... 寄达",按用户 tz 渲染,对齐 0:00 / 12:00 UTC 边界
- v1.2.11 fix(perf+ux) — 8 处审计发现的边缘问题
- v1.2.10 fix(sessions) — 登录继承设备名 + 暴露本机自我提升主设备
- v1.2.9 fix(sessions) — 整组原子下线,修退出后残留 session 重现为「另一台设备」
- v1.2.8 feat(sessions) — 已登录设备按设备去重 + 自定义设备名
- v1.2.7 docs — 全面更新 README 反映 v1.1.6 → v1.2.6 全部变更
- APNs badge 全推送覆盖 —
pushToUser兜底真实未读数(floor=1),scheduler 广播也走同一管道,~25 种推送类型桌面图标都会变红圈
- 多设备会话管理 — DeviceListCard / 主设备制 / 强制下线 /
isSessionActive即时生效 - 多设备 APNs 推送 —
device_tokens表替代单值,pushToUser fan-out 全设备 - 跨端状态全部上服务端 — daily_seen / inbox_seen / outbox_seen / letter_draft 全部服务端字段,only-advance SQL 守卫
- 上传可靠性 — 401 主动续期 + 超时 catch 兜底 + 429 不再误判登出
- pair_id 关系实体 + 90 天数据 TTL + 重绑数据复活(v1.2.0)
- 8 项稳定性修复 — 历史按用户 tz 分组 / streak 跨 tz / snap 上传 race / scheduler 并发推送 / 删冗余轮询 / API 超时(v1.2.4)
- 快照反窥探 + 蒸蚌/天气分类/嘻嘻/难过 表情(v1.2.3)
- 7 项屏幕响应式适配(v1.2.2)
- 时区一致性 / 邮戳精度 / 标题渐变贴边 / 早晚安同日规则(v1.2.1)
- 跟帖 3 处修复 — 不再自动续开 / 空草稿不持久化 / 单条可撕(v1.1.6)
- 信箱大改造 — 半日达多发 / OutboxScreen 上下滑 / 写信成功 toast / tab 红点直点 / 时区 / 快照评价加标签(v1.1.8)
- 9 项安全 / 竞态打磨:socket touch 限速 / 客户端死循环熔断 / capsule 索引 +
notified_at持久 dedup / refresh token 事务化轮换 / presence 防闪 / 跟帖 vs 撕贴竞态 / AppState 定时器 cleanup / presence stale-closure guard / 写信草稿覆盖 sealing 阶段
- 信箱大重构:三入口(收件箱 / 废件箱 / 小贴吧)+ 底部固定写信 pill
- 统一写信流程:write → sealing → kind → capsuleDetails → sending 五阶段,封信 + 寄出过场动画
- 正式信件版式:致 / 落款 / 双时区邮戳,奶油色信纸 + 棕墨字
- 择日达分钟级日期时间选择:年/月/日/时/分五段下拉 + 月日联动 clamp + 双时区即时预览
- 写信键盘可收回 + 草稿持久化 + KeyboardAvoidingView 防遮挡
- 小贴吧:双方共享便利贴墙;木色背景 + 奶油纸 + 双色字
- 跟帖 = 订书钉串联的便利贴堆 + 圆钉装饰 + 老贴 relayout
- 掉落入场动画 + 撕下来过场动画 + 永久删除
- 双击便利贴跟个帖 + 编辑器 tap-外保留草稿
- 自定义灵动岛 PillTabBar + spring + onPressIn 瞬切 + 4 tab 红点
- App 级 Toolbar Slot + 顶部 scroll-bound fade
- 通知 deep-link 路由(cold-launch + warm-tap 双路径)
- APNs payload
bodywrap + iOS 锁屏通知合并 - 7 个真实漏洞修复 + 95 接口测试
- 信箱模块重命名:树洞信箱 → 半日达、时间胶囊 → 择日达
- 写完后双方都看不到信件内容直到送达 + 封信 / 开信过场动画
- 第 6 个 tab「🎀 约定」:纪念日 + 心愿清单合并管理
- 收件箱(Apple Wallet 风格)+ 垃圾篓
- 4 个安全/逻辑漏洞修复
- MVP:5 tab 结构、双场信箱、couple ID、APNs 推送、JWT 双 token、95 接口测试
为我和我的另一半而做。如果你也想给伴侣做一个,欢迎 fork。
MIT License — 自由使用 / 修改 / 商用,唯一要求是保留版权声明。