外观
多端能力补齐与缺陷修复 — 技术方案
对应 PRD 原文。本文档定义"怎么做";"做什么"以 PRD 为准,两者冲突时以 PRD 为准并回头修本文。 状态:草案(待评审)
术语看不懂请直接跳到 第 13 章 名词解释。上期已定义过的术语(授权码、PKCE、访问令牌、刷新令牌、JWKS、吊销集合、webhook、HMAC、壳、子应用等)见 2026-08-23 技术方案第 13 章,本文沿用不重复。
1. 背景
上一迭代交付了独立认证中心 nexo-auth,主链路跑通。但验收会记下的缺陷有一个共同形状——系统里没有真正的「设备」。
devices 表的 id 是 uuid().defaultRandom(),login-service.ts 每次登录直接 insert 一条新行——这张表记的其实是"登录次数",不是"设备"。表注释自己也写了:「每登录一次就多一行、吊销后的行也不删除,是本库里增长最快的表」。
界面上大部分时候看不出来:devices-service.ts 的列表查询带了 isNull(revoked_at),而主动退出会吊销当前设备,所以正常退出再登,列表里始终只有一条。问题出在别处:
- 不是主动退出的重新登录会留下并排的两条——令牌过期、清掉浏览器数据、换个入口重登,旧行的
revoked_at都是空的,于是它和新行一起出现在列表里,还都显示在线 - 稳定指认一台设备这件事根本做不到。
id每次登录都变,于是设备地址历史、网页端读桌面端、按设备拉消息、按设备投递通知——本期这些需求全部没有落脚点 - 「该动哪条会话」只能靠各处代码自己判断,判错就互相误伤:BUG-3(旧令牌重放清掉两条认证)、BUG-4(越权请求清掉当前登录态)都是这么来的
所以本期技术方案的地基只有一件事:把设备从"每次登录的一行记录"变成有身份的长期实体,心跳、双端登录态、消息同步、账号级未读全都建在它上面。(密码端侧加密是另一条独立线,见 §7.10。)
2. 需求分析
PRD 列了 13 条功能需求与 10 条缺陷,落到技术上是七块,依赖关系不是平行的:
① 是前置,必须先落地;②③④⑤ 依赖它;⑥ 只依赖消息模型,⑦ 只改登录链路,两者都可并行开工。
三条不可退让的约束,来自上期已交付的设计与已验收的用例:
| 约束 | 出处 | 违反的后果 |
|---|---|---|
| 资源服务本地验签、不回头问认证中心 | 上期技术方案 13.2 | 认证中心变成每请求依赖,单点故障放大 |
| 关闭浏览器 / 重启应用后仍处登录态 | 上期 TC C5(已通过) | 回归失败;FR-3 / FR-4 的免密登录失去意义 |
| 双方消息顺序完全一致、向上翻页可加载更早消息 | 0816 欠账,已于上期验收通过 | 回归失败 |
第二条直接决定了 FR-5 的做法(见 7.4),第三条直接否掉了撤回的两种候选实现(见 7.6)。
3. 产品方案
见 PRD。本文只在实现影响产品行为时重述,其余不复制。
4. 相关资料
- 2026-08-30 PRD —— 本期"做什么"
- 2026-08-23 技术方案 —— 认证中心与桌面壳,本期在其上改造
- 2026-08-23 验收用例 —— C5、B1、B2 等本期须回归的用例
- 跨仓工程规范 —— 后端目录结构与共享包边界
5. 参与人
| 角色 | 人 |
|---|---|
| 需求 | 待认领 |
| 认证中心(拆表、令牌、本地通道) | 待认领 |
| IM 服务端(心跳、撤回、未读、信令) | 待认领 |
| 客户端(桌面壳、IM PC、本地存储、P2P) | 待认领 |
| 验收 | 全员 |
6. 整体设计
6.1 改造范围
新增的跨服务通道只有 ③(IM → 认证中心,上报连接上下线),复用上期已有的 HMAC 签名机制,方向反过来即可,不新开鉴权体系。
6.2 职责边界
| 谁 | 管什么 | 不管什么 |
|---|---|---|
nexo-auth | 设备身份、登录会话、令牌签发与吊销、本地通道授权码 | 消息、未读、在线状态的判定 |
nexo-im-api | 消息、撤回与归档、已读位置、连接存活判定、RTC 信令转发 | 签发任何凭证、决定设备是否合法 |
| 客户端 | 持有设备标识、本地消息库、P2P 建连、前后台状态上报 | 自行决定该不该弹通知(见 7.8) |
6.3 两个"活跃"必须分开
本期最容易写错的地方:连接活跃与登录活跃是两件事,混在一起就会做出"关掉浏览器就登出"。
| 连接活跃 | 登录活跃 | |
|---|---|---|
| 载体 | WebSocket 连接 | 认证会话(sessions 行)与刷新令牌 |
| 判定方 | nexo-im-api(心跳) | nexo-auth(令牌有效期) |
| 失效后果 | 释放 socket、连接槽位、在线态路由表项;「登录设备」转离线 | 需要重新登录 |
| 恢复方式 | 客户端自动重连,用户无感 | 用户重新输密码 |
| 谁能让它失效 | 网络抖动、休眠、隧道超时、进程退出 | 主动退出、被踢、令牌到期 |
心跳判死只碰左列,永远不碰右列。 这条是 7.4 的全部内容,也是 PRD FR-5 的核心。
6.4 数据访问:操作原子化
本期新增的所有查询遵守后端工程规范 §4「数据访问:操作原子化」,那里是权威定义,本节只记本期怎么落。
一句话原则:每次数据访问都是一个单一目的、可独立执行的最小操作——读不写 JOIN(不论是否同库),写不依赖数据库级联。拆开之后要用分层批量取数(查主表拿一批 id → IN (...) 批量查关联表 → 应用层组装),不是在循环里逐条查——那是把 JOIN 换成 N+1,比 JOIN 更糟。
本期涉及的三处:
| 场景 | 拆法 |
|---|---|
| 「登录设备」列表 | ① sessions WHERE user_id=? AND revoked_at IS NULL(只有活跃会话,两三条)→ ② devices WHERE id IN (...) → ③ 应用层组装。注意顺序:先 sessions 后 devices,反过来会把一大堆历史设备全捞出来,见 §7.1 |
| 未读计数 | ① conversations WHERE user_a_id=? OR user_b_id=?(我的会话,单表,已有索引)→ ② conversation_read_positions 取各会话已读位置 → ③ 把 read_seq 展开成条件查 messages 计数 → ④ 组装 |
| 推送投递 | IM 侧不持有 push_token,经 /internal/* 按 did 批量取 |
外键:7.1 的表设计仍保留外键约束——当前单库,它能挡住脏数据、代价接近零。但业务逻辑不得依赖级联删除 / 更新,该级联的在应用层显式处理;将来去掉外键时只是少一层校验,不改变业务行为。
数据归属:用户、设备、登录会话的唯一权威方是 nexo_auth。IM 侧要用就经 /internal/* 批量取,或落本地只读投影,不反向依赖 auth 的表结构。
6.5 数据生命周期:只做逻辑删除
约束:服务端数据一律只做逻辑删除,不物理删行。
| 数据 | 怎么"删" |
|---|---|
devices | 归属某个 user_id,不存在无主设备。行永不物理删除;将来若要"移除设备",打 deleted_at 标记 |
sessions | 用 revoked_at + revoked_reason 标记失效,行保留。吊销历史因此完整——安全事件事后可查 |
| 撤回的消息 | 原文移入归档表,messages 行本身保留(recalled_at 标记)。见 7.6 |
| 迁移中的历史数据 | 不去重、不折叠、不删行。见 7.1 的迁移方案 |
为什么:吊销、重放、越权这类事件的价值全在事后追溯,物理删掉就等于把追溯能力一起删了;而当前数据量下,保留的存储成本可以忽略。
例外只有客户端本地库:那是缓存,退出登录时按账号隔离(§7.7.1),与服务端的数据生命周期无关。
6.6 配置管理
本期形态:环境变量注入 + 部署文档登记。
后续方向(已确定):所有配置收拢进配置中心,其中影响线上行为的部分作为线上开关接入发布审批流程。
因此本期有三条设计约束,做错了将来接配置中心要返工:
- 集中读取——所有配置经统一的配置模块暴露,业务代码不直接读
process.env。将来换数据源只改这一处 - 不要固化成模块级常量——配置值要能在运行时重新取到。写成
const X = Number(process.env.X)这种模块加载期求值的形式,将来配置中心改了也推不动 - 启动即校验——每项配置有默认值与取值校验,缺失或非法时启动失败,而不是等到运行时才炸
本期配置清单与分类:
| 配置项 | 分类 | 改动的影响面 |
|---|---|---|
WS_HEARTBEAT_INTERVAL_MS / MAX_MISSED / DEAD_MS | 线上开关 | 判死快慢与「登录设备」在线状态 |
| 刷新令牌绝对有效期 / 空闲超时 | 线上开关 | 全体用户的登录态存续 |
MESSAGE_RECALL_WINDOW_MS | 线上开关 | 改动即产品行为变更(撤回时限从无到有) |
| 本地通道端口列表 | 静态配置 | 客户端构建期确定 |
| P2P 建连超时 | 静态配置 | 多久判定建连失败(失败即不支持同步,无降级) |
| 公共 STUN 服务器列表 | 静态配置 | 跨网段建连成功率;换服务商只改此项 |
| presence TTL | 静态配置 | 通知误弹率 |
标为线上开关的三组,将来进配置中心后改动需走发布审批——它们的共同点是:改一个数字就能即时改变所有在线用户的体验,且改错了不会立刻报错,只会让人"觉得哪里怪怪的"。
7. 模块设计
7.1 数据模型变更
nexo_auth:devices 一拆为二
关键约束与改动:
| 项 | 内容 |
|---|---|
| 唯一约束 | unique(user_id, device_key) WHERE deleted_at IS NULL(partial unique)—— 同一账号同一标识,未删除的设备只可能有一行。并发登录靠数据库兜底,不靠应用层判断 |
| 逻辑删除与 upsert 的冲突 | 用普通 unique(user_id, device_key) 会踩坑:设备被逻辑删除后,用户拿同一台机器再登录,device_key 不变、会撞上那条已删除的行。规则定死:命中已 deleted_at 的行就复活它(deleted_at 置回 NULL),因为那本来就是同一台设备。partial unique 索引配合这条规则,两种实现都不会产生重复行 |
devices 行 | 必须归属某个 user_id,不存在无主设备;行永不物理删除,也没有 revoked_at——吊销是 session 的事。预留 deleted_at 供将来"移除设备"用,本期不写入(§6.5) |
sessions 行 | 每次登录插一条,承载 revoked_at / revoked_reason。吊销历史因此完整保留,安全事件事后可查 |
| 外键迁移 | refresh_tokens.device_id → session_id;pending_revocations.device_id → session_id |
sessions.user_id | 冗余字段,值等于 devices.user_id。禁 JOIN 之后必须有它,理由见下方 |
| 索引 | sessions(user_id) WHERE revoked_at IS NULL(查某人的活跃会话,列表主入口)、sessions(device_id)(按设备反查)、devices(user_id) WHERE deleted_at IS NULL |
为什么 sessions 要冗余 user_id
按范式,user_id 挂在 devices 上就够了,sessions 经 device_id 就能推出属于谁。允许 JOIN 时这样没问题;禁了 JOIN 之后就不成立——「查某个用户的活跃会话」变成了一个无法直接表达的查询,只能先捞出这个用户的全部 devices、再拿 device_id IN (...) 去查。
而迁移之后每个用户名下会挂着一大堆历史 device(原来每次登录一行,见下方迁移方案),活跃的却只有两三个。先查 devices 等于每次都把历史全捞一遍,再拼一个很长的 IN 列表——查询顺序恰好搞反了。
冗余一个 user_id 之后,方向可以掉过来:
| 步骤 | 查询 | 结果规模 |
|---|---|---|
| ① | sessions WHERE user_id = ? AND revoked_at IS NULL | 只有活跃会话,两三条 |
| ② | devices WHERE id IN (①的 device_id) | 同样两三条 |
| ③ | 应用层组装、按最近活跃排序 | —— |
这是禁 JOIN 之后的常规代价:适度冗余。 不能再靠 JOIN 把过滤条件下推到关联表,就得让每张表自己带上常用的过滤维度。代价是写入时要保证
sessions.user_id与devices.user_id一致——由于 session 只在登录时创建、user_id此后不变,一致性风险接近零。
在线状态由 session 的 connected_at / disconnected_at 判定(见 7.4)。排序在应用层做(每人设备数是个位数,不值得为它设计索引)。
nexo_im:三处变更
| 表 | 变更 | 用途 |
|---|---|---|
messages | 新增 type('text' | 'recall',默认 'text')、ref_message_id(撤回事件指向被撤回的消息)、recalled_at | 撤回,见 7.6 |
recalled_message_archive(新) | message_id PK、conversation_id、sender_id、content、recalled_at、archived_at | 撤回正文归档,业务接口从不关联此表 |
conversation_read_positions(新) | user_id、conversation_id、read_seq、updated_at,unique(user_id, conversation_id) | 账号级未读,见 7.8 |
messages.content是notNull。撤回时置为空字符串而非改列定义——改notNull会让"正文缺失"从异常变成合法状态,此后任何读到空正文的代码都无法区分"被撤回"和"写入 bug"。
迁移
当前 nexo_auth.devices 里全是"每次登录一行"的历史记录,且只有 5 个种子账号、零真实业务数据。迁移脚本按此顺序:
- 建
sessions表,把现有devices每行的会话字段(client_id/logged_in_at/revoked_at/revoked_reason)搬过去,并把原行的user_id一并写入冗余列;session.id沿用原devices.id——这样refresh_tokens与pending_revocations的外键值不用改,只改列名与引用目标 devices行原地保留,不去重、不折叠、不删行(§6.5):每行填device_key = 'legacy:' || id——用原主键做后缀天然唯一,不会撞上(user_id, device_key)约束;会话字段清出(已搬入sessions),user_id原样保留,不产生无主设备- 客户端升级后带上真实
device_key登录,按(user_id, device_key)新建一行;历史行随其 session 全部吊销而自然不再出现在列表里(列表只显示有活跃 session 的设备,见下方查询说明)
这是"一行拆两行"而非"多行并一行":原
devices的每一行都变成「一个历史 device + 一个 session」,零删除、零合并。代价是迁移后到客户端升级之间的过渡期里,每次登录仍会产生一个独立 device——属预期,不影响用户可见的列表。
不写回滚脚本,写重建脚本。种子账号可重建、无真实业务数据,出问题清库重来比回滚更快也更可靠——这是当前这个窗口的特权,下一期就没有了。
这与 §6.5「只做逻辑删除」不矛盾:那条约束管的是运行时的业务数据,这里说的是迁移失败时的开发期重来。上线后就没有"清库"这个选项了。
7.2 令牌载荷变更
{
sub: <user.id>, // 不变
sid: <session.id>, // 语义变更:原为 devices.id,现为 sessions.id
did: <device.id>, // 新增:设备身份
aud, exp, iat, jti // 不变
}sid管吊销:吊销集合、webhook、refresh_tokens全部挂在它上面,粒度是"这一次登录"did管业务:P2P 选数据源设备、通知投递到设备、消息按设备隔离,资源服务直接从令牌里取,不回查认证中心(保住 §2 的第一条约束)
两者极易混用,规则只有一条:要让它失效就用
sid,要指认它是谁就用did。 代码评审时凡是拿did去查吊销、或拿sid去标识设备的,一律打回。
7.3 设备标识的获取与上报
两端形态对称,都是"首次生成随机 UUID 并持久化,之后只读":
| 端 | 存储位置 | 重装应用 | 清数据 |
|---|---|---|---|
| 桌面端 | 系统凭证库(macOS 钥匙串 / Windows 凭据管理器),复用上期存刷新令牌那条通路 | 保留 | 用户手动清凭证库才会丢 |
| 网页端 | localStorage | —— | 丢失,下次登录算新设备(PRD 认可的边界) |
- 选凭证库而非硬件标识:PRD 只要求"重装应用后不变",凭证库条目在卸载后仍保留,恰好够用;读
IOPlatformUUID/MachineGuid需要写 Rust 侧原生调用,且属硬件级隐私信息,超出实际需要 - 登录请求带上
device_key,认证中心按(user_id, device_key)upsert:命中则复用该devices行、更新last_active_at与地址,然后新建一条session;未命中则新建设备行
device_key 是客户端可控值,必须假定它会被伪造。 防护靠 (user_id, device_key) 这个复合唯一键:伪造者只能命中自己账号下的设备行,冒用不到别人的。因此 device_key 不可用作任何跨账号查询的键,也不可单独作为主键。
FR-2 地址列:登录与令牌续期时取请求来源 IP,反查归属地写入 devices.last_ip / last_location。归属地查询必须异步且可失败——查不到写 null,列表渲染成"未知",绝不能因为归属地服务超时把登录卡住。
7.4 心跳与连接回收(FR-5)
现状:协议层 ping/pong 已有(connection-registry.startHeartbeatMonitor),判死后 unregister 连接。缺的是两件事——判死后没有对外反映,以及阈值写死在代码里。
判死后做什么、不做什么
| 做 | 不做 |
|---|---|
| 释放 socket、连接槽位、在线态路由表项 | 吊销 session |
上报 disconnected_at,「登录设备」转离线 | 删除 devices 行 |
| 停止向该连接投递消息,转由通知通道处理(7.8) | 使令牌失效 |
阈值配置(建议值,评审拍板)
| 配置项 | 建议值 | 依据 |
|---|---|---|
WS_HEARTBEAT_INTERVAL_MS | 30000 | 客户端 ws.ts 现已是 30s,保持一致 |
WS_HEARTBEAT_MAX_MISSED | 2 | 允许连续丢 2 次;第 3 次仍未收到才判死。 单次乃至连续两次网络抖动都不会误判 |
WS_HEARTBEAT_DEAD_MS | 90000 | = INTERVAL × (MAX_MISSED + 1) = 30s × 3。三者必须保持这个关系,改任一项都要跟着重算,否则会出现"还没丢够次数就先超时判死" |
三项写入部署文档。PRD 非功能要求"失活到转离线 ≤ 判死阈值 + 5s",其中 5s 是上报链路的预算。
上报失败怎么办。 disconnected 上报失败(IM 崩溃、认证中心短暂不可用)会让「登录设备」里挂着一条假在线。兜底:认证中心把 connected_at 距今超过 2 × WS_HEARTBEAT_DEAD_MS 且无 disconnected_at 的一律按离线渲染。在线状态是软状态,不允许任何安全判断依赖它。
7.5 桌面端内置登录与网页端读桌面端(FR-3 / FR-4)
7.5.1 桌面端内置登录
上期走"本地回环 + 系统浏览器",本期改为 WebView 加载认证中心托管的登录页,流程本身不变(授权码 + PKCE),只是把承载登录页的浏览器换成应用内 WebView,回调从本地回环改为应用可拦截的重定向。
不新开任何登录接口——认证中心侧零改动,改的只是桌面端拿什么东西打开登录页。
密码是否经过应用本体,不再作为设计约束。 Tauri 下 WebView 就是应用自己的,想读表单总是读得到——与其在这上面做纸面隔离,不如把防护移到密码本身:FR-13 要求端侧先做单向加密再传输(见 §7.10),应用与服务端拿到的都不是明文。
7.5.2 网页端读桌面端
关键设计:本地通道不下发令牌,只下发一次性授权码。网页端拿码走标准 /token 端点换取自己的令牌,于是它天然是一台独立设备、独立 session,与桌面端互不牵连——正好满足 PRD"两端会话独立"。
安全边界(准入条件,不是优化项)
| 措施 | 说明 |
|---|---|
| Origin 白名单 | 只接受预配置的正式域名。任何网站的 JS 都能请求 127.0.0.1;不校验 Origin,用户打开恶意站点的那一刻登录态就被静默取走 |
| 严格 CORS | 逐 Origin 回显,禁止 Access-Control-Allow-Origin: *。非白名单请求不带 CORS 头,浏览器侧读不到响应体 |
| 每次确认 | 每次索取授权码都在桌面端弹窗,不记住 Origin、不提供"下次不再询问"。弹窗须显示请求方 Origin,让用户看得见是谁在要 |
| 一次性授权码 | 通道只吐码不吐令牌,码单次可用、短有效期(≤60s)、绑定 code_challenge |
| 不在防护范围 | 本机恶意程序直接请求该端口(不受 CORS 约束)。该场景下系统凭证库同样保不住,属已全线失守,不为它做重设计 |
「每次」到底是多久一次。 本地通道只在网页端需要建立新登录会话时才走一遭;拿到自己的令牌之后,续期走标准刷新令牌流程,不再碰这条通道。所以弹窗频率约等于"网页端重新登录的频率",而不是每次接口调用——正常使用下并不密集。
不记住带来两个收益:实现上不必做确认态的存储与撤销 UI;安全上即使 Origin 白名单配错,每一次索取都在用户眼皮底下发生,不会静默泄露。代价是用户可能被养成无脑点确认的习惯——所以弹窗文案必须写清"某某站点正在请求以你的身份登录",而不是一句干巴巴的"是否允许"。
端口:17650 / 17651 / 17652 顺次尝试,占用则回退;网页端按同一列表探测。全部占用则视为桌面端不可用。
桌面端未运行时:ping 连接失败,网页端安静回退到常规密码登录,不弹任何错误——探测失败是常态,不是异常。
⚠️ 本 FR 的第一个动作是可行性实测,不是写代码。 https 页面请求
http://127.0.0.1受 mixed content 与 Private Network Access 两套规则影响:Chrome 把 localhost 视作可信来源,但 PNA 要求这类请求先发预检(Access-Control-Request-Private-Network),各浏览器推进程度不一致,Safari 行为尤其需要实测。验证矩阵:Chrome / Safari / Firefox × macOS / Windows,逐格记录能否发出请求、是否需要预检、预检响应头要求。不通则 FR-4 当期降级(降级方案:改自定义协议
nexo://唤起,代价是失去静默探测,用户须主动点一下)。这份实测结论要在评审会上给出,而不是开发到一半才发现。
7.6 消息撤回(FR-7)
7.6.1 为什么不能原地改
增量拉取走的是 after_seq(seq > readSeq),这个模型只能感知新增,感知不到原地修改。给 messages 加个 recalled_at 是最直觉的做法,但那条消息的 seq 没变、早被拉过,离线设备再上线也不会重新拉它——屏幕上永远是原文,PRD"离线设备上线后拉到的也是撤回后的状态"直接落空。
另一个候选是把被撤回的消息 bump 到新 seq,它能被拉到,但消息会突然跳到会话最底下,破坏上期刚验收通过的"双方消息顺序完全一致"。
所以:撤回自己占一个新 seq,借现有增量通道白送给所有离线设备。
7.6.2 落法
| 要点 | 处理 |
|---|---|
| 正文去向 | 写入 recalled_message_archive,messages.content 置空串。业务接口从不关联归档表——这是结构性隔离,不是"记得过滤"。访问归档表单独鉴权并记审计日志 |
| 为什么不删 | 将来的消息复核与合规检查,最需要看的恰恰是被撤回的内容。删掉是自毁证据(PRD FR-7 明确要求"业务接口拿不到"而非"数据不存在") |
| 时限 | 第一版不设。校验点在 service 层留成一个可配开关(MESSAGE_RECALL_WINDOW_MS,默认 0 = 不限制),将来开启不改协议、不动客户端 |
| 未读计数 | type='recall' 的行不计入未读,也不参与"最后一条消息"预览的正文展示(预览显示「XX 撤回了一条消息」) |
| 权限 | 只能撤回自己发出的消息。他人消息返回 403——注意是 403 不是 401(见 BUG-4) |
| 幂等 | 已撤回的消息再次撤回直接返回成功,不重复插事件行、不重复归档 |
7.7 消息本地存储与 P2P 同步(FR-8)
7.7.1 本地库:卸载重装不丢
硬要求:卸载应用再重装,本地消息仍在;只有用户在卸载时明确勾选"清空数据"(或在应用内主动清理)才丢。
这一条直接否掉了 IndexedDB 作为桌面端方案——它存在 WebView 的 profile 目录里,卸载必然一起被清掉。所以两端必须分开:
| 端 | 存储 | 位置 | 卸载重装 |
|---|---|---|---|
| 桌面端 | SQLite | 系统用户数据目录(macOS ~/Library/Application Support/<bundle-id>/、Windows %APPDATA%\<app>\) | 保留 |
| 网页端 | IndexedDB | 浏览器站点数据 | 无"卸载"概念;清站点数据即丢,与 FR-1 的设备标识同命 |
平台差异要如实写进产品说明:
- macOS 没有标准卸载器,用户把
.app拖进废纸篓,用户数据目录天然保留。要清数据只能靠应用内的入口 - Windows 卸载器默认可能连数据目录一起删,必须在打包配置里显式保留,并在卸载界面提供**「同时删除本地消息」勾选项**(默认不勾)
- 两端都要在应用内提供**「清空本地消息」**入口——macOS 上这是唯一的清理途径
架构影响(本期新增的一块工作量):nexo-im-pc 是跑在壳里的子应用,它拿不到原生能力。SQLite 必须由壳(nexo-app)通过 Tauri 侧暴露,子应用经桥接调用。需要新增:Tauri 的 SQL 能力(插件或自写 Rust 命令)、壳向子应用暴露的存储接口、以及子应用侧"在壳内用 SQLite / 独立访问用 IndexedDB"的分流——这个分流要封装在 SDK 里,业务代码只看到一套接口(与上期 getAccessToken() 的处理方式一致)。
两端共同的约定:
- 按账号隔离,不删库:库文件名 / 库名带
userId。PRD 要求的是"清除或按账号隔离",二选一——选隔离,同一个账号回来时本地历史还在,只需增量补齐。退出登录就删是错的,那会让每次重登都从头同步一遍 - 表结构:
messages(conversation_id + seq复合索引,喂饱按会话翻页与增量合并)、conversations、sync_ranges(记录各会话本地已有的 seq 区间,供 7.7.2 计算缺口) - 桌面端 SQLite 与网页端 IndexedDB 的 schema 保持同构,迁移脚本各写一套但版本号对齐
7.7.2 数据同步:只走 P2P
硬约束:批量同步的数据不允许经服务端中转。历史消息是 GB 量级,走服务器的话进出流量会被直接刷爆。
务必分清两件事,否则会把 IM 改坏:
- 常规业务请求(新消息推送、
after_seq增量、会话内向上翻页)—— 照常走服务端,量小、必须可靠。不受本约束限制- 批量历史同步(FR-8 的"从 A 设备拉取某段时间")—— 只走 P2P,这才是本节说的"数据同步"
| 项 | 设计 |
|---|---|
| 信令 | 复用现有 WS:{ type: 'rtc-signal', to: <did>, payload },服务端按 did 路由。信令本身流量极小,走服务端没有成本问题 |
| ICE | host + srflx,接入公共 STUN(建议配 2–3 个做冗余)。支持跨网段 |
| TURN | 不用,也不能用——TURN 是中继,全部流量过服务器,与本节的硬约束直接冲突 |
| 建连失败 | 不降级。明确提示用户当前网络不支持,同步不进行。这是本期唯一没有兜底路径的功能 |
| 传输 | 先压缩后加密,DataChannel 分批发送(建议 200 条/批)。接收方按 (conversation_id, seq) 去重合并——seq 天然幂等,重传不会产生重复 |
| 断点续传 | 按 sync_ranges 记录已完成区间,中断后从缺口继续,不从头再来 |
| 离线设备 | 不支持。对方不在线直接提示不可用,不做中转与补偿 |
压缩与加密
- 压缩:整批 JSON 压缩后再传。文本消息压缩比很高,这是省流量最直接的一刀
- 加密:DataChannel 本身强制 DTLS,传输层已经是加密的。应用层再加一层的意义在于防信令服务器中间人——SDP 与 DTLS 指纹都经我们自己的服务端交换,理论上服务端有篡改能力
密钥协商方式待定(见 §12)。威胁模型不同,选择完全不同:只防第三方窃听,DTLS 已经够;要防我们自己的服务端,就需要带外验证(比如两台设备上比对一次配对码),而那会牺牲"选一下就开始同步"的体验。这一条不定,加密就只是个形式。
公共 STUN 的代价,记录在案
接入公共 STUN 意味着把设备的公网 IP 暴露给第三方服务(STUN 服务器天然会看到)。此前的方案里因此排除了它,本期为了支持跨网段而接受这个代价。如果将来觉得不可接受,替代方案是自建 STUN——注意 STUN 只做地址发现、不中继流量,自建的资源开销很小,与"不走服务端"的约束并不冲突。
已知会失败的场景,写进方案免得验收时被当 bug 报:
- 对称 NAT:公共 STUN 也穿不透,只有 TURN 能救,而 TURN 被本约束排除
- 企业防火墙禁 UDP:WebRTC 基本走不通
- AP isolation:同网段也互相不可达
- 以上三种都直接进"不支持"分支,用户会看到明确提示,而不是转圈等待
7.8 未读账号级消费与通知决策(FR-9)
7.8.1 已读位置搬到服务端
现状:readSeq 是客户端传上来的参数(conversation-service.ts 注释写着"客户端本端已读到的 seq"),服务端只帮忙 count。这意味着每台设备各算各的未读——正是 PRD 要消灭的行为。
改造:新增 conversation_read_positions,未读改由服务端按该表计算,接口不再接受客户端传入的 readSeq。
| 要点 | 处理 |
|---|---|
| 单调性 | GREATEST(现有, 传入),永不回退。多端乱序上报、网络重排都不会让未读"复活" |
| 未读口径 | seq > read_seq 且非本人发送 且 type != 'recall' |
| 查询方式 | 两次查询,不联表(§6.4):① 一次取出该用户全部会话的 read_position → ② 把各会话的 read_seq 展开成 (会话1 AND seq>s1) OR (会话2 AND seq>s2) ... 再 GROUP BY 计数。第二步沿用现有写法,只是 read_seq 的来源从"客户端传参"换成"第一步查出来的" |
| 广播范围 | 同账号的所有在线连接,包含上报者自己(简化客户端逻辑:本地先乐观更新,收到广播再对齐) |
| 离线端 | 上线后只拉当前真实未读,不补发、不重新计入 |
| 接口变更 | 会话列表与未读接口不再接受客户端 readSeq 参数——这是破坏性变更,两个客户端须同期改 |
7.8.2 通知决策与投递通道
PRD 要求二者解耦,否则将来接移动端要整条重做。难点在于:服务端不知道"应用在后台"——桌面端最小化时 WS 还连着好好的,光看连接在不在判不出来。
| 项 | 设计 |
|---|---|
| 状态上报 | 客户端经 WS 上报 { type:'presence', state:'foreground'|'background', conversationId }。切换时上报,建连时带初始值 |
| 脏状态兜底 | presence 带 TTL(建议 2 × 心跳间隔),过期即按 background 处理——宁可多弹一次通知,不可漏弹 |
| 通道抽象 | 决策产出「该通知哪台设备」,通道只管送达。新增通道不得要求改动决策逻辑——这是能否平滑接移动端的唯一判据 |
| 预留字段 | push_channel / push_token 加在 nexo_auth.devices(设备信息的唯一权威在 auth),本期不写入、不使用 |
| 跨服务取值 | IM 侧不持有也不缓存 push_token。将来接推送时,按 did 经 /internal/* 批量取,不逐个查、更不跨库关联(§6.4) |
| 通知 vs 未读 | 多台设备同时在后台都可以弹(提醒是尽力送达)。一端读了之后,7.8.1 的广播让其余端不再弹新通知 |
| 不撤回已弹出的通知 | 撤回需要维护通知句柄,且跨平台能力不一致(网页端要 tag 或 ServiceWorker、桌面端不一定拿得到句柄),换来的收益只是消掉一条用户已经看过的提示。本期明确不做,已弹出的由用户自行忽略或点掉——点进去会看到已读状态,不会出现数据错乱 |
7.9 缺陷的技术处理
10 条缺陷里有 6 条是"该动哪条会话搞错了",拆表之后它们的正确行为才有地方落。
| 缺陷 | 根因 | 修复 |
|---|---|---|
| BUG-3 旧令牌重放清掉两条认证 | 吊销粒度错位:现在一次登录一条 devices 行,重放时的清理逻辑波及了同账号的其他行 | 复用检测只吊销涉事的那一条 session(revoked_reason='token_reuse')。devices 行不动,同账号其他 session 不受影响 |
| BUG-4 越权请求清掉登录态 | 前端把 403 当 401 处理 | 拦截器按状态码分流:401 清凭证回登录页;403 只提示"无权访问",登录态原样保留。两者共用一个错误处理分支是本 bug 的直接成因,必须拆开 |
| BUG-2 退出登录后页面无法操作 | 退出时只清了部分状态,界面卡在中间态 | 退出走统一的 teardown:清令牌 → 关 WS → 重置内存 store 与 UI 状态 → 跳登录页,任一步失败也要走完后续步骤。注意不删本地消息库(§7.7.1 按账号隔离,删了下次重登要从头同步) |
| BUG-8 退出不清未读 | 同上,内存态残留 | 并入上面的 teardown:清空内存中的未读、草稿、会话缓存。换账号看不到残留靠的是库名带 userId 的隔离,不是删库 |
| BUG-5 强制下线无提醒 | 吊销事件到达后直接跳登录页,没带原因 | 吊销 webhook 与 WS 断开帧携带 reason,客户端据此展示「你的账号已在其他设备上退出登录」 |
| BUG-6 自动退出登录时间不明 | 从未定义 | 见下方建议值 |
| A5 授权码可兑换两次 | 兑换未原子化 | 兑换改为带条件更新(UPDATE ... WHERE used_at IS NULL RETURNING),受影响行数为 0 即判定重放:拒绝签发 + 吊销该 session。靠数据库保证原子性,不靠先查后写 |
BUG-6 建议值(评审拍板)
| 配置项 | 建议值 | 说明 |
|---|---|---|
| 刷新令牌绝对有效期 | 30 天 | 沿用上期,到期必须重新登录 |
| 空闲超时 | 14 天 | 连续 14 天无任何续期即失效。注意口径是"无请求",不是"无长连接"——掉线不算空闲(§6.3) |
| 到期提示 | 回登录页并提示「登录已过期,请重新登录」 | 与被踢下线的文案区分开,否则用户会误以为账号异常 |
其余 3 条(BUG-1 授权后窗口自关、BUG-9 滚动条布局偏移、BUG-10 空白区白边)是客户端实现细节,不涉及跨模块设计,在各自 issue 里处理。BUG-7(草稿被清)需要草稿按 conversationId 存于本地库并在会话切换时保留,随 7.7.1 的本地库一起做。
7.10 密码的端侧加密与落盘(FR-13)
现状:客户端传明文(靠 HTTPS 保护),服务端 scrypt(明文, 随机盐) 落盘,格式 scrypt:cost:blockSize:parallelization:salt:hash。实现本身没问题,改的是「明文到不到得了服务端」。
改造:两段单向加密
两段天然是不同算法:H1 用 PBKDF2、H2 沿用现有 scrypt,满足 FR-13「不同的算法」。
选 PBKDF2 做 H1 的理由很实际:Web Crypto 原生支持它,浏览器与 Tauri WebView 都不必引入额外依赖;scrypt / Argon2 在浏览器里要挂 WASM 库,为一个登录接口不值当。
盐怎么来——这里最容易做错
H1 需要盐,而盐的取法不能泄露账号是否存在。最直觉的「登录前向服务端要该用户的盐」恰恰是错的:不存在的用户要不到盐,攻击者据此就能枚举账号,直接撞上期 TC 的 A8。
正确做法是确定性派生,客户端自己算得出来:
salt1 = SHA-256( 应用域串 ‖ 规范化后的用户名 )不存在的用户一样能算出盐、一样完成 H1、一样发出请求,服务端一样返回「用户名或密码错误」——全流程无可区分点。
代价:用户名变更会让密码失效。Nexo 的 5 个账号预置且用户名不变,可接受;但要写进约束——将来若支持改用户名,必须同时走改密流程。
参数建议(评审拍板)
| 段 | 算法 | 参数 |
|---|---|---|
| H1(客户端) | PBKDF2-HMAC-SHA256 | 迭代 100_000,输出 32 字节 |
| H2(服务端) | scrypt | 沿用现有参数不改:cost=16384, r=8, p=1, keyLen=64,随机盐 |
H1 的迭代数要在最慢的目标设备上实测,控制在几百毫秒内——它是同步计算,卡住的是登录按钮。
这个改造解决什么、不解决什么
| 解决 | 不解决 |
|---|---|
| 服务端永远看不到明文:日志误记、内存转储、内部人员窥探都拿不到 | 不替代 HTTPS。H1 的输出就是事实上的凭证,截获即可登录,无需还原原始密码 |
| 用户在别处复用同一密码时,我们被攻破也不会连带 | 不让数据库泄露的后果变轻。原本存的就是不可逆哈希,泄露后同样无法直接登录 |
右列必须写进文档,为的是防一件事:有人以为「反正端侧加密过了」,于是在传输层或日志上放松。H1 输出的敏感级别等同于密码,同样禁止落日志、禁止进 URL。
可选加固:pepper
FR-13 提到的「相同算法配不同密钥」对应的做法是给 H2 加 pepper——一个只存在服务端配置里、不进数据库的密钥参与哈希。数据库泄露而 pepper 未泄露时,攻击者无法离线爆破。
代价是密钥轮换极其麻烦(轮换 = 全员重设密码)。本期建议不做,登记为可选项,等有真实合规要求再上。
迁移与存量
- 存量
password_hash是scrypt(明文),改造后全部失效。服务端没有明文、无法自动转换——5 个种子账号直接重置 - 管理员重置密码要走同一套:管理端提交前先做 H1,或把账号置为「待激活」、由用户首次登录时设定
password.ts顶部那条警告(算法与存储格式须与nexo-im-api逐字节一致)是迁移期遗留。本期nexo-im-api已不再持有users表,该约束解除——但动手前先确认那次迁移确实执行完毕
8. 排期
前置项必须先合,否则后面全部悬空。
| 阶段 | 内容 | 依赖 |
|---|---|---|
| P0(最先) | 7.1 拆表与迁移、7.2 令牌载荷、7.3 设备标识 | 无 |
| P0 并行 | FR-4 本地通道可行性实测(7.5.2 的验证矩阵) | 无,越早越好 |
| P0 并行 | 7.10 密码端侧加密 | 无。要早:它改登录链路且需全员重置密码,越晚做发布窗口越紧 |
| P1 | 7.4 心跳回收、7.9 会话语义类缺陷 | P0 |
| P1 并行 | 7.6 消息撤回、7.8.1 已读位置 | 仅依赖消息模型 |
| P1 并行(新增工作量) | 7.7.1 本地库:桌面端 SQLite 接入 + 壳向子应用暴露存储能力 + SDK 侧分流 + Windows 打包保留数据目录 | 独立于其他项,但涉及壳、子应用、打包三处,开工要早 |
| P2 | 7.5 内置登录与双端同步、7.7.2 P2P、7.8.2 通知决策 | P1 + 实测结论 |
| P3 | 客户端 UI 类缺陷、FR-10 外框自绘、FR-11 图标 | 无强依赖,可随时插空 |
9. 发布计划
| 项 | 安排 |
|---|---|
| 迁移 | 先在本地与验收环境各跑一次;失败即清库重建(种子账号可重建、零真实业务数据) |
| 破坏性接口变更 | 未读接口不再接受客户端 readSeq(7.8.1)、令牌新增 did(7.2)。服务端与两个客户端须同批发布,不做灰度 |
| 令牌兼容 | 旧令牌(无 did、sid 语义为旧 device.id)在发布后一律作废,全员重新登录一次。5 人规模不值得为兼容写双读逻辑 |
| 密码重置 | FR-13 改造后存量哈希全部失效,发布时须为全部账号重置密码并通知本人。漏了这一步的表现是「所有人都登不进去」,而报错文案是「密码错误」,极难定位 |
| 配置项 | 全部写入部署文档。分类与后续接入配置中心的约束见 §6.6——标为线上开关的三组(心跳三参数、令牌有效期、撤回时限)将来改动要走发布审批 |
| 回滚 | 数据库层面走重建脚本;代码层面按仓库各自回滚上一 tag |
10. 稳定性保障
| 项 | 措施 |
|---|---|
| 迁移正确性 | 迁移后断言:每个账号的 devices 行数 ≤ 迁移前、sessions 总数 = 迁移前 devices 总数、所有外键无悬挂 |
| 吊销链路回归 | 上期 B2(强制下线 ≤5s)、C3(旧刷新令牌重放)、D3(已吊销设备令牌)全量回归——本期动了 sid 语义,这三条最容易被打破 |
| 登录持久化回归 | 上期 C5 必须回归。7.4 若做错就会在这条上暴露 |
| 消息顺序回归 | 0816 那三条欠账(顺序一致、多端收发、分页完整性)回归——7.6 的撤回事件占用新 seq,直接触及顺序 |
| 卸载重装不丢 | 两个平台各跑一轮装 → 用 → 卸 → 重装,确认历史仍在;再跑一轮勾选「同时删除本地消息」的卸载,确认确实清掉。这条只能靠人工实测,没有别的验证手段 |
| 密码改造验证 | 上期 A8(错误密码与不存在账号响应不可区分)必须回归——§7.10 的盐派生若做错,这条会立刻暴露。另需确认:H1 输出未进日志、未进 URL;全员重置后能正常登录 |
| 数据访问合规 | code review 卡两条(§6.4):新增查询里不得出现 JOIN;循环体内不得查库。后者是把 JOIN 换成 N+1,比 JOIN 更糟 |
| 关键日志 | 沿用上期 @cmtlyt/logger。新增事件:设备 upsert(命中/新建)、连接上下线上报、撤回与归档、已读位置更新、本地通道授权(含 Origin 与确认结果)、RTC 建连成功/失败、密码校验失败(不记任何密码材料) |
| 日志红线 | 归档表内容永不进日志;device_key 脱敏后再记 |
11. 风险
| 风险 | 应对 |
|---|---|
| PNA / mixed content 不通,FR-4 做不了 | 实测排在 P0 并行,结论在评审会上给。不通则降级为 nexo:// 唤起(失去静默探测),不拖到开发中期才发现 |
| 拆表牵动吊销链路,引入认证回归 | session.id 沿用原 devices.id,外键值不变、只改引用目标,把改动面压到最小;上期 A / C 两组用例全量回归 |
| P2P 建连失败 | 没有兜底——这是本期唯一没有降级路径的功能(服务端中转被流量约束排除)。会议室网络多半不通(企业 Wi-Fi 常开 AP isolation、可能禁 UDP),验收前必须实测;不通就改用手机热点组网,并如实说明真实网络下的可用性边界,而不是回避 |
| Windows 卸载器把数据目录一起删了 | 「卸载重装不丢」是 FR-8 的硬要求,删了即不通过。打包配置显式保留数据目录,卸载界面加默认不勾的「同时删除本地消息」;发版前在干净 Windows 上实测一轮装→用→卸→重装 |
| 桌面端 SQLite 与网页端 IndexedDB 实现分叉 | schema 保持同构、版本号对齐;分流封装在 SDK 内,业务代码只看到一套接口。code review 卡住业务层出现 if (isDesktop) 这类写法 |
| 未读接口破坏性变更导致客户端不一致 | 服务端与两个客户端同批发布,不做灰度;发布后立刻验一遍跨端未读同步 |
| 本地通道被做成"先能用再加安全" | Origin 白名单与严格 CORS 是准入条件,未实现则 FR-4 直接判不通过,不接受"后面补" |
| 范围过载 | 挪出顺序:FR-11 图标 → FR-6 时间精确到秒 → FR-9 通知决策 → FR-8 的 P2P 部分。7.1 / 7.4 / 7.9 / 7.10 不可砍——前三是地基与缺陷,7.10 密码改造牵动全员重置密码,中途放弃比做完更麻烦 |
12. 待评审确认点
| # | 确认项 | 倾向 |
|---|---|---|
| 1 | 心跳三参数:30s / 2 次 / 90s | 按 §7.4 |
| 2 | 刷新令牌 30 天 + 空闲超时 14 天 | 按 §7.9 |
| 3 | FR-3 方向反转(桌面端改 WebView 内置登录,不再跳系统浏览器) | 会上已定,但推翻上期已交付方案,需显式点头。注意"密码不经过应用本体"已不再是约束,防护改由 §7.10 承担 |
| 4 | 令牌不做兼容,发布后全员重登一次 | 按 §9 |
| 5 | 归档表的访问权限归谁、审计日志留多久 | 本期只建表与隔离,权限策略待定 |
| 6 | 本地通道端口 17650-17652 是否与现有服务冲突 | 需在各人机器上确认一遍 |
| 7 | P2P 同步的加密威胁模型:只防第三方窃听(DTLS 已够),还是要防我们自己的信令服务端(需带外验证配对码,牺牲体验) | 不定则 §7.7.2 的"应用层加密"只是形式 |
| 8 | 公共 STUN 选哪几家、要不要改自建 | 公共 STUN 会把设备公网 IP 暴露给第三方;自建 STUN 只做地址发现不中继,开销很小且不违反流量约束 |
| 9 | H1 迭代次数(建议 100_000) | 需在最慢的目标设备上实测,登录按钮的卡顿感由它决定 |
| 10 | H2 要不要加 pepper | 能防住「库泄露后离线爆破」,代价是轮换即全员改密。倾向本期不做 |
13. 名词解释
只解释本文新出现的术语,且只写它在本系统里的作用。上期已定义的(授权码模式、PKCE、访问令牌、刷新令牌、JWT、JWKS、复用检测、吊销集合、webhook、轮询兜底、HMAC、nonce、/internal/*、壳、子应用、Tauri、系统凭证库等)见 2026-08-23 技术方案第 13 章。
13.0 数据访问
| 术语 | 含义 |
|---|---|
| 权威方 | 某类数据唯一有权修改、也唯一说了算的服务。用户与设备的权威方是 nexo_auth,其他服务只读取、不改写,更不复制其表结构 |
| 分层批量 | 替代 JOIN 的取数方式:查主表拿一批 id → IN (...) 一次批量查关联表 → 应用层组装。关键在"批量",退化成循环里逐条查就成了 N+1,比 JOIN 更糟 |
| 只读投影 | 把他方数据的公开字段在本地存一份副本(如 nexo_im.user_profiles),避免每次渲染都跨服务取。上期已引入,定义见上期第 13.4 节 |
13.1 设备与会话
| 术语 | 含义 |
|---|---|
| device(设备) | 长期身份,devices 表一行。永不销毁,没有吊销概念。一台机器上的桌面端、Chrome、Safari 各是一个 device |
| session(登录会话) | 一次登录,sessions 表一行。承载 revoked_at / revoked_reason,被踢、重放、主动退出终结的都是它。同一个 device 一生可以有很多个 session |
device_key | 客户端持有并出示的稳定标识(桌面端存系统凭证库、网页端存 localStorage)。服务端按 (user_id, device_key) 认设备。客户端可控值,只能命中自己账号下的设备行 |
sid | JWT 里的 session id。管吊销——吊销集合、webhook、刷新令牌全挂它 |
did | JWT 里的 device id。管业务标识——P2P 选数据源、通知投递、消息隔离用它。规则:要让它失效用 sid,要指认它是谁用 did |
| 连接活跃 / 登录活跃 | 必须分开的两件事。前者是 WS 连接,掉线就该释放;后者是认证会话与令牌,只有主动退出 / 被踢 / 到期才该动。心跳判死只碰前者 |
| 软状态 | 允许短暂不准、可由超时自愈的状态,如设备在线与否。不允许任何安全判断依赖它 |
13.2 本地通道
| 术语 | 含义 |
|---|---|
| 本地通道 | 桌面端在 127.0.0.1 上常驻监听、供同机网页端索取登录态的接口。本期只吐一次性授权码,不吐令牌 |
| Origin | 协议 + 域名 + 端口三元组。浏览器在跨源请求中自动带上且页面无法伪造,因此是判断"请求来自哪个站点"的可信依据 |
| CORS | 跨源资源共享。服务端用响应头声明允许哪些 Origin 读取响应;未声明则浏览器拦下响应体。本地通道上禁用 * 通配 |
| mixed content | https 页面加载 http 资源时的浏览器拦截规则。localhost 通常有豁免,但不是所有浏览器行为一致 |
| PNA(Private Network Access) | 限制公网页面访问私有网络(含 127.0.0.1)的规范,要求先发预检请求。各浏览器推进程度不一致,是 FR-4 最大的不确定性 |
13.3 消息与传输
| 术语 | 含义 |
|---|---|
after_seq 增量 | 客户端带上已有的最大 seq,服务端返回更大的部分。只能感知新增,感知不到原地修改——这是撤回必须占新 seq 的根本原因 |
| 撤回事件 | type='recall' 的一行消息,占用新 seq,ref_message_id 指向被撤回的原消息。它借现有增量通道白送给所有离线设备 |
| 归档表 | recalled_message_archive,存放被撤回消息的原文。业务接口从不关联它——结构性隔离,不靠"记得过滤" |
| IndexedDB | 浏览器内置的本地数据库,存在浏览器站点数据里。本期只有网页端用它——桌面端不能用,因为它随应用卸载一起被清掉,做不到 FR-8 的「卸载重装不丢」 |
| 用户数据目录 | 系统为应用划出的持久化目录(macOS ~/Library/Application Support/、Windows %APPDATA%)。桌面端 SQLite 库文件放这里,卸载应用不会自动清除——这是「卸载重装不丢」的实现基础 |
| WebRTC / DataChannel | 在两个客户端之间建立点对点连接的标准,DataChannel 是其中传任意数据的通道。本期用来在设备间直传历史消息 |
| 信令 | 建立 P2P 连接前交换 SDP 与 ICE candidate 的过程。本期复用现有 WebSocket,不新开服务 |
| host candidate | ICE 候选中来自本机网卡的那一类,只能同网段直连 |
| srflx candidate | 经 STUN 发现的公网映射地址(server reflexive)。有了它才能跨网段建连,这是本期接入公共 STUN 的目的 |
| STUN | 帮设备发现自己公网地址的服务,只做地址发现、不经手数据。本期接入公共 STUN 以支持跨网段 |
| TURN | 穿不透时代为中继全部流量的服务。本期明确排除——它会让同步数据全部过服务器,与「不走服务端」的流量约束直接冲突 |
| 对称 NAT | 一类严格的 NAT,STUN 拿到的地址对其他对端无效,只有 TURN 能救。因此本期在对称 NAT 下同步不可用 |
| AP isolation | 无线路由器上的客户端隔离功能,开启后同网段设备也互相不可达。企业 Wi-Fi 常开,会让 P2P 建连失败——本期没有降级路径,直接提示不可用 |
| mDNS 候选 | 浏览器为防指纹,把本地 IP 替换成 .local 名称的 ICE 候选。部分组合下会导致同网段也建不起连 |
| 批量历史同步(区别于常规请求) | FR-8 说的「数据同步」专指设备间批量搬运历史消息(GB 量级,只走 P2P)。新消息推送、after_seq 增量、会话内向上翻页属常规业务请求,照常走服务端,不受流量约束限制 |
13.4 未读与通知
| 术语 | 含义 |
|---|---|
已读位置(read_seq) | 某账号在某会话中已读到的 seq。本期从"客户端传参"改为服务端状态,取 GREATEST 单调不回退 |
| 未读消费 | 把一条未读读掉这个动作。本系统里未读是账号级的——A 端读了 B 端就没有了,不是每台设备各消费一遍 |
| presence | 客户端上报的前后台状态与当前停留的会话,服务端据此决定该不该通知。带 TTL,过期按 background 处理(宁可多弹,不可漏弹) |
| 通知决策 / 投递通道 | 决策判"该通知哪台设备",通道判"怎么送达"。二者解耦的判据只有一条:新增通道不需要改动决策逻辑 |
| APNs / FCM | Apple 与 Google 的推送服务。移动端 App 被系统冻结后只有经它们才能送达。本期只预留通道接口与 push_token 字段,不接入 |