Skip to content

多端能力补齐与缺陷修复 — 技术方案 ​

对应 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. 相关资料 ​

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 配置管理 ​

本期形态:环境变量注入 + 部署文档登记。

后续方向(已确定):所有配置收拢进配置中心,其中影响线上行为的部分作为线上开关接入发布审批流程。

因此本期有三条设计约束,做错了将来接配置中心要返工:

  1. 集中读取——所有配置经统一的配置模块暴露,业务代码不直接读 process.env。将来换数据源只改这一处
  2. 不要固化成模块级常量——配置值要能在运行时重新取到。写成 const X = Number(process.env.X) 这种模块加载期求值的形式,将来配置中心改了也推不动
  3. 启动即校验——每项配置有默认值与取值校验,缺失或非法时启动失败,而不是等到运行时才炸

本期配置清单与分类:

配置项分类改动的影响面
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 个种子账号、零真实业务数据。迁移脚本按此顺序:

  1. 建 sessions 表,把现有 devices 每行的会话字段(client_id / logged_in_at / revoked_at / revoked_reason)搬过去,并把原行的 user_id 一并写入冗余列;session.id 沿用原 devices.id——这样 refresh_tokens 与 pending_revocations 的外键值不用改,只改列名与引用目标
  2. devices 行原地保留,不去重、不折叠、不删行(§6.5):每行填 device_key = 'legacy:' || id——用原主键做后缀天然唯一,不会撞上 (user_id, device_key) 约束;会话字段清出(已搬入 sessions),user_id 原样保留,不产生无主设备
  3. 客户端升级后带上真实 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_MS30000客户端 ws.ts 现已是 30s,保持一致
WS_HEARTBEAT_MAX_MISSED2允许连续丢 2 次;第 3 次仍未收到才判死。 单次乃至连续两次网络抖动都不会误判
WS_HEARTBEAT_DEAD_MS90000= 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 路由。信令本身流量极小,走服务端没有成本问题
ICEhost + 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 密码端侧加密无。要早:它改登录链路且需全员重置密码,越晚做发布窗口越紧
P17.4 心跳回收、7.9 会话语义类缺陷P0
P1 并行7.6 消息撤回、7.8.1 已读位置仅依赖消息模型
P1 并行(新增工作量)7.7.1 本地库:桌面端 SQLite 接入 + 壳向子应用暴露存储能力 + SDK 侧分流 + Windows 打包保留数据目录独立于其他项,但涉及壳、子应用、打包三处,开工要早
P27.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
3FR-3 方向反转(桌面端改 WebView 内置登录,不再跳系统浏览器)会上已定,但推翻上期已交付方案,需显式点头。注意"密码不经过应用本体"已不再是约束,防护改由 §7.10 承担
4令牌不做兼容,发布后全员重登一次按 §9
5归档表的访问权限归谁、审计日志留多久本期只建表与隔离,权限策略待定
6本地通道端口 17650-17652 是否与现有服务冲突需在各人机器上确认一遍
7P2P 同步的加密威胁模型:只防第三方窃听(DTLS 已够),还是要防我们自己的信令服务端(需带外验证配对码,牺牲体验)不定则 §7.7.2 的"应用层加密"只是形式
8公共 STUN 选哪几家、要不要改自建公共 STUN 会把设备公网 IP 暴露给第三方;自建 STUN 只做地址发现不中继,开销很小且不违反流量约束
9H1 迭代次数(建议 100_000)需在最慢的目标设备上实测,登录按钮的卡顿感由它决定
10H2 要不要加 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) 认设备。客户端可控值,只能命中自己账号下的设备行
sidJWT 里的 session id。管吊销——吊销集合、webhook、刷新令牌全挂它
didJWT 里的 device id。管业务标识——P2P 选数据源、通知投递、消息隔离用它。规则:要让它失效用 sid,要指认它是谁用 did
连接活跃 / 登录活跃必须分开的两件事。前者是 WS 连接,掉线就该释放;后者是认证会话与令牌,只有主动退出 / 被踢 / 到期才该动。心跳判死只碰前者
软状态允许短暂不准、可由超时自愈的状态,如设备在线与否。不允许任何安全判断依赖它

13.2 本地通道 ​

术语含义
本地通道桌面端在 127.0.0.1 上常驻监听、供同机网页端索取登录态的接口。本期只吐一次性授权码,不吐令牌
Origin协议 + 域名 + 端口三元组。浏览器在跨源请求中自动带上且页面无法伪造,因此是判断"请求来自哪个站点"的可信依据
CORS跨源资源共享。服务端用响应头声明允许哪些 Origin 读取响应;未声明则浏览器拦下响应体。本地通道上禁用 * 通配
mixed contenthttps 页面加载 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 candidateICE 候选中来自本机网卡的那一类,只能同网段直连
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 / FCMApple 与 Google 的推送服务。移动端 App 被系统冻结后只有经它们才能送达。本期只预留通道接口与 push_token 字段,不接入