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

更多推荐



所有评论(0)