Files
trade-message-center/docs/archive/重构 OneTalk 消息收发同步机制-detail.md
T

25 KiB
Raw Blame History

结论先说:接收链路不能只分成“全量历史”和“实时消息”,还必须有“增量追赶”这一种状态。

完整模型是:

  1. 首次初始化或没有锚点:全量历史重建。
  2. 已有锚点、断线重连或页面出现新消息:增量追赶。
  3. 增量追赶完成后:实时消息接收。

这三种情况最终都走同一条事实链路:

OneTalk 页面观察
  → 插件 IndexedDB 持久化
  → TradeBright WebSocket
  → 授权/字段校验
  → Bright DB 事务提交
  → 插件逐条确认
  → TradeMind 页面 WebSocket 通知

1. 先区分本次请求和 prd.md 约束

本次请求的核心是:

  • 设计 OneTalk MAIN world 到 TradeBright WebSocket 的整体通信方案。
  • 说明全量历史、实时消息以及其它异常情况。
  • 用时序图表达各类通信过程。

prd.md 中需要作为架构约束的内容是:

  • OneTalk 页面是唯一事实源。
  • Bright DB 的 onetalk_message 是消息事实唯一服务端存储。
  • TradeMind 不复制 OneTalk 消息到自己的 messages 表。
  • OneTalk 不进入旧 outbox、worker 或 dispatch 链路。
  • 消息唯一键是:
channelAccountId + conversationId + messageId
  • 必须同时存在以下四个 OneTalk 字段才允许写入正常消息:
messageId
conversationId
senderId
loginUserId
  • 不允许用 latest-*hist_*、正文 hash 等合成值替代 messageId
  • 插件必须先写 IndexedDB,再上传 Bright。
  • Bright 必须逐条确认,数据库提交后才能通知 Mind。
  • 发件只有三种结果:
confirmed_sent
rejected_before_send
delivery_unknown
  • 不自动重试未确认的发件,也不保存发送任务。
  • 设备接管、授权撤销、认证库不可用都必须 fail closed。

prd.md 明确推迟到子项目设计的内容,本方案不擅自定死:

  • WebSocket 具体帧格式。
  • 游标具体类型。
  • 超时时间和 heartbeat 间隔。
  • HTTP 路径。
  • 数据库索引细节。
  • token 的具体传输机制。

2. 推荐的实际拓扑

逻辑上可以称为:

OneTalk MAIN world → TradeBright WebSocket

但实际不建议让 MAIN world 直接持有 Bright 凭证或直接维护 WebSocket。更安全的落地路径是:

flowchart LR
    OT["OneTalk 页面"]
    MAIN["MAIN world 页面适配器"]
    BRIDGE["同源 postMessage / ISOLATED bridge"]
    SW["Extension Service Worker<br/>WebSocket + IndexedDB + 会话调度"]
    BWS["TradeBright WebSocket"]
    BDB[("Bright DB")]
    AUTH[("Mind 最小认证视图")]
    MP["TradeMind 消息页面"]
    MINDAPI["TradeMind API"]
    MINDB[("Mind DB")]

    OT --> MAIN
    MAIN -->|"page.hello / observed / command"| BRIDGE
    BRIDGE -->|"runtime.Port"| SW
    SW <-->|"WSS:控制帧 + 消息帧"| BWS
    BWS --> BDB
    BWS -->|"只读授权校验"| AUTH

    MP -->|"HTTP:会话/历史查询"| BWS
    MP <-->|"WebSocket:实时事件/发件"| BWS
    MP --> MINDAPI
    MINDAPI --> MINDB

当前仓库已经有这个底座:

因此,MAIN world 更适合作为“页面事实适配器”,Service Worker 才是 Bright WebSocket 客户端和 IndexedDB 调度中心。


3. 四个核心边界

项目 规则
授权范围 插件:channelAccountId + binding(deviceId 仅作来源/连接完整性字段);服务端/Mind 授权视图:mindUserId + workspaceId + channelAccountId + binding
消息幂等键 channelAccountId + conversationId + messageId
会话锚点键 channelAccountId + conversationId
消息属性 senderIdloginUserId,不参与唯一键
消息确认顺序 IndexedDB → Bright DB commit → 插件 ack → Mind 推送
发送请求 ID sendRequestId 只做临时关联,不落库
异常消息 只进 onetalk_message_anomaly,不能降级写入正常消息表

3.1 Binding 认证与插件配置

  • binding 是唯一绑定业务概念。Mind 生成 binding,插件只携带 channelAccountId + binding + deviceIdBright 服务端向 Mind 最小只读授权视图按账号确认 binding 归属,deviceId 只作来源和同一 socket 的 scope 完整性字段。
  • 授权成功返回 authorizationVersionpermissions。配置缺失、scope 不匹配、binding 不匹配或授权视图不可用时,连接、同步和发送均 fail closed。
  • 插件 popup 输入并保存 Bright WebSocket URL、channelAccountIddeviceIdbindingchrome.storage.localService Worker 启动时读取,popup 修改或清除后动态重建或关闭连接。
  • mindUserIdworkspaceId 是 Mind 的服务端业务/授权上下文,不是插件输入,也不是 OneTalk 业务字段。插件不得校验、携带或把它们写入 MAIN world、页面 localStorage 或消息事实。
  • 协议、服务端上下文、数据库字段和诊断统一使用 binding;不再保留另一套绑定标识命名。

最重要的一点是:

插件的“历史同步”与 Mind 页面的“历史读取”不是同一件事。

  • 插件 → Bright WebSocket:把 OneTalk 页面观察到的历史事实写入 Bright。
  • Mind 页面 → Bright HTTP:读取已经提交的 Bright 历史事实。
  • Bright → Mind WebSocket:推送已经提交的新事实和发送结果。

4. 建议的插件会话状态

每个 channelAccountId + conversationId 独立维护状态,不建立账号级 ready/degraded 状态。

UNSEEN
  ↓
DISCOVERED
  ↓
FULL_SYNC / INCREMENTAL_SYNC
  ↓
AWAITING_ANCHOR
  ↓
UPLOADING
  ↓
SUCCEEDED
  ├─ SUCCEEDED_WITH_ANOMALIES
  ├─ FAILED
  └─ INCOMPLETE
  ↓
LIVE

同一会话必须串行处理:

  • 全量同步进行中时,实时消息先写 IndexedDB,不能并行推进锚点。
  • 增量同步进行中时,新实时消息进入同一会话队列。
  • 当前同步完成后,再按页面观察顺序处理队列。
  • 不同会话可以并行,不应因为一个会话失败阻塞其它会话。

5. 连接、认证和会话发现

5.1 WebSocket 连接与页面 Port 诊断时序

页面 Port 链路与 Bright WebSocket 链路是两条并行链路。page_port accepted 不是 WebSocket 状态,而是 ISOLATED content script 已经通过 runtime.connect 注册到 Service Worker;同步启动前必须同时满足:brightAuthenticatedanchorSnapshotReceivedpageReady

sequenceDiagram
    autonumber
    participant PAGE as OneTalk 页面<br/>MAIN world
    participant CONTENT as Content Script<br/>ISOLATED world
    participant SW as Extension Service Worker
    participant BRIGHT as Bright WebSocket 服务端

    Note over SW: 旧连接清理或 Service Worker 初始化
    SW-->>SW: Bright status closed<br/>readyState=-1
    SW-->>SW: Sync state_snapshot<br/>brightStatus=unconfigured
    SW-->>SW: Sync state_snapshot<br/>brightStatus=idle

    par 页面 Port 链路
        CONTENT->>SW: chrome.runtime.connect({name: page})
        SW-->>SW: [OneTalk Page] page_port accepted<br/>connectionCount=1
        PAGE->>CONTENT: window.postMessage(page.hello)
        CONTENT->>SW: Port message page.hello
        SW-->>SW: [OneTalk Page] page_hello accepted
        SW-->>SW: [OneTalk Sync] page_ready ready
    and Bright WebSocket 链路
        SW-->>SW: [OneTalk Bright] status connecting<br/>readyState=-1
        SW->>SW: new WebSocket(endpoint)
        SW-->>SW: socket_created<br/>readyState=0
        SW-->>SW: socket_open<br/>readyState=1
        SW->>BRIGHT: frame outbound: ws.hello<br/>endpoint + protocol parameters
        BRIGHT-->>SW: frame inbound: ws.accepted
        SW-->>SW: status authenticated<br/>readyState=1
        BRIGHT-->>SW: frame inbound: anchor.snapshot
        SW-->>SW: anchor_snapshot received<br/>anchorSnapshotReceived=true
    end

    SW-->>SW: sync_gate
    Note over SW: 三项都为 true 后才允许 bootstrap
    Note over SW: brightAuthenticated=true<br/>anchorSnapshotReceived=true<br/>pageReady=true

    SW->>CONTENT: page.command: onetalk.sync
    CONTENT->>PAGE: window.postMessage(page.command)
    PAGE-->>CONTENT: page.command-result
    CONTENT-->>SW: page.command-result
    SW-->>SW: sync_bootstrap completed

各诊断参数的正常出现顺序如下:

status closed / state_snapshot unconfigured / state_snapshot idle
  ↓
status connecting                  readyState=-1
  ↓
socket_created                     readyState=0
  ↓
socket_open                        readyState=1
  ↓
frame outbound                     ws.hello
  ↓
frame inbound                      ws.accepted
  ↓
status authenticated               readyState=1
  ↓
frame inbound                      anchor.snapshot
  ↓
anchor_snapshot                    anchorSnapshotReceived=true
  ↓
page_port accepted                 connectionCount=1
  ↓
page_hello accepted
  ↓
page_ready                         pageReady=true
  ↓
sync_gate                           bootstrap_running
  ↓
page_command / page_command_result
  ↓
sync_bootstrap                      completed

其中页面 Port 与 Bright WebSocket 的中间事件可以交错出现,不要求 page_port accepted 一定早于 socket_created;但在 sync_gate 允许启动前,三项状态必须全部为 true。如果日志停在 anchorSnapshotReceived=truepageReady=falsebootstrap_waiting,并且没有 page_port accepted,说明问题发生在页面脚本注入、runtime.connect 或页面桥接注册阶段,尚未进入同步命令阶段。

本次日志对应的实际路径是:

socket_created → socket_open → ws.hello → ws.accepted
→ authenticated → anchor.snapshot → bootstrap_waiting

缺失的关键路径是:

page_port accepted → page_hello accepted → page_ready
sequenceDiagram
    participant MAIN as OneTalk MAIN world
    participant BR as ISOLATED bridge
    participant SW as Extension Service Worker
    participant BW as TradeBright WS
    participant AUTH as Mind 认证视图
    participant DB as Bright DB
    participant MP as TradeMind 页面

    MAIN->>BR: page.hello(channelAccountId)
    BR->>SW: 注册精确页面身份
    SW->>BW: WSS hello(scope={channelAccountId, deviceId}, payload.binding, protocolVersion)

    BW->>AUTH: 以 channelAccountId + binding 查询 Mind 授权视图

    alt 授权失败 / 认证视图不可用
        AUTH-->>BW: reject
        BW-->>SW: auth.reject
        BW-->>SW: close
    else 授权成功
        AUTH-->>BW: binding 归属确认 + authorizationVersion + permissions
        BW-->>SW: auth.ok
        BW-->>SW: 当前账号全部会话锚点

        SW->>MAIN: enumerate conversations
        loop 每个 OneTalk 会话
            MAIN-->>SW: conversation.discovered
            SW->>BW: conversation.discovered
            BW->>DB: upsert 技术会话
        end

        MP->>BW: 查询已发现会话
        BW->>DB: 查询 Bright 技术会话
        DB-->>BW: 会话列表
        BW-->>MP: 会话列表与同步状态
    end

注意:

  • Mind 不能主动创建一个插件从未发现的 OneTalk 会话。
  • channelAccountId 必须来自 OneTalk 页面;Bright/Mind 授权视图确认插件携带的 binding 与账号、设备精确匹配。
  • 插件不携带、不校验 mindUserIdworkspaceId;这些字段只在 Bright/Mind 服务端授权上下文中解析。
  • Bright 每次发送、同步、heartbeat 都要重新验证授权版本。
  • 授权失效后不能依赖旧缓存继续放行。

6. 首次全量历史同步

全量同步适用于:

  • 当前账号第一次初始化。
  • 新会话没有服务端锚点。
  • 增量扫描到 OneTalk 明确历史结束,但找不到旧锚点后转为会话级重建。
sequenceDiagram
    participant MAIN as OneTalk MAIN world
    participant SW as Service Worker
    participant IDB as 插件 IndexedDB
    participant BW as TradeBright WS
    participant DB as Bright DB
    participant MP as TradeMind 页面

    SW->>MAIN: sync.start(conversationId, mode=full)

    loop 从最新向最早分页
        MAIN->>SW: history.page(messages, cursor, pageIdentity)
        SW->>IDB: 事务写入本批消息

        loop 批内逐条处理
            alt 四字段齐全
                SW->>BW: message.observed(source=history)
                BW->>DB: 授权校验 + 幂等写入
                DB-->>BW: commit
                BW-->>SW: message.ack(accepted / duplicate)
                SW->>IDB: 标记 confirmed
            else 缺少必需字段
                SW->>BW: message.observed(invalid)
                BW->>DB: anomaly upsert
                BW-->>SW: message.ack(anomaly)
                SW->>IDB: 标记 anomaly
            end
        end
    end

    MAIN-->>SW: history.end(明确结束证据)
    SW->>BW: sync.complete

    alt 所有有效消息已确认
        BW->>DB: 保存会话状态并推进 latestMessageId
        DB-->>BW: commit
        BW-->>SW: sync.succeeded
        BW-->>MP: sync.status after commit
    else 游标停滞 / 页面身份变化 / 有效消息未确认
        BW->>DB: 记录 failed 或 incomplete
        BW-->>SW: sync.failed 或 sync.incomplete
        BW-->>MP: 同步失败状态
    end

全量完成的必要条件:

  1. OneTalk 明确返回历史结束。
  2. 所有四字段齐全的有效消息都获得 Bright 逐条确认。
  3. IndexedDB 中不存在待确认的有效消息。
  4. 缺字段异常可以存在,但不能阻塞后续有效消息。
  5. 有效消息存在时,锚点使用最新有效 messageId
  6. 零消息会话可以成功,但不能生成伪造锚点。

如果存在异常但其它条件满足,结果应该是:

succeeded_with_anomalies

而不是伪装成无异常成功。


7. 已有锚点时的增量追赶

增量同步是断线、重新连接和实时事件处理的关键。

sequenceDiagram
    participant MAIN as OneTalk MAIN world
    participant SW as Service Worker
    participant IDB as 插件 IndexedDB
    participant BW as TradeBright WS
    participant DB as Bright DB
    participant MP as TradeMind 页面

    MAIN->>SW: new-message signal / reconnect
    SW->>BW: 获取 conversationId 的 latestMessageId

    loop 从最新向历史方向扫描
        MAIN->>SW: history.page(messages)
        SW->>IDB: 写入 awaiting_anchor

        alt 找到旧锚点
            SW->>IDB: 标记边界,停止扫描
        else 尚未找到
            Note over SW,IDB: 候选消息不能上传 Bright
        end
    end

    alt 找到旧锚点
        loop 候选消息从旧到新
            SW->>BW: message.observed(source=incremental)
            BW->>DB: 幂等写入
            DB-->>BW: commit
            BW-->>SW: message.ack
            SW->>IDB: 标记 confirmed
        end

        SW->>BW: sync.complete(newestValidMessageId)
        BW->>DB: 全部确认后推进锚点
        DB-->>BW: commit
        BW-->>MP: 提交后的新增消息事件
    else 到达明确历史结束仍未找到旧锚点
        BW->>DB: anomaly(incremental_anchor_not_found)
        BW-->>SW: 切换为 conversation-level full rebuild
        Note over SW,IDB: 复用已有候选和分页检查点,不重复抓取
    end

关键约束:

  • 找到锚点前,候选消息只能留在 IndexedDB 的 awaiting_anchor 状态。
  • 找到锚点后,候选消息从旧到新上传。
  • 锚点本身只是停止边界,不是消息排序依据,也不是幂等键。
  • 最新消息缺少 messageId 时记录异常,本次不得推进锚点。
  • 找不到锚点时不能把扫描结果直接当成增量事实写入 Bright。
  • 自动转全量时必须复用已有 IndexedDB 候选,不能重新抓取一遍。

8. 稳态实时收件

实时消息和历史消息最终进入同一个 onetalk_message 表,只是 observationType 不同。

sequenceDiagram
    participant MAIN as OneTalk MAIN world
    participant SW as Service Worker
    participant IDB as 插件 IndexedDB
    participant BW as TradeBright WS
    participant AUTH as Mind 认证视图
    participant DB as Bright DB
    participant MP as TradeMind 页面

    MAIN->>SW: page.observed(source=live)
    SW->>IDB: 先持久化 pending_live
    SW->>BW: message.observed({channelAccountId, deviceId}, binding, four IDs, content)

    BW->>AUTH: 校验 binding 属于当前 channelAccountIddeviceId 仅校验 socket scope 完整性)

    alt 授权通过且四字段齐全
        BW->>DB: transaction insert by account/conversation/message
        DB-->>BW: commit
        BW-->>SW: message.ack(accepted / duplicate)
        BW-->>MP: message.created after commit
        SW->>IDB: 标记 confirmed
    else 缺字段
        BW->>DB: anomaly upsert
        BW-->>SW: message.ack(anomaly)
        SW->>IDB: 标记 anomaly
        Note over BW,MP: 不写消息表,不推进锚点
    else 授权失败
        BW-->>SW: rejected
        Note over SW,IDB: 保留诊断,不切换到其它页面或账号
    end

Bright 的正确顺序必须是:

DB commit
  → plugin ack
  → Mind WebSocket publish

如果 Mind 页面 WebSocket 推送失败,不能回滚消息事实。页面下次刷新或重新连接时,通过 Bright 历史查询补回即可。


9. TradeMind 发件与三态结果

发件不能直接写消息表。只有插件在 OneTalk 页面完成事实闭环后,才把它作为普通 sent 消息写入。

sequenceDiagram
    participant MP as TradeMind 页面
    participant BW as TradeBright WS
    participant AUTH as Mind 认证视图
    participant SW as Service Worker
    participant MAIN as OneTalk MAIN world
    participant DB as Bright DB

    MP->>BW: send.request(sendRequestId, conversation scope, content)
    BW->>AUTH: 校验 Mind 页面 read/send,并为插件路由复核 binding、设备和账号

    alt 无 active binding / 插件离线 / 无精确页面
        BW-->>MP: rejected_before_send
        Note over DB: 不写消息、不排队
    else 可以路由到精确插件
        BW->>SW: send.command(sendRequestId, conversationId)
        SW->>MAIN: 精确页面命令

        MAIN->>MAIN: 冻结发送前最新消息快照
        MAIN->>MAIN: 驱动精确 OneTalk 页面发送
        MAIN->>MAIN: 发送后重新检查页面事实

        alt 证据闭环完整
            MAIN-->>SW: confirmed_sent + message fact
            SW->>BW: send.confirmed(sendRequestId, fact)
            BW->>DB: 按账号/会话/messageId 幂等写入
            DB-->>BW: commit
            BW-->>MP: confirmed_sent
            BW-->>MP: message.created
        else 尚未产生副作用且明确拒绝
            MAIN-->>SW: rejected_before_send
            SW-->>BW: rejected_before_send
            BW-->>MP: rejected_before_send
        else 已尝试发送但证据不足、超时或断线
            SW-->>BW: delivery_unknown
            BW-->>MP: delivery_unknown
            Note over DB: 不写消息、不自动重试
        end
    end

confirmed_sent 的证据至少包括:

  • 页面账号和会话与授权范围精确匹配。
  • 发送后出现发送前快照中没有的新 messageId
  • conversationIdsenderIdloginUserId 齐全。
  • 正文、附件或引用与发送请求精确匹配。
  • 时间不早于本次发送。
  • 没有人工操作或并发发送歧义。

如果发送后断线,原请求只能是:

delivery_unknown

即使之后插件观察到了真实发件,也不能反向修改原请求状态。迟到的真实消息直接走普通实时收件路径:

OneTalk 页面观察
  → IndexedDB
  → Bright message.observed
  → onetalk_message
  → Mind message.created

10. 断线、重连和设备接管

sequenceDiagram
    participant OLD as 旧插件设备
    participant NEW as 新插件设备
    participant BW as TradeBright
    participant AUTH as Mind 认证视图
    participant DB as Bright DB
    participant MP as TradeMind 页面

    OLD--xBW: WebSocket 断开
    BW-->>MP: plugin_offline
    MP->>BW: 历史查询
    BW->>DB: 查询已提交事实
    BW-->>MP: 历史可读
    MP->>BW: send.request
    BW-->>MP: rejected_before_send

    AUTH->>AUTH: 原子撤销旧 binding,激活新设备
    OLD->>BW: heartbeat / late send result
    BW->>AUTH: 重新校验授权版本
    AUTH-->>BW: old binding revoked
    BW-->>OLD: reject + close

    NEW->>BW: auth({channelAccountId, deviceId}, new binding)
    BW->>AUTH: 校验新 binding 与账号
    BW->>DB: 读取共享会话锚点
    BW-->>NEW: anchors.snapshot
    NEW->>NEW: 重新枚举 OneTalk 会话
    NEW->>BW: 按会话执行增量或首次全量

规则是:

  • 插件离线时,Bright 已有历史仍可读。
  • 插件离线时,发送和实时接收都不可用。
  • 新设备不创建自己的消息副本或锚点副本。
  • 旧设备迟到回报必须被拒绝。
  • 旧设备真正已经发出的消息,仍可被新设备后续观察并正常入库。
  • 认证视图不可用时,Bright 连接、历史、同步和发送全部 fail closed。

11. 不同情况的处理矩阵

情况 处理 消息表 是否继续
新账号/新会话无锚点 全量同步 有效消息逐条写入 其它会话继续
已有锚点重连 增量追赶 找到锚点后写入 正常继续
找不到旧锚点 记录异常,转会话级全量 找到历史结束前不写增量候选 其它会话继续
messageId 写异常表 不写 同批后续继续
重复消息 幂等确认 不新增 正常继续
页面账号不匹配 fail closed 不写 不切换其它页面
binding 缺失/不匹配 fail closed 不写 不切换其它页面
Bright DB 暂时失败 不确认,IndexedDB 保留 不写 后续同步恢复
插件发送前离线 rejected_before_send 不写 不排队
发件后断线/证据不足 delivery_unknown 不写 不自动重试
迟到真实发件 普通消息观察 正常写入 不修改原发送结果
Mind WS 推送失败 保留数据库事实 已写入 页面重连后查询
设备被接管 旧设备拒绝,新设备取共享锚点 不产生设备副本 新设备继续

12. 推荐落地顺序

  1. 先确定 binding 协议字段、插件 popup 配置、错误码和 Mind 最小认证视图。
  2. Bright 建立消息事实、技术会话/锚点、异常诊断及事务边界。
  3. 插件实现 MAIN world 适配、IndexedDB、会话枚举、全量/增量状态机。
  4. Bright 接入插件上传确认、Mind 历史查询和提交后实时推送。
  5. 最后实现发件三态、设备接管、旧协议拒绝和切换开关。
  6. 第一条新 Bright 消息写入后,不再恢复旧 OneTalk outbox/dispatch 链路。

最终可以把整个方案概括为:

历史不是实时的替代品;
实时也不是历史同步的替代品。

历史负责建立事实和锚点,
增量负责跨断线补齐事实,
实时负责低延迟触发观察,
IndexedDB 负责浏览器侧不丢候选,
Bright DB 负责服务端唯一事实,
Mind WebSocket 只发布已经提交的事实。