25 KiB
结论先说:接收链路不能只分成“全量历史”和“实时消息”,还必须有“增量追赶”这一种状态。
完整模型是:
- 首次初始化或没有锚点:全量历史重建。
- 已有锚点、断线重连或页面出现新消息:增量追赶。
- 增量追赶完成后:实时消息接收。
这三种情况最终都走同一条事实链路:
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
当前仓库已经有这个底座:
- manifest.json 已区分
MAIN和ISOLATED。 - page-bridge/main.ts 已使用同源
postMessage。 - service-worker/runtime.ts 已负责精确页面命令路由。
- websocket/index.ts 当前还是占位 WebSocket 路由。
因此,MAIN world 更适合作为“页面事实适配器”,Service Worker 才是 Bright WebSocket 客户端和 IndexedDB 调度中心。
3. 四个核心边界
| 项目 | 规则 |
|---|---|
| 授权范围 | 插件:channelAccountId + binding(deviceId 仅作来源/连接完整性字段);服务端/Mind 授权视图:mindUserId + workspaceId + channelAccountId + binding |
| 消息幂等键 | channelAccountId + conversationId + messageId |
| 会话锚点键 | channelAccountId + conversationId |
| 消息属性 | senderId、loginUserId,不参与唯一键 |
| 消息确认顺序 | IndexedDB → Bright DB commit → 插件 ack → Mind 推送 |
| 发送请求 ID | sendRequestId 只做临时关联,不落库 |
| 异常消息 | 只进 onetalk_message_anomaly,不能降级写入正常消息表 |
3.1 Binding 认证与插件配置
binding是唯一绑定业务概念。Mind 生成 binding,插件只携带channelAccountId + binding + deviceId;Bright 服务端向 Mind 最小只读授权视图按账号确认 binding 归属,deviceId 只作来源和同一 socket 的 scope 完整性字段。- 授权成功返回
authorizationVersion与permissions。配置缺失、scope 不匹配、binding 不匹配或授权视图不可用时,连接、同步和发送均 fail closed。 - 插件 popup 输入并保存 Bright WebSocket URL、
channelAccountId、deviceId和binding到chrome.storage.local;Service Worker 启动时读取,popup 修改或清除后动态重建或关闭连接。 mindUserId、workspaceId是 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;同步启动前必须同时满足:brightAuthenticated、anchorSnapshotReceived 和 pageReady。
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=true、pageReady=false、bootstrap_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 与账号、设备精确匹配。- 插件不携带、不校验
mindUserId或workspaceId;这些字段只在 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
全量完成的必要条件:
- OneTalk 明确返回历史结束。
- 所有四字段齐全的有效消息都获得 Bright 逐条确认。
- IndexedDB 中不存在待确认的有效消息。
- 缺字段异常可以存在,但不能阻塞后续有效消息。
- 有效消息存在时,锚点使用最新有效
messageId。 - 零消息会话可以成功,但不能生成伪造锚点。
如果存在异常但其它条件满足,结果应该是:
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 属于当前 channelAccountId(deviceId 仅校验 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。 conversationId、senderId、loginUserId齐全。- 正文、附件或引用与发送请求精确匹配。
- 时间不早于本次发送。
- 没有人工操作或并发发送歧义。
如果发送后断线,原请求只能是:
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. 推荐落地顺序
- 先确定
binding协议字段、插件 popup 配置、错误码和 Mind 最小认证视图。 - Bright 建立消息事实、技术会话/锚点、异常诊断及事务边界。
- 插件实现 MAIN world 适配、IndexedDB、会话枚举、全量/增量状态机。
- Bright 接入插件上传确认、Mind 历史查询和提交后实时推送。
- 最后实现发件三态、设备接管、旧协议拒绝和切换开关。
- 第一条新 Bright 消息写入后,不再恢复旧 OneTalk outbox/dispatch 链路。
最终可以把整个方案概括为:
历史不是实时的替代品;
实时也不是历史同步的替代品。
历史负责建立事实和锚点,
增量负责跨断线补齐事实,
实时负责低延迟触发观察,
IndexedDB 负责浏览器侧不丢候选,
Bright DB 负责服务端唯一事实,
Mind WebSocket 只发布已经提交的事实。