37 KiB
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=5与content.version=1独立演进;跨 MAIN 的消息仅是 shared normalizedtext | image | file | business_card | inquiry | ordercontent。业务卡只允许已观察的sdk_flat_history完整联合条件产生:名片在消息事实中只保存{ version: 1, kind: "business_card" }marker,询盘只有类别,订单只含批准的摘要投影。名片的客户资料由独立 profile ledger 按[channelAccountId, conversationId]关联,并只在服务端读取时以内存组合扩展;不会因为__conversationListData__与消息观察异步而在 MAIN 侧拼接。rawcustom.data、顶层text/contentType、卡片原始正文、params、sign和完整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。
card-observer/entry.ts以 MutationObserver 批次作为 rendered-card 的唯一主动触发。reader 以当前 single conversation 加 card/wrapperdata-expinfo的严格消息 identity 为键;任一已经出现的会话候选字段空、非法或冲突时 fail closed,不能忽略后回退。它只发布无kind的{ version: 1, ...namedFields },并只可由一个可见锚点分派内部字段解析器:订单=订单状态,询盘=详细需求,商品=最小订购量/Minimum order。零个或多个锚点命中时不发布;这个内部解析分派绝不进入 payload、bridge 或数据库。所有图片是从已验证 owner 取第一张安全 URL 的单值image。订单输出可见“订单”标题、商品图及已审核productCount、status、total、deliveryDate、shippingAddress;询盘从.description-container读取保留换行的innerText,输出title、image、purchaseQuantity与reference;商品在同一.dx-event-nodeowner 内以三段 MOQ label/value/unit 定位标题、价格和安全图片,MOQ 输出组合字符串。images、productImages、attributes、promotionTexts与对象 MOQ 不是合法公开字段。模板 ID 和路径只可用于调试/fixture,绝不能作为采集准入或字段映射门槛。读取时排除template、hidden、aria-hidden、display:none、visibility:hidden祖先,图片必须通过无 query/fragment 的安全 URL guard;未知或不安全单字段省略,动作节点不投影。成功后按conversationId + messageId + fingerprint去重;不得按 cardType/messageType/template 推断业务类型,也不得全局轮询、使用 React/Fiber/props 或把原始 metadata 跨 MAIN。
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 只读取该 profile;history/手动 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,不新建发送关联器或上报管线 |
| rendered-card 已挂载后文本/图片或 metadata 才补齐 | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 |
| identity、可见快照或安全图片 URL 不完整 | 不发布、不写 ledger;仅等待该 card 的下一次子树变化 |
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/异常及不重试断言。onetalk-rendered-card-reader.test.js覆盖 card/wrapper identity、三个单锚点解析器、零/多锚点 fail-closed、嵌套商品字段、询盘正文换行、隐藏/template 文本排除和安全图片 URL;onetalk-rendered-card-observer.test.js覆盖后续子树/属性/文本变化后立即重新读取并只生成一次 approved observation。- 对构建后的 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 contract(page 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 transport;service-worker/routing/frame-router.ts 是唯一 inbound business-frame dispatcher;service-worker/flows/frame-writers.ts 只创建 canonical plugin outbound frames;service-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/genericws.error状态转换、诊断和 guardedsend(frame);不得公开 sync/profile/buyer/send-confirmation 专用send*facade,也不决定业务 frame consumer。 - router 以一张 typed executable table 单播到 owner:该表自身同时定义本地业务 type、runtime narrowing 和 owner delegation,不能另写平行 literal union、key list 或
switchselector。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,再调用 transportsend(frame);不得复制 decoder/schema、绕开 guarded send、创建 outbox 或对未确认发送自动重试。 - send Flow 将
send.command交给现有routePageCommand,保持channelAccountId + conversationId精确路由,pagerequestId与sendRequestId分离,并只回写confirmed_sent、rejected_before_send或delivery_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
- Good:
frame-router.ts的"message.ack"route 只调用sync.handleServerFrame(frame);ACK/checkpoint/anchor state 仍由 sync engine 内原 owner 处理。 - Good:profile Flow 创建
contact.profile.observed后调用 guarded transportsend;profile ACK/future-skew 仍只操作 profile ledger。 - Base:不支持 buyer state 的独立组合入口不提供 buyer owner,也不能以 profile/sync 或全局 listener 代替;若将来支持 buyer,显式组合 buyer coordinator 后再接入 router。
- Bad:Bright client 同时创建
send.confirmation、路由页面命令并保存 pending send;或 router 把所有 frame 广播给 sync/profile/buyer。 - Bad:sync engine 直接
bright.subscribe(engine.handleServerFrame),或把OneTalkFrame暴露为它的 public server ingress。
4.6 Tests Required
- transport:decode/scope/accepted/error/reconnect/guarded-send 顺序,以及 transport 不公开业务 facade。
- router:sync/profile/buyer/send 各有唯一目标;connection-only frame 被忽略;
profile_observed_at_future仅进 profile。 - Flow:canonical outbound frame creator、guarded send、页面 request correlation 与三态 confirmation。
- sync:
OneTalkSyncServerFrame类型检查、anchor/ACK 既有状态机和 router 驱动的 inbound test;不得用直接 Bright subscription 伪造组合。 - composition:configured session 与 standalone runtime 各自订阅/释放 router 和 Bright status,dispose 后不再保留 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)读取;URLactiveAccountId仅是当前对话账号。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 不重复启动 snapshot;profile 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 + messageId;checkpoint 不按 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 collect;history 不触发。profile 相同 fingerprint 在 coordinator 的唯一 durable read 后静默结束,不能通过 MAINseen、时间水位或 timer 再建第二去重状态。 oldVersion < 7的数据库先执行全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实;v8→v9 只新增 profile scope index、逐条保留 records;v9→v10 只新增 rendered-card scope indexes 与 buyer facts store、逐条保留既有 records/indexes),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。v9 profile pending flush 只读取[channelAccountId, pending.observedAtMs]index;v10 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 adapter;Bright 只接受 canonical OneTalkSendResultPayload。
- confirmed_sent 必须携带完整 sent message;rejected_before_send 和 delivery_unknown 必须携带有限 reason,decoder 保持 fail-closed。
- 服务端只有在消息事实完成入库和既有发布顺序后,才返回 confirmed_sent。
Identity and diagnostics boundaries
- deviceId 是扩展安装实例身份,独立存储,不接受 Popup 手工覆盖。
- 诊断只输出稳定错误 code 和经过审计的元数据/错误投影,不输出 binding、credential、Cookie、认证头、token、scope、会话 ID、消息正文或原始 frame。
- development source map 和非压缩构建只属于 development;production 保持当前构建暴露边界。
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 用页面登录账号(currentUserAccountId 或 IcbuIM.UserUtil.currentUser.accountId)校验 __conversationListFullData__ 中统一的 owner.accountId,再用同一 owner 的 aliId 从原始 participant 中返回匹配到的原值;它不生成或改写 ali ID。resolveDirection 的 undefined 是缺少可验证方向事实,不能被消费者转换成默认方向。
6.3 Contracts
- self 输入必须来自页面 owner 的
accountId与登录账号一致、以及 owner 的aliId与 participant 的明确关系;participant 必须是完整的两个、无重复、无空白且形如单一@icbusuffix 的原始值。 - participant 的排列只保留页面收到的原始顺序,不表达 self 角色;
senderId永远等于原始message.sender.uid。 senderId === selfParticipant才产生sent;sender 是另一个 participant 才产生received;sender 不在集合或 self 无法唯一确定时不写direction。- self 判定禁止使用以下任何替代来源:
- URL
activeAccountId,或 participant 的固定位置; - 消息时间顺序;
target.id或fromAccId/toAccId;- accountId 与 aliId 的直接比较或拼接;
- UI 文本猜测。
- URL
- 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 顺序 |
| 缺登录账号、非数组、非双人、空白/异形、重复或多重账号匹配 | 返回 null;observer 不写 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@icbu,sender 为 S 时方向均为sent。 - Base:self 账号缺失或 participant 不是完整双人集合;观察消息可保留原始 sender/participants,但不产生 direction,后续同步按 anomaly 处理。
- Bad:
senderId === 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_card、inquiry 或 order。此场景只适用于 sdk_flat_history;raw 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=1、viewType=0、msgType=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链路写入 Brightonetalk_contact_profiles,业务键为[channelAccountId, conversationId]。/messages等读取边界先按账号和会话读取当前 profile,再以内存方式把 marker 扩展为OneTalkBusinessCardViewContent;只允许contactName、companyName、countryCode、avatarUrl四项。没有 profile 时返回 marker;profile 存在但单字段缺失时,该字段为null。读取投影不得反向修改消息事实或 profile ledger,也不得使用其他会话/账号的资料。isOneTalkMessageContent只接受 marker;isOneTalkCenterMessageContent接受 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原文、完整contact、params、加密标识、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 标题猜测类别;把params、sign或订单正文放进 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 不变、每一种订单解码失败和敏感字段未越界。 - server:marker 与同账号同会话 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 为准。