mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
697 lines
26 KiB
Markdown
697 lines
26 KiB
Markdown
# OneTalk 非当前会话图片发送与 WebSocket 观测可行性报告
|
||
|
||
> 日期:2026-09-10
|
||
> 性质:测试环境运行态调查与实现可行性报告,不是 Trellis task,不包含代码实现
|
||
> 范围:本地图片发送到非当前打开会话,以及通过 WebSocket observer 确认 sent 图片事实
|
||
>
|
||
> v6 同步说明(2026-09-11):本报告中的 raw upload、像素尺寸和当时 live 记录是历史调查证据;当前 canonical image、post-upload metadata、confirmation fingerprint 与 harness display 均不使用 `width` / `height`。未在本报告的旧运行环境复跑 v6 live 验收。
|
||
|
||
## 1. 结论
|
||
|
||
本次调查确认两件事:
|
||
|
||
1. **可以在页面当前打开其它会话时,向显式指定的目标会话发送图片。** 最终路由由发送参数中的 `cid` 决定,不要求切换页面 selected conversation。
|
||
2. **可以在 WebSocket 观测阶段使用图片的已验证 canonical metadata 辅助确认发送结果。** v6 MAIN-world decoder 将 live WS 图片归一化为 `content.kind="image"`,只保留 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 和 `urlScope`。
|
||
|
||
但 `sizeBytes` 不是唯一键;即使 `md5` 和 `fileId` 可用,重复发送同一文件时也可能相同。本次连续发送同一图片后,扩展存储中出现了两条不同的 live sent 消息。因此 metadata 只能作为无 candidate ID 时的复合匹配条件,不能单独生成 `confirmed_sent`。
|
||
|
||
推荐确认顺序:
|
||
|
||
```text
|
||
可靠候选 messageId
|
||
→ conversationId + direction=sent
|
||
→ content.kind=image
|
||
→ post-upload sizeBytes + md5 + optional fileId
|
||
→ 短时间窗口
|
||
→ 必须唯一匹配,否则 send_ambiguous
|
||
```
|
||
|
||
## 2. 调查范围与安全边界
|
||
|
||
本次使用已启动的 Chromium CDP `127.0.0.1:9222`,目标页面为:
|
||
|
||
```text
|
||
https://onetalk.alibaba.com/message/weblitePWA.htm
|
||
```
|
||
|
||
运行环境:
|
||
|
||
| 项目 | 结果 |
|
||
| ---------------------------------------- | ------------------------- |
|
||
| CDP protocol | `1.3` |
|
||
| OneTalk host | `seller_pwa` |
|
||
| SaaS channel | `true` |
|
||
| GGS proxy | `false` |
|
||
| 活动 chat bundle | `im-weblite-chat/1.0.700` |
|
||
| 页面 selected conversation | 恰好一个 |
|
||
| selected conversation 与目标会话 | 不同 |
|
||
| 发送过程中是否切换 selected conversation | 否 |
|
||
|
||
测试图片记为 `I1`:
|
||
|
||
```text
|
||
格式:JPEG
|
||
文件大小:370256 bytes
|
||
像素尺寸:1192 × 1188
|
||
```
|
||
|
||
报告不记录以下内容:
|
||
|
||
- 真实目标会话 ID;
|
||
- 登录账号、联系人及加密身份值;
|
||
- Cookie、token、请求头和 POST body;
|
||
- MD5 实值、fileId、messageId;
|
||
- 完整媒体 URL 及 URL query。
|
||
|
||
## 3. 本次运行态证据
|
||
|
||
### 3.1 非当前会话定向发送
|
||
|
||
发送前确认:
|
||
|
||
- 当前 selected conversation 与目标会话不同;
|
||
- 目标会话在 `window.__conversationListFullData__` 中唯一存在;
|
||
- 文件选择器中保留一个与 `I1` 对应的 `File`;
|
||
- 页面目标组件具备 `sendFileToOss`;
|
||
- 目标是单聊会话。
|
||
|
||
执行时显式向 `sendFileToOss` 传入目标会话对象,没有修改页面 selected conversation。
|
||
|
||
运行结果:
|
||
|
||
| 证据 | 结果 |
|
||
| ---------------------------- | ------------------------------------ |
|
||
| `prepareSendFileWithGroup` | HTTP `200` |
|
||
| `buildFileRelationWithGroup` | HTTP `200` |
|
||
| OSS 二进制上传 | 未发生,命中文件已存在/去重分支 |
|
||
| BaaS `sendMessageBase` | 已进入一次 |
|
||
| 页面 `send-msg-success` | 一次 |
|
||
| WebSocket 帧 | 出站 3、入站 3 |
|
||
| Runtime exception | 0 |
|
||
| selected conversation | 全程不变 |
|
||
| 目标会话只读历史 | 找到一条与 `I1` 大小一致的 sent 图片 |
|
||
|
||
页面 `send-msg-success` 和 SDK Promise 只证明本地受理,不能单独证明发送完成。目标会话只读历史中的 sent 图片事实才排除了“只插入了页面假消息”的情况。
|
||
|
||
### 3.2 live WebSocket 归一化证据
|
||
|
||
扩展 Service Worker 的 IndexedDB 中,对目标会话和 `I1` 的归一化字段进行只读匹配:
|
||
|
||
| 项目 | 结果 |
|
||
| --------------------------------------------- | ---------------- |
|
||
| exact image candidate | 3 条 |
|
||
| `observationSource="live"` | 2 条 |
|
||
| `observationSource="history"` | 1 条 |
|
||
| candidate status | 全部 `confirmed` |
|
||
| 当时 live 记录的 raw `sizeBytes/width/height` | 与 `I1` 完全一致 |
|
||
|
||
手工调用的只读历史接口没有把返回值送入页面 bridge 或 Service Worker;同时 raw WebSocket history response 会被 observer 主动忽略。因此 `observationSource="live"` 的两条记录证明当时的 live 观察链已能接收图片事实。该记录来自 v6 前的调查,不构成对当前 v6 normalized shape 的运行态验证:当前 MAIN decoder 忽略 raw `width` / `height`,它们不进入归一化存储边界。
|
||
|
||
这两条 live 消息也构成反例:相同图片重复发送时,媒体 metadata 可以相同,但它们仍是不同的消息事实。
|
||
|
||
## 4. 图片发送路径与参数
|
||
|
||
### 4.1 页面普通本地图片路径
|
||
|
||
OneTalk 页面普通图片发送链路为:
|
||
|
||
```text
|
||
隐藏的 input[type=file][multiple]
|
||
→ FileList 转成 File[],为每个 File 分配 uid
|
||
→ Upload.beforeUpload 返回 false,阻止通用 Upload 自动请求
|
||
→ OneTalk beforeUpload(File) 接管
|
||
→ 文件校验、类型判断、本地 preview URL
|
||
→ 适用时压缩图片
|
||
→ 计算文件 MD5
|
||
→ prepareSendFileWithGroup
|
||
→ OSS 上传,或命中文件已存在后跳过上传
|
||
→ buildFileRelationWithGroup
|
||
→ sendFile
|
||
→ messageBox/sendMessage
|
||
→ getMessageServiceV2().sendUIMessages(...)
|
||
→ PaaS send / live WebSocket echo
|
||
```
|
||
|
||
页面 UI 还会用 `tmpKey` 插入本地假消息和上传进度。假消息只属于展示层,不是 sent 消息事实。
|
||
|
||
### 4.2 文件输入参数
|
||
|
||
文件选择阶段的核心对象是浏览器 `File`:
|
||
|
||
```ts
|
||
type UploadImageFile = File & {
|
||
uid: string;
|
||
};
|
||
```
|
||
|
||
业务流程实际使用的字段包括:
|
||
|
||
```text
|
||
file.name
|
||
file.type
|
||
file.size
|
||
file.uid
|
||
```
|
||
|
||
图片大于等于约 `1_000_000` bytes 时,页面会尝试压缩。压缩后的 `File` 才进入后续上传,因此原始 `file.size` 不一定等于最终消息中的 `originalData.size`。
|
||
|
||
### 4.3 目标会话参数
|
||
|
||
本地图片上传不只需要 `cid`。建立云盘文件关系和构造消息还需要目标会话的完整页面模型,至少涉及:
|
||
|
||
```ts
|
||
type TargetConversationContext = {
|
||
cid: string;
|
||
conversationType: number;
|
||
accountId: string | number;
|
||
accountIdEncrypt?: string;
|
||
aliId?: string | number;
|
||
aliIdEncrypt?: string;
|
||
chatToken?: string;
|
||
contact?: {
|
||
aliId?: string | number;
|
||
};
|
||
owner?: {
|
||
accountId?: string | number;
|
||
aliId?: string | number;
|
||
};
|
||
};
|
||
```
|
||
|
||
这些字段只应留在 OneTalk MAIN world 内使用,不应作为新的 Bright 或 Mind 原始合同整体向外传递。
|
||
|
||
文件关系使用的发送双方参数形态为:
|
||
|
||
```ts
|
||
type FileRelationParticipants = {
|
||
from: string | number;
|
||
to: string | number;
|
||
fromAliId: string | number;
|
||
toAliId: string | number;
|
||
};
|
||
```
|
||
|
||
本次定向发送传给页面内部上传函数的参数等价于:
|
||
|
||
```ts
|
||
sendFileToOss({
|
||
file,
|
||
fromTo: {
|
||
from: currentUserAccountId,
|
||
to: targetConversation.accountId,
|
||
fromAliId: targetConversation.owner?.aliId,
|
||
toAliId: targetConversation.contact?.aliId ?? targetConversation.aliId,
|
||
},
|
||
tmpKey,
|
||
contact: targetConversation,
|
||
previewUrl,
|
||
traceId,
|
||
});
|
||
```
|
||
|
||
### 4.4 准备上传参数
|
||
|
||
页面通过 MTop 调用:
|
||
|
||
```text
|
||
mtop.alibaba.interaction.clouddisk.prepareSendFileWithGroup
|
||
```
|
||
|
||
核心业务参数:
|
||
|
||
```ts
|
||
{
|
||
appKey: "OneChat",
|
||
fileName: `${md5}.${extension}`,
|
||
scene: JSON.stringify({
|
||
sceneType: isGroupChat ? "2" : "1",
|
||
idType: "2",
|
||
from: fromAliId,
|
||
to: toAliId,
|
||
}),
|
||
isToken: false,
|
||
}
|
||
```
|
||
|
||
较大文件存在单独的 token/上传分支,不能假设所有文件始终使用完全相同的 prepare 参数。
|
||
|
||
prepare 成功后会返回上传目录、最大文件限制、文件是否已存在和 OSS policy。报告只记录这些字段是否存在,不记录其值。
|
||
|
||
### 4.5 OSS 上传参数
|
||
|
||
未命中去重时,页面向 prepare 返回的动态 OSS host 上传:
|
||
|
||
```ts
|
||
{
|
||
file,
|
||
filename,
|
||
action: policyDTO.host,
|
||
data: {
|
||
key,
|
||
policy,
|
||
OSSAccessKeyId,
|
||
success_action_status: "200",
|
||
callback,
|
||
signature,
|
||
},
|
||
onProgress,
|
||
}
|
||
```
|
||
|
||
本次 `I1` 命中文件已存在分支,因此没有实际发生 OSS 二进制 POST;这不影响后续文件关系建立和消息发送。
|
||
|
||
### 4.6 建立文件关系参数
|
||
|
||
页面随后调用:
|
||
|
||
```text
|
||
mtop.alibaba.interaction.clouddisk.buildFileRelationWithGroup
|
||
```
|
||
|
||
概念参数为:
|
||
|
||
```ts
|
||
{
|
||
appKey: "OneChat",
|
||
scene: {
|
||
sceneType,
|
||
idType,
|
||
from,
|
||
to,
|
||
},
|
||
node: {
|
||
nodeName,
|
||
nodeSize,
|
||
materialType,
|
||
md5,
|
||
bucket,
|
||
extend,
|
||
},
|
||
params: {
|
||
source: "",
|
||
needCardUrl: true,
|
||
},
|
||
}
|
||
```
|
||
|
||
成功结果提供后续 `sendFile` 所需的媒体关系,例如 `fileId`、`fileCardUrl`、`redirectFileUrl` 和文件节点信息;若上游关系含图片尺寸,它们仍只属于 raw upload evidence,v6 downstream 不读取。
|
||
|
||
### 4.7 最终页面发送参数
|
||
|
||
图片扩展名被归类为 `imageCard`。页面的 `sendFile` 构造:
|
||
|
||
```ts
|
||
const data = {
|
||
cid: targetConversation.cid,
|
||
mediaInfo: {
|
||
...relationInfo,
|
||
nodeName,
|
||
},
|
||
baasMsgType: 102,
|
||
content: relationInfo.fileCardUrl,
|
||
msgType: 60,
|
||
accountId: targetConversation.accountId,
|
||
accountIdEncrypt: targetConversation.accountIdEncrypt,
|
||
bizType,
|
||
bizId,
|
||
clientVersion,
|
||
chatToken: targetConversation.chatToken,
|
||
};
|
||
|
||
sendMessage({
|
||
sendType: "file",
|
||
tmpKey,
|
||
data,
|
||
traceId,
|
||
contentType: "imageCard",
|
||
scene: "_sendFile",
|
||
callback,
|
||
});
|
||
```
|
||
|
||
`messageBox/sendMessage` 会补齐页面 scene、`ext`、client 信息和必要身份字段,当前 SaaS 分支最终调用:
|
||
|
||
```ts
|
||
window.IcbuIM.IMBaaSSDK.default.getMessageServiceV2().sendUIMessages(data);
|
||
```
|
||
|
||
V2 转换器显式以 `cid` 选择会话:
|
||
|
||
```ts
|
||
conversationCode = input.cid || sdkContext.cid;
|
||
```
|
||
|
||
因此只要传入非空目标 `cid`,当前页面 selected conversation 不参与最终路由;缺失 `cid` 时才会回退 SDK 当前上下文,存在发错会话风险。
|
||
|
||
图片会被转换为 BaaS raw `originalData`:
|
||
|
||
```ts
|
||
{
|
||
height,
|
||
width,
|
||
url: redirectFileUrl,
|
||
isOriginal: 1,
|
||
size: nodeSize ?? size,
|
||
md5,
|
||
fileId,
|
||
suffix: materialType,
|
||
}
|
||
```
|
||
|
||
这是页面上游的 raw upload payload 示例,不是 v6 canonical contract。即使该 raw payload 含 `width` / `height`,MAIN decoder 也会忽略它们,且 post-upload metadata、correlator 和下游 public content 都不会读取或传递这两个字段。
|
||
|
||
### 4.8 `sendImageMessage` 快捷入口
|
||
|
||
页面 SDK 还暴露:
|
||
|
||
```ts
|
||
messageService.sendImageMessage({
|
||
cid: targetConversationId,
|
||
picUrl: [uploadedImageUrl],
|
||
});
|
||
```
|
||
|
||
它对应 SDK command:
|
||
|
||
```text
|
||
im.singlemsg.sendImageMessage
|
||
```
|
||
|
||
该入口适用于已经拥有可用图片 URL 的场景。它不能直接接收本地 `File`;本次本地文件定向发送验证走的是 `sendFileToOss → sendFile → sendUIMessages`,没有把 `sendImageMessage` 快捷入口冒充为已验证的本地上传路径。
|
||
|
||
## 5. WebSocket 观测方法
|
||
|
||
### 5.1 观测入口
|
||
|
||
扩展 MAIN-world observer 只旁路观察:
|
||
|
||
```text
|
||
wss-icbu.dingtalk.com
|
||
```
|
||
|
||
入口实现位于:
|
||
|
||
- [`websocket.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts)
|
||
- [`index.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts)
|
||
- [`new.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts)
|
||
|
||
处理顺序:
|
||
|
||
```text
|
||
WebSocket message event
|
||
→ 跳过 heartbeat
|
||
→ JSON.parse
|
||
→ 要求 frame.code === 200
|
||
→ 忽略 body.userMessageModels 历史响应
|
||
→ 仅处理 live body[]
|
||
→ singleChatUserConversation.lastMessage.message
|
||
→ 验证消息身份和 sent/received 方向
|
||
→ 解码 content
|
||
→ 输出 ObservedOneTalkMessage
|
||
```
|
||
|
||
MessagePack `/s/sync` push 当前不会被当作普通 live 媒体消息处理,因此不能将 JSON live 路径的结论直接外推到所有 WebSocket frame。
|
||
|
||
### 5.2 图片内容解码
|
||
|
||
live 图片的 raw 内容形态:
|
||
|
||
```ts
|
||
{
|
||
contentType: 101,
|
||
custom: {
|
||
type: 7,
|
||
data: "<Base64 JSON>",
|
||
},
|
||
}
|
||
```
|
||
|
||
[`content-decoder.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts) 在 MAIN world 执行:
|
||
|
||
```text
|
||
Base64
|
||
→ UTF-8
|
||
→ JSON.parse
|
||
→ exact field validation
|
||
→ normalized image content
|
||
```
|
||
|
||
输出合同定义于 [`content.ts`](../packages/onetalk-contract/src/content.ts):
|
||
|
||
```ts
|
||
type OneTalkImageContent = {
|
||
version: 1;
|
||
kind: "image";
|
||
fileId: string;
|
||
extension: string;
|
||
sizeBytes: number;
|
||
isOriginal: boolean;
|
||
md5: string | null;
|
||
previewUrl: string | null;
|
||
urlScope: "onetalk_session";
|
||
};
|
||
```
|
||
|
||
send correlator 不需要也不应该继续读取 raw `originalData`。应比较已经验证、清洗后的:
|
||
|
||
```text
|
||
message.content.sizeBytes
|
||
message.content.md5
|
||
message.content.fileId (when present)
|
||
```
|
||
|
||
### 5.3 observer 与 correlator 的调用顺序
|
||
|
||
[`page-script-entry.ts`](../apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts) 当前顺序是:
|
||
|
||
```ts
|
||
sendObservation.observe(batch.messages);
|
||
observedSink(batch);
|
||
```
|
||
|
||
因此图片 live batch 在跨 MAIN bridge、写 IndexedDB 或上传 Bright 之前,已经可以交给发送确认 correlator。无需新增第二个 WebSocket observer,也不应增加另一套 raw payload parser。
|
||
|
||
## 6. 当前 v6 图片确认模型
|
||
|
||
### 6.1 Pending 数据
|
||
|
||
v6 的 [`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 已使用 text/image/file 判别联合;图片 pending 保存目标会话、候选消息 ID 与最终 post-upload metadata,而不是给文本记录追加可选字段。下列 image 分支是当前实现的形状:
|
||
|
||
```ts
|
||
type PendingTextSend = {
|
||
kind: "text";
|
||
conversationId: string;
|
||
content: string;
|
||
sentAfterMs: number;
|
||
candidateMessageIds: Set<string>;
|
||
};
|
||
|
||
type PendingImageSend = {
|
||
kind: "image";
|
||
conversationId: string;
|
||
sentAfterMs: number;
|
||
candidateMessageIds: Set<string>;
|
||
expected: {
|
||
sizeBytes: number;
|
||
md5: string;
|
||
fileId?: string;
|
||
};
|
||
};
|
||
|
||
type PendingObservation = PendingTextSend | PendingImageSend | PendingFileSend;
|
||
```
|
||
|
||
`md5` 和 `fileId` 可以提高不同图片之间的区分度,但同一文件去重或重复发送时它们也可能相同,仍不能当作每次发送的唯一 ID。
|
||
|
||
### 6.2 登记时机
|
||
|
||
图片 pending 在取得最终 media relation 后、最终 native send 前登记:
|
||
|
||
```text
|
||
压缩完成
|
||
→ 上传/去重完成
|
||
→ buildFileRelationWithGroup
|
||
→ 得到最终 mediaInfo
|
||
→ 登记 PendingImageSend
|
||
→ 立即调用 sendUIMessages
|
||
```
|
||
|
||
实现以 `tmpKey` 隔离上传回调;在 `buildFileRelationWithGroup` 成功、`sendFile` 已拿到最终 `sizeBytes`、非空 `md5` 和可用 `fileId` 后,立即将该 metadata 交给 correlator 并调用最终 SDK send。45 秒预算覆盖下载、上传和 live 确认,correlator 使用其剩余时间。
|
||
|
||
不能在用户选择原始文件时登记,原因有两个:
|
||
|
||
1. 图片压缩可能改变最终 `sizeBytes`;
|
||
2. 上传时间可能超过该历史调查时 correlator 的 `10_000ms` 超时;当前 image 预算为 45 秒。
|
||
|
||
上传阶段和消息发送确认阶段应是两个状态,不要让消息确认定时器覆盖完整上传耗时。
|
||
|
||
### 6.3 匹配顺序
|
||
|
||
已实现的匹配顺序:
|
||
|
||
```text
|
||
1. message.direction 必须是 sent
|
||
2. message.conversationId 必须等于 pending.conversationId
|
||
3. message 必须通过完整 OneTalkMessage guard
|
||
4. 如果存在可靠 candidateMessageId:只按 messageId 匹配,不回退媒体指纹
|
||
5. 否则要求 pending.kind=image 且 message.content.kind=image
|
||
6. 比较 post-upload `sizeBytes`、`md5` 和可用 `fileId`
|
||
7. 这些 metadata 不能被当作单次发送唯一键
|
||
8. 要求消息位于 pending 生命周期和允许的时钟偏差内
|
||
9. 一个消息必须只匹配一个 pending;多个匹配立即 send_ambiguous
|
||
```
|
||
|
||
概念判断:
|
||
|
||
```ts
|
||
const imageMatches = (
|
||
expected: PendingImageSend["expected"],
|
||
actual: OneTalkImageContent,
|
||
): boolean =>
|
||
actual.sizeBytes === expected.sizeBytes &&
|
||
actual.md5 === expected.md5 &&
|
||
(expected.fileId === undefined || actual.fileId === expected.fileId);
|
||
```
|
||
|
||
该函数只能是复合匹配的一部分,不能绕过 conversation、direction、时间窗口和唯一性检查。
|
||
|
||
## 7. 验证方法
|
||
|
||
### 7.1 自动化测试
|
||
|
||
至少需要补充以下测试:
|
||
|
||
| 用例 | 预期 |
|
||
| --------------------------------------- | ------------------------------------------------------------ |
|
||
| live WS 图片 raw payload | 忽略 raw `width` / `height`;输出无尺寸的 v6 canonical image |
|
||
| 正确会话、方向、指纹和时间 | `confirmed_sent` |
|
||
| 错误 conversation | 不匹配 |
|
||
| `direction=received` | 不匹配 |
|
||
| `sizeBytes`、`md5` 或可用 `fileId` 不同 | 不匹配 |
|
||
| 候选 message ID 匹配 | 即使时间窗口外仍按 ID 确认 |
|
||
| 候选 message ID 不匹配 | 不回退图片指纹 |
|
||
| 同图两个并发 pending | `send_ambiguous` |
|
||
| 图片压缩后大小变化 | 使用 post-upload 大小确认 |
|
||
| 超时无 live echo | `delivery_unknown/send_timeout` |
|
||
| 非法 Base64/JSON/schema | anomaly,不进入 correlator |
|
||
|
||
定向测试覆盖 raw 图片解码、flat history 图片归一化、非法 live 媒体隔离,以及无尺寸 live sent image 驱动实际 image pending 至 `confirmed_sent`。这只是自动化证据;尚未在本报告原有 Chromium 环境执行 v6 真实发送联调。
|
||
|
||
### 7.2 Chromium/CDP 联调
|
||
|
||
推荐的最小真实联调步骤:
|
||
|
||
1. 打开会话 A,但指定目标会话 B。
|
||
2. 记录 selected conversation 的内部比较结果,不输出真实 ID。
|
||
3. 选择一张已知大小的测试图片。
|
||
4. 对大图额外记录压缩后的最终 metadata。
|
||
5. 在最终 `sendUIMessages` 前登记 image pending。
|
||
6. 观察 prepare、OSS/去重、build relation 和 BaaS send 的状态。
|
||
7. 捕获 live WS normalized message,并验证来源为 `live`。
|
||
8. 断言 selected conversation 未变化。
|
||
9. 断言目标会话只读历史存在同一 sent 图片。
|
||
10. 断言 correlator 返回完整 `confirmed_sent`,而不是仅凭页面 success event。
|
||
|
||
### 7.3 完成判据
|
||
|
||
只有同时满足以下条件,才算图片发送确认链闭合:
|
||
|
||
```text
|
||
SDK send 已执行
|
||
+ live WS observer 输出完整 sent OneTalkMessage
|
||
+ conversationId 精确匹配目标会话
|
||
+ candidate ID 或媒体复合指纹唯一匹配
|
||
+ 完整消息通过 OneTalkMessage guard
|
||
```
|
||
|
||
以下证据不能单独生成 `confirmed_sent`:
|
||
|
||
- `prepareSendFileWithGroup` HTTP 200;
|
||
- OSS 上传成功;
|
||
- `buildFileRelationWithGroup` HTTP 200;
|
||
- 页面出现图片或上传进度;
|
||
- 页面 `send-msg-success`;
|
||
- SDK Promise resolve;
|
||
- 只看到 WebSocket 帧数量增加,但没有解析业务消息;
|
||
- 历史接口稍后出现图片,但实时 correlator 没有观察到对应 live 消息。
|
||
|
||
历史查询适合作为联调和审计的独立验证,不应成为每次实时发送确认的必需轮询路径。
|
||
|
||
## 8. 注意点与风险
|
||
|
||
### 8.1 媒体 metadata 不唯一
|
||
|
||
同一图片重复发送会产生不同的 messageId,但 `sizeBytes`、`md5` 和 `fileId` 可以完全相同。本次运行态已经得到两条这样的 live sent 记录。
|
||
|
||
不得采用:
|
||
|
||
```text
|
||
找到第一条同尺寸图片 → confirmed_sent
|
||
```
|
||
|
||
### 8.2 压缩改变待比较字段
|
||
|
||
本次 `I1` 小于压缩阈值,最终消息大小与本地文件一致。大图会先压缩,必须使用压缩后、上传关系返回的实际 metadata。
|
||
|
||
### 8.3 URL 不能作为稳定关联键
|
||
|
||
媒体 URL 可能包含临时授权、重定向和会话作用域,上传前后也可能改变。`previewUrl`、`redirectFileUrl`、`fileCardUrl` 不适合作为首要关联键,更不能写入普通诊断日志。
|
||
|
||
### 8.4 MD5 和 fileId 也不是每次发送唯一键
|
||
|
||
它们能区分多数不同文件,但文件去重意味着重复发送同一图片时可能继续相同。真正可靠的候选 messageId 应优先于所有媒体指纹。
|
||
|
||
### 8.5 本地受理不等于真实发送
|
||
|
||
V2 `sendUIMessages` 的 Promise 可以在 local callback 得到 clientId/opId 后 resolve。后续 PaaS change 和 live sent message 才能证明消息事实。
|
||
|
||
不能把 clientId/opId 不经验证地当成最终 messageId。如果需要以候选 ID 为主键,必须先验证它与 live messageId 的稳定映射。
|
||
|
||
### 8.6 目标会话上下文要求
|
||
|
||
本次目标会话已经存在于 `window.__conversationListFullData__`,因此能够取得文件关系所需的双方身份、账号和 chat token。只有一个任意 `cid`、但没有完整目标会话上下文时,本地文件上传路径尚未验证为安全可用。
|
||
|
||
不应从会话 ID 字符串、participant 顺序或当前页面联系人猜测缺失身份字段。
|
||
|
||
### 8.7 群聊未验证
|
||
|
||
页面上传代码存在 group scene 分支,但本次仅验证单聊。群聊 participant、关系建立和 live message envelope 需要独立验证。
|
||
|
||
### 8.8 不确定发送禁止自动重试
|
||
|
||
上传完成后如果 SDK 连接丢失、live echo 缺失或多个 pending 发生歧义,应返回 `delivery_unknown`,不得自动重发图片,以免产生重复消息。
|
||
|
||
### 8.9 保持单一解析边界
|
||
|
||
raw `originalData` 只应在 MAIN world 短暂存在。send correlator 应消费已经归一化的 `OneTalkImageContent`,不要在 correlator、Service Worker 或 Bright 中再实现第二套 Base64/JSON 图片解析。
|
||
|
||
上游 raw `originalData` 可含 `width` / `height`;v6 MAIN decoder 忽略它们,correlator 的 post-upload expected 和 public/read payload 只使用无尺寸的 canonical contract。
|
||
|
||
## 9. 当前实现边界
|
||
|
||
当前实现限定为:
|
||
|
||
1. 为页面图片发送定义独立、最小的输入合同;
|
||
2. 在上传关系成功后生成 post-upload image fingerprint;
|
||
3. 使用 text/image/file 判别的 pending 集合;
|
||
4. 复用现有 live WS observer 和 normalized content,不新增旁路;
|
||
5. 候选 ID 优先,媒体指纹仅作无 ID 回退;
|
||
6. 保留唯一匹配与 `send_ambiguous`;
|
||
7. 分离 upload timeout 与 send confirmation timeout;
|
||
8. 保持 SDK 异常和不确定投递 fail closed,不自动重试。
|
||
|
||
[`page-command.ts`](../apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts) 现已严格接收 outbound `text | image | file` contract,并将 image 交给 `sendOneTalkImage`;[`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 先按 candidate message ID 确认,缺少候选 ID 时才以同会话、sent 方向、image kind、`sizeBytes`、`md5`、可用 `fileId`、时间窗和唯一性回退。当前自动化证据不代替未执行的 v6 Chromium live 验收。
|
||
|
||
## 10. 相关代码
|
||
|
||
- 页面 WebSocket tap:[`websocket.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts)
|
||
- live frame 分发:[`index.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts)
|
||
- live message 解析:[`new.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts)
|
||
- MAIN 内容归一化:[`content-decoder.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts)
|
||
- 归一化媒体合同:[`content.ts`](../packages/onetalk-contract/src/content.ts)
|
||
- observer/correlator 接线:[`page-script-entry.ts`](../apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts)
|
||
- 当前文本发送确认:[`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts)
|
||
- 当前页面命令入口:[`page-command.ts`](../apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts)
|
||
- 媒体解码测试:[`onetalk-media-content-decoder.test.js`](../apps/chrome-extension/test/onetalk-media-content-decoder.test.js)
|
||
- WebSocket observer 测试:[`onetalk-websocket-tap.test.js`](../apps/chrome-extension/test/onetalk-websocket-tap.test.js)
|