【OpenHarmony/HarmonyOs 】18|好友申请状态机:从 `pending` 到可回溯流转,避免重复申请与脏状态

【OpenHarmony/HarmonyOs 】18|好友申请状态机:从 `pending` 到可回溯流转,避免重复申请与脏状态

【OpenHarmony/HarmonyOs 】18|好友申请状态机:从pending到可回溯流转,避免重复申请与脏状态 💬

本文基于项目FriendRequest.etsChatPage.etsSearchFriendOverlay.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 阶段只保留pendingacceptedrejected完全合理。

真正重要的是:每个状态可以转向哪里,必须提前定义清楚。


四、推荐的状态流转规则

可以把基础流转画成下面这样:

接收方同意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:boolean=false;

提交前判断:

if(this.isSending) {return; }this.isSending =true;

请求结束后恢复按钮状态。

这种方式能改善用户体验,但不能作为最终一致性保障。

因为并发请求可能来自不同设备,也可能来自网络重试。

数据层还要做幂等判断

发送前至少查询:

fromId=当前用户 and toId=目标用户 and status=pending

如果已经存在,就直接返回“申请已发送”。

更稳妥的做法是为“一对用户的一轮有效申请”设计稳定业务键,或者通过服务端事务完成检查与创建。

核心原则是:

页面负责避免误触,数据层负责抵御并发。


六、真实问题二:A 申请 B,B 同时申请 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

它适合用来检测两人之间是否存在有效申请或好友关系。

但申请记录本身仍应保留fromIdtoId,否则会丢失发起方向。


七、真实问题三:同意申请不等于好友关系已经完整建立

很多实现只做了这一步:

FriendRequest.status= accepted

然后页面就宣布双方成为好友。

这存在一个隐患:申请状态是过程数据,好友关系是结果数据。

更完整的数据模型通常包含两类实体。

申请表负责记录过程

FriendRequest-id-fromId-toId-status-createdAt-handledAt

好友关系表负责记录结果

FriendRelation-id-userAId-userBId-createdAt-status

同意申请时,需要保证两件事一起完成:

  1. 把申请从pending更新为accepted
  2. 创建唯一的好友关系。

如果第一步成功、第二步失败,就会出现脏状态:

  • 申请页显示已同意;
  • 联系人列表却查不到好友。

理想情况下,这两个写操作应放在事务或可信服务端逻辑中执行。

如果当前 Cloud DB 接入方式不支持跨记录事务,也要设计补偿机制,例如页面加载时检查并修复缺失关系。


八、真实问题四:拒绝后多久允许再次申请?

拒绝后的规则没有唯一答案,需要根据产品定位确定。

方案一:立即允许再次申请

适合演示项目和低风险场景。

缺点是容易被反复骚扰。

方案二:设置冷却时间

例如拒绝后 24 小时或 7 天内不能再次发送。

判断逻辑需要读取最近一次拒绝时间:

当前时间 - rejectedAt >=cooldown

方案三:由接收方控制

接收方可以选择:

  • 仅拒绝本次;
  • 拒绝并不再接收此人的申请;
  • 直接加入黑名单。

此时仅靠rejected已经不够,还需要黑名单或权限关系数据。

重要的是不要删除拒绝记录后假装什么都没发生。

历史数据是后续限频与风控判断的重要依据。


九、状态字段为什么不应该长期使用任意字符串?

当前模型中的status: string上手很快,但它允许写入任意值:

penddingacceptok 已同意

这些拼写差异不会在编译期暴露,却会让查询条件失效。

在 ArkTS 中可以集中定义状态常量:

exportclassFriendRequestStatus{staticreadonlyPENDING:string='pending';staticreadonlyACCEPTED:string='accepted';staticreadonlyREJECTED:string='rejected';staticreadonlyCANCELED:string='canceled'; }

业务代码统一引用:

req.setStatus(FriendRequestStatus.PENDING);

这样做的价值包括:

  • 避免手写字符串拼错;
  • 便于全局查找引用;
  • 修改状态名称时影响范围清晰;
  • UI 与数据层共享同一套定义。

如果项目模型生成规则限制枚举使用,常量类通常是更稳妥的选择。


十、不要让 UI 自己猜状态

按钮文案应由真实业务状态决定。

推荐映射关系如下:

数据状态发起方看到接收方看到
无申请、非好友申请好友申请好友
pending等待验证同意 / 拒绝
accepted发消息发消息
rejected暂不可申请或重新申请已拒绝
已拉黑不可申请已屏蔽

页面进入时不能只查好友申请表。

建议按以下优先级判断:

  1. 是否为自己;
  2. 是否存在黑名单关系;
  3. 是否已经是好友;
  4. 是否存在有效pending
  5. 是否存在冷却期内的rejected
  6. 最后才显示“申请好友”。

这个优先级可以避免“已经是好友,但旧申请仍为 pending”时出现错误按钮。


十一、处理申请时要防止并发覆盖

假设用户在手机和平板上同时打开申请页。

手机点击“同意”,平板几乎同时点击“拒绝”。

如果更新语句只是:

根据id更新 status

最后状态取决于哪个请求后到,业务结果不可预测。

正确思路是条件更新:

仅当 id 匹配且当前 status=pending 时执行更新

第一个操作成功后,第二个操作会因为当前状态不再是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,生成一条pending
  • B 同意,申请变为accepted
  • B 拒绝,申请变为rejected
  • A 不能处理发给 B 的申请;
  • B 不能撤回 A 发出的申请。

重复与并发用例

  • A 连续点击两次,只保留一个有效申请;
  • 两台设备同时发送,不产生多个有效pending
  • 两台设备分别同意和拒绝,只允许一个操作成功;
  • 网络重试不会创建重复关系。

反向申请用例

  • A 申请 B 后,B 再申请 A;
  • 系统按产品规则自动同意或阻止重复;
  • 最终只存在一份有效好友关系。

异常数据用例

  • 申请已accepted,好友关系却缺失;
  • 好友关系已存在,申请仍是pending
  • 用户账号被删除后仍有申请记录;
  • status出现无法识别的旧值。

测试目标不仅是“按钮能点”,而是任意操作顺序下,数据都不会进入无法解释的状态。


十九、从 MVP 到稳定版本的渐进式改造

不需要一次性重写整个好友模块。

可以按风险优先级逐步升级。

第一阶段:阻止明显重复

  • 页面增加发送中状态;
  • 发送前查询同方向pending
  • 已是好友时隐藏申请按钮。

第二阶段:补齐状态约束

  • 集中定义状态常量;
  • 只有pending可以处理;
  • 增加反向申请判断;
  • 拒绝记录保留历史。

第三阶段:保证关系一致性

  • 将好友关系独立建模;
  • 同意操作同时更新申请与关系;
  • 加入失败补偿和数据修复。

第四阶段:增强风控与审计

  • 增加冷却时间;
  • 支持撤回和拉黑;
  • 记录处理时间;
  • 对高频申请进行限流。

渐进式改造比“为了状态机而状态机”更符合真实项目节奏。


二十、状态机设计中的几个常见误区

误区一:状态越多越专业

状态过多会让转换关系指数级复杂。

只添加当前业务真正需要的状态。

误区二:所有规则都写在页面里

页面可以控制交互,但不能成为最终数据防线。

多设备和重试场景会绕过页面状态。

误区三:一条申请记录就是好友关系

申请描述过程,关系描述结果。

二者职责不同,不应长期混用。

误区四:失败后重新插一条就行

盲目重试会制造重复数据。

重试前必须确认操作是否已经成功。

误区五:历史记录没有价值

拒绝、撤回和过期记录都能帮助解释当前状态,也是限频与问题排查的重要依据。


二十一、一次真实问题的排查路径

假设用户反馈:“对方已经同意了,我这里仍显示等待验证。”

可以按以下顺序排查:

  1. 查询申请记录的真实status
  2. 检查双方页面读取的是不是同一个申请 ID;
  3. 检查状态更新是否写到了错误记录;
  4. 检查 UI 是否在状态变化后重新查询;
  5. 检查本地数组是否通过可观察方式更新;
  6. 检查好友关系是否创建成功;
  7. 检查旧订阅事件是否覆盖了新数据;
  8. 检查多条重复申请中是否只有一条被同意。

最后一种情况非常常见。

用户看到的是较早的pending,而接收方处理的是另一条重复记录。

这说明重复申请不是“数据库不够整洁”的小问题,而会直接制造可见的业务错误。


二十二、总结

好友申请表面上只是一次插入操作,实际上包含三层含义:

  • 申请记录描述一次业务过程;
  • 状态机约束过程如何变化;
  • 好友关系记录最终业务结果。

当前项目使用pendingacceptedrejected,已经迈出了正确的第一步。

下一步不是盲目增加字段,而是把以下规则真正接入发送、处理和展示流程:

  • 一个用户对不能同时存在多条有效申请;
  • 只有pending能进入下一状态;
  • 同意后必须形成稳定且唯一的好友关系;
  • UI 必须消费真实状态,不能只依赖本地按钮状态;
  • 并发、重试和多设备不能破坏最终一致性;
  • 历史状态应可查询、可解释、可回溯。

好友申请不是一条“发完就结束”的记录,而是一条带方向、带权限、带历史、带关系结果的社交流。没有状态机,功能也许能跑;有了状态机,它才有机会长期稳定地跑下去。✅