# 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。