好友申请已通过,为什么不等于仍是好友?事务与重复提交的边界

简介: 好友申请“已通过”仅表示历史操作完成,不等于当前仍是好友;关系删除后,旧申请重试不会恢复关系。本文通过双表模型与事务设计,厘清申请状态与好友关系的独立性,避免重复建联。(239字)

好友申请列表里显示“已通过”,就能据此认定两个人现在仍是好友吗?如果后来删除了好友,网络重试又把旧的“通过申请”请求发来一次,系统会不会悄悄把关系恢复?

这两个问题来自同一个容易被忽略的区别:申请状态记录一次操作的处理结果,好友关系描述当前两个人之间的连接。 把两者合成一个 isFriend 字段,既难解释历史记录,也容易让重复请求产生新的副作用。

米米商聊的产品方手机端资料确认,添加好友需要对方通过申请,转发好友名片后仍需发起申请,并有删除联系人等操作。本文以这些真实功能为背景,分析同类系统如何划分申请与关系。资料没有说明其数据库、重复请求策略或桌面端完整审批流程,以下状态、表结构和代码均为独立教学模型,不代表产品内部实现。文章与配图使用 AI 辅助,示例另做独立验证。

1. “处理过申请”与“仍有关系”是两次查询

可以把问题拆成两个对象:

对象 回答的问题 教学示例
一次好友申请 谁向谁申请,这一次是否已经处理 r1 从 pending 变为 accepted
当前好友关系 此刻两个人之间是否存在关系 账号 1 与 2 的关系记录存在或不存在

因此,申请已经通过而当前关系不存在,并不必然是数据损坏:关系可能在申请完成之后被删除。反过来,当前已经是好友,也不能自动证明某一条新申请已经处理。

这不是为产品补充未确认的规则。它只是一个通用建模选择:保存历史处理结果,同时单独管理当前关系。删除关系要不要保留申请记录、保存多久、怎样向用户展示,应由实际产品规则与数据保留要求决定。

2. 重复提交不能把“曾经成功”变成“再次建立”

设想一组纯教学操作,账号与申请标识均为虚构数据:

  1. 账号 1 发起申请 r1,账号 2 通过,建立关系。
  2. 之后这段关系被删除,但 r1 仍记录为已通过。
  3. 旧的通过操作因重试再次到达。

申请历史与当前好友关系的双线时间图

图:独立教学模型。同一申请的历史结果保持已通过,当前关系可以被后续删除;旧申请重试不重新建关系。不是 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 配合关系缺失,既可能来自后续删除,也可能来自历史数据异常。若要区分原因,需要受控的操作记录或关系生命周期信息,而不是自动回填关系。本文没有实现完整审计日志。

回到开头:一次申请“曾经通过”,不能替代“现在仍是好友”的判断。将申请历史与当前关系分开保存,让首次处理在事务中完成,再让同一申请的重试只返回历史结果,才能保留可解释的状态,并避免旧操作把已经删除的关系重新建立。

参考资料

相关文章
|
2天前
|
缓存 人工智能 自然语言处理
引用回复为什么不能只存一段文字?消息标识、上下文与失效引用
本文探讨聊天系统中“引用回复”的本质设计问题:如何确保回复**准确指向原消息**(而非靠内容或位置)、**当前可展示什么**(区分加载/权限/删除状态)、**点击后能否可靠定位**(需独立于分页与缓存)。强调用`(会话ID, 消息ID)`稳定标识,分离关系、展示、定位三重契约,避免摘要误导与位置漂移。(239字)
|
1天前
|
缓存 C++
0.8MB 跑通 Qwen|第 3-1 篇:推理引擎的 mmap 直挂——为什么权重加载可以只要一秒多
本文实测RK3588板端冷启动仅1.0–1.2秒,核心在于mmap直挂VQF权重文件:不全量读取,仅映射+校验头部,权重页由内核按需缺页加载,配合预量化布局,较safetensors全量读快33倍。(239字)
 0.8MB 跑通 Qwen|第 3-1 篇:推理引擎的 mmap 直挂——为什么权重加载可以只要一秒多
|
1天前
|
编译器 C语言
0.8MB 跑通 Qwen|第 2-1 篇:推理引擎的 C11 `_Static_assert`——让编译器守卫你的内存布局
本文详解C11 `_Static_assert` 在内存布局守卫中的关键作用:针对mmap直挂场景,通过编译期断言钉死VQF格式三结构体(200/432/64字节),杜绝因对齐差异导致的静默错位。真机RK3588实测验证,实现错误前移。
0.8MB 跑通 Qwen|第 2-1 篇:推理引擎的 C11 `_Static_assert`——让编译器守卫你的内存布局
|
1天前
|
人工智能 API 语音技术
阿里云百炼TokenPlan 个人版又上新!Standard 和 Pro 套餐新增 12 类 Harness 权益
阿里云百炼TokenPlan个人版升级:Standard/Pro套餐新增12类Harness权益(如网页解析、图像生成等),享每月免费额度及超量88折,独立计量不扣Credits。
|
1天前
|
人工智能 API
任务跑一半,API 突然报“对话超长“断流:我给 Agent 修的紧急逃生通道
本文是开源终端AI编程智能体`codeAgent`系列第二篇,详解其“紧急上下文压缩”机制:统一识别四家API超长报错、动态瘦身重试、双保险防死循环,并附60行核心源码。
30 0
|
1天前
|
人工智能 编解码 算法
肝脏病理病变检测数据集 | 4000张YOLO数字病理数据集
本数据集含约4000张YOLO格式肝脏病理切片图像,精准标注肝细胞气球样变、纤维化、炎症、脂肪变性4类病变,覆盖NAS评分核心指标,专为数字病理辅助诊断、肝毒性评估及YOLO系列模型训练设计,支持自动化量化分析与科研教学。(239字)
|
1天前
|
人工智能 Rust Java
用餐厅和盘子理解栈和堆
最近在学 Rust。其实之前就学过一段时间,但学到所有权这里就卡住了,概念一多加上当时在专升本,后面就放弃了🥲。趁着国庆假期有整块时间,决定重新捡起来,这次争取把它啃下来。 本文的内容来源于 Rust 官方教程 [What Is Ownership?](https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html),在学习所有权(R
32 3
|
1天前
|
运维 安全 小程序
企业内网穿透安全实践:警惕整站映射风险,网关路径访问控制加固方案
本文针对企业内网穿透中“整站映射”导致管理后台、调试接口等敏感路径暴露的高风险问题,提出网关级URL路径访问控制(路由白名单)最佳实践。无需改造业务代码,通过边缘网关精准拦截非法请求,大幅收缩公网攻击面、减轻内网负载,支持多隧道差异化策略与多安全能力叠加,助力企业安全高效实现远程办公与系统对接。(239字)
|
1天前
|
人工智能 安全
装修公司老板裁光员工利润反涨30%:装修设计行业OPC案例深度拆解
本文是「OPC一人公司通关手册」第23篇,深度拆解一位装修设计师转型AI驱动一人公司的实战案例:十年经验者裁掉7名员工,专注纯设计,借AI实现获客、谈单、交付、沉淀全链路提效——效率↑40%,利润↑20%-30%,早九晚六轻松运转。核心不在AI,而在狠砍施工、材料、售后三座成本大山,重构轻资产盈利结构。(239字)
|
21小时前
|
人工智能 Linux 数据安全/隐私保护
如何快速开发命令行自动化脚本——以 Linux 检查为例(本轮脚本生成大概用时10分钟)
本文介绍一种高效开发自动化脚本的新范式:基于真实手工日志,通过AI辅助完成需求提炼、脚本生成、沙箱测试与真机验证闭环。5条核心命令(df/du/docker)自动转化为可批量下发的Ruby脚本,全程约10分钟,支持阈值自定义、命令映射契约及本地化调试。