【OpenHarmony/HarmonyOs 】18好友申请状态机从pending到可回溯流转避免重复申请与脏状态 本文基于项目FriendRequest.ets与ChatPage.ets中SearchFriendOverlay.sendRequest()的真实实现展开。很多人以为“加好友”只是插入一条记录但真正做社交功能时它其实是一条跨用户、跨时间、可回放的状态流。一、项目里的好友申请目前怎么写当前发送逻辑如下const req newFriendRequest(); req.setId(Date.now().toString()); req.setFromId(this.myUid); req.setToId(this.searchResultUid); req.setStatus(pending); req.setTimestamp(Date.now()); await zone.executeUpsert(req);模型定义也很清晰exportclassFriendRequest {id:string; fromId:string; toId:string; status:string; timestamp:number; }这段代码已经具备状态机的雏形fromId表示申请发起人toId表示申请接收人status表示当前处理阶段timestamp表示申请产生时间id用来稳定标识本次申请记录。从页面角度看用户只点击了一次“申请好友”。从数据角度看系统创建的是一条未来还会继续变化的业务记录。二、为什么好友申请不是简单新增新增记录只回答了“有没有发生过申请”。好友业务还要回答更多问题当前申请是否仍然有效接收方是否已经处理两人是否已经成为好友发起方是否主动撤回拒绝后是否允许立即再次申请双方同时申请时应该保留几条记录拉黑后历史申请如何展示如果只执行executeUpsert()却没有约束状态流转数据库最终会积累大量语义冲突的数据。常见结果包括同一用户连续产生多条pending已经同意后仍然能再次申请一端显示“待处理”另一端显示“已是好友”拒绝记录被新申请直接覆盖页面刷新前后按钮文案不一致网络重试把一次点击变成多次申请。因此好友申请不是普通 CRUD而是一个需要业务规则约束的小型工作流。三、把状态字段升级为真正的状态机当前项目已经使用三个基础状态pending accepted rejected它们分别表达状态含义是否允许接收方处理pending等待对方处理是accepted对方已同意否rejected对方已拒绝否如果业务继续发展还可以增加状态含义典型触发者canceled发起方主动撤回发起方expired长时间未处理而失效系统任务blocked因拉黑而终止接收方或系统不过状态并不是越多越好。MVP 阶段只保留pending、accepted、rejected完全合理。真正重要的是每个状态可以转向哪里必须提前定义清楚。四、推荐的状态流转规则可以把基础流转画成下面这样接收方同意pending--------------------------accepted | | 接收方拒绝 v rejectedpending-- 发起方撤回 --canceledpending-- 超时任务 --expired建议先确定以下约束。1. 只有pending可以被处理接收方点击“同意”或“拒绝”时更新条件中必须包含当前状态为pending。这样能避免重复点击同意多端同时处理已拒绝记录被旧页面改成同意延迟请求覆盖新状态。2.accepted是终态一旦申请被同意本轮申请就结束了。后续页面应该根据好友关系显示“发消息”或“已是好友”不能继续显示“发送申请”。3.rejected要保留业务语义拒绝不是垃圾数据。它可以用于控制再次申请的冷却时间展示申请历史分析骚扰行为判断是否需要隐藏重复申请入口。4. 状态不能任意回退不建议直接把accepted改回pending。如果双方解除好友后又重新申请应创建新一轮申请而不是篡改历史记录。五、真实问题一连续点击产生重复申请移动端按钮并不天然防重复。以下情况都可能触发重复提交用户快速连续点击页面卡顿后再次点击请求超时触发自动重试两台设备同时登录同一账号组件重复响应事件。如果每次都以Date.now()创建新 ID那么数据库会得到A- B, pending,10:00:00.101A- B, pending,10:00:00.245A- B, pending,10:00:00.680页面只想表达一次申请数据却表达了三次。页面层防抖只能解决一部分问题页面可以增加提交状态StateprivateisSending:booleanfalse;提交前判断if(this.isSending) {return; }this.isSending true;请求结束后恢复按钮状态。这种方式能改善用户体验但不能作为最终一致性保障。因为并发请求可能来自不同设备也可能来自网络重试。数据层还要做幂等判断发送前至少查询fromId当前用户 and toId目标用户 and statuspending如果已经存在就直接返回“申请已发送”。更稳妥的做法是为“一对用户的一轮有效申请”设计稳定业务键或者通过服务端事务完成检查与创建。核心原则是页面负责避免误触数据层负责抵御并发。六、真实问题二A 申请 BB 同时申请 A这是好友系统最容易被忽略的边界。数据库可能同时出现A-B, pendingB-A, pending从记录方向看它们是两条不同数据。从社交语义看它们表达的是同一件事A 和 B 都愿意建立好友关系。可选策略有三种。策略一保留两条申请各自处理优点是实现简单。缺点是用户体验奇怪双方明明都主动申请了还需要再点击一次同意。策略二反向申请自动视为同意B 向 A 发送申请时如果发现A - B已经是pending系统直接将原申请更新为accepted创建或确认好友关系不再新增B - A申请。这种方式最符合多数社交产品体验。策略三统一无方向关系键为用户对生成稳定键min(A, B)_ max(A, B)例如 A 的编号小于 B则无论谁申请谁关系键始终是A_B它适合用来检测两人之间是否存在有效申请或好友关系。但申请记录本身仍应保留fromId和toId否则会丢失发起方向。七、真实问题三同意申请不等于好友关系已经完整建立很多实现只做了这一步FriendRequest.status accepted然后页面就宣布双方成为好友。这存在一个隐患申请状态是过程数据好友关系是结果数据。更完整的数据模型通常包含两类实体。申请表负责记录过程FriendRequest-id-fromId-toId-status-createdAt-handledAt好友关系表负责记录结果FriendRelation-id-userAId-userBId-createdAt-status同意申请时需要保证两件事一起完成把申请从pending更新为accepted创建唯一的好友关系。如果第一步成功、第二步失败就会出现脏状态申请页显示已同意联系人列表却查不到好友。理想情况下这两个写操作应放在事务或可信服务端逻辑中执行。如果当前 Cloud DB 接入方式不支持跨记录事务也要设计补偿机制例如页面加载时检查并修复缺失关系。八、真实问题四拒绝后多久允许再次申请拒绝后的规则没有唯一答案需要根据产品定位确定。方案一立即允许再次申请适合演示项目和低风险场景。缺点是容易被反复骚扰。方案二设置冷却时间例如拒绝后 24 小时或 7 天内不能再次发送。判断逻辑需要读取最近一次拒绝时间当前时间 - rejectedAt cooldown方案三由接收方控制接收方可以选择仅拒绝本次拒绝并不再接收此人的申请直接加入黑名单。此时仅靠rejected已经不够还需要黑名单或权限关系数据。重要的是不要删除拒绝记录后假装什么都没发生。历史数据是后续限频与风控判断的重要依据。九、状态字段为什么不应该长期使用任意字符串当前模型中的status: string上手很快但它允许写入任意值penddingacceptok 已同意这些拼写差异不会在编译期暴露却会让查询条件失效。在 ArkTS 中可以集中定义状态常量exportclassFriendRequestStatus{staticreadonlyPENDING:stringpending;staticreadonlyACCEPTED:stringaccepted;staticreadonlyREJECTED:stringrejected;staticreadonlyCANCELED:stringcanceled; }业务代码统一引用req.setStatus(FriendRequestStatus.PENDING);这样做的价值包括避免手写字符串拼错便于全局查找引用修改状态名称时影响范围清晰UI 与数据层共享同一套定义。如果项目模型生成规则限制枚举使用常量类通常是更稳妥的选择。十、不要让 UI 自己猜状态按钮文案应由真实业务状态决定。推荐映射关系如下数据状态发起方看到接收方看到无申请、非好友申请好友申请好友pending等待验证同意 / 拒绝accepted发消息发消息rejected暂不可申请或重新申请已拒绝已拉黑不可申请已屏蔽页面进入时不能只查好友申请表。建议按以下优先级判断是否为自己是否存在黑名单关系是否已经是好友是否存在有效pending是否存在冷却期内的rejected最后才显示“申请好友”。这个优先级可以避免“已经是好友但旧申请仍为 pending”时出现错误按钮。十一、处理申请时要防止并发覆盖假设用户在手机和平板上同时打开申请页。手机点击“同意”平板几乎同时点击“拒绝”。如果更新语句只是根据id更新 status最后状态取决于哪个请求后到业务结果不可预测。正确思路是条件更新仅当 id 匹配且当前 statuspending 时执行更新第一个操作成功后第二个操作会因为当前状态不再是pending而失败。失败的一端应重新查询数据并提示“该申请已在其他设备处理”。这属于乐观并发控制。它不需要长时间锁定记录却能避免旧操作覆盖新状态。十二、时间字段要区分创建和处理只有一个timestamp时很难回答它表示什么申请创建时间最后更新时间同意时间拒绝时间推荐逐步拆分createdAt updatedAt handledAt其中createdAt创建后不再变化updatedAt每次状态变化时更新handledAt在同意或拒绝时写入。如果还支持撤回可以增加canceledAt但不要为了“字段齐全”一次性加入大量暂时用不到的列。对当前项目而言先把timestamp明确为创建时间再在需要时补充处理时间就已经能显著提高可读性。十三、为什么不建议同意后删除申请记录删除看起来能让表更干净但会失去审计信息。保留已处理申请可以回答谁先发起了好友关系什么时候发起什么时候被同意是否曾被多次拒绝某次关系建立是否来自好友申请更合理的做法是默认列表只查询pending历史页面按需查询已处理记录数据归档由独立策略完成不通过物理删除掩盖状态设计问题。状态机的价值之一就是让历史可追踪。十四、推荐的发送申请流程一个相对完整的发送流程可以拆成以下步骤1.校验当前用户和目标用户2.禁止给自己发送申请3.查询是否已经是好友4.查询是否存在黑名单关系5.查询同方向 pending6.查询反方向 pending7.检查最近拒绝记录与冷却时间8.创建申请或自动接受反向申请9.更新页面状态10.给用户明确反馈其中每一步都有明确目的。校验身份不能只相信页面传入的myUid。真正写库时应尽量从可信登录上下文获取当前用户 ID。检查好友关系申请表没有待处理记录不代表双方不是好友。好友关系必须单独检查。检查反向申请这是优化体验的关键步骤。双方已经互相表达意愿时可以直接建立关系。最终反馈需要区分发送成功已经发送过对方已申请你已经是好友暂时不能申请网络错误。不要把所有结果都显示成“操作失败”。十五、推荐的处理申请流程接收方点击同意时可以按以下顺序执行1.重新读取申请记录2.验证 toId 是当前用户3.验证当前状态为 pending4.检查双方是否已是好友5.创建或补齐好友关系6.更新申请为 accepted7.记录 handledAt8.刷新申请列表与联系人列表拒绝流程与之类似1.重新读取申请记录2.验证处理权限3.验证状态为 pending4.更新状态为 rejected5.记录 handledAt6.从待处理列表移除权限验证尤其重要。不能因为客户端拿到了某条申请 ID就允许它任意修改状态。只有toId对应的用户才能同意或拒绝。只有fromId对应的用户才能撤回。十六、Cloud DB 场景下的查询与索引思路好友申请常用查询包括查询发给我的 pending 查询我发出的 pending 查询两人之间最近一条申请 按timestamp倒序查询申请历史因此字段设计应围绕真实查询模式而不是只围绕对象结构。常见组合条件是toId status timestampfromId status timestampfromId toId status数据量小时所有查询似乎都很快。数据量增长后没有合适索引或查询能力限制会直接影响申请列表体验。如果 Cloud DB 对复合查询和唯一约束支持有限可以把复杂一致性逻辑移动到云函数或可信服务端处理。客户端直连数据库适合快速完成 MVP但不应该承担所有并发与权限责任。十七、错误处理不能只打印日志真实项目中常见写法是捕获异常后输出日志。但好友申请属于用户主动操作失败后必须给出可理解反馈。建议区分网络不可用登录状态失效记录已经存在对方不存在权限不足数据状态已变化未知服务异常。同时注意两点第一不要在日志中输出完整用户隐私数据。第二不要在失败时直接把本地 UI 改成成功状态。如果采用乐观 UI也必须在请求失败后回滚。十八、如何测试这套状态机状态机最适合使用场景表测试。基础用例A 首次申请 B生成一条pendingB 同意申请变为acceptedB 拒绝申请变为rejectedA 不能处理发给 B 的申请B 不能撤回 A 发出的申请。重复与并发用例A 连续点击两次只保留一个有效申请两台设备同时发送不产生多个有效pending两台设备分别同意和拒绝只允许一个操作成功网络重试不会创建重复关系。反向申请用例A 申请 B 后B 再申请 A系统按产品规则自动同意或阻止重复最终只存在一份有效好友关系。异常数据用例申请已accepted好友关系却缺失好友关系已存在申请仍是pending用户账号被删除后仍有申请记录status出现无法识别的旧值。测试目标不仅是“按钮能点”而是任意操作顺序下数据都不会进入无法解释的状态。十九、从 MVP 到稳定版本的渐进式改造不需要一次性重写整个好友模块。可以按风险优先级逐步升级。第一阶段阻止明显重复页面增加发送中状态发送前查询同方向pending已是好友时隐藏申请按钮。第二阶段补齐状态约束集中定义状态常量只有pending可以处理增加反向申请判断拒绝记录保留历史。第三阶段保证关系一致性将好友关系独立建模同意操作同时更新申请与关系加入失败补偿和数据修复。第四阶段增强风控与审计增加冷却时间支持撤回和拉黑记录处理时间对高频申请进行限流。渐进式改造比“为了状态机而状态机”更符合真实项目节奏。二十、状态机设计中的几个常见误区误区一状态越多越专业状态过多会让转换关系指数级复杂。只添加当前业务真正需要的状态。误区二所有规则都写在页面里页面可以控制交互但不能成为最终数据防线。多设备和重试场景会绕过页面状态。误区三一条申请记录就是好友关系申请描述过程关系描述结果。二者职责不同不应长期混用。误区四失败后重新插一条就行盲目重试会制造重复数据。重试前必须确认操作是否已经成功。误区五历史记录没有价值拒绝、撤回和过期记录都能帮助解释当前状态也是限频与问题排查的重要依据。二十一、一次真实问题的排查路径假设用户反馈“对方已经同意了我这里仍显示等待验证。”可以按以下顺序排查查询申请记录的真实status检查双方页面读取的是不是同一个申请 ID检查状态更新是否写到了错误记录检查 UI 是否在状态变化后重新查询检查本地数组是否通过可观察方式更新检查好友关系是否创建成功检查旧订阅事件是否覆盖了新数据检查多条重复申请中是否只有一条被同意。最后一种情况非常常见。用户看到的是较早的pending而接收方处理的是另一条重复记录。这说明重复申请不是“数据库不够整洁”的小问题而会直接制造可见的业务错误。二十二、总结好友申请表面上只是一次插入操作实际上包含三层含义申请记录描述一次业务过程状态机约束过程如何变化好友关系记录最终业务结果。当前项目使用pending、accepted、rejected已经迈出了正确的第一步。下一步不是盲目增加字段而是把以下规则真正接入发送、处理和展示流程一个用户对不能同时存在多条有效申请只有pending能进入下一状态同意后必须形成稳定且唯一的好友关系UI 必须消费真实状态不能只依赖本地按钮状态并发、重试和多设备不能破坏最终一致性历史状态应可查询、可解释、可回溯。好友申请不是一条“发完就结束”的记录而是一条带方向、带权限、带历史、带关系结果的社交流。没有状态机功能也许能跑有了状态机它才有机会长期稳定地跑下去。✅