chore: 归档 OneTalk 文档并清理旧资料

This commit is contained in:
YBF
2026-09-03 10:50:21 +08:00
parent 3448ace12d
commit 5a185b20e3
8 changed files with 3 additions and 2814 deletions
@@ -0,0 +1,671 @@
结论先说:接收链路不能只分成“全量历史”和“实时消息”,还必须有“增量追赶”这一种状态。
完整模型是:
1. 首次初始化或没有锚点:全量历史重建。
2. 已有锚点、断线重连或页面出现新消息:增量追赶。
3. 增量追赶完成后:实时消息接收。
这三种情况最终都走同一条事实链路:
```text
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 链路。
- 消息唯一键是:
```text
channelAccountId + conversationId + messageId
```
- 必须同时存在以下四个 OneTalk 字段才允许写入正常消息:
```text
messageId
conversationId
senderId
loginUserId
```
- 不允许用 `latest-*``hist_*`、正文 hash 等合成值替代 `messageId`
- 插件必须先写 IndexedDB,再上传 Bright。
- Bright 必须逐条确认,数据库提交后才能通知 Mind。
- 发件只有三种结果:
```text
confirmed_sent
rejected_before_send
delivery_unknown
```
- 不自动重试未确认的发件,也不保存发送任务。
- 设备接管、授权撤销、认证库不可用都必须 fail closed。
`prd.md` 明确推迟到子项目设计的内容,本方案不擅自定死:
- WebSocket 具体帧格式。
- 游标具体类型。
- 超时时间和 heartbeat 间隔。
- HTTP 路径。
- 数据库索引细节。
- token 的具体传输机制。
---
## 2. 推荐的实际拓扑
逻辑上可以称为:
```text
OneTalk MAIN world → TradeBright WebSocket
```
但实际不建议让 MAIN world 直接持有 Bright 凭证或直接维护 WebSocket。更安全的落地路径是:
```mermaid
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](/Users/ybf/work/trade-message-center/apps/chrome-extension/public/manifest.json) 已区分 `MAIN``ISOLATED`
- [page-bridge/main.ts](/Users/ybf/work/trade-message-center/apps/chrome-extension/src/onetalk/page-bridge/main.ts) 已使用同源 `postMessage`
- [service-worker/runtime.ts](/Users/ybf/work/trade-message-center/apps/chrome-extension/src/onetalk/service-worker/runtime.ts) 已负责精确页面命令路由。
- [websocket/index.ts](/Users/ybf/work/trade-message-center/apps/server/src/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` 状态。
```text
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`
```mermaid
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
```
各诊断参数的正常出现顺序如下:
```text
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` 或页面桥接注册阶段,尚未进入同步命令阶段。
本次日志对应的实际路径是:
```text
socket_created → socket_open → ws.hello → ws.accepted
→ authenticated → anchor.snapshot → bootstrap_waiting
```
缺失的关键路径是:
```text
page_port accepted → page_hello accepted → page_ready
```
```mermaid
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 明确历史结束,但找不到旧锚点后转为会话级重建。
```mermaid
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. 零消息会话可以成功,但不能生成伪造锚点。
如果存在异常但其它条件满足,结果应该是:
```text
succeeded_with_anomalies
```
而不是伪装成无异常成功。
---
# 7. 已有锚点时的增量追赶
增量同步是断线、重新连接和实时事件处理的关键。
```mermaid
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` 不同。
```mermaid
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 的正确顺序必须是:
```text
DB commit
→ plugin ack
→ Mind WebSocket publish
```
如果 Mind 页面 WebSocket 推送失败,不能回滚消息事实。页面下次刷新或重新连接时,通过 Bright 历史查询补回即可。
---
# 9. TradeMind 发件与三态结果
发件不能直接写消息表。只有插件在 OneTalk 页面完成事实闭环后,才把它作为普通 `sent` 消息写入。
```mermaid
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` 齐全。
- 正文、附件或引用与发送请求精确匹配。
- 时间不早于本次发送。
- 没有人工操作或并发发送歧义。
如果发送后断线,原请求只能是:
```text
delivery_unknown
```
即使之后插件观察到了真实发件,也不能反向修改原请求状态。迟到的真实消息直接走普通实时收件路径:
```text
OneTalk 页面观察
→ IndexedDB
→ Bright message.observed
→ onetalk_message
→ Mind message.created
```
---
# 10. 断线、重连和设备接管
```mermaid
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 链路。
最终可以把整个方案概括为:
```text
历史不是实时的替代品;
实时也不是历史同步的替代品。
历史负责建立事实和锚点,
增量负责跨断线补齐事实,
实时负责低延迟触发观察,
IndexedDB 负责浏览器侧不丢候选,
Bright DB 负责服务端唯一事实,
Mind WebSocket 只发布已经提交的事实。
```
@@ -0,0 +1,282 @@
# OneTalk 消息中心服务端重构 PRD
> 本文从根目录 [`prd.md`](./prd.md) 拆分,仅约束当前仓库 `apps/server` 所承载的 TradeBright 服务端。父 PRD 的事实归属、失败边界和切换约束优先级高于本文;如两者冲突,必须先回到父 PRD 澄清,不能由实现自行改变系统边界。
## 1. 目标
在当前 TradeBridge 仓库内把 `apps/server` 建设为 TradeBright 的 OneTalk 消息中心服务端,使其成为 OneTalk 消息事实的唯一服务端读写入口,并同时服务 Chrome 插件与 TradeMind 消息页面。
服务端必须做到:
- 只持久化插件从精确 OneTalk 页面观察到的真实收件,以及完成页面事实闭环确认的真实发件。
- 使用统一的消息事实模型表达收件和确认发件,不创建发送任务、发送占位消息或第二份消息投影。
- 基于 Mind 的最小认证授权视图按 `channelAccountId + binding` 校验插件授权,并在服务端解析对应的 Mind user/workspace 授权上下文;`deviceId` 只保留为来源和连接完整性字段。
- 为插件提供消息逐条确认、技术会话进度和共享增量锚点,为 Mind 页面提供受权历史查询、会话读取、实时消息与临时发送回执。
- OneTalk 全链路绕过现有 outbox/worker/claim/dispatch 机制,同时保持其它渠道原有链路可用。
## 2. 当前基线
- 服务端包位于 `apps/server/`,当前只有 `package.json`
- 当前唯一运行时依赖是 `fastify@^5.12.1`;尚无启动入口、HTTP 路由、WebSocket、配置、数据库、迁移、日志和测试实现。
- 根配置要求 Node.js `>=22.22.2 <23`、pnpm `11.7.0`、TypeScript strict、ES2022 和 NodeNext。
- 本任务是服务端能力从零落地,不得假设现有代码已经提供数据库模型、认证协议、错误码或消息兼容层。
## 3. 事实归属与系统边界
### 3.1 TradeBright 服务端拥有
- OneTalk 收件与确认发件的消息正文、附件/引用内容、方向及 OneTalk 原始关联 ID。
- `channelAccountId + conversationId + messageId` 范围内的消息幂等事实。
- `channelAccountId + conversationId` 范围内的技术会话存在性、同步进度和 `latestMessageId` 锚点。
- 缺字段、同步失败和协议异常的耐久诊断事实。
- Bright 的消息、同步、HTTP、WebSocket 和错误码契约及其版本。
- 当前有效插件连接的内存路由,以及页面与插件之间临时发送请求的内存关联。
### 3.2 TradeBright 服务端不拥有
- OneTalk 页面的抓取、分页、身份提取、IndexedDB 账本和发件事实判断;这些属于插件。
- Mind 的用户、workspace、客户、联系人、负责人、摘要、未读、激活码和其它 CRM 业务事实。
- Mind 页面展示状态、临时发送气泡和业务数据组合。
- 未确认发送的耐久任务、自动恢复、自动重试或恰好一次保证。
- TradeMind 的 OneTalk 消息副本或 Bright 消息后台投影。
### 3.3 数据访问边界
- Bright 服务端独占 Bright DB 的读写,浏览器、插件和 Mind 服务端均不得直连 Bright DB。
- Bright 只能以只读方式访问 Mind DB 暴露的版本化最小认证授权视图,不得读取客户、负责人、摘要、未读、激活码秘密等业务表。
- Mind DB 或最小认证授权视图不可用时,Bright 的历史查询、连接、同步和发送全部 fail closed;不得用过期授权缓存继续放行。
- Mind 页面读取历史时直接访问 Bright 的受权接口,不经过 Mind 服务端代理。
## 4. 服务端能力拆分
### S1. 服务基础设施
建立可运行、可配置、可测试的 Fastify 服务端边界,包括 HTTP、WebSocket、配置校验、统一错误协议、数据库连接与迁移、结构化日志和优雅停机能力。
本 PRD 不指定 ORM/驱动、目录名称、路由路径、帧格式或日志库;这些决策必须在服务端 design 中确定并同步到 `.trellis/spec/server/backend/`
### S2. Mind 认证授权适配
消费 Mind 提供的版本化最小只读认证视图,统一完成以下校验:
- 插件认证输入只包含 `channelAccountId + binding + deviceId`Bright 使用 `channelAccountId + binding` 向 Mind 授权视图查询 active binding。
- Mind 授权视图确认 binding 属于该 `channelAccountId`,并返回服务端使用的 `mindUserId``workspaceId``authorizationVersion``permissions`deviceId 不参与 active binding decision。
- 用户、workspace、账号和 binding 状态有效且未撤销;deviceId 仅作为来源标识,不参与该 decision。
- 历史和会话读取具备 `read` 权限;发送具备 `send` 权限和有效 active binding。
- 同一 Mind 用户在 MVP 中只有一个 active binding 和一个 active `channelAccountId`deviceId 不参与 active binding decision。
`mindUserId``workspaceId` 只属于 Mind/Bright 服务端授权上下文,不是插件输入,也不是 OneTalk 字段;插件不负责校验这两个字段。开发环境由独立 Mind HTTP mock 提供最小授权视图,Bright 通过 development-only loopback `MIND_AUTH_BASE_URL` 调用固定 binding/session endpointmock 只按 `channelAccountId + binding`、Session Cookie、授权版本和权限校验,配置缺失或不匹配时 fail closed。生产环境仍由真实 Mind Session/授权服务提供该上下文。
Bright 必须在连接建立、每次读取/发送/同步操作以及 WebSocket heartbeat 周期中复核授权版本。binding 接管、撤销、账号范围变化或授权依赖异常时,应立即拒绝后续操作并断开失效连接。
### S3. 连接注册与精确路由
服务端维护两类受权实时连接:插件连接和 Mind 页面连接。插件连接必须绑定到精确的 `channelAccountId + binding`,并保留 deviceId 作为来源/同一 socket 完整性字段;所有账号特定命令只允许路由到该精确连接。Mind 页面连接的 `mindUserId + workspaceId` 仍由页面登录/授权上下文处理,不下发给插件。
- 不广播账号特定工作。
- 不建设多设备 lease、设备自动选择或 fallback 到其它 OneTalk 标签页。
- `emit`、fallback 或任何替代传输分支都必须执行同一套 `channelAccountId + conversationId` 校验。
- 插件离线时,受权用户仍可读取已持久化历史,但发送和实时接收能力必须明确不可用。
### S4. 统一消息事实持久化
Bright DB 必须包含统一的 `onetalk_message` 事实表,收件与确认发件只通过方向和页面事实字段区分。
- 唯一幂等范围固定为 `channelAccountId + conversationId + messageId`
- `binding``mindUserId``workspaceId``deviceId` 只作为授权/来源上下文,不进入消息唯一键;数据库列名也统一为 `binding`,不再保留旧的绑定标识命名。
- `senderId``loginUserId` 是消息属性,不进入唯一键,也不由 Bright 校验其业务关系。
- 正常入库的结构门槛是非空且可序列化的 `messageId``conversationId``senderId``loginUserId`;不额外推断 ID 格式或业务合理性。
- 禁止把 `latest-*``hist_*`、正文/时间/方向 hash 或其它合成值写入 `messageId`
- 不生成 Bright 内部 account/conversation/message ID 来替代共享原始 ID。
- 不提供普通消息物理删除路径,不导入或转换 TradeMind legacy OneTalk 消息。
重复上报必须返回同一事实的幂等确认,不得产生重复消息。消息事务提交成功后,才能确认插件上传并向 Mind 页面发布实时事实。
### S5. 技术会话与同步锚点
Bright 必须保存独立的技术会话同步进度。技术会话只能由插件从 OneTalk 页面枚举后创建,Mind 不得主动创建尚未被 Bright 发现的会话。
- 唯一范围为 `channelAccountId + conversationId`,不按 binding 或设备复制。
- 至少表达首次/增量/失败状态、`latestMessageId` 和最近观察时间,并能表示零消息会话。
- 会话级结果需能区分 `succeeded``succeeded_with_anomalies``failed``incomplete`;不得形成账号级 `ready/degraded` 持久业务状态。
- 插件通过认证后,Bright 返回当前 active `channelAccountId` 下的全部服务端会话锚点。
- 锚点只作为增量扫描停止边界,不参与消息排序、消息入库资格或幂等。
- 只有插件明确报告页面历史结束、所有有效消息已获 Bright 逐条确认且不存在待确认有效消息后,Bright 才能创建或推进锚点。
- 最新页面消息缺少 `messageId`、全量未完成或本次有效消息未全部确认时,不得推进锚点。
### S6. 消息上传与逐条确认
服务端接收插件从 IndexedDB 耐久账本上传的消息,并对每条消息独立执行:
1. 协议版本与输入结构校验。
2. 当前连接的 `channelAccountId + binding` 授权校验;deviceId 只校验同一 socket 的 scope 完整性。
3. 必需 OneTalk 原始字段存在性校验。
4. 精确范围幂等写入。
5. 数据库提交后的逐条确认与实时发布。
一条记录失败不得阻塞同批或后续有效记录。服务端不负责保存插件分页位置、`awaiting_anchor` 候选或 IndexedDB 状态,也不得在插件找到旧锚点前接收候选增量消息作为正常事实。
### S7. 异常诊断
Bright DB 必须使用独立耐久表 `onetalk_message_anomaly` 保存缺字段和协议观测异常。
- 至少记录可获得的 binding、Mind 授权视图解析出的用户/workspace、账号、设备、会话范围,缺失字段,观测来源,清洗后的异常 payload,首次/最近出现时间,出现次数以及 `open``resolved``ignored` 状态。
- 可使用诊断 fingerprint 合并重复观测;fingerprint 只用于诊断去重,不得作为消息 ID、消息幂等键或锚点。
- payload 必须移除 Cookie、CSRF、反爬令牌、认证头和无关敏感字段,并避免保存无排障必要的消息正文。
- 异常表不得被消息读取、发送、增量定位、worker 或重试流程消费。
- 异常写入失败必须显式返回/记录失败;无论如何不得把不合格记录降级写入消息表。
`incremental_anchor_not_found` 等同步异常需要可诊断,但候选消息与分页位置仍由插件持有,Bright 不得把未闭环的候选当作消息事实。
### S8. 历史与会话读取
Bright 为 Mind 页面提供受权的会话列表、精确会话打开和历史读取能力。
- 每次 Mind 页面查询必须校验页面侧 `mindUserId + workspaceId + channelAccountId` 的 read 权限;这些字段不由插件提交或校验。
- 只能返回 Bright 已由插件发现的技术会话和已提交的消息事实。
- 不返回或拼装客户、负责人、摘要、未读等 Mind 业务字段。
- 不读取 TradeMind legacy OneTalk 消息补齐历史。
- HTTP/查询游标、排序规则、分页大小和断线补读策略在服务端 design 中确定,但必须只基于稳定的 Bright 消息事实,不得复用同步锚点冒充查询游标。
### S9. 临时发件协调
服务端承担 `Mind 页面 → Bright → 精确插件连接 → OneTalk 页面` 的临时请求协调,并沿反向链路传递结果。
- 每一跳使用同一个稳定、非敏感 `sendRequestId`
- `sendRequestId` 只存在于当前进程的临时关联中;终端响应、超时、断线或进程终止后即失效,不写消息表、任务表或其它耐久存储。
- 不创建 `pending``dispatching``confirming``failed` 等占位消息行。
- 对外结果仅允许 `confirmed_sent``rejected_before_send``delivery_unknown`
- 插件离线、精确连接不存在或发送副作用发生前身份校验失败时,返回 `rejected_before_send`,不排队。
- 已尝试发送但证据不足、超时、断线或结果丢失时,返回 `delivery_unknown`,不恢复检查、不自动重发。
- 只有插件返回满足契约的 `confirmed_sent` 页面事实,Bright 才按普通消息规则幂等入库;提交成功后才向页面返回最终成功并发布消息。
- 旧 binding 的迟到回报必须拒绝,原请求保持 `delivery_unknown`。之后由有效设备重新观察到的真实发件,按普通消息上传入库,不追溯修改原请求结果。
页面应展示的提示文案由 Mind 负责;Bright 负责返回稳定、可区分的结果码。
### S10. 协议版本、切换与旧链路隔离
- Bright 侧拥有消息、同步、HTTP、WebSocket 和错误码契约,需提供共享类型/规则及 contract test,供插件和 Mind 使用。
- 全量切换后,旧协议插件发起 OneTalk 同步或发送必须明确返回 `onetalk_protocol_upgrade_required`
- 新 OneTalk 收件、发件和同步不得创建、claim、消费或 fallback 到现有 `trademind_delivery_outbox``outbound_message` 或 Mind dispatch。
- 其它渠道的既有 outbox/dispatch 行为保持不变。
- 第一条新 Bright 消息事实写入前允许整体回滚部署;写入后旧 OneTalk 事实链路永久禁用,只能暂停新链路、修复并恢复。
### S11. 可观测性与安全
服务端必须为发送请求、插件检查结果、同步会话、分页批次、异常记录和消息写入提供可关联的结构化诊断,至少覆盖:
- request ID / sendRequestId(适用时)
- binding(仅记录非敏感摘要)、mindUserId、workspaceId、channelAccountId、deviceId(按日志规范脱敏)
- conversationId、协议版本、操作类型、结果分类和时间戳
日志、错误和诊断中禁止记录 session/token、Cookie、CSRF、认证头、连接串、OneTalk 反爬令牌或无必要的完整消息正文。内部异常不得直接序列化给客户端。
## 5. 服务端需求
### 5.1 不可违背的事实规则
- SR1. OneTalk 页面是消息唯一事实源;Bright 只接受插件重新观察或确认的页面事实。
- SR2. 入站和确认出站共用一张 `onetalk_message` 表。
- SR3. 未闭环发送不是消息事实,不持久化、不排队、不重试。
- SR4. Bright 是 Bright DB 唯一读写入口,且只读 Mind 最小认证授权视图。
- SR5. 消息、会话和锚点按 OneTalk 原始账号/会话 ID 共享,不绑定当前设备。
- SR6. 所有账号、会话、binding 或权限不匹配均 fail closed。
- SR7. 消息必须提交后再确认和推送;不得先广播再补写数据库。
- SR8. 缺字段记录只能进入独立异常诊断,不得进入正常消息表。
- SR9. OneTalk 新链路必须彻底绕过旧 outbox/dispatch,且不得影响其它渠道。
- SR10. Bright 不吸收 Mind CRM 业务事实,也不建立 OneTalk 消息的 Mind 投影。
### 5.2 共享字段基线
Bright/Mind 服务端身份与关联上下文只允许以下命名作为当前基线;这不表示插件必须提交全部字段:
- `mindUserId`
- `workspaceId`
- `channelAccountId`
- `deviceId`
- `conversationId`
- `messageId`
- `senderId`
- `loginUserId`
其中 `channelAccountId` 是 OneTalk 页面运行时提供的登录人原始账号 ID(`currentUserAccountId``IcbuIM.UserUtil.currentUser.accountId`);URL 的 `activeAccountId` 仅是当前对话账号,不能作为 channel account。字段具体类型、空值规则和序列化形式必须由服务端 design 与共享契约统一确定;未经新的页面证据和业务批准,不新增 `sellerAccount``channelAccount``sellerAccountId` 等替代概念。
插件 OneTalk WebSocket `ws.hello` 的字段子集固定为 `channelAccountId + deviceId`binding 固定放在 `payload.binding`Bright 按 `channelAccountId + binding` 执行授权匹配,deviceId 只用于来源和同一 socket 完整性。`mindUserId``workspaceId` 由 Bright 从 Mind 登录/授权结果或开发授权视图获得,仅用于服务端授权,不进入插件 popup、插件协议或页面事实。
## 6. 交付分区与依赖顺序
以下是能力依赖,不是具体代码目录或提交清单:
1. **契约与基础边界**:先确定配置、错误、共享字段、协议版本、认证适配、数据库与测试策略。
2. **事实存储**:建立消息、技术会话/锚点、异常诊断及真实 PostgreSQL 迁移验证。
3. **授权连接与上传**:完成 Mind 最小认证视图接入、插件连接、会话发现、逐条上传确认和 publish-after-commit 边界。
4. **Mind 读取与实时链路**:开放受权历史/会话读取及提交后的实时消息事件。
5. **临时发件闭环**:接入内存请求关联、精确插件路由、三态回执和断线/接管边界。
6. **切换保护**:旧协议拒绝、OneTalk 旧链路隔离、最低插件版本和不可回退开关。
外部前置依赖:
- Mind 提供版本化最小认证授权视图及 session/token 校验方式。
- 插件确认 OneTalk 页面真实字段来源,并实现 IndexedDB、会话枚举、全量/增量扫描和发送事实闭环。
- Mind 页面接入 Bright 的历史、实时和发件三态契约。
服务端设计和本地实现可以先于外部系统完成,但在前置契约未明确时只能使用可替换的测试适配,不能把猜测固化为生产协议。
## 7. 服务端验收标准
- [ ] SAC1. `apps/server` 具备可重复运行的 build、typecheck、test 和真实启动入口,配置缺失时明确失败。
- [ ] SAC2. 真实 PostgreSQL 迁移可从空库执行;消息唯一约束能阻止 `channelAccountId + conversationId + messageId` 重复行。
- [ ] SAC3. 收件与 `confirmed_sent` 发件写入同一消息表;其它两种发送结果及超时/断线均为消息表零写入。
- [ ] SAC4. 重复上报返回同一消息事实,数据库提交后才向插件确认并向 Mind 页面推送。
- [ ] SAC5. 缺少任一必需原始 ID 的记录不进入消息表,独立异常表可合并重复观测、更新状态且不阻塞后续有效消息。
- [ ] SAC6. 清洗测试证明异常、日志和错误响应不包含认证秘密或无必要的消息正文。
- [ ] SAC7. 技术会话和锚点以 `channelAccountId + conversationId` 唯一,支持零消息会话和四类会话结果,设备接管后不产生副本。
- [ ] SAC8. 全量未结束、消息未全部确认或最新消息缺少 `messageId` 时,服务端拒绝推进锚点。
- [ ] SAC9. 新设备取得同一账号的共享锚点;旧设备在 binding 被撤销后无法继续 heartbeat、上传、发送或提交迟到回报。
- [ ] SAC10. Mind 授权视图不可用、授权撤销、范围不匹配或权限不足时,HTTP 与 WebSocket 均 fail closed,且不会依赖旧授权缓存放行。
- [ ] SAC10a. 插件握手只接受 `channelAccountId + binding + deviceId`Bright 能在 Mind 授权视图或开发 `.env.development.local` 视图中按 `channelAccountId + binding` 确认授权后返回 `authorizationVersion``permissions`deviceId 不参与 decision,缺失或不匹配时 fail closed。
- [ ] SAC11. `read``send` 权限分别生效;Mind session/token 不出现在 URL、日志或业务错误中。
- [ ] SAC12. Mind 页面只能查询已发现会话和已提交事实;不能通过 Bright 创建 OneTalk 会话,也不会从响应得到 Mind CRM 业务字段。
- [ ] SAC13. 插件离线时历史仍可读取,发送明确拒绝且不排队;实时状态可被页面识别为不可用。
- [ ] SAC14. 发件结果只出现三态;`sendRequestId` 在终态、超时、断线或重启后没有任何耐久记录。
- [ ] SAC15. 发送后断线、设备接管、迟到回报和迟到真实消息测试证明:原请求保持 `delivery_unknown`,真实消息仅通过普通上传事实入库。
- [ ] SAC16. 集成测试证明 OneTalk 请求不会创建或消费任何旧 outbox/dispatch 行,且非 OneTalk 渠道行为不受影响。
- [ ] SAC17. legacy OneTalk 消息和 `latest-*``hist_*`、正文/时间 hash 均无法进入新消息表或锚点。
- [ ] SAC18. 旧协议 OneTalk 请求稳定返回 `onetalk_protocol_upgrade_required`,不会静默忽略或回退旧链路。
- [ ] SAC19. contract test 覆盖插件上传/确认、锚点、Mind 历史读取、实时事件、发件三态和稳定错误码。
- [ ] SAC20. 故障测试覆盖数据库失败、Mind 认证依赖失败、WebSocket 中断、重复消息、并发发送关联和进程重启;不出现伪成功、提前广播或自动重试。
## 8. 明确不在服务端任务范围内
- 插件对 OneTalk 页面字段的发现、验证和适配。
- 插件 IndexedDB、分页游标、页面结束证据、`awaiting_anchor` 候选与中断续传。
- TradeMind 页面、客户关联、负责人、摘要、未读、搜索、引用和深层分析。
- Mind 的 binding 创建/接管事务、激活码验证和账号选择 UI;Bright 只消费其最终授权事实。
- TradeMind 后台消息消费者、Bright 消息投影、两库消费 cursor 和最终一致重试。
- 删除、迁移或转换旧 outbox 及历史数据。
- 普通消息删除、OneTalk 撤回/编辑生命周期和账号跨 workspace 迁移。
- 对未确认发送提供任务表、恢复检查、自动重试或恰好一次保证。
## 9. 已接受风险
- 发送无法保证恰好一次;用户手动重试前必须自行核对 OneTalk 页面。
- 服务端或 WebSocket 在发送尝试后中断时,结果只能是 `delivery_unknown`
- 临时发送状态不持久化,页面刷新后允许消失。
- 进程重启会丢失尚未终结的内存发送关联,这是“不恢复、不自动重试”边界的一部分。
- Mind 认证视图故障会使 Bright 全部 fail closed,即使 Bright DB 本身仍可用。
## 10. 后续 design 必须回答的问题
以下问题不阻塞本 PRD,但必须在写服务端代码前定稿:
- Fastify 启动/模块边界、HTTP 路径、WebSocket 握手和帧格式。
- 数据库驱动或 ORM、连接池、迁移工具、事务边界及生产数据库权限模型。
- `onetalk_message`、技术会话进度和异常表的精确字段类型、空值、索引和时间语义。
- 消息历史排序、查询游标、分页、实时断线补读和事件序列规则。
- Mind session/token 的跨服务验证方式,以及凭证在 HTTP/WS 中的安全传输方式。
- heartbeat、授权复核、发送请求和数据库操作的具体超时值。
- 进程内连接注册和水平扩容时的路由方案;该方案不得演变为耐久发送队列。
- 消息上传批次、逐条 ack、会话完成声明与锚点推进之间的原子性协议。
- 统一错误结构、稳定错误码、HTTP 状态码和 WS close code 映射。
- 结构化日志、指标、trace、脱敏和诊断数据保留周期。
- 新写入开关、最低插件协议版本、灰度验收、暂停和不可回退保护的部署实现。
@@ -0,0 +1,250 @@
# 重构 OneTalk 消息收发同步机制
## Goal
以 OneTalk 页面为消息唯一事实源,由 TradeBright 服务端保存插件从精确 OneTalk 页面确认过的真实收件与发件;OneTalk 完全绕过现有 outbox 型消息搬运机制,TradeMind 不保存 OneTalk 消息副本,只读 TradeBright 的统一 `onetalk_message` 表并通过 WebSocket 与新链路交互。
## Background
- 用户提供的总体架构图表达了以下边界:Chrome 插件通过 WebSocket 与 TradeBright 双向通信;TradeBright 读写数据库;TradeMind 服务端只读该数据库;TradeMind 消息页面通过 WebSocket 与 TradeBright 双向通信。
- 当前 TradeBridge 入站投递使用 `trademind_delivery_outbox`,出站发送使用 `outbound_message`。这些表和通用机制继续保留,历史数据不迁移,但 OneTalk 不再进入其中。
- 当前 TradeMind 已有跨渠道通用 `messages` 表,发件还通过 durable communication dispatch task 编排。目标架构中,OneTalk 消息不再写入该通用表或使用 dispatch,其他渠道不受影响。
- 用户提供的发送确认流程为:TradeMind 消息页面向 TradeBright 发起发送;TradeBright 通过 WebSocket 转发给插件;插件驱动精确匹配的 OneTalk 页面;页面事实检查结果返回插件;插件把发送结果回报 TradeBrightTradeBright 向 TradeMind 页面发送对应回执。
- 用户明确接受以下取舍:不持久化发送任务,不自动恢复或自动重试,无法保证恰好发送一次;数据库中只保存经 OneTalk 页面事实闭环确认的真实消息。
- 现有代码从 `messageId``msgId``messageID``msgIdStr``id` 等候选路径读取所谓 `externalMessageId`,并在缺失时生成 `latest-<hash>``hist_...` 合成值;这些实现均不能证明 OneTalk 提供了稳定真实消息 ID,且合成值已知可能碰撞。
- 当前 OneTalk 链路没有满足本方案的 IndexedDB 消息账本;已有 Chrome storage 分页状态不能替代逐批落地、逐条确认和中断续传所需的耐久本地事实缓冲。
- 本次重构分为三个主要系统面:Chrome 插件、TradeBright 服务端、TradeMind 业务系统。当前任务先作为父级架构规划确定事实归属、系统边界、接口方向、交付顺序与切换策略;字段、游标、超时和具体表结构留到对应子项目设计。
## Architecture Planning Scope
- 父任务负责:总体目标、事实归属、三系统职责、跨系统契约方向、依赖顺序、全局验收和切换/回滚边界。
- 插件子项目负责:OneTalk 页面适配、身份范围、IndexedDB、本地全量/增量状态机、发送确认和异常采集。
- TradeBright 子项目负责:OneTalk 技术域模型、消息事实持久化、binding 精确路由、锚点/异常、WebSocket 协议及只读访问边界。
- TradeMind 子项目负责:消息页面、业务会话/客户关联、权限、摘要/未读/引用等业务能力改造,以及移除 OneTalk 对本地 `messages`/dispatch 的依赖。
- 低层实现决策必须服从父任务的事实归属和失败边界;当前阶段不因某个现有表或接口方便而倒置系统所有权。
- Bright 直接通过当前 TradeBridge 项目重构实现,不创建新的服务仓库或第三套部署单元。
- 用户将自行拆分后续插件、Bright、Mind 和集成任务;当前父任务不自动创建 Trellis 子任务。
- 跨仓契约在后续拆分时采用共享 type/规则范式:Bright 侧拥有消息/同步/HTTP/WS/错误码契约,Mind 侧拥有最小认证视图契约,两边通过版本和 contract test 防止漂移。
## Confirmed Architecture Boundaries
- 系统使用两个独立数据库:Bright DB 与 Mind DB。Bright DB 是 OneTalk 消息正文及关联 ID 的唯一事实源;Mind DB 不保存 OneTalk 消息正文副本。
- Bright 的产品职责是 OneTalk 消息中心,主要持有消息正文、附件/引用内容、OneTalk 原始 ID、关联范围 ID,以及前文确认的同步锚点和异常诊断;不得吸收 TradeMind 的 workspace 权限、客户关系、负责人、摘要、未读或其它 CRM 业务事实。
- Bright 保存 sender ID 与 login user ID 作为消息原始关联 ID,但发送人的名称、客户身份、组织关系、负责人和其它业务资料由 Mind DB 持有。
- TradeMind 继续拥有 workspace 权限、客户关联、负责人、摘要、未读和其它业务能力,并通过 Bright 消息/会话关联 ID 消费 OneTalk 消息事实。
- Mind 服务端读写 Mind DB,不连接 Bright DB,也不建设 Bright 消息投影。Bright DB 只允许 Bright 服务端访问。历史消息不经过 Mind 服务端代理,Mind 页面直接调用 Bright 的受权历史查询接口;浏览器仍不直接连接任一数据库。
- Mind 消息页面通过 Bright HTTP/查询接口读取历史,通过 Bright WebSocket 发送/接收实时 OneTalk 消息,通过 Mind 服务端取得已有客户关联、负责人等业务信息,并按共享 ID 组合。
- Bright 或其消息连接不可用时,TradeMind 可暂时保留页面已加载内容,但必须明确显示连接中断;停止发送和接收,不排队、不回退旧链路。
- 上线采用新代码路径全量一次性切换,不保留新旧双写;某个当前登录用户对应的 OneTalk 账号完成首次初始化后即可工作,不等待其它账号。系统不建立账号级 `ready/degraded` 业务状态。
- 新链路产生第一条 Bright 消息事实后,禁止回退旧 OneTalk/outbox 路径;严重故障只能暂停 OneTalk 收发、修复新链路并恢复。首次启用新写入前仍可整体回滚部署。
- 推荐部署顺序:先部署 Mind 最小认证视图;再部署关闭新写入的 Bright DB/服务端;发布新协议插件;部署但关闭入口的新 Mind 页面;用测试账号完成首次初始化、收件、发件、设备接管和故障验收;随后全局关闭旧 OneTalk 链路并要求最低插件版本;最后开启 Bright 新写入和新页面。第一条 Bright 消息写入后进入不可回退状态。
- 两个数据库使用共享原始字段契约关联,不生成 Bright 内部 account/conversation/message ID。消息关联与唯一键至少为 `channelAccountId + conversationId + messageId`binding 和 deviceId 可作为插件授权/来源上下文,`mindUserId``workspaceId` 仅由 Mind/Bright 服务端解析和使用,不是 OneTalk 或插件字段;这些上下文均不参与消息主键、去重或会话锚点。
- Mind DB 可保存相同原始 ID 来关联客户、负责人、摘要、未读等业务数据,但不得复制消息正文。sender ID 与 login user ID 是消息属性,不属于唯一键。
- Mind DB 继续作为认证、激活码、Mind user/workspace、binding 和账号授权事实源。Bright 只读其中认证授权所需的数据,不读取客户、摘要、负责人、未读等业务数据。
- Bright 服务端独占 Bright DB 写入;Mind 服务端独占 Mind DB 业务写入。Mind 只读 Bright 消息,Bright 只读 Mind 认证授权;双方均不得直接写对方数据库。
- 激活码仍由 Mind 认证流程验证和消费,Mind 生成并持有 bindingBright 只获得验证后的 binding 授权结果。Bright 无法读取 Mind 认证授权数据时,连接、发送和接收全部 fail closed。
- Mind 用户可以属于多个 workspace,每个 workspace 可以拥有多个 `channelAccountId` 归属记录;MVP 同一用户只激活其中一个。当前系统没有账号迁移功能,本项目不设计账号迁移或相关数据处理。
- 同一 Mind 用户同一时刻只允许一个有效 binding;binding 接管时 Mind 必须使旧 binding 失效。插件安装实例可用 deviceId 标识来源,但该标识不改变 binding 的授权结论。
- MVP 中 Mind 侧 active binding 的授权作用域为 `mindUserId + workspaceId + channelAccountId``binding``deviceId` 仅是插件安装实例的来源标识和同一连接的完整性字段,不参与 active binding decision;插件不携带也不校验 `mindUserId``workspaceId`。同一 Mind 用户只允许一个 active binding;切换授权范围或 binding 时必须原子撤销旧 binding。
- `channelAccountId` 是 OneTalk 页面运行时提供的登录人原始账号 ID(`currentUserAccountId`,或 `IcbuIM.UserUtil.currentUser.accountId`),不是 URL `activeAccountId` 所指向的当前对话账号,也不是 Mind 或 Bright 生成的内部 ID。插件从页面取得该值,并在连接中携带 `channelAccountId + binding + deviceId`;由 Bright/Mind 授权视图精确确认 binding 归属,插件不自行查询 Mind 业务身份。
- Bright 只通过 Mind DB 的版本化最小只读认证视图读取 binding、设备、user/workspace、账号范围、权限、状态、撤销时间、版本和更新时间;Bright 数据库凭据不得读取 Mind 客户、摘要、负责人、未读、激活码秘密或其它业务表。
- Bright 复用现有 Mind session/token,不建设独立登录系统。Mind 页面访问 Bright HTTP/WebSocket 时携带现有凭证且不得放在 URL,Bright 校验页面侧的 `mindUserId + workspaceId + channelAccountId` 及 read/send 权限;插件连接只提交 `channelAccountId + binding + deviceId`,由 Bright 通过 Mind 授权视图解析并校验服务端 user/workspace 上下文。
- 插件 popup 负责输入并保存 Bright WebSocket URL、`channelAccountId``deviceId``binding``chrome.storage.local`Service Worker 启动时读取,popup 修改或清除后动态重建或关闭连接。`binding` 不得进入 MAIN world、页面 `localStorage` 或消息事实 payload。
- 开发环境不得默认跳过认证。`pnpm dev` 启动独立的本地 Mind HTTP mockBright 通过 development-only loopback `MIND_AUTH_BASE_URL` 调用 binding/session 授权接口;mock 只校验固定 fixture 的 `channelAccountId + binding`、Session Cookie、授权版本和权限,缺失或不匹配时 fail closed。`mindUserId``workspaceId` 只来自 Mind HTTP 授权响应,不作为插件输入;生产环境仍使用 HTTPS 的真实 Mind 授权服务。
- 插件离线但 Bright 与 Mind 认证库可用时,用户仍可读取已持久化历史;页面显示插件离线,禁止发送且没有实时新消息。
- Bright 消息接口可用但 Mind 业务接口失败时,页面可显示消息和原始 ID,业务区域显示不可用;Mind 业务接口可用但 Bright 不可用时,只显示业务壳并明确消息不可用。Mind 认证库不可用时 Bright 全部 fail closed。
- 首次切换必须保留核心消息能力:会话列表与精确会话打开、历史读取、实时收件、发件三类结果、客户/会话精确关联、基础未读、权限与账号选择。
- AI 摘要、搜索、引用操作和深层分析等非核心能力可后续迁移;未迁移能力必须明确禁用,不得读取 TradeMind 旧 OneTalk 消息伪装可用。
- 首发 Mind 能力重新限定为:权限与账号选择、Bright 已发现会话列表/精确打开、历史读取、实时收件、发件三类结果,以及展示 Mind 中已存在的客户关联和负责人。本次不建设新未读持久化、自动客户关联、自动摘要或 Bright 消息后台投影。
- OneTalk 是会话存在性的唯一来源。插件枚举并同步到 Bright 后,Mind 才能读取该会话并建立业务关联;Mind 不得主动创建尚未被 Bright 发现的 OneTalk 会话。
- Mind 后台消费 Bright 消息、未读/摘要派生和两库最终一致机制属于本次暂缓的边缘功能,不在当前设计与实现范围。客户关联、负责人、摘要、未读和权限的事实归属仍全部在 Mind。
## Requirements
### Fact ownership and boundaries
- R0. 从现在开始,项目中所有声称来自 OneTalk 的字段、字段路径、类型和语义都视为不可信;现有代码、命名、fixture 和测试只能作为待验证候选,不能据此声称 OneTalk 提供 `externalMessageId` 或其它供应方契约。
- R1. OneTalk 页面是消息唯一事实源。
- R2. 收件与经确认的发件共用 TradeBright 的一张 `onetalk_message` 事实表。
- R3. TradeBright 是 `onetalk_message` 的唯一服务端读写入口;Mind 服务端和浏览器都不直接连接 Bright DB。Mind 页面只能通过 Bright HTTP/WebSocket 访问消息。
- R3a. Mind 页面历史消息直接请求 Bright 服务端;Bright 使用 Mind 登录/授权信息校验 `mindUserId + workspaceId + channelAccountId` 后查询 Bright DB 并返回,Mind 服务端不代理历史消息请求。
- R3b. Bright 历史读取要求 `read` 权限;发送还要求有效 active binding 与 `send` 权限。激活码不是 Bright API 凭证,session/token 禁止放入 URL,具体传输机制由部署设计确定。
- R4. TradeMind 不得再把 OneTalk 消息复制到自身通用 `messages` 表;非 OneTalk 渠道继续使用原有 `messages` 表。
- R5. TradeMind 对 OneTalk 消息的客户关联、摘要、未读、引用和发送流程必须使用统一事实表的消息主键及 OneTalk `messageId`,不得通过维护第二份消息正文或投递状态恢复旧行为。
- R6. 现有 outbox 表、worker、claim/lease API 和其他渠道使用的通用机制全部保留;新的 OneTalk 收件、发件和状态同步必须完全绕过该机制,不得双写、claim、消费或 fallback。
### Identity, routing and correlation
- R7. 新链路不使用 `externalMessageId` 概念。消息满足入库结构门槛只要求 OneTalk 提供的 `messageId`、conversation ID、sender ID、当前 login user ID 四项同时存在且为非空标量值。
- R7a. 对这四项只做存在性与基本可序列化检查,不校验格式、长度模式、业务合理性、ID 之间的关系或跨接口一致性;字段值的业务真实性由 OneTalk 负责。
- R7b. 插件必须保留这四项的原始观测来源以供诊断,但不得用 `msgId``messageID``msgIdStr`、通用 `id` 等未经新契约确认的候选字段静默替代缺失的 `messageId`
- R7c. `latest-*``hist_*`、正文/时间/方向 hash 等客户端合成值不得存入 `messageId`,也不得作为去重、增量锚点或重建完成依据。
- R7d. TradeBright 按 `channelAccountId + conversationId + messageId` 建立幂等边界;binding 和 deviceId 只用于证明上传设备获得该账号授权和记录来源,`mindUserId``workspaceId` 由服务端授权适配解析,不进入插件上传帧或幂等键。senderId 与 loginUserId 作为消息属性保存,不做合理性判断。
- R7e. Bright/Mind 服务端上下文只允许:`mindUserId``workspaceId``channelAccountId``deviceId``conversationId``messageId``senderId``loginUserId`。其中 `mindUserId``workspaceId` 仅属于 Mind 授权/业务上下文,不是 OneTalk 页面字段,也不进入插件认证帧;插件认证字段固定为 `channelAccountId``deviceId``binding`。其中 `channelAccountId` 是 OneTalk 页面运行时登录人的原始账号 ID(`currentUserAccountId``IcbuIM.UserUtil.currentUser.accountId`),URL `activeAccountId` 只表示当前对话账号。两库必须共享字段名称、类型和空值规则;不得新增 `sellerAccount``channelAccount``sellerAccountId`,除非后续证据证明 OneTalk 提供且业务另行批准。
- R8. 所有缺失或不匹配的页面账号、渠道账号或会话身份必须 fail closed;不得回退到其他 OneTalk 标签页或广播账号特定工作。
- R8a. 设备能否连接、同步或发送由 Mind 的 binding 授权决定;Bright 必须在接受对应操作前通过最小认证视图校验授权,失败或认证数据不可用时拒绝操作。
- R8aa. 插件 `ws.hello` 只携带 `channelAccountId + deviceId`,并在 `payload.binding` 中提交 bindingBright 必须向 Mind 授权视图确认 binding 属于该 `channelAccountId`,再返回 `authorizationVersion``permissions``deviceId` 只用于来源和同一 socket 的 scope 完整性校验,不参与授权匹配。插件不得携带或校验 `mindUserId``workspaceId`
- R8ab. `binding` 是唯一绑定业务概念;协议、服务端上下文、数据库字段和诊断统一使用 `binding`,不得引入另一套绑定标识命名。
- R8b. 同一 Mind 用户不得同时保持多个有效 bindingbinding 接管后旧 binding 立即失效。会话事实和锚点归 `channelAccountId + conversationId`,不归设备,换安装实例不得形成消息或锚点副本。
- R8c. 因单用户单设备约束,不建设多设备同步 lease、自动发送设备选择或广播发送。所有页面驱动同步和发送只路由到当前有效 binding。
- R8d. Bright 在连接时、每次发送/同步操作前以及 WebSocket heartbeat 周期中复核 Mind 授权版本;发现撤销、接管、账号范围变化或认证视图不可用时立即 fail closed 并断开旧连接。
- R8da. 插件离线不阻止授权用户读取 Bright 已持久化历史,但必须禁用发送并标记实时接收不可用;插件重连后按共享锚点恢复同步。
- R8e. MVP 每个 Mind 用户只有一个 active binding 和一个 active `channelAccountId` 授权;切换 binding 或 active channelAccountId 必须先撤销旧 binding,不支持并行管理多个 active channel account。
- R8ea. Mind 侧 active binding 固定 `mindUserId + workspaceId + channelAccountId + binding`;授权范围或 binding 变化都属于接管并原子撤销旧 binding,插件只感知其中的 `channelAccountId + deviceId + binding`,其中 deviceId 不参与授权 decision。
- R8f. 新设备接管发生在旧设备已经触发发送但尚未回报时,旧连接和迟到回报均被拒绝,原请求视为 `delivery_unknown`;若 OneTalk 实际已发送,新设备后续观察到四字段完整消息时按普通消息事实入库。
- R9. 一次发送沿 `TradeMind 页面 → TradeBright → 插件 → 精确 OneTalk 页面` 传递,并沿相反方向返回结果;每一跳必须携带同一个稳定、非敏感的发送请求 ID。
- R10. `emit` 和 fallback 必须执行相同的 `channelAccountId + conversationId` 精确校验。
### Outbound send and confirmation
- R11. TradeBright 不得为发送请求创建 `pending``dispatching``confirming``failed` 或其他占位消息行;发送请求不是消息事实。
- R12. 插件发送前必须冻结当前精确会话的最新消息快照,发送后重新检查最新消息。
- R13. 插件可以综合使用 OneTalk 消息事件/emitter、发送接口返回值、页面 SDK 或消息列表数据、history/fallback 拉取和 DOM 展示结果;DOM 只是观测手段之一。
- R14. `confirmed_sent` 必须同时满足:页面 `channelAccountId + conversationId` 与精确 binding 授权匹配;发送后出现发送前快照中没有的新 `messageId`;该记录同时具备 conversationId、senderId 与 loginUserId;正文、附件或引用与发送请求精确匹配;消息时间不早于本次发送;发送接口提供候选 `messageId` 时与页面观测值一致;不存在人工操作或并发发送歧义。除这些确定性条件外,不判断四个 ID 的业务合理性。
- R15. 确认规则必须是确定性证据闭环,不使用模糊置信度或评分;任一关键证据不足都不得返回 `confirmed_sent`
- R16. 发送结果仅分为 `confirmed_sent``rejected_before_send``delivery_unknown`
- R17. `confirmed_sent` 表示页面事实闭环,TradeBright 按精确账号/会话范围和 `messageId` 幂等写入统一消息表,并在数据库提交成功后向 TradeMind 页面发送最终回执。
- R18. `rejected_before_send` 只适用于尚未产生发送副作用且已明确拒绝的请求,例如插件离线、没有精确匹配页面或身份不一致;不得创建消息行或排队等待。
- R19. 已尝试发送但无法可靠确认时必须返回 `delivery_unknown`,不得创建消息行或自动重试。TradeMind 页面提示:“发送结果无法确认。请刷新或打开 OneTalk 页面核对;确认未发送后,再手动重试。”
- R20. TradeBright 或 WebSocket 在发送尝试后中断时,不恢复检查、不自动重发;页面刷新后未确认的临时发送气泡允许消失。
- R20a. `sendRequestId` 只用于本次 TradeMind 页面、TradeBright 与插件之间的内存关联,到终端响应、超时或连接中断即失效,不写入消息表、任务表或其它耐久存储。
- R20b. TradeMind 已收到 `delivery_unknown` 或连接已经中断后,如果插件稍后从 OneTalk 页面观察到四字段齐全的真实发件,TradeBright 仍必须把它作为普通 OneTalk 消息事实幂等入库并推送。
- R20c. 迟到消息不得追溯修改或伪造原临时发送请求的结果;页面允许先显示“结果无法确认”,随后通过正常消息流看到实际发出的消息。
### Inbound receive
- R21. 插件从精确 OneTalk 页面观察消息后,只要 `messageId`、conversation ID、sender ID、login user ID 四项齐全,即可连同消息内容发送给 TradeBright;不校验这四项的业务合理性。
- R22. TradeBright 先通过 Mind 认证授权数据验证当前 binding 有权代表该 `channelAccountId` 上传,再按 `channelAccountId + conversationId + messageId` 幂等写入统一表;数据库提交后再通过 WebSocket 通知 TradeMind 页面。
- R23a. 任一必需字段缺失时,该记录不得写入 `onetalk_message`,必须作为异常信息记录缺失字段与上下文;异常不能阻塞同页后续记录、后续分页或该会话后续增量同步。
- R23b. TradeBright 必须使用独立耐久诊断表 `onetalk_message_anomaly` 保存异常消息,供开发后续确认;该表不是消息事实表或 outbox,不参与发送、claim、worker、自动重试、消息展示或增量锚点。
- R23c. 异常记录至少包含可获得的 binding、mindUserId、workspaceId、channelAccountId、deviceId、conversationId 范围、缺失字段列表、观测来源、清洗后的异常 payload、首次与最近出现时间、出现次数以及 `open``resolved``ignored` 处理状态。
- R23d. 允许对清洗后的异常 payload 生成诊断 fingerprint,以合并同一异常的重复观测;该 fingerprint 只能用于诊断去重,绝不能成为 `messageId`、消息幂等键或增量锚点。
- R23e. 异常 payload 必须删除 Cookie、CSRF、反爬令牌、认证头和与排查无关的敏感字段;异常保存失败必须显式暴露,但不得把缺字段记录写入正常消息表。
- R23. 收件与经确认的发件最终形成同一种持久化消息事实,只以 direction 和页面事实字段区分。
- R24. 新 `onetalk_message` 表不得直接复制或转换 TradeMind 旧 OneTalk 消息;历史数据只能由插件从精确 OneTalk 页面重新观察、验证并重建。
- R25. 未经插件重新确认的 TradeMind 旧 OneTalk 消息冻结为 legacy 数据,不得参与新链路的发送确认、实时同步、幂等判断或事实读取。
- R25a. 插件从 OneTalk 页面枚举到会话后,Bright 才能创建 `channelAccountId + conversationId` 技术会话同步进度;Mind 只能读取 Bright 已发现的会话,不能主动制造 OneTalk 会话事实。
- R25b. senderId/loginUserId 四字段齐全时消息正常入 Bright;Mind 仅用现有业务逻辑按 ID 查询已有联系人/客户资料,查不到时显示原始 senderId 或“未关联联系人”。消息链路不自动创建客户,也不调用客户详情接口补全消息资料。
- R25c. OneTalk 页面可以在 MAIN world 观察当前已加载单聊的白名单基础资料,并通过独立的 profile observation 链路投递给 Mind;这不改变消息事实归属,也不表示 Mind 已完成 DB 落库。
### Full rebuild and incremental anchor
- R26. 首次初始化的范围是当前 active `channelAccountId` 下页面可枚举的全部会话;初始化是一次同步过程,不形成账号级 `ready/degraded` 持久业务状态。
- R26a. 首次初始化后只执行会话级同步:新会话执行该会话首次全量;单个会话找不到旧锚点时只自动重新同步该会话;新消息确认后只推进该会话锚点。
- R27. 首次初始化和会话级重建均按 `channelAccountId` 范围执行,不按 binding 创建设备副本;新设备接管后从 Bright 共享会话锚点重建本地状态。
- R28. 单聊和群聊均纳入全量重建;无法可靠验证身份的会话必须记录明确失败,不得静默跳过。
- R29. “全量”只表示 OneTalk 页面实际可枚举和读取的完整范围,不得宣称覆盖页面未暴露的数据。
- R30. 插件先枚举全部会话,再让每个会话独立从最新向最早分页;每批消息必须先耐久写入插件 IndexedDB,再上传 TradeBright。
- R31. TradeBright 对每条消息执行身份、范围与幂等校验并逐条确认;插件只有收到逐条确认后才能把对应 IndexedDB 项标记为已确认。
- R32. 插件必须耐久保存每个会话的分页位置、页面结束证据、待上传/待确认消息和重建结果,支持浏览器或 service worker 中断后从同一会话检查点继续。
- R33. 会话分页只能在 OneTalk 明确返回历史结束时完成;游标停滞、响应缺失、身份变化或页面不可用必须明确失败或未完成,不得解释为历史结束。
- R34. 单个会话的重建完成判定至少要求 OneTalk 明确返回历史结束、所有四字段齐全的消息均已上传、TradeBright 已逐条确认、IndexedDB 不存在待确认的有效消息;缺字段异常不得中断扫描或阻止后续有效消息同步。
- R34a. 满足完成条件且存在异常时,会话标记 `succeeded_with_anomalies`;账号汇总必须显示异常会话数和异常记录数,不得伪装成无异常成功。
- R35. 全部会话必须分别记录 `succeeded``failed``incomplete`;部分成功不得冒充整个账号重建完成。
- R36. 全量同步只有在 OneTalk 明确返回历史结束且有效消息全部获 TradeBright 确认后,才能记录该会话最新有效消息的 `messageId` 作为增量锚点;全量未明确结束时禁止创建或更新锚点。
- R36a. 服务端锚点必须按 `channelAccountId + conversationId` 唯一保存,值为该会话当前 `latestMessageId`;可附带 senderId 与 loginUserId,但 binding、mindUserId、workspaceId 和 deviceId 不参与锚点归属,禁止使用裸 `messageId` 跨会话或跨账号查找。
- R36aa. `latestMessageId` 的归属和作用不因当前授权设备变化而改变;新设备读取并继续推进同一会话锚点,不创建设备独立锚点。
- R36ab. 插件建立连接并通过认证后,Bright 返回当前 active `channelAccountId` 下的全部服务端会话锚点;插件随后枚举页面全部会话以重建本地同步状态。
- R36ac. 页面会话存在服务端锚点时,插件从最新向该锚点执行增量追赶;不存在锚点时,只对该会话执行首次全量历史同步。
- R36ad. “全量重建同步状态”表示重新枚举会话并用服务端锚点恢复每个会话的本地状态,不等于每次连接都重新分页抓取所有历史;只有无锚点或锚点找不到的会话才抓取全历史。
- R36ae. Bright 必须保存独立的技术性会话同步进度,至少包含 `channelAccountId + conversationId``latestMessageId`、首次/增量/失败状态和最近观察时间,用于换设备或重连后恢复;该记录不保存消息正文、客户、负责人、摘要或未读。
- R36b. 锚点只定义增量扫描停止边界,不参与消息排序、正常消息入库资格或去重;消息幂等仍使用精确账号/会话范围加 `messageId`
- R36c. 增量同步由 OneTalk 页面新消息信号触发,插件从当前最新消息向历史方向分页,直到遇到服务端保存的 `latestMessageId`;扫描过程中时间上比旧锚点更新的有效消息是本次候选新增消息,旧锚点可重复上报供服务端幂等校验,也可只作为边界。
- R36d. 增量扫描到的四字段完整消息必须先耐久写入插件 IndexedDB 并标记为 `awaiting_anchor`;找到旧锚点前禁止上传 TradeBright、写入 `onetalk_message` 或通知 TradeMind。
- R36e. 找到旧锚点后,插件把锚点之前的候选消息按页面扫描顺序反向整理,从旧到新逐条上传 TradeBright;Bright 按精确会话范围加 `messageId` 幂等确认。
- R36f. 只有本次全部候选消息获得 TradeBright 确认后,才可把锚点推进到本次扫描最前端的最新有效消息,并通知 TradeMind 读取这批已确认事实。
- R36g. 旧锚点本身只作为扫描边界,默认不作为新增消息上传;即使实现选择重新上报,也只能由 Bright 幂等忽略。
- R36h. 最新页面消息缺少 `messageId` 时写异常表并继续扫描,但本次不得推进该会话锚点;后续有效消息同步不受阻塞。
- R36i. 新会话没有锚点时直接执行该会话首次全量同步。
- R36j. 增量扫描直到 OneTalk 明确历史结束仍找不到旧锚点时,必须记录 `incremental_anchor_not_found`;所有候选继续保留在 IndexedDB,不得按增量结果上传或通知 TradeMind,并自动把当前会话切换为会话级全量重建。
- R36k. 自动会话重建必须复用锚点未找到时的候选消息和分页位置,不重复抓取已经耐久保存的页面消息,并在页面明确返回历史结束后按全量规则写入;其它会话继续独立增量同步。
- R36l. 浏览器或 service worker 重启后,插件必须从 IndexedDB 恢复 `awaiting_anchor` 候选、分页位置和扫描模式,不能丢弃候选或越过锚点边界。
### Observability and security
- R37. 每个发送请求、插件检查结果、重建会话、分页批次和消息写入必须保留可关联的结构化、非敏感诊断,至少包含 request ID、binding、账号范围、会话 ID、结果分类和时间戳;诊断不得记录 binding 原值、Cookie、CSRF、令牌或不必要的消息正文。
- R38. 插件不得直接调用 OneTalk CRM 端点、读取或重放 Cookie/CSRF/反爬令牌、接收任意远程 selector/script,或使用模糊客户名和列表位置点击。允许 MAIN world 读取 OneTalk 页面已经加载的、经过固定白名单清洗的基础资料;Service Worker 和服务端不得重放页面 token 或自行拼接 CRM 请求。
- R38a. 基础资料只读取 `__conversationListData__` 初始快照和 `im-conversation-list:syncData` 更新;邮箱、注册时间、买家标签、详情微应用、DOM 刷新和群聊成员另行规划,不得以缺字段为由扩大本链路。
- R39. 本项目不设计普通消息删除、账号迁移历史搬运或 OneTalk 召回/编辑生命周期;`onetalk_message` 不提供常规物理删除路径。未来如需支持,必须单独规划 Bright 事实变化与 Mind 业务引用一致性。
- R40. 本项目不支持 `channelAccountId` 在 workspace 之间迁移,不迁移、复制或重新归属客户、负责人、摘要、未读或 Bright 消息。消息复用只适用于当前有效授权关系下的同一 channelAccountId。
- R41. 新架构首次写入 Bright 消息后,旧 OneTalk outbox/dispatch 路径永久保持禁用;故障处置只能暂停新链路、修复并恢复,不能回退旧事实链路。
- R42. 全量切换后,旧协议插件发起 OneTalk 同步或发送时必须明确返回 `onetalk_protocol_upgrade_required` 并提示升级,禁止静默忽略或写入旧 outbox;旧插件的其它兼容渠道可继续工作。
## Acceptance Criteria
- [ ] AC1. 同一张 `onetalk_message` 表表达入站与经确认的出站消息,并通过精确范围唯一约束和真实 PostgreSQL 测试保证幂等。
- [ ] AC2. 新出站请求在插件完成页面事实闭环前不会产生消息行或显示为最终成功。
- [ ] AC3. 插件只返回 `confirmed_sent``rejected_before_send``delivery_unknown`;只有 `confirmed_sent` 产生消息行。
- [ ] AC4. 发送接口返回成功但页面事实未闭环、相同文本并发发送、人工发送干扰或页面消息延迟时,结果为 `delivery_unknown` 且数据库零写入。
- [ ] AC5. `delivery_unknown` 不触发自动恢复或自动重试,页面显示约定提示;刷新后临时气泡可以消失。
- [ ] AC5a. 临时 `sendRequestId` 在终端响应、超时或断线后不留耐久任务记录。
- [ ] AC5b. 超时或断线后迟到的有效发件仍作为普通消息事实入库和推送,但原请求结果保持 `delivery_unknown`
- [ ] AC6. 插件离线、身份不匹配或精确 OneTalk 页面不存在时返回 `rejected_before_send`,不排队、不切换页面且数据库零写入。
- [ ] AC7. 插件确认一条新发件后,重复上报相同精确范围、conversation ID 和 `messageId` 只返回同一消息事实,不创建重复行。
- [ ] AC8. 控制命令按精确授权 binding 路由;消息事实按 `channelAccountId + conversationId + messageId` 幂等,锚点按 `channelAccountId + conversationId` 共享,不因不同授权设备产生副本或串号。
- [ ] AC8a. 未授权、已撤销或认证视图不可用的设备无法连接、同步或发送;Bright 不通过缓存旧授权继续放行。
- [ ] AC8b. 插件 WebSocket 握手只提交 `channelAccountId + binding + deviceId`Bright/Mind 授权视图按 `channelAccountId + binding` 确认归属后返回 `authorizationVersion``permissions`,deviceId 仅作来源/连接完整性字段,缺失或不匹配时 fail closed。
- [ ] AC8c. 插件 popup 配置持久化 Bright WebSocket URL、`channelAccountId``deviceId``binding`;配置变更能重建或关闭 Service Worker 连接,且 `mindUserId``workspaceId` 不进入插件配置、页面世界或消息事实。
- [ ] AC9. 收件与确认发件写入同一张表,TradeMind 页面只在数据库提交后通过 WebSocket 看到该消息事实。
- [ ] AC10. 现有 outbox 表和非 OneTalk 使用路径保持可用;集成测试证明 OneTalk 不会创建或消费任何 outbox 或 TradeMind dispatch 行。
- [ ] AC11. TradeMind 服务端不能写 TradeBright 的 `onetalk_message` 表,越权写入在数据库权限或等价边界处失败。
- [ ] AC11a. Mind 页面只有在 Bright 根据 Mind 登录/授权信息确认当前 workspace/user/channelAccountId 可读后才能取得历史;历史请求不经过 Mind 服务端,也不暴露数据库连接。
- [ ] AC11b. Mind 服务端没有 Bright DB 凭据或消息投影;Bright DB 只接受 Bright 服务端连接。
- [ ] AC11c. Bright 复用 Mind session/token 完成 HTTP/WS 授权,token 不出现在 URLread 与 send 权限分别生效。
- [ ] AC12. 新 OneTalk 消息不会写入 TradeMind 通用 `messages` 表;其页面仍能读取、关联、发送并实时显示 OneTalk 消息。
- [ ] AC13. WhatsApp、邮件等非 OneTalk 渠道继续通过 TradeMind 原有 `messages` 与 dispatch 机制工作。
- [ ] AC14. 缺少发送请求 ID,或消息缺少 `messageId`、conversation ID、sender ID、login user ID 任一项时,不写消息表并产生不含业务正文的结构化异常;同批和后续消息继续处理。
- [ ] AC14a. `senderId` 缺失的消息进入异常诊断而非正常消息表,开发能看到异常,且同批和后续同步继续。
- [ ] AC15. TradeBridge 与 TradeMind 的相关类型检查、单元测试及真实 PostgreSQL 迁移、幂等和断线边界测试通过。
- [ ] AC16. 新表的历史消息均可追溯到插件重新观察的精确 OneTalk 页面事实;没有任何 TradeMind legacy 消息被直接复制进新表。
- [ ] AC17. `latest-*``hist_*` 或正文/时间 hash 不会写入 `messageId`,也不会被用作增量锚点。
- [ ] AC18. 当前登录用户的 OneTalk 账号首次初始化枚举全部会话;初始化完成后,新会话或锚点缺失只触发对应会话同步,不维护账号级 ready/degraded 状态。
- [ ] AC19. 全量重建枚举当前精确账号下页面可见的全部单聊和群聊,并为每个会话独立保存分页检查点与结果。
- [ ] AC20. 每批消息在上传前已写入 IndexedDB;断线或 service worker 重启后从耐久检查点续传,已确认消息不会重复创建。
- [ ] AC21. 游标停滞、会话/页面账号身份无法建立精确 binding 范围或任一有效消息未确认都会阻止该会话成功;单条缺字段异常不会阻塞其后的有效消息。
- [ ] AC22. 首次初始化结果准确列出每个会话的成功、失败与未完成状态;该结果是同步报告,不形成账号级 ready/degraded 业务事实。
- [ ] AC23. 缺字段异常耐久写入独立诊断表;重复观测只增加出现次数,开发可将其标记为 `resolved``ignored`
- [ ] AC24. 异常诊断表不被任何消息读取、发送、增量定位或重试流程消费,且清洗测试证明不保存认证秘密。
- [ ] AC25. 重建扫描到异常后继续处理后续消息;满足其它完成条件时结果为 `succeeded_with_anomalies` 并准确汇总异常数。
- [ ] AC26. 全量同步只有在页面明确返回历史结束且所有有效消息获确认后才保存精确范围的 `latestMessageId` 锚点。
- [ ] AC27. 增量同步从最新向历史扫描并在旧锚点停止;只有旧锚点之前遇到的候选消息被处理,锚点本身不影响排序或入库资格。
- [ ] AC28. 找到旧锚点前,候选消息全部处于 IndexedDB `awaiting_anchor`TradeBright 消息表和 TradeMind 通知均为零变化。
- [ ] AC29. 找到旧锚点后,候选消息从旧到新逐条上传并幂等确认;全部确认后才推进锚点并通知 TradeMind。
- [ ] AC30. 成功增量完成后锚点推进到最新有效消息;最新消息缺少 `messageId` 时记录异常且不推进锚点。
- [ ] AC31. 无锚点的新会话执行首次全量;找不到旧锚点时记录 `incremental_anchor_not_found`,候选留在 IndexedDB,不按增量结果写消息表或通知 TradeMind,并自动转入当前会话全量。
- [ ] AC32. 会话自动全量复用已有候选和分页位置;重启后仍能恢复,不重复抓取或丢失候选,也不阻塞其它会话。
- [ ] AC33. 新设备连接后获得服务端全部会话锚点并重新枚举页面会话;有锚点会话只做增量,无锚点会话才执行首次全量。
- [ ] AC34. 新设备接管会撤销旧 binding;旧设备无法继续连接、同步或发送,新设备从共享服务端锚点恢复且不产生设备副本。
- [ ] AC35. Bright 认证账号只能读取 Mind DB 的版本化最小授权视图,连接、操作和 heartbeat 均能感知授权撤销或设备接管。
- [ ] AC36. 同一 Mind 用户无法同时激活第二台设备或第二个精确账号 binding;接管会原子撤销旧 binding。
- [ ] AC37. 接管期间旧设备迟到回报被拒绝且请求为 `delivery_unknown`;实际已发消息可由新设备后续观察并正常入库。
- [ ] AC38. 系统没有账号迁移入口或隐式归属变更;消息复用只发生在当前 active binding 授权的同一 channelAccountId。
- [ ] AC39. 第一条新 Bright 消息写入后,任何配置或错误处理都不能重新启用旧 OneTalk outbox/dispatch 路径。
- [ ] AC40. 全量切换后旧插件的 OneTalk 请求明确返回 `onetalk_protocol_upgrade_required`,不会进入旧 outbox;其它兼容渠道继续工作。
- [ ] AC41. Bright 技术会话同步进度能表示零消息会话、首次/增量/失败状态、锚点与最近观察时间,且不含消息正文或 Mind 业务字段。
- [ ] AC42. Mind 无法创建 Bright 尚未由 OneTalk 插件发现的会话;插件枚举并提交技术会话后,Mind 才能读取并建立业务关联。
- [ ] AC43. senderId/loginUserId 完整但 Mind 无对应资料时消息仍显示原始 ID/未关联占位;消息链路不会自动创建客户或触发详情资料补全。
- [ ] AC44. 插件离线时历史仍可读取、发送和实时接收禁用;插件重连后从服务端锚点恢复。
- [ ] AC45. Bright 消息与 Mind 业务接口能按确认的部分故障规则独立降级;Mind 认证视图不可用时 Bright 历史、连接、同步和发送全部拒绝。
- [ ] AC46. 首次有效 OneTalk 页面 hello 仅对当前已加载单聊产生 profile snapshot;新单聊和白名单资料变化产生独立 profile event,经现有 Bright WebSocket 和 binding/read 授权投递 MindBright 不保存 profile,任意 HTTP response 才 ACK,无 response 保留 pending。
## Out of Scope
- 删除、改造或迁移现有 outbox 表及其中历史数据。
- 重构 WhatsApp、邮件等非 OneTalk 渠道,除非共享契约需要最小兼容调整。
- 为未确认发送增加任务表、占位消息、自动恢复或自动重试。
- 保证恰好发送一次。
- 把 TradeMind 旧 OneTalk 消息直接转换成新表事实。
- 本次重新设计或实现 Mind 后台消息消费者、未读/摘要派生、两库消费 cursor 和最终一致重试机制;这些业务事实继续归 Mind,后续另立子项目。
## Accepted Risks And Trade-offs
- 无法保证恰好发送一次;用户未经核对就手动重试仍可能造成重复。
- Bright 或 WebSocket 在发送后中断时只能视为结果未知,不能自动恢复检查。
- 未确认发送不持久化,TradeMind 刷新后临时发送气泡可以消失。
- 相同文本、并发发送、人工操作或页面消息延迟都可能造成歧义;系统宁可返回 `delivery_unknown`,也不误报成功。
- TradeMind 原先依赖自身 `messages` 表的 OneTalk 关联、摘要、未读和引用功能需要改造。
## Open Questions
- Q1. WebSocket 断线补读、精确字段类型/空值、游标、超时、索引、表结构、部署域名和凭证传输方式等低层决策,明确推迟到用户后续拆分的对应子项目设计,不阻塞父级架构确认。