mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
432 lines
13 KiB
Markdown
432 lines
13 KiB
Markdown
# OneTalk 图片与附件消息同步 PRD
|
||
|
||
> 日期:2026-09-02
|
||
> 状态:Draft
|
||
> 性质:独立 PRD,不创建 Trellis task,不包含实现授权
|
||
|
||
## 1. 背景
|
||
|
||
OneTalk 历史消息已经包含图片和附件,但当前跨层合同缺少稳定的媒体语义。消费端不能可靠判断一条消息是图片、附件还是其它业务卡片,也不能确定哪个 URL 用于预览或下载。
|
||
|
||
本 PRD 固定三个问题:
|
||
|
||
1. 图片和附件从哪里获取。
|
||
2. 解码后返回什么 TypeScript 类型。
|
||
3. 如何从 OneTalk 原始消息中安全取得并规范化数据。
|
||
|
||
## 2. 目标
|
||
|
||
- 在 OneTalk MAIN world 内完成 Base64、UTF-8、JSON 解码和字段校验。
|
||
- 将图片和附件转换为唯一的 typed content,不让下游继续解析 OneTalk raw payload。
|
||
- 明确图片预览地址、附件预览地址和附件下载地址的选择规则。
|
||
- 将合法但暂未支持的业务卡片返回为 `unsupported`,不伪装成附件或文本。
|
||
- 下载地址缺失时返回 `null`,不拼接、修改或猜造 URL。
|
||
|
||
## 3. 非目标
|
||
|
||
- 不下载图片或附件二进制。
|
||
- 不验证 URL 在无登录环境、Mind Origin 或长期存储后的可用性。
|
||
- 不实现图片或附件发送、上传和发送确认。
|
||
- 不依赖 `window.__conversationListFullData__`、`msgCache` 等调试全局作为生产合同。
|
||
- 不把 `activeAccountId` 当作 `conversationCode`。
|
||
|
||
## 4. 已确认的运行态事实
|
||
|
||
| 类型 | WebSocket 原始判定 | SDK 归一化判定 | 当前样本 |
|
||
| ---------- | ------------------------------------------------------ | -------------------------------------------- | -------- |
|
||
| 图片 | `contentType=101`、`custom.type=7` | `msgType=102`、`subType=60` | JPEG |
|
||
| 文件附件 | `contentType=101`、`custom.type=10010`、`cardType=12` | `msgType=10010`、`subType=61`、`cardType=12` | ZIP、PDF |
|
||
| 非文件卡片 | `contentType=101`、`custom.type=10010`、`cardType!=12` | 当前样本为 `msgType=10010`、`subType=2000` | 业务卡片 |
|
||
|
||
补充事实:
|
||
|
||
- 图片 `originalData.url` 的 `fileAction=imagePreview`。
|
||
- ZIP 附件 `params.url` 的 `fileAction=download`。
|
||
- PDF 附件 `params.url` 的 `fileAction=officePreview`。
|
||
- 两条附件的 `params.downloadUrl` 都是空字符串。
|
||
- `thumbnailUrl` 是缩略图操作地址,不是下载地址。
|
||
- 顶层 SDK `message.content` 是字符串,但不等于图片 URL 或附件 `params.url`,不能作为媒体资源地址。
|
||
|
||
## 5. 获取路径
|
||
|
||
### 5.1 会话身份
|
||
|
||
```text
|
||
URL activeAccountId
|
||
→ 只用于定位当前选中的买家/会话对象
|
||
→ 从会话对象读取 conversation.cid
|
||
→ conversation.cid 作为 conversationCode
|
||
```
|
||
|
||
必须满足:
|
||
|
||
- `activeAccountId` 不能直接作为 `conversationCode`。
|
||
- 找不到唯一会话对象或缺少 `cid` 时立即失败,不回退到 URL、当前页面猜测值或其它会话。
|
||
- 每次历史请求都携带明确的 `conversationCode: conversation.cid`。
|
||
|
||
### 5.2 历史消息入口
|
||
|
||
当前 Chromium 运行态的 SDK 调用路径是:
|
||
|
||
```ts
|
||
window._imsdk.getMessageService().listMessageWithConversationCodeForHistory({
|
||
conversationCode: conversation.cid,
|
||
cursor,
|
||
count,
|
||
});
|
||
```
|
||
|
||
该方法是异步触发型入口,直接返回值为 `undefined`。生产适配器不得把函数返回值当作消息列表,而必须等待与本次 `requestId + conversationCode` 对应的历史完成事件或 WebSocket 响应。
|
||
|
||
底层已观察到的 WebSocket 方法为:
|
||
|
||
```text
|
||
/r/MessageManager/listUserMessages
|
||
```
|
||
|
||
### 5.3 原始数据路径
|
||
|
||
媒体解码器的生产输入来自同次历史响应:
|
||
|
||
```text
|
||
WebSocket response
|
||
→ body.userMessageModels[]
|
||
→ item.message
|
||
→ item.message.content
|
||
```
|
||
|
||
字段路径:
|
||
|
||
```text
|
||
图片:
|
||
message.content.contentType
|
||
message.content.custom.type
|
||
message.content.custom.data
|
||
|
||
附件:
|
||
message.content.contentType
|
||
message.content.custom.type
|
||
message.content.custom.data
|
||
→ Base64 decode
|
||
→ UTF-8 decode
|
||
→ JSON.parse
|
||
→ cardType / params
|
||
```
|
||
|
||
### 5.4 SDK 交叉校验路径
|
||
|
||
SDK 归一化对象只用于同条消息的类型和字段交叉校验:
|
||
|
||
```text
|
||
图片:
|
||
sdkMessage.msgType
|
||
sdkMessage.subType
|
||
sdkMessage.originalData
|
||
|
||
附件:
|
||
sdkMessage.msgType
|
||
sdkMessage.subType
|
||
sdkMessage.originalData.cardType
|
||
sdkMessage.originalData.params
|
||
```
|
||
|
||
生产实现不得只根据 `msgType=10010` 判断附件,因为相同 `msgType` 还包含 `cardType=2000` 的非文件卡片。
|
||
|
||
## 6. 返回数据类型
|
||
|
||
```ts
|
||
type OneTalkUrlScope = "onetalk_session";
|
||
|
||
type OneTalkImageContent = {
|
||
version: 1;
|
||
kind: "image";
|
||
fileId: string;
|
||
extension: string;
|
||
sizeBytes: number;
|
||
isOriginal: boolean;
|
||
md5: string | null;
|
||
previewUrl: string | null;
|
||
urlScope: OneTalkUrlScope;
|
||
};
|
||
|
||
type OneTalkFileContent = {
|
||
kind: "file";
|
||
fileId: string;
|
||
parentId: string;
|
||
fileName: string;
|
||
extension: string;
|
||
sizeBytes: number;
|
||
md5: string | null;
|
||
previewUrl: string | null;
|
||
thumbnailUrl: string | null;
|
||
downloadUrl: string | null;
|
||
downloadState: "available" | "not_provided";
|
||
urlScope: OneTalkUrlScope;
|
||
};
|
||
|
||
type OneTalkUnsupportedContent = {
|
||
kind: "unsupported";
|
||
sourceContentType: number;
|
||
sourceCustomType: number | null;
|
||
cardType: number | null;
|
||
reason: "unsupported_card";
|
||
};
|
||
|
||
type OneTalkNormalizedMediaContent =
|
||
OneTalkImageContent | OneTalkFileContent | OneTalkUnsupportedContent;
|
||
|
||
type OneTalkMediaDecodeResult =
|
||
| {
|
||
status: "decoded";
|
||
content: OneTalkNormalizedMediaContent;
|
||
}
|
||
| {
|
||
status: "invalid";
|
||
reason:
|
||
| "invalid_base64"
|
||
| "invalid_utf8"
|
||
| "invalid_json"
|
||
| "invalid_schema"
|
||
| "payload_too_large";
|
||
};
|
||
```
|
||
|
||
合同约束:
|
||
|
||
- URL 字段统一返回 `string | null`,不使用空字符串表达缺失。
|
||
- URL 是 OneTalk 登录上下文中的临时操作地址,`urlScope` 固定为 `onetalk_session`。
|
||
- 第一阶段不得承诺这些 URL 能在 Mind、无 Cookie 环境或长期持久化后直接访问。
|
||
- `content` 是唯一事实源;兼容字段 `message.text` 只能由 `content.kind === "text"` 派生。
|
||
|
||
## 7. 怎么取图片数据
|
||
|
||
### 7.1 判定
|
||
|
||
```ts
|
||
content.contentType === 101 && content.custom.type === 7;
|
||
```
|
||
|
||
SDK 交叉校验:
|
||
|
||
```ts
|
||
sdkMessage.msgType === 102 && sdkMessage.subType === 60;
|
||
```
|
||
|
||
### 7.2 解码路径
|
||
|
||
```text
|
||
content.custom.data
|
||
→ 校验 Base64 字符串和最大长度
|
||
→ Base64 解码为 bytes
|
||
→ TextDecoder("utf-8", { fatal: true })
|
||
→ JSON.parse
|
||
→ 校验图片 schema
|
||
```
|
||
|
||
解码后的字段:
|
||
|
||
```ts
|
||
type OneTalkImagePayload = {
|
||
fileId: string;
|
||
suffix: string;
|
||
size: number;
|
||
isOriginal: 0 | 1;
|
||
md5: string;
|
||
url: string;
|
||
// OneTalk raw payload may contain width/height. The MAIN decoder ignores them.
|
||
};
|
||
```
|
||
|
||
映射规则:
|
||
|
||
```ts
|
||
return {
|
||
version: 1,
|
||
kind: "image",
|
||
fileId: payload.fileId,
|
||
extension: payload.suffix.toLowerCase(),
|
||
sizeBytes: payload.size,
|
||
isOriginal: payload.isOriginal === 1,
|
||
md5: payload.md5 || null,
|
||
previewUrl: payload.url,
|
||
urlScope: "onetalk_session",
|
||
};
|
||
```
|
||
|
||
OneTalk raw image payload may retain `width` or `height` as upstream evidence, but the MAIN decoder neither reads nor validates them. They never cross this normalized boundary into page bridge, IndexedDB, Bright, Mind, public reads, or confirmation metadata.
|
||
|
||
图片 URL 必须是绝对 HTTPS URL,当前允许的运行态 host 为 `clouddisk.alibaba.com`,当前动作是 `fileAction=imagePreview`。
|
||
|
||
## 8. 怎么取附件数据
|
||
|
||
### 8.1 判定
|
||
|
||
```ts
|
||
content.contentType === 101 && content.custom.type === 10010 && decoded.cardType === 12;
|
||
```
|
||
|
||
SDK 交叉校验:
|
||
|
||
```ts
|
||
sdkMessage.msgType === 10010 &&
|
||
sdkMessage.subType === 61 &&
|
||
sdkMessage.originalData.cardType === 12;
|
||
```
|
||
|
||
### 8.2 解码后的字段
|
||
|
||
```ts
|
||
type OneTalkFilePayload = {
|
||
cardType: 12;
|
||
params: {
|
||
type: string;
|
||
ctime: string;
|
||
version: string;
|
||
extensionType: string;
|
||
id: string;
|
||
parentId: string;
|
||
md5: string;
|
||
name: string;
|
||
size: string;
|
||
url: string;
|
||
thumbnailUrl: string;
|
||
downloadUrl: string;
|
||
};
|
||
};
|
||
```
|
||
|
||
### 8.3 文件字段映射
|
||
|
||
```text
|
||
fileId ← params.id
|
||
parentId ← params.parentId
|
||
fileName ← params.name
|
||
extension ← params.extensionType
|
||
sizeBytes ← 十进制解析 params.size
|
||
md5 ← params.md5 或 null
|
||
thumbnailUrl ← 非空 params.thumbnailUrl,否则 null
|
||
```
|
||
|
||
必须校验:
|
||
|
||
- `params.name` 的后缀和 `params.extensionType` 一致。
|
||
- `params.size` 是十进制非负整数字符串,转换后是安全整数。
|
||
- 所有非空 URL 都是绝对 HTTPS URL并通过 host allowlist。
|
||
- 文件名只能作为文本展示,不能作为 HTML。
|
||
|
||
## 9. 附件预览与下载地址规则
|
||
|
||
先解析 URL 的 `fileAction`,但不得修改原 URL。
|
||
|
||
```ts
|
||
function selectFileAccessUrls(params: OneTalkFilePayload["params"]): {
|
||
previewUrl: string | null;
|
||
thumbnailUrl: string | null;
|
||
downloadUrl: string | null;
|
||
downloadState: "available" | "not_provided";
|
||
} {
|
||
const sourceAction = readValidatedFileAction(params.url);
|
||
const explicitDownloadUrl = normalizeOptionalHttpsUrl(params.downloadUrl);
|
||
|
||
const downloadUrl = explicitDownloadUrl ?? (sourceAction === "download" ? params.url : null);
|
||
|
||
const previewUrl = sourceAction === "officePreview" ? params.url : null;
|
||
|
||
return {
|
||
previewUrl,
|
||
thumbnailUrl: normalizeOptionalHttpsUrl(params.thumbnailUrl),
|
||
downloadUrl,
|
||
downloadState: downloadUrl ? "available" : "not_provided",
|
||
};
|
||
}
|
||
```
|
||
|
||
确定规则:
|
||
|
||
- `params.downloadUrl` 非空且通过 URL 校验时,优先作为下载地址。
|
||
- `params.downloadUrl` 为空且 `params.url.fileAction === "download"` 时,使用 `params.url`。
|
||
- `params.url.fileAction === "officePreview"` 时,它只是预览地址,`downloadUrl` 返回 `null`。
|
||
- `thumbnailUrl` 永远不能作为下载地址。
|
||
- 不允许把 `officePreview`、`imagePreview` 改成 `download` 来猜造下载链接。
|
||
|
||
当前样本结果:
|
||
|
||
| 文件类型 | `params.url.fileAction` | `params.downloadUrl` | 返回 `downloadUrl` |
|
||
| -------- | ----------------------- | -------------------- | ------------------ |
|
||
| ZIP | `download` | 空 | `params.url` |
|
||
| PDF | `officePreview` | 空 | `null` |
|
||
|
||
## 10. 非文件卡片
|
||
|
||
以下内容不是附件:
|
||
|
||
```ts
|
||
content.contentType === 101 && content.custom.type === 10010 && decoded.cardType !== 12;
|
||
```
|
||
|
||
返回:
|
||
|
||
```ts
|
||
{
|
||
status: "decoded",
|
||
content: {
|
||
kind: "unsupported",
|
||
sourceContentType: 101,
|
||
sourceCustomType: 10010,
|
||
cardType: decoded.cardType,
|
||
reason: "unsupported_card",
|
||
},
|
||
}
|
||
```
|
||
|
||
不得返回 `file`,也不得降级为空文本。
|
||
|
||
## 11. 数据流
|
||
|
||
```text
|
||
Service Worker history request
|
||
→ MAIN world resolves exact conversation.cid
|
||
→ OneTalk history SDK request
|
||
→ /r/MessageManager/listUserMessages WebSocket response
|
||
→ body.userMessageModels[].message.content
|
||
→ media decoder
|
||
→ image | file | unsupported
|
||
→ page bridge typed result
|
||
→ IndexedDB / Bright / Mind consumers only read normalized content
|
||
```
|
||
|
||
Raw `content.custom.data` 只能在 MAIN world 短暂存在。页面桥、IndexedDB、Bright 和 Mind 不得继续解析或持久化 OneTalk raw payload。
|
||
|
||
图片 raw payload 中存在的 `width` / `height` 同样止于该边界:v6 canonical image 只包含 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 和 `urlScope`(以及内容 `version`、`kind`)。
|
||
|
||
## 12. 错误处理
|
||
|
||
- 非法 Base64:`invalid_base64`。
|
||
- UTF-8 解码失败:`invalid_utf8`。
|
||
- JSON 解析失败:`invalid_json`。
|
||
- 字段缺失、类型错误、数值溢出、URL 不合法:`invalid_schema`。
|
||
- 输入超过固定上限:`payload_too_large`。
|
||
- 合法但不支持的卡片:返回 `unsupported`,不是 `invalid`。
|
||
- 任一失败都不得静默变为空文本、空 URL 或猜测的附件。
|
||
|
||
## 13. 验收标准
|
||
|
||
- JPEG 样本被规范化为 `kind="image"`,保留 canonical 白名单字段;SDK `originalData` 中可能存在的 raw `width` / `height` 被 MAIN decoder 忽略。
|
||
- ZIP、PDF 样本都被规范化为 `kind="file"`,共同满足 `cardType=12`。
|
||
- ZIP 返回 payload 自带的下载 URL。
|
||
- PDF 当前返回 `downloadUrl=null`、`previewUrl=params.url`。
|
||
- `cardType=2000` 返回 `unsupported`,不识别为文件。
|
||
- 顶层 SDK `message.content` 不作为图片或附件 URL。
|
||
- `activeAccountId` 不作为 `conversationCode`;请求使用唯一匹配会话的 `conversation.cid`。
|
||
- SDK 入口返回 `undefined` 时,适配器仍通过异步响应边界得到正确消息页,不读取同步返回值。
|
||
- URL 缺失或动作不支持时返回明确状态,不改写 query 参数。
|
||
- 页面桥之后只存在 normalized union,不包含 raw `custom.data`、认证值或完整 SDK payload。
|
||
|
||
## 14. 尚未确认
|
||
|
||
- 图片是否存在 payload 自带的独立下载 URL。
|
||
- PDF 是否有页面 SDK 提供的正式“获取下载 URL”动作。
|
||
- URL 是否依赖 OneTalk Cookie、有效期多长,以及能否在 Mind Origin 使用。
|
||
- TXT、DOCX、XLSX 等附件是否与 ZIP/PDF 完全同构。
|
||
- live 图片/附件 push 是否与历史消息使用相同 raw schema。
|