
【OpenHarmony/HarmonyOs 】29聊天附件建模从“占位文本”到图片、文件、位置、联系人的可演进消息结构 本文基于“灵犀互聊”项目ChatDetailPage.ets与ChatMessage.ets的真实实现展开。当前附件面板、Cloud DB 发送和实时订阅链路已经跑通但图片、文件、位置、联系人仍以文本占位。本文将从这个真实阶段出发设计一套兼容现有数据、适合 ArkTS 严格类型、能够逐步演进的附件消息方案。一、先看真实项目已经完成了什么聊天详情页已经有完整的附件入口。项目通过getAttachItems()返回六类操作getAttachItems(): AttachItem[] {conststrgetStr(this.currentLang);return[ { name:str.camera, icon:,color:#E3F2FD}, { name:str.photos, icon:️,color:#FFF3E0}, { name:str.document, icon:,color:#F3E5F5}, { name:str.location, icon:,color:#E8F5E9}, { name:str.contact, icon:,color:#E0F2F1}, { name:str.poll, icon:,color:#FFEBEE} ]; }从 UI 看附件能力已经具备清晰入口相机 相册 文件 位置 联系人 投票点击入口后当前项目会构造一段占位文本handleAttachClick(name: string) { conststr getStr(this.currentLang); let msgContent ;if(namestr.camera ||namestr.photos) { msgContent 【图片】️; }elseif(namestr.location) { msgContent 【位置】 北京; }elseif(namestr.document) { msgContent 【文件】 项目报告.pdf; }elseif(namestr.contact) { msgContent 【联系人】 张三; }else{ AlertDialog.show({ message:str.feature_coming_soon }); return; } this.doSendMessage(msgContent,text); this.showAttachPanel false; }这段代码没有真正选择和上传文件。但它已经打通了附件面板 ↓ 入口识别 ↓ 生成消息内容 ↓ doSendMessage ↓ CloudDBexecuteUpsert ↓ 快照订阅回调 ↓ 聊天列表刷新对于 MVP这条链路非常有价值。二、占位文本不是错误而是阶段性协议很多开发者看到this.doSendMessage(【文件】 项目报告.pdf,text);会立刻认为设计失败。其实它解决了早期开发中的几个关键问题验证底部附件面板交互验证不同入口能否正确分支验证消息写入 Cloud DB验证实时订阅是否能同步验证发送后列表是否滚动到底部验证发送方与接收方气泡方向。如果一开始就接入相机、相册、文件选择器、云存储、上传进度、权限申请和缩略图任何问题都可能阻塞聊天主链路。所以合理的研发顺序是第一阶段文本消息跑通 第二阶段附件入口跑通 第三阶段占位文本验证消息链路 第四阶段引入结构化载荷 第五阶段接入文件上传与下载 第六阶段补齐进度、失败重试与权限占位文本的意义是隔离风险而不是最终形态。三、当前ChatMessage已经预留了扩展入口真实模型包含以下核心字段exportclassChatMessage{id:string;senderId:string;receiverId:string;content:string;msgType:string;timestamp:number;isRead:boolean;constructor() {this.iddefault_id;this.senderIddefault_senderId;this.receiverIddefault_receiverId;this.content;this.msgTypetext;this.timestampnewDate().getTime();this.isReadfalse; } }发送方法会创建真实 Cloud DB 对象const msg newChatMessage(); msg.setId(Date.now().toString()); msg.setSenderId(this.myUid); msg.setReceiverId(this.targetUid); msg.setContent(content); msg.setMsgType(type); msg.setTimestamp(Date.now()); msg.setIsRead(false); await zone.executeUpsert(msg);msgType就是协议演进的入口。当前主要值可以理解为类型content当前语义text普通文本或附件占位文本emoji表情字符image已预留但尚未完整落地这意味着不需要推翻现有模型。可以从msgType出发逐步扩展。四、为什么不能永远把附件伪装成文本占位文本适合验证链路但无法承载真实附件行为。例如【文件】 项目报告.pdf它只能告诉用户“看起来有一个文件”。却无法回答文件存在哪里文件有多大MIME 类型是什么是否可以下载下载是否完成文件是否已失效是否需要显示缩略图点击后应该预览还是保存上传失败后如何重试。位置占位文本也有类似问题【位置】 北京它没有经纬度无法打开地图或导航。联系人占位文本只有名字无法稳定识别联系人卡片中的号码或应用用户。所以最终协议必须从“描述附件”升级为“承载附件”。五、附件建模首先要区分三类数据完整附件消息至少包含三层信息。1. 消息通用字段所有消息都需要消息 ID发送者接收者时间戳已读状态消息类型。这些字段继续由ChatMessage承载。2. 附件业务元数据例如图片宽高、文件名、经纬度、联系人名称。这些字段决定 UI 如何展示和交互。3. 本地传输状态例如上传中、上传失败、下载进度、本地缓存 URI。这类字段通常不应该直接写入 Cloud DB 的消息记录。因为它们属于当前设备状态。因此可以形成ChatMessage云端消息事实 AttachmentPayload 附件业务载荷 MessageViewModel 页面展示与本地传输状态六、第一种演进方案继续复用content存 JSON 载荷如果暂时不想修改 Cloud DB 对象类型可以约定msgType 决定content的解析方式例如msgTypecontenttext普通文本emoji表情字符image图片载荷 JSONfile文件载荷 JSONlocation位置载荷 JSONcontact联系人载荷 JSON图片载荷接口interface ImagePayload { remoteUrl: string; thumbnailUrl: string;width: number;height: number; size: number; mimeType: string;caption: string; }文件载荷接口interfaceFilePayload{ remoteUrl:string; fileName:string; size: number; mimeType:string; extension:string; }位置载荷接口interface LocationPayload { latitude: number; longitude: number;address: string; placeName: string; }联系人载荷接口interfaceContactPayload{ displayName:string; phoneSummary:string; appUid:string; avatarUrl:string; }发送时执行const payload:FilePayload {remoteUrl:uploadedUrl,fileName:selectedFileName,size:selectedFileSize,mimeType:selectedMimeType,extension:selectedExtension}; this.doSendMessage(JSON.stringify(payload), file);这是一种低成本演进方案。七、为什么 JSON 载荷适合当前项目当前ChatMessage已经有content和msgType。使用 JSON 有四个现实优势不必立即修改 Cloud DB 对象类型旧文本消息仍可正常读取每种附件可以拥有不同字段页面可以根据msgType选择明确解析接口。它尤其适合当前阶段UI 和实时链路已完成 CloudDB模型已投入使用 附件字段仍在快速变化 希望最小化数据库结构调整但 JSON 方案也有代价Cloud DB 无法直接按载荷内部字段查询JSON 格式错误需要客户端容错字段升级要处理版本兼容服务端规则难以精细校验内部字段。所以它适合中小规模附件协议而不是所有场景的最终答案。八、第二种演进方案为通用附件增加明确字段如果 Cloud DB 对象类型允许稳定升级可以在ChatMessage中增加通用字段mediaUrl:string;thumbnailUrl:string;fileName:string;fileSize:number;mimeType:string;extra:string;字段语义可以约定为字段作用mediaUrl原始附件云端地址thumbnailUrl图片或视频缩略图fileName文件展示名称fileSize字节数mimeType内容类型extra类型特有 JSON 扩展优点是常用字段可以直接访问。缺点是消息对象会逐渐膨胀。位置消息不需要文件大小。联系人消息不需要缩略图。大量字段对多数消息为空会降低模型清晰度。因此通用字段只应放真正跨类型复用的属性。九、第三种演进方案消息表与附件表分离当附件功能继续增长可以使用两个 Cloud DB 对象ChatMessageidsenderId receiverId msgType content timestamp isRead ChatAttachmentidmessageId attachmentType remoteUrl thumbnailUrl fileName fileSize mimeType metadata一条消息可以对应一个或多个附件。这种方案适合一次发送多张图片图文混合消息一个消息包含多个文件附件需要独立更新审核状态附件生命周期与消息不同需要独立统计存储用量。但它也引入关联查询和一致性问题。对于当前单附件 MVP直接把结构化载荷放入消息更简单。架构演进应服从真实复杂度。十、推荐的消息类型常量当前模型使用自由字符串msg.setMsgType(type);如果页面到处手写image、file很容易出现拼写不一致。可以建立字符串常量类exportclassChatMessageType{staticreadonlyTEXT:stringtext;staticreadonlyEMOJI:stringemoji;staticreadonlyIMAGE:stringimage;staticreadonlyFILE:stringfile;staticreadonlyLOCATION:stringlocation;staticreadonlyCONTACT:stringcontact;staticreadonlyPOLL:stringpoll; }发送时this.doSendMessage(content, ChatMessageType.FILE);渲染时if (message.msgTypeChatMessageType.FILE) { this.buildFileMessage(message);}这样可以减少魔法字符串。如果项目已有同类枚举或常量应优先复用现有风格。十一、图片消息应该保存哪些字段最小图片消息只需要远程地址。但真实体验通常还需要原图地址缩略图地址宽度高度字节大小MIME 类型可选说明文字。推荐载荷interface ImagePayload { remoteUrl: string; thumbnailUrl: string;width: number;height: number; size: number; mimeType: string;caption: string; }为什么要存宽高因为图片未下载完成前气泡也需要预估比例。如果没有宽高图片加载完成后布局会突然跳动。为什么要有缩略图因为聊天列表应优先加载小图而不是直接下载原图。点击缩略图后再进入大图预览。十二、相机和相册入口应该共享后半段流程项目目前把相机和相册都映射为msgContent【图片】️;真实实现中两者只在“获取本地 URI”阶段不同。后续流程应该复用相机拍摄 ─┐ ├→ 本地图片 URI → 校验 → 压缩 → 上传 → 构造 ImagePayload → 发送 相册选择 ─┘可以抽象一个统一方法privateasyncsendImageFromUri(localUri:string): Promisevoid{constimageInfo awaitthis.prepareImage(localUri);constuploaded awaitthis.uploadImage(imageInfo);constpayload this.createImagePayload(imageInfo, uploaded);awaitthis.doSendMessage( JSON.stringify(payload), ChatMessageType.IMAGE ); }入口层只负责拿到 URI。上传和建模不应在两个分支中重复。十三、文件消息不仅是“文件名加 URL”推荐文件载荷interfaceFilePayload{ remoteUrl:string; fileName:string; size: number; mimeType:string; extension:string; checksum:string; }其中checksum可用于上传后完整性校验下载后校验识别相同文件防止缓存错配。文件卡片可以显示项目报告.pdf2.4 MB · PDF 下载 / 打开而不是仅显示【文件】项目报告.pdf还要对文件名进行安全处理。不要把本地完整路径发送给对方。例如不要发送/data/storage/el2/base/files/private/report.pdf消息中只保存展示名称与云端对象标识。十四、位置消息必须保存经纬度推荐位置载荷interface LocationPayload { latitude: number; longitude: number;address: string; placeName: string; mapPreviewUrl: string; }其中latitude和longitude用于地图定位address用于显示完整地址placeName用于卡片标题mapPreviewUrl可选用于静态地图预览。点击位置卡片时页面可以打开应用内地图调用系统地图展示路线复制地址。如果只保存“北京”这些能力都无法稳定实现。位置还是敏感数据。发送前应明确由用户确认不能静默读取并发送当前位置。十五、联系人消息不要直接泄露完整通讯录数据推荐联系人载荷interfaceContactPayload{ displayName:string; phoneSummary:string; appUid:string; avatarUrl:string; }这里使用phoneSummary而不是默认发送所有电话号码。更安全的交互是用户主动选择联系人页面展示即将分享的字段用户确认后发送接收方看到联系人卡片只有明确操作后才保存到通讯录。如果联系人本身是应用用户优先发送appUid。这样接收方可以进入应用内资料页而不必依赖电话号码。十六、投票消息为什么比其他附件更像业务对象项目附件面板中还有投票入口。当前它提示功能开发中。投票与图片、文件不同。它不仅有静态载荷还有持续变化的业务状态选项列表是否多选截止时间投票人每项票数是否匿名是否已结束。如果把全部结果写进消息 JSON每次投票都需要更新同一条消息容易产生并发覆盖。更合理的结构是ChatMessagemsgType pollcontent pollId ChatPollpollId question options deadline status ChatPollVotepollId voterUid optionId这说明不同“附件”未必都应该采用相同存储方式。文件是不可变资源。投票是持续变化的业务实体。十七、发送附件前必须先上传还是可以先发消息有两种主流策略。策略一上传成功后发送消息流程选择文件 ↓ 上传云存储 ↓ 获得 remoteUrl ↓ 写入 ChatMessage优点接收方看到的消息一定可下载Cloud DB 消息记录结构简单不需要同步上传中状态。缺点大文件上传期间聊天列表没有消息用户可能误以为点击无效。策略二先创建本地临时消息流程选择文件 ↓ 本地列表插入 uploading 消息 ↓ 后台上传 ↓ 上传成功后写 CloudDB↓ 快照消息替换本地临时消息优点是反馈即时。缺点是状态管理更复杂。当前项目更适合先采用策略一再逐步增加本地临时消息。十八、上传状态不应直接等同于消息已读状态ChatMessage当前有isRead:boolean;它描述接收方是否阅读消息。上传状态则属于另一条状态轴preparing uploading sending sent failed下载状态又是第三条状态轴notDownloaded downloading downloaded downloadFailed不要复用isRead表示上传成功。推荐在页面 ViewModel 中增加本地状态interface MessageItem {id:string; content:string; msgType:string; isSelf:boolean;time:string; transferState:string; progress:number; localUri:string; }云端消息模型负责跨设备一致状态。ViewModel 负责本设备传输状态。十九、Cloud DB 消息到页面模型也需要转换真实订阅回调当前只转换四个字段newMessages.push({ id: msg.getId(), content: msg.getContent(), isSelf: msg.getSenderId() this.myUid, time: newDate(msg.getTimestamp()).toLocaleTimeString([], { hour:2-digit, minute:2-digit}) });要支持附件页面模型至少需要保留msgTypeinterfaceMessageItem{ id:string; content:string; msgType:string; isSelf:boolean; time:string; }转换时const item: MessageItem {id: msg.getId(), content: msg.getContent(), msgType: msg.getMsgType(), isSelf: msg.getSenderId() this.myUid, time: this.formatMessageTime(msg.getTimestamp()) };newMessages.push(item);否则 Cloud DB 即使保存了imageUI 仍然只会把它当普通文字画出来。二十、解析附件时必须容错历史消息、旧版本客户端和异常数据都可能导致 JSON 不合法。不要在build()中直接执行const payloadJSON.parse(message.content);可以在转换层解析privateparseFilePayload(content: string): FilePayload |null{if(!content) {returnnull; }try{constpayload JSON.parse(content)asFilePayload;if(!payload.remoteUrl || !payload.fileName) {returnnull; }returnpayload; }catch(e) {returnnull; } }解析失败时使用降级文案[文件消息暂时无法显示]不能让一条损坏消息阻塞整个聊天列表。二十一、为载荷增加版本号附件协议上线后仍然会变化。例如第一版图片消息没有宽高。第二版增加缩略图。第三版增加加密信息。建议载荷包含版本号interface ImagePayload { version: number; remoteUrl: string; thumbnailUrl: string;width: number;height: number; size: number; mimeType: string;caption: string; }解析时根据版本处理version1→ 使用原图地址并采用默认比例version2→ 使用缩略图与真实宽高 未知版本 → 显示兼容提示版本号比“猜字段是否存在”更容易长期维护。二十二、兼容现有占位文本的迁移策略现有 Cloud DB 中可能已经保存msgTypetextcontent【图片】️新版本不能让这些消息消失。建议采用向后兼容策略msgTypetext → 按普通文本展示包括旧占位文本msgTypeimage/file/location/contact → 尝试解析结构化载荷 解析失败 → 显示类型对应的降级卡片不建议仅根据文本前缀判断类型content.startsWith(【图片】)因为普通用户也可能输入相同文本。消息协议应以msgType为准。旧占位消息继续作为文本保留即可。二十三、消息气泡应该按类型分发 Builder当前文字消息可以使用统一气泡。附件增加后可以建立分发构建器Builder buildMessageContent(message: MessageItem){if(message.msgTypeChatMessageType.IMAGE) { this.buildImageMessage(message)}elseif(message.msgTypeChatMessageType.FILE) { this.buildFileMessage(message)}elseif(message.msgTypeChatMessageType.LOCATION) { this.buildLocationMessage(message)}elseif(message.msgTypeChatMessageType.CONTACT) { this.buildContactMessage(message)}else{ this.buildTextMessage(message)} }每个 Builder 只处理一种展示结构。外层消息行继续统一处理左右方向头像时间发送状态长按菜单。这样不会为每种附件复制整套消息布局。二十四、图片消息的 UI 解决方案图片气泡建议包含缩略图圆角裁剪上传或下载进度遮罩失败重试入口可选说明文字点击预览。布局宽度应有上限。同时根据原始宽高计算比例避免超长图撑满页面。加载失败时不能留空白。应显示图片加载失败 点击重试接收方点击图片时可以优先读取本地缓存。没有缓存再下载远程资源。二十五、文件消息的 UI 解决方案文件卡片通常包含文件类型图标 文件名 可读大小 下载状态字节数需要转换856B24.5KB2.4MB1.1GB不要在 Cloud DB 中保存格式化后的“2.4 MB”。应保存原始字节数size。因为格式化规则可能受语言和精度影响。文件类型图标可以根据 MIME 类型或扩展名选择。但扩展名只用于展示安全判断应以实际内容和 MIME 校验为准。二十六、位置与联系人卡片的 UI 解决方案位置卡片建议显示地点名称地址摘要静态地图预览点击打开地图。联系人卡片建议显示头像展示名称脱敏联系方式查看资料按钮。卡片中不要默认展示完整敏感号码。点击后再进入明确的详情和授权流程。UI 只是协议的消费方。只有载荷字段明确卡片才能稳定实现。二十七、文件上传与 Cloud DB 写入不是同一个事务一个常见失败场景是文件上传成功 ↓ CloudDB消息写入失败此时云存储中会留下孤立文件。反过来也可能出现CloudDB先写入 URL ↓ 文件上传最终失败接收方会看到无法下载的附件。推荐顺序是上传到临时对象路径上传成功并获得元数据写入 Cloud DB 消息写入失败时记录待清理对象后台任务清理长期无引用文件。Cloud DB 与对象存储通常无法构成真正的跨服务事务。因此需要补偿机制而不是假设两步永远同时成功。二十八、附件 URL 不应默认永久公开如果把永久公开 URL 直接写进消息任何拿到地址的人都可能访问文件。更安全的方式包括使用受权限保护的对象存储保存对象 key而不是永久公开 URL下载时由服务生成短期签名地址服务端校验请求者是否属于会话敏感附件增加加密与过期策略。当前 Cloud DB 查询还在客户端过滤会话消息。生产环境还应收紧查询权限确保用户只能读取自己参与的会话。附件权限不能比消息权限更宽松。二十九、文件安全校验不能只看扩展名用户选择report.pdf不代表文件内容一定是 PDF。发送前至少应检查文件大小是否超限MIME 类型是否允许文件名是否包含异常字符实际内容与声明类型是否匹配是否命中禁止类型是否需要服务端病毒扫描。接收端也不能因为扩展名是图片就直接按图片解码。客户端校验提升体验。服务端校验才是最终安全边界。三十、消息 ID 不能只依赖毫秒时间戳当前项目使用msg.setId(Date.now().toString());MVP 中通常够用。但附件上传可能并发完成。同一毫秒内发送多条消息时纯时间戳存在冲突风险。更稳妥的 ID 应包含发送者UID时间戳 随机片段或序列号例如逻辑形式uid_timestamp_random如果平台提供可靠 UUID API应优先使用官方能力。稳定主键还用于ForEach key本地临时消息与云端消息对齐上传失败重试去重消息撤回。三十一、一条消息需要conversationId当前聊天订阅按时间查询全部消息再在内存中过滤双方 UID。MVP 可以工作但附件消息通常体积更大继续订阅全量数据会放大成本。可以增加稳定会话 IDconversationId两个 UID 排序后组合例如uidA_uidB发送消息时写入conversationId。订阅时只查询当前会话。收益包括减少无关快照数据降低附件元数据传输更容易实施会话权限支持会话列表最后消息支持按会话清理历史记录。附件演进不只是增加字段也会推动查询模型升级。三十二、多设备与本地 URI 的边界文件选择器返回的 URI 通常只在当前设备可用。不能把本地 URI 当作附件地址写入 Cloud DBmsg.setContent(localUri);接收方设备无法访问发送方沙箱。正确链路是本地 URI ↓ 当前设备读取文件 ↓ 上传云存储 ↓ 获得远程对象标识 ↓ 写入消息载荷本地 URI 可以放在MessageViewModel.localUri中用于发送方预览和缓存。远程标识才属于跨设备消息协议。三十三、断点续传和重试应该如何建模大文件上传可能因网络切换失败。本地任务可以记录interfaceTransferTask{taskId:string;messageId:string;localUri:string;remoteKey:string;progress:number;state:string;retryCount:number;}这类任务不必全部放进ChatMessage。它属于本地传输队列。重试时应复用同一个messageId或客户端临时 ID避免每次重试都产生重复消息。上传成功后再把最终载荷写入 Cloud DB。页面可以通过任务 ID 更新进度。三十四、已读、送达、上传完成是三种不同状态消息系统常见状态至少有本地创建 上传中 发送中 服务端已保存 对方设备已收到 对方已读当前isRead只覆盖最后一部分。不要用一个布尔值表达所有阶段。可以逐步增加本地transferState云端deliveryState云端或独立记录中的阅读回执。初期不必一次完成全部状态。但字段语义必须单一不能让isRead true同时表示“上传成功”。三十五、撤回和删除附件时如何处理文件删除消息不一定意味着立即删除云端文件。要考虑仅自己删除双方撤回文件是否被其他消息引用缓存是否仍需保留合规保留期限对象存储清理延迟。一种常见策略是消息标记已撤回 ↓ 客户端不再展示附件入口 ↓ 后台检查文件引用 ↓ 超过保留期且无引用后删除对象不要在 UI 点击删除时盲目同步删除远程文件。跨端消息状态可能尚未完成同步。三十六、推荐的渐进式落地路线结合当前真实项目可以按下面顺序实施。阶段一保留现有占位消息保证聊天主链路持续可用。阶段二页面模型增加msgType让 UI 具备类型分发能力。阶段三先落地图片消息相机与相册共享上传、载荷和渲染逻辑。阶段四增加文件消息补充文件选择、大小限制、下载与打开。阶段五增加位置和联系人重点处理权限、确认与隐私。阶段六增加传输 ViewModel支持进度、失败、重试与本地缓存。阶段七升级会话查询增加conversationId缩小订阅范围。阶段八补齐云端安全保护对象存储、限制 Cloud DB 查询、清理孤立文件。这样的顺序不会推翻已有代码。每一步都能独立验证。三十七、附件协议测试清单上线前至少覆盖以下场景图片相机拍摄成功相机权限拒绝相册选择取消超大图片压缩上传失败重试缩略图加载失败原图预览横图、竖图和长图布局。文件文件选择取消文件超过大小限制禁止类型拦截中文和超长文件名下载中断下载后校验失败本地无对应打开应用。位置与联系人定位权限拒绝无定位结果用户取消确认联系人权限拒绝敏感字段脱敏接收方无法识别应用用户。协议兼容旧文本消息正常显示旧占位附件仍按文本显示JSON 载荷损坏时降级未知msgType显示兼容提示未知载荷版本不会导致崩溃多端同时在线时消息不重复。三十八、建模时最容易犯的错误错误一把本地 URI 写入云端接收方设备无法读取。错误二永久公开附件 URL消息权限与文件权限脱节。错误三所有附件都塞进一个巨大接口大量可选字段导致语义模糊。错误四在build()中直接解析 JSON一条坏消息可能影响整个列表。错误五只保存格式化文件大小无法重新按语言或精度格式化。错误六用文件扩展名作为安全依据文件内容可能与扩展名不一致。错误七上传失败仍写入最终消息接收方会看到不可用附件。错误八忽略旧版本消息升级后历史聊天记录无法展示。错误九复用isRead表示发送状态不同状态轴被混为一谈。错误十没有稳定消息 ID重试和快照同步会产生重复项。三十九、项目级职责划分建议随着附件落地可以按职责组织现有代码而不必一次重构所有页面。职责建议位置附件入口与面板状态ChatDetailPage相机、相册、文件选择选择器适配服务文件校验与元数据提取附件服务上传、下载与删除存储服务Cloud DB 消息读写消息 Repository 或现有服务JSON 载荷编解码消息 Mapper/Codec气泡渲染各类型 Builder 或组件上传进度和本地 URI页面 ViewModel/传输任务当前项目可以先把方法放在页面附近。当图片与文件出现重复逻辑时再提取服务。不要为了目录漂亮提前制造空架构。四十、一个适合当前项目的最小可行方案如果只做第一版真实附件可以采用Cloud DB ChatMessage 保持现有字段 msgType 扩展 image/file/location/contact content 保存带version的JSON附件文件上传到受保护云存储 页面订阅时把 ChatMessage 转为 MessageItem 转换阶段解析载荷并提供降级值 UI 按 msgType 分发不同 Builder 本地 transferState 不写入 Cloud DB 旧text消息完全兼容这套方案具备以下特点对现有模型影响小不破坏实时订阅能逐类上线附件符合 ArkTS 明确接口要求能处理历史数据为未来拆分附件表保留空间。四十一、总结“灵犀互聊”当前附件实现处于一个非常真实的工程阶段UI 入口已经完成Cloud DB 发送链路已经完成实时订阅已经完成msgType已经预留附件仍以文本占位。这不是终点但它是合理起点。下一步不应该简单地把本地文件路径塞进content。而应该先建立清晰边界ChatMessage保存跨设备消息事实 AttachmentPayload 保存附件业务元数据 MessageViewModel 保存本地展示与传输状态 对象存储保存真正的二进制文件 msgType 决定载荷解析与气泡类型真正的附件能力不是附件面板里有多少图标而是消息协议能否稳定承载不同类型、不同生命周期和不同安全级别的数据。占位文本可以帮助项目快速跑通主链路但消息协议最终必须从“看起来像附件”演进到“能够上传、同步、解析、预览、下载、重试和授权的真实附件”。只要沿着兼容旧消息、结构化载荷、传输状态分离、云端权限收紧这条路线演进现有聊天模块就不需要推倒重来也能逐步成长为完整的多媒体消息系统。