Files
trade-message-center/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md
T

35 KiB
Raw Blame History

OneTalk 插件双向运行时与耐久同步契约(总览)

1. Scope / Trigger

OneTalk 插件同时需要以下能力时,遵循本总览和对应子规范:

  • MAIN world 访问 OneTalk 页面 SDK
  • TradeBright 主动向插件下发发送命令,插件内部调度历史与资料采集命令;
  • 插件向 TradeBright 上传页面事实;
  • 历史、增量和实时消息在浏览器或 Service Worker 重启后恢复;
  • Popup、Service Worker 和 Bright 授权链路使用稳定的扩展安装实例 deviceId;
  • Service Worker 向 Popup 和开发者输出安全、可验证的状态与错误诊断。

本文件只维护跨子系统的总数据流、所有权和不变量。具体规则分散在以下规范:

规范 负责内容
OneTalk 页面桥、Port 与命令路由 MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和 command 路由
OneTalk 耐久同步与连接生命周期 IndexedDB、full/incremental/live、ACK、checkpoint、重启恢复和连接生命周期
OneTalk 联系人资料 Bright 持久化 profile 白名单、Bright profile frame、pre-v7 清空后重采集、v9 profile scope indexes、v10 rendered-card scope indexes 与统一 buyer facts store、ACK/HWM/future-skew 和账号/epoch 隔离
OneTalk 扩展安装实例设备身份 deviceId 生成、迁移、独立存储、配置清除和生命周期
OneTalk Service Worker 状态与诊断 getSnapshot、错误投影、敏感信息脱敏和 development 构建
OneTalk PWA 出站发送 SOP sendUIMessages 输入、SDK-only 发送和 WebSocket 旁路事实确认

2. Global data flow

页面事实:

```text OneTalk MAIN world -> window.postMessage -> ISOLATED Content Script -> runtime.Port -> Service Worker -> IndexedDB durable write -> Bright WebSocket upload -> per-message ACK

联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker pre-v7 clean-state / v9 scope-indexed profile ledger and v10 rendered-card scope-indexed ledger(位于统一 schema,另含 buyer facts store)→ existing Bright plugin WebSocket contact.profile.observed → Bright guarded profile transaction → contact.profile.ack。 ```

服务端命令:

```text Bright WebSocket -> Service Worker -> unique registered page Port -> window.postMessage -> OneTalk MAIN world -> page command result -> Service Worker -> Bright confirmation/result ```

插件到 Bright 的 WebSocket 与 TradeMind 页面到 Bright 的 WebSocket 是两条独立连接;不得混用连接状态、request ID 或消息事实。

3. Global ownership

  • MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
  • bootstrap 的发现/逐会话提示是 MAIN world 内单页 UI 状态,唯一契约见 OneTalk 页面增强控件;它不是 historyProgress 的展示投影,也不进入 observation schema、Service Worker 状态、IndexedDB、Bright 或诊断。
  • ISOLATED Content Script 只拥有页面桥和 runtime.Port;不得解释业务 payload、保存同步状态或选择备用页面。
  • Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
  • 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
  • protocolVersion=5content.version=1 独立演进;跨 MAIN 的消息仅是 shared normalized text | image | file | business_card | inquiry | order content。业务卡只允许已观察的 sdk_flat_history 完整联合条件产生:名片在消息事实中只保存 { version: 1, kind: "business_card" } marker,询盘只有类别,订单只含批准的摘要投影。名片的客户资料由独立 profile ledger 按 [channelAccountId, conversationId] 关联,并只在服务端读取时以内存组合扩展;不会因为 __conversationListData__ 与消息观察异步而在 MAIN 侧拼接。raw custom.data、顶层 text/contentType、卡片原始正文、paramssign 和完整 contact 不能到达 ISOLATED、Service Worker、IndexedDB 或 Bright。
  • Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
  • Bright 是联系人资料当前事实源;Mind 只提供页面/插件授权上下文,不接收 profile delivery,也不作为 Bright read model 的 profile projection owner。
  • 每个跨层业务概念必须只有一个 owner:页面 command 结果先在页面边界形成,发送三态先在 contract/adapter 边界收窄,服务端事实只在 server ingest 中提交。

3.1 MAIN 采集与命令流程边界

Scope / Trigger

新增采集能力、页面命令或调整 MAIN 组合入口时,按采集上报与命令执行识别业务入口,继续共享观察器、页面桥和既有状态 owner。page_command 还承载插件内部的同步与资料快照,不能把所有 page command 或 WS ACK 归成服务端业务命令。

Signatures

以下路径相对 src/onetalk/main-page/

文件 / 接口 职责
page-script-entry.ts 唯一 MAIN 组合根,创建共用实例并安排安装顺序
collection/entry.ts / installOneTalkCollectionObservers(pageWindow, publish, buyerFactSink) 安装 buyer/message observer 和公开手动历史入口
collection/observed-publisher.ts / createOneTalkObservedPublisher(observedSink, observeSentMessages) 返回共用 OneTalkObservedMessageSink,筛选批次并按原序通知两个消费者
commands/index.ts / createOneTalkPageCommandHandler(pageWindow, dependencies) 返回现有 OneTalkPageCommandHandler,只按 action 委派
commands/send.ts / handleOneTalkSendCommand(pageWindow, message, sendObservation) 校验发送输入并执行 text 或委派原 image/file 适配
current-conversation-history/page-command.ts 处理 discover/sync/sync.conversation,不接收发送关联器
contact-observer/page-command.ts / handleOneTalkContactProfileCommand(observer, command) 严格校验并执行 snapshot 或 targeted collect,返回 completed/profileCount;不承担 action 分发

Contracts

  • 组合顺序固定为:页面 UI 能力 → 单份发送关联器 → profile observer → 共用 publisher 与命令 handler → page bridge → buyer/message/manual-history。profile 仍直接使用既有 installer,不能为合并入口而提前/推迟其安装或引入 prepare/start 状态机。
  • commands/index.ts 将 send 委派给 send handler,将 contact.snapshot 与 contact.collect 委派给 profile handler,将三个 discover/sync 动作委派给 history handler。dispatcher 不解析业务字段,不用 handler 返回 null 逐个试路由,未知动作直接返回既有 invalid_request。
  • publisher 对非空消息或有诊断的批次先调用 observeSentMessages(batch.messages),再调用 observedSink(batch);实时、历史命令和公开历史函数共用这一出口。采集只收到 observe 回调,不能调用 execute 或接管发送 pending。
  • message-observer/send-observation.ts 保持发送 pending、候选 ID、timeout 和 settle 的唯一 owner;命令侧通过 execute/executeImage/executeFile 使用。image-send.ts 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。
  • profile observer 可将 syncData 的 direct profiles 再次发布;它不维护 seen、baseline 或其他上传资格状态。snapshot 只读取当前已加载资料,collect 只读取一个明确 conversationId;两者都复用已安装 observer,命令只返回统计,不能再二次 publish。
  • 新 installer、publisher 和 dispatcher 不拥有 Map、Set、timer、账本或连接。消息投递、profile/buyer 账本、ACK、页面请求关联及 Bright 生命周期继续归各原 Service Worker owner。

Validation & Error Matrix

输入 / 条件 必须行为
batch 有消息 先提供给原发送关联器,再正常上报;关联成功不吞掉事实
batch 无消息但有 unsupported/invalid/anomaly 诊断 继续发布一次诊断批次
batch 无消息且无诊断 两个消费者都不调用
initial contact.snapshot 在 Bright 认证、页面可用且 ledger 为空后到达 使用预先安装 observer 读取当前 loaded direct profiles;重复 hello/replay/status 不再请求全量 snapshot
contact.collect 带精确 direct conversationId 使用预先安装 observer 只读取该 profilehistory/手动 history 不触发 collect
contact snapshot/collect 多余字段、缺 conversationId 或错误类型 rejected_before_send/invalid_request,不访问 observer 或 SDK
未知 action rejected_before_send/invalid_request,不访问 SDK 或 snapshot
send 的 SDK 本地接受、超时或歧义 保持既有发送 SOP,不增加重试或假成功
历史命令返回 batch 使用同一 publisher,不新建发送关联器或上报管线

Good / Base / Bad Cases

  • Good:同一 sent batch 完成唯一发送确认,同时经原采集管线投递;各状态 owner 独立处理自己的终态。
  • Base:插件内部发 page command 请求 snapshot/history,仍复用已有采集能力与账本。
  • Bad:命令侧再安装消息观察器、复制媒体 WeakMap、snapshot 后再 publish,或为两类流程创建第二个 WS/通用可配置流水线。

Tests Required

  • onetalk-page-flow-boundaries.test.js 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/collect exact payload、targeted collect、未知动作与实际页面入口安装顺序。
  • onetalk-send-page.test.js 保留 send/history 的输入、结果、tooltip 和发布行为;onetalk-send-observation.test.js 与媒体测试保留唯一确认、timeout/异常及不重试断言。
  • 对构建后的 MAIN IIFE 执行 scripts/verify-release-bundle.mjs,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。

Wrong vs Correct

// Wrong: 两个实例各自持有 pending,观察无法确认已执行命令。
const send = createSendObservationCorrelator();
const publish = createOneTalkObservedPublisher(sink, createSendObservationCorrelator().observe);

// Correct: 一个状态 owner,通过窄回调供采集消费。
const sendObservation = createSendObservationCorrelator();
const publish = createOneTalkObservedPublisher(sink, sendObservation.observe);

3.2 Version vocabulary

各文档提到版本号时必须使用下表名称,不得统一简写为 "version" 或“协议版本”;三者互相独立演进:

版本 唯一所有者 表达的边界
protocolVersion=5 @trade-message-center/onetalk-contract Bright 插件 WebSocket wire frame 协议
ONE_TALK_PAGE_BRIDGE_VERSION = 4 src/onetalk/page-bridge/model.ts MAIN/ISOLATED/Service Worker 页面消息 envelope
content.version=1 shared page message content contractpage bridge model/decoder 消息正文 versioned text | image | file | business_card | inquiry | order content 结构

4. Service Worker transport、frame router 与业务 Flow

4.1 Scope / Trigger

当 Service Worker 同时维护 Bright WebSocket、消费 server frame、发送同步/profile/buyer 事实或处理 send.command 时,使用本节确定代码目录和唯一 owner。适用于新增 frame family、移动 Service Worker 模块、修改 router/Flow 组合,或替换 Bright client 公开发送接口;不改变 @trade-message-center/onetalk-contract wire contract。

4.2 Signatures

type OneTalkBrightClient = {
    send(frame: OneTalkFrame): boolean;
    subscribe(listener: (frame: OneTalkFrame) => void): () => void;
    subscribeStatus?(listener: (state: OneTalkBrightClientState) => void): () => void;
};

type OneTalkSyncServerFrame = Extract<
    OneTalkFrame,
    { type: "anchor.snapshot" | "message.ack" | "messages.ack" | "conversation.ack" | "conversations.ack" }
>;

type OneTalkSyncEngine = {
    handleServerFrame(frame: OneTalkSyncServerFrame): void;
};

type OneTalkServiceWorkerFrameRouter = {
    handle(frame: OneTalkFrame): void;
};

目录按变化原因表达职责:service-worker/transport/bright-client.ts 是唯一 socket transportservice-worker/routing/frame-router.ts 是唯一 inbound business-frame dispatcherservice-worker/flows/frame-writers.ts 只创建 canonical plugin outbound framesservice-worker/flows/send-command-flow.ts 只负责 send.command 到页面精确路由与 confirmation。既有 sync-engine/、profile 和 buyer coordinator 仍是各自 state owner,不能为了目录整齐机械迁移。

4.3 Contracts

  • Bright transport 只负责 socket/reconnect、decode、scope guard、ws.hello/heartbeat、ws.accepted/generic ws.error 状态转换、诊断和 guarded send(frame);不得公开 sync/profile/buyer/send-confirmation 专用 send* facade,也不决定业务 frame consumer。
  • router 以一张 typed executable table 单播到 owner:该表自身同时定义本地业务 type、runtime narrowing 和 owner delegation,不能另写平行 literal union、key list 或 switch selector。anchor 与四类 ACK 到 sync、contact.profile.ack 和精确 profile_observed_at_future 到 profile、buyer.facts.ack 到 buyer、send.command 到 send Flow。connection-only frame 不进入 router;没有 event bus、多播、自动注册或 fallback owner。
  • router 必须在调用 sync 前将 OneTalkFrame 收窄到 OneTalkSyncServerFrame。sync engine 保留已有 runtime guards/ACK 次序,但不能重新实现 general-frame routing。
  • outbound Flow 只能用 @trade-message-center/onetalk-contract 的 canonical creator/type 构造业务 frame,再调用 transport send(frame);不得复制 decoder/schema、绕开 guarded send、创建 outbox 或对未确认发送自动重试。
  • send Flow 将 send.command 交给现有 routePageCommand,保持 channelAccountId + conversationId 精确路由,page requestIdsendRequestId 分离,并只回写 confirmed_sentrejected_before_senddelivery_unknown 的 canonical confirmation。页面路由异常只能收敛为 delivery_unknown/send_state_lost
  • 每个 composition root 只订阅一次 router,并在 dispose 时取消 router 与 status subscription 后断开 Bright;配置 revision/epoch 不得让旧 session 消费新 frame。

4.4 Validation & Error Matrix

条件 必须行为
transport 出现业务 send* facade 移至对应 Flow writer;调用方只保留 bright.send(frame) 边界
router 收到 connection-only 或无 route 的 frame 不转发、不广播、不创建 fallback state owner
ws.error 的 code 不是 profile_observed_at_future 只由 transport 处理连接错误;不得触及 profile pending ledger
sync owner 收到非 anchor/ACK frame 编译期拒绝;不得以完整 OneTalkFrame 作为公开 ingress
page command reject/throw/回执丢失 仅写 delivery_unknown/send_state_lost confirmation;不重试、不持久化未确认发送
composition root dispose 或配置 revision 变化 取消 router/status subscriptions,旧 owner 不再消费任何后续 frame

4.5 Good / Base / Bad Cases

  • Goodframe-router.ts"message.ack" route 只调用 sync.handleServerFrame(frame)ACK/checkpoint/anchor state 仍由 sync engine 内原 owner 处理。
  • Goodprofile Flow 创建 contact.profile.observed 后调用 guarded transport sendprofile ACK/future-skew 仍只操作 profile ledger。
  • Base:不支持 buyer state 的独立组合入口不提供 buyer owner,也不能以 profile/sync 或全局 listener 代替;若将来支持 buyer,显式组合 buyer coordinator 后再接入 router。
  • BadBright client 同时创建 send.confirmation、路由页面命令并保存 pending send;或 router 把所有 frame 广播给 sync/profile/buyer。
  • Badsync engine 直接 bright.subscribe(engine.handleServerFrame),或把 OneTalkFrame 暴露为它的 public server ingress。

4.6 Tests Required

  • transportdecode/scope/accepted/error/reconnect/guarded-send 顺序,以及 transport 不公开业务 facade。
  • routersync/profile/buyer/send 各有唯一目标;connection-only frame 被忽略;profile_observed_at_future 仅进 profile。
  • Flowcanonical outbound frame creator、guarded send、页面 request correlation 与三态 confirmation。
  • syncOneTalkSyncServerFrame 类型检查、anchor/ACK 既有状态机和 router 驱动的 inbound test;不得用直接 Bright subscription 伪造组合。
  • compositionconfigured session 与 standalone runtime 各自订阅/释放 router 和 Bright statusdispose 后不再保留 listener 或 socket。
  • 所有目录移动必须做全量 import 搜索、strict typecheck、format 和 git diff --check;真实 Chromium/WebSocket smoke 未执行时单独标记 deferred。

4.7 Wrong vs Correct

// Wrong: transport owns business construction and sync receives every decoded frame.
bright.sendSendConfirmation(result);
bright.subscribe((frame) => sync.handleServerFrame(frame));

// Correct: router narrows inbound ownership; the Flow writes through guarded send.
router.handle(frame);
const writer = createOneTalkSendConfirmationFrameWriter({ scope, send: bright.send, createRequestId });
writer.sendSendConfirmation(result);

5. Cross-document invariants

Page and account boundaries

  • 页面账号从 OneTalk 运行时登录人 currentUserAccountId(或 IcbuIM.UserUtil.currentUser.accountId)读取;URL activeAccountId 仅是当前对话账号。SPA 当前会话来自页面自身的 selected 状态,不从 URL conversationId 猜测。
  • 页面 Port 必须按固定名称、origin、tab/frame 身份注册。
  • 页面命令不得广播、跨账号回退或随机选择。
  • bootstrap 重试仅在 onetalk.sync.conversation 命令中携带私有布尔 finalAttempt;它只决定页面本地提示何时将失败会话视为终态,不能加入 observation、checkpoint、持久化、Bright frame 或诊断 payload。
  • onetalk.sync 使用精确的账号/会话页面路由。
  • onetalk.send 的页面选择、SDK 输入与确认语义唯一见 OneTalk PWA 出站发送 SOP
  • 页面身份变化、Port 断开和 Service Worker 重启必须让旧 correlation 失效;不得由迟到结果恢复旧请求。
  • 相同 page identity 的重复 hello 不重复启动 snapshotprofile page/config/connection epoch 失效时不得操作替换后的 engine/coordinator。
  • Profile 初始 setup 同时受 Bright authenticated、page identity、configuration epoch 和 active coordinator fence 约束;任何 await 后都要重验。ledger read 后认证丢失、页面/账号切换或 coordinator 替换时不得发送 snapshot;失效 claim 不得阻塞下一次有效 setup。

Durable and lifecycle boundaries

  • 页面 observation 先完成 IndexedDB durable write,再允许上传或通知持久化上层。
  • 消息幂等键是 channelAccountId + conversationId + messageIdcheckpoint 不按 deviceId 或 binding 复制。
  • 增量候选在命中旧 anchor 前保持 awaiting_anchor;完成声明必须等待匹配 anchor.snapshot 证据。
  • Bright 连接断开或发送结果丢失不得自动重发,不创建隐式发送任务。
  • authorization_unavailable 表示授权依赖暂时不可用,只关闭当前 Bright socket 并沿既有连接退避自动重连;只有凭证、授权版本、binding、scope 或协议版本等确定性错误才阻断自动重连并进入 unauthorized。
  • Service Worker 重启从 IndexedDB 恢复 checkpoint、候选和模式,不信任旧内存 cursor。
  • live messageType: "new"sent 或 received)只触发所属 conversation 的 profile collecthistory 不触发。profile 相同 fingerprint 在 coordinator 的唯一 durable read 后静默结束,不能通过 MAIN seen、时间水位或 timer 再建第二去重状态。
  • oldVersion < 7 的数据库先执行全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实;v8→v9 只新增 profile scope index、逐条保留 recordsv9→v10 只新增 rendered-card scope indexes 与 buyer facts store、逐条保留既有 records/indexes),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。v9 profile pending flush 只读取 [channelAccountId, pending.observedAtMs] indexv10 rendered-card pending flush 只读取 [channelAccountId, "pending_ack"],两者都不得扫描其它账号或 terminal record。ACK 只在当前 [channelAccountId, conversationId, fingerprint, observedAtMs] 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。

Send and protocol boundaries

  • PWA 出站发送的 SDK 输入、页面路由与旁路关联确认以 OneTalk PWA 出站发送 SOP 为唯一契约;本节只保留跨层不变量。
  • SDK 返回的 opId、undefined、页面显示文本和任意普通对象都不是 sent fact。
  • 发送事实只由 WebSocket 旁路观察到的目标会话、完整、direction: sent 消息证明。
  • 页面任意 JSON result 必须经过 page-send-outcome adapterBright 只接受 canonical OneTalkSendResultPayload。
  • confirmed_sent 必须携带完整 sent messagerejected_before_send 和 delivery_unknown 必须携带有限 reasondecoder 保持 fail-closed。
  • 服务端只有在消息事实完成入库和既有发布顺序后,才返回 confirmed_sent。

Identity and diagnostics boundaries

  • deviceId 是扩展安装实例身份,独立存储,不接受 Popup 手工覆盖。
  • 诊断只输出稳定错误 code 和经过审计的元数据/错误投影,不输出 binding、credential、Cookie、认证头、token、scope、会话 ID、消息正文或原始 frame。
  • development source map 和非压缩构建只属于 developmentproduction 保持当前构建暴露边界。

6. Live observer direction normalization

6.1 Scope / Trigger

当 MAIN world 旁路观察 OneTalk WebSocket 的实时或历史消息,并需要把原始 sender 归一化为 sent/received 时,使用本契约。方向只在 main-page/message-observer/model.ts 产生一次;页面桥、Service Worker、Bright、server 和 UI 只能校验、复制和持久化结果。

6.2 Signatures

const readSelfParticipant = (
    pageWindow: OneTalkPageWindow,
    participants: unknown,
): string | null;

const resolveDirection = (
    senderId: string | null | undefined,
    participantIds: string[] | undefined,
    selfParticipant: string | null,
): "sent" | "received" | undefined;

readSelfParticipant 用页面登录账号(currentUserAccountIdIcbuIM.UserUtil.currentUser.accountId)校验 __conversationListFullData__ 中统一的 owner.accountId,再用同一 owner 的 aliId 从原始 participant 中返回匹配到的原值;它不生成或改写 ali ID。resolveDirectionundefined 是缺少可验证方向事实,不能被消费者转换成默认方向。

6.3 Contracts

  • self 输入必须来自页面 owner 的 accountId 与登录账号一致、以及 owner 的 aliId 与 participant 的明确关系;participant 必须是完整的两个、无重复、无空白且形如单一 @icbu suffix 的原始值。
  • participant 的排列只保留页面收到的原始顺序,不表达 self 角色;senderId 永远等于原始 message.sender.uid
  • senderId === selfParticipant 才产生 sentsender 是另一个 participant 才产生 receivedsender 不在集合或 self 无法唯一确定时不写 direction
  • self 判定禁止使用以下任何替代来源:
    • URL activeAccountId,或 participant 的固定位置;
    • 消息时间顺序;
    • target.idfromAccId/toAccId
    • accountId 与 aliId 的直接比较或拼接;
    • UI 文本猜测。
  • self participant 只存在于 MAIN 函数参数/内存上下文,不进入 ObservedOneTalkMessage、Page Bridge、Bright wire、IndexedDB 或 server schema。

6.4 Validation & Error Matrix

条件 行为
两个合法且唯一 participant,页面 owner.accountId 与登录账号一致,且恰有一个 ali 部分匹配 owner.aliId 返回该原始 participant
participant 顺序交换 self 结果不变,输出仍保留原始 participant 顺序
缺登录账号、非数组、非双人、空白/异形、重复或多重账号匹配 返回 nullobserver 不写 direction
sender 等于 self participant direction: "sent"
sender 等于另一合法 participant direction: "received"
sender 缺失或不在 participant 集合,self 不在集合 不写 direction;同步边界记录 anomaly/fail closed
bridge、Service Worker、Bright 或 server 收到 direction 原样校验/复制,不重新交换

6.5 Good / Base / Bad Cases

  • Good:页面 owner 的 accountId 与登录账号一致、owner ali 为 S,原始 pair 为 [S@icbu, C@icbu][C@icbu, S@icbu]resolver 返回对应原始 S@icbusender 为 S 时方向均为 sent
  • Baseself 账号缺失或 participant 不是完整双人集合;观察消息可保留原始 sender/participants,但不产生 direction,后续同步按 anomaly 处理。
  • BadsenderId === participantIds.at(-1)、把 activeAccountId 当 self、把 channelAccountId + "@icbu" 当 sender,或在 server/UI 再交换一次方向。

6.6 Tests Required

  • page-context 测试断言两种 participant order 返回同一原始 self participant,并覆盖 UserUtil fallback、缺账号、非法/重复/非双人和 URL active contact。
  • observer 测试对同一 sender/self 的 [self, contact][contact, self] 分别断言 senderId 不变、participantIds 顺序原样保留、direction 相同;contact sender 两种顺序均为 received
  • history 测试断言 cid participant order、senderId 和既有 sent/received 结果不回归。
  • 缺 self、sender 不在集合、sender 缺失和异形 pair 必须断言没有 direction,并验证无效消息不会成为正常上传事实。
  • 静态审计必须确认 direction 的领域生产点只有 MAIN resolver,跨层消费者没有补偿映射;真实 OneTalk smoke 未执行时必须标记 deferred。

6.7 Wrong vs Correct

// Wrong: participant 数组位置不是稳定的 self 契约。
direction = senderId === participantIds.at(-1) ? "sent" : "received";

// Correct: 只使用页面内唯一匹配的原始 self participant;无法验证则保持缺失。
const direction = resolveDirection(senderId, participantIds, selfParticipant);
if (direction !== undefined) output.direction = direction;

7. 历史 SDK 结构化业务卡

7.1 Scope / Trigger

当已观察的 OneTalk 历史扁平 SDK 条目需要纳入业务卡事实时,扩展可在 MAIN world 将其归一化为 business_cardinquiryorder。此场景只适用于 sdk_flat_historyraw WebSocket、DOM 文本和普通卡片正文不属于证据来源,不能借此扩展实时采集。

7.2 Signatures

type OneTalkMessageContentKind =
    | "text" | "image" | "file"
    | "business_card" | "inquiry" | "order";

type OneTalkBusinessCardContent = {
    version: 1;
    kind: "business_card";
};

type OneTalkBusinessCardViewContent = OneTalkBusinessCardContent & {
    contactName: string | null;
    companyName: string | null;
    countryCode: string | null;
    avatarUrl: string | null;
};

type OneTalkCenterMessageContent =
    | OneTalkMessageContent
    | OneTalkBusinessCardViewContent;

decodeOneTalkMessageContent(source: OneTalkMessageContentSource) ->
    { status: "decoded", content: OneTalkMessageContent }
    | { status: "ignored" | "unsupported_skipped" }
    | { status: "anomaly", code: OneTalkMediaAnomalyCode, mediaKind: "image" | "file" | "card" };

packages/onetalk-contract/src/content.ts 是这六个 kind、exact-shape guard 和跨层读取模型的唯一 owner。message-observer/content-decoder.ts 是历史 SDK tuple 分类、受控 Base64 摘要解码和白名单投影的唯一生产点;它对名片只生产 marker,不生产客户资料 view。read-projection.ts 是服务端读取时将 marker 与同一账号/会话的当前 profile 组合为 OneTalkBusinessCardViewContent 的唯一 owner。

7.3 Contracts

  • 历史业务卡必须同时满足 messageType="rec"type=1viewType=0msgType=10010,以及唯一的 (subType, cardType):名片 (57,1)、询盘 (50,6)、订单 (59,9)。任一条件缺失均不分类。
  • 名片在 MAIN、bridge、IndexedDB 与消息事实表中只投影 { version: 1, kind: "business_card" } marker。历史条目的 item.contact 可能是登录人或发送者资料,不是与会话关联客户的可靠来源,禁止用于名片内容。
  • window.__conversationListData__ / profile 观察与消息采集可能异步完成;消息观察阶段不得等待、读取或拼接 profile。profile 仍沿独立 contact.profile.observed 链路写入 Bright onetalk_contact_profiles,业务键为 [channelAccountId, conversationId]
  • /messages 等读取边界先按账号和会话读取当前 profile,再以内存方式把 marker 扩展为 OneTalkBusinessCardViewContent;只允许 contactNamecompanyNamecountryCodeavatarUrl 四项。没有 profile 时返回 markerprofile 存在但单字段缺失时,该字段为 null。读取投影不得反向修改消息事实或 profile ledger,也不得使用其他会话/账号的资料。
  • isOneTalkMessageContent 只接受 markerisOneTalkCenterMessageContent 接受 marker 或四字段 view。持久化 contract 与对外读取 view 是两个明确层次,不得把 view 形状写回消息事实。
  • 询盘只有 { version: 1, kind: "inquiry" };订单只投影关联 ID、金额/币种、状态键、动作键与可用 payStep。所有 ID 接受受限字符串、任意 safe integer 或 null
  • Base64、UTF-8、JSON、大小、动作 properties 或任一 order schema 失败必须形成安全的 card_* anomaly;不得降级为 text、空 order、inquiry 或正常 unsupported。
  • 仅 normalized content 可经 page bridge、IndexedDB、Bright、JSONB 与 Mind read 复用。content 原文、完整 contactparams、加密标识、sign、token 和未批准 URL 永不跨 MAIN。

7.4 Validation & Error Matrix

条件 必须结果
完整历史 tuple + exact-shape 白名单投影 decoded 对应业务 kind
完整名片 tuple (57,1) 只返回 marker;不读取 item.contact 或异步 __conversationListData__
item.contact 存在,或其资料看起来像当前登录人/发送者 忽略该对象,不把资料写入 message content
读取时存在同账号同会话 profile marker 在 server read projection 中扩展四项 view 字段
读取时 profile 不存在 返回 marker,不伪造空资料或 fallback 到登录人
profile 只有部分字段 view 中缺失字段保留 null;不从消息 raw/contact 补齐
同为 msgType=10010 但 subtype/cardType 不匹配 保留既有 file/unsupported 行为,不误分类
订单摘要非 Base64、非 UTF-8、非 JSON、过大或 schema/action properties 非对象 card_* anomaly,不携带 raw 输入
card kind 出现额外键或非法 URL/金额/ID shared guard 拒绝,桥/服务端不接纳
raw WebSocket 或 DOM 才有的字段 本期不采集,既有 raw decoder 不变

7.5 Good / Base / Bad Cases

  • Good:完整名片 tuple 先持久化 marker;读取同账号同会话的当前 profile 后,返回四项批准 view 字段。
  • Base:profile 尚未到达或没有客户资料时仍返回 marker;部分 profile 只在对应 view 字段返回 null
  • Bad:在观察阶段读取 item.contact/__conversationListData__ 拼接名片,或把读取 view 写回消息事实;以 msgType=10010 或 DOM 标题猜测类别;把 paramssign 或订单正文放进 bridge/frame/JSONB。

7.6 Tests Required

  • contract:六类 exact-shape、额外键、名片 URL、订单金额/ID/action 边界(包括负 safe integer)和旧 kind 回归。
  • extension:三种完整 tuple、名片只输出 marker、item.contact 不越界、异步 profile 不参与观察、相邻 10010 卡、raw WebSocket 不变、每一种订单解码失败和敏感字段未越界。
  • servermarker 与同账号同会话 profile 的 read projection、无 profile/部分 profile 行为、wire/domain/read round-trip、JSONB kind CHECK、旧行迁移、提交 → ACK → publish 次序与旧 content 回归。
  • harness:按 shared exact-shape 验证 marker 或 view;无客户资料显示“客户资料暂未提供”,有资料时显示关联客户资料;真实 Chromium 历史卡与 PostgreSQL migration 未运行时单独标记 deferred。

7.7 Wrong vs Correct

// Wrong: 在消息观察阶段把 item.contact(可能是登录人/发送者)拼入名片。
if (matchesHistoryCard(item, 57, 1)) {
    return { kind: "business_card", contactName: item.contact?.name };
}

// Correct: MAIN 只产生 marker;读取边界再按账号+会话组合当前客户资料。
if (matchesHistoryCard(item, 57, 1)) {
    return { version: 1, kind: "business_card" };
}
const view = projectCenterMessage(marker, currentConversationProfile);

8. Reading and change ownership

修改页面桥、Port 或 command 路由时,先阅读 page-bridge.md

修改 IndexedDB、checkpoint、ACK、full/incremental/live 或重启恢复时,先阅读 durable-sync.md

修改 Popup 配置、deviceId、扩展重启或配置清除时,先阅读 device-identity.md

修改状态面板、错误日志、敏感信息处理或 Vite development 构建时,先阅读 runtime-diagnostics.md

修改任何出站发送 API、发送结果、旁路观察或 server confirmation 时,先阅读 send-sop.md,并同时检查页面桥与耐久同步边界。

9. Global validation

  • 端到端确认两条 WebSocket 的连接类型和消息方向互不混用。
  • 确认 MAIN 不访问 Bright token、chrome.runtime 或 IndexedDB。
  • 确认页面 observation durable-first,发送和同步不会使用第二套状态源。
  • 确认发送与同步路由语义分离:发送放宽 selected gate 不影响同步精确路由。
  • 确认所有跨层 JSON 在进入 Bright frame 前已经通过唯一 adapter/decoder。
  • 确认 profile frame/envelope 的方向、账号、大小和字段白名单在各自边界 fail closed,且 profile 不进入消息状态源。
  • 确认 Service Worker 重启、Port 断开、Bright 断线和迟到结果不会造成重复事实或自动重发。
  • 确认构建产物、Manifest、页面入口、IIFE/ES 格式和 source map 遵循对应子规范。
  • 各子规范的具体测试命令和断言点以其 Tests Required 为准。