好友申请列表里显示“已通过”,就能据此认定两个人现在仍是好友吗?如果后来删除了好友,网络重试又把旧的“通过申请”请求发来一次,系统会不会悄悄把关系恢复?
这两个问题来自同一个容易被忽略的区别:申请状态记录一次操作的处理结果,好友关系描述当前两个人之间的连接。 把两者合成一个 isFriend 字段,既难解释历史记录,也容易让重复请求产生新的副作用。
米米商聊的产品方手机端资料确认,添加好友需要对方通过申请,转发好友名片后仍需发起申请,并有删除联系人等操作。本文以这些真实功能为背景,分析同类系统如何划分申请与关系。资料没有说明其数据库、重复请求策略或桌面端完整审批流程,以下状态、表结构和代码均为独立教学模型,不代表产品内部实现。文章与配图使用 AI 辅助,示例另做独立验证。
1. “处理过申请”与“仍有关系”是两次查询
可以把问题拆成两个对象:
| 对象 | 回答的问题 | 教学示例 |
|---|---|---|
| 一次好友申请 | 谁向谁申请,这一次是否已经处理 | r1 从 pending 变为 accepted |
| 当前好友关系 | 此刻两个人之间是否存在关系 | 账号 1 与 2 的关系记录存在或不存在 |
因此,申请已经通过而当前关系不存在,并不必然是数据损坏:关系可能在申请完成之后被删除。反过来,当前已经是好友,也不能自动证明某一条新申请已经处理。
这不是为产品补充未确认的规则。它只是一个通用建模选择:保存历史处理结果,同时单独管理当前关系。删除关系要不要保留申请记录、保存多久、怎样向用户展示,应由实际产品规则与数据保留要求决定。
2. 重复提交不能把“曾经成功”变成“再次建立”
设想一组纯教学操作,账号与申请标识均为虚构数据:
- 账号
1发起申请r1,账号2通过,建立关系。 - 之后这段关系被删除,但
r1仍记录为已通过。 - 旧的通过操作因重试再次到达。

图:独立教学模型。同一申请的历史结果保持已通过,当前关系可以被后续删除;旧申请重试不重新建关系。不是 App 界面或产品实际数据库。
如果处理函数每次看到 accepted 都补插一条关系,就会把第三步解释成一次新的同意。“保证重试不重复建行”还不够;关系删除以后,旧请求也不应重新创建已经结束的关系。
本文选择的策略是:同一申请已经处理成功,再次执行只返回“已经处理”,不写好友关系;重新建立关系需要另一条申请获得新的明确处理。这里的“新的申请”不是简单换一个请求标识绕过限制,实际服务仍需要检查当前申请与关系规则。
幂等的对象在这里是“一次申请的通过操作”,不是“无论何时调用,都保证这对账号存在好友关系”。历史成功结果可以复用,已经发生过的副作用不能在后续状态变化后重新执行。
3. 用两个表约束一次申请与一对关系
下面使用 SQLite 演示。账号标识假定为稳定的正整数;关系以较小账号、较大账号组成规范化二元组,避免 (1, 2) 与 (2, 1) 变成两条记录。这不是按昵称排序,也不要求真实产品采用数字账号。
CREATE TABLE friend_request (
request_id TEXT PRIMARY KEY NOT NULL CHECK(length(request_id) > 0),
sender_id INTEGER NOT NULL CHECK(sender_id > 0),
recipient_id INTEGER NOT NULL CHECK(recipient_id > 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'accepted', 'rejected')),
CHECK(sender_id <> recipient_id)
) STRICT;
CREATE TABLE friendship (
user_low INTEGER NOT NULL CHECK(user_low > 0),
user_high INTEGER NOT NULL,
PRIMARY KEY(user_low, user_high),
CHECK(user_low < user_high)
) STRICT;
STRICT 是本例对字段类型的选择,需要 SQLite 3.37.0 或以上;它仍允许可无损转换的类型值,并不替代业务输入校验。正式系统还需要账号存在性等约束,本例省略了账号表,没有把正整数检查写成账号真实性证明。SQLite:STRICT Tables
申请表保留方向,关系表只保存本例的对称连接。这里不实现黑名单、单向联系人或其他访问规则;这些规则如果存在,应独立定义,不能仅从这张对称关系表推断。
4. 在一个事务中“通过申请并建立关系”
只先更新申请,会出现“已通过但关系没有写入”;只先写关系,会出现“关系存在但申请还显示待处理”。两个写入需要共同提交,失败则共同回滚。
下面的 Python 函数使用专用连接,调用时连接不应已经处于其他事务中。actor_id 应从服务端认证会话取得,不能直接信任客户端提交的“当前用户”。
def accept_request(db, request_id, actor_id):
if type(actor_id) is not int or actor_id <= 0:
raise PermissionError("invalid-actor")
db.execute("BEGIN IMMEDIATE")
try:
row = db.execute(
"SELECT sender_id, recipient_id, status FROM friend_request "
"WHERE request_id = ?", (request_id,)
).fetchone()
if row is None:
raise ValueError("request-unavailable")
sender, recipient, status = row
if actor_id != recipient:
raise PermissionError("not-recipient")
if status == "accepted":
db.execute("COMMIT")
return "already-processed"
if status != "pending":
raise ValueError("not-pending")
low, high = sorted((sender, recipient))
db.execute(
"INSERT INTO friendship VALUES (?, ?) "
"ON CONFLICT(user_low, user_high) DO NOTHING", (low, high)
)
changed = db.execute(
"UPDATE friend_request SET status = 'accepted' "
"WHERE request_id = ? AND status = 'pending'", (request_id,)
).rowcount
if changed != 1:
raise RuntimeError("state-conflict")
db.execute("COMMIT")
return "accepted"
except Exception:
if db.in_transaction:
db.execute("ROLLBACK")
raise
几个边界比语句顺序更重要:
- 先核对处理者。 发起人和无关账号都不能代收件人同意申请。示例异常供内部测试区分原因,真实接口怎样返回错误还需考虑信息暴露,不能原样当作公开接口规范。
- 已处理分支不补插关系。 这正是删除之后重试不会恢复关系的原因。不能用“发现关系缺失”替换这一分支。
- 只处理明确的唯一键冲突。
ON CONFLICT(user_low, user_high) DO NOTHING允许当前关系已经存在,随后仍明确处理这条待处理申请;其他约束错误会进入失败处理。SQLite 官方文档说明,UPSERT 针对唯一性约束,不会吞掉CHECK、NOT NULL等失败。SQLite:UPSERT - 确认提交后才返回成功。
BEGIN IMMEDIATE尽早申请写事务,但不保证一定拿到它。SQLite 同时只允许一个写事务,另一个写入者可能导致忙错误;本例将失败交给调用层处理,不在函数里无限重试。SQLite:Transaction
连接创建时使用 isolation_level=None,由代码显式执行 BEGIN、COMMIT 和 ROLLBACK;不要再叠加另一层隐式事务管理。如果应用已有事务边界,应调整函数职责,而不是嵌套 BEGIN。Python 官方文档解释了该模式与显式 SQL 事务的关系。Python 3.12:Transaction control
5. 直接验证“通过、删除、旧请求重试”
把第一个 SQL 代码块原样保存为 UTF-8 的 schema.sql,将下面代码接在前面的 Python 函数后运行。它只创建内存教学数据库,不读取真实联系人。
import sqlite3
from pathlib import Path
db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript(Path("schema.sql").read_text(encoding="utf-8"))
db.execute("INSERT INTO friend_request VALUES ('r1', 1, 2, 'pending')")
assert accept_request(db, "r1", 2) == "accepted"
assert accept_request(db, "r1", 2) == "already-processed"
assert db.execute("SELECT * FROM friendship").fetchall() == [(1, 2)]
db.execute("DELETE FROM friendship WHERE user_low = 1 AND user_high = 2")
assert accept_request(db, "r1", 2) == "already-processed"
assert db.execute("SELECT * FROM friendship").fetchall() == []
assert db.execute(
"SELECT status FROM friend_request WHERE request_id = 'r1'"
).fetchone() == ("accepted",)
db.execute("INSERT INTO friend_request VALUES ('r2', 1, 2, 'pending')")
assert accept_request(db, "r2", 2) == "accepted"
assert db.execute("SELECT * FROM friendship").fetchall() == [(1, 2)]
db.close()
print("通过、重复提交、删除后重试与新申请检查通过")
本轮在 Python 3.12.14、SQLite 3.53.1 中实际执行了文中的建表 SQL 与两个 Python 代码块,并补充了独立边界验证,共 14 类检查通过:重复处理、删除后重试、新申请、错误处理者、拒绝态、不存在的申请、已有关系、相反方向的申请、约束检查、写入中途失败回滚以及两个连接的写锁冲突等。
中途失败测试在更新申请前人为制造数据库拒绝,确认关系插入也被回滚,申请仍为 pending。写锁测试只验证第二连接在写锁占用期间失败、释放后能重新处理,不是并发吞吐量测试,也不能推广为其他数据库隔离级别的验证。
这些检查只覆盖本地教学模型,没有验证产品客户端、真实好友接口、账号权限系统或设备间同步。
6. 接入界面时,分别显示操作结果和当前关系
申请记录中的“已通过”可以继续说明历史结果;是否显示“当前好友”,应查询当前关系。接口返回 already-processed 时,也不能由客户端直接把联系人按钮改成好友状态,应按当前关系重新读取。
这个例子只阻止已经通过的旧申请再次产生建关系副作用。删除关系时,要不要使尚未处理的旧申请失效;两边是否允许重复申请;拉黑是否阻止通过;申请是否过期,仍需另定规则。不能把未处理的旧申请改个标识就当成获得了新的授权。
同样,accepted 配合关系缺失,既可能来自后续删除,也可能来自历史数据异常。若要区分原因,需要受控的操作记录或关系生命周期信息,而不是自动回填关系。本文没有实现完整审计日志。
回到开头:一次申请“曾经通过”,不能替代“现在仍是好友”的判断。将申请历史与当前关系分开保存,让首次处理在事务中完成,再让同一申请的重试只返回历史结果,才能保留可解释的状态,并避免旧操作把已经删除的关系重新建立。
参考资料
- 功能背景:产品方提供的手机端好友申请、好友名片与联系人管理说明;只采用已确认事实,不由截图入口推断桌面审批行为。
- SQLite:Transaction:事务边界、写事务与忙错误。
- SQLite:UPSERT:明确唯一键冲突与
DO NOTHING的范围。 - SQLite:STRICT Tables:类型约束与版本要求。
- Python 3.12:sqlite3 transaction control:显式 SQL 事务控制。