← API | 列表 | 同伴App_缓存架构文档
提示信息
# 同伴 App · 缓存架构文档 v3.0

> 由三份历史文档(`缓存架构文档.md` / `缓存文档.md` / `同伴App_缓存架构文档 v1.0`)合并整理而成。
> 全面对齐同伴 App 前端开发规范 v5,覆盖头像、信号、私聊各类媒体消息的完整缓存规则、清理策略及防劣化机制。
> v3.0:完成全部"待完善项"并修复多项缓存相关 Bug。

---

## 目录

1. [设计原则](#一设计原则)
2. [整体目录结构](#二整体目录结构)
3. [头像缓存](#三头像缓存)
4. [信号资源缓存](#四信号资源缓存)
5. [私聊媒体缓存](#五私聊媒体缓存)
6. [闪照缓存](#六闪照缓存)
7. [CacheImage 通用组件](#七cacheimage-通用组件)
8. [缓存管理与清理](#八缓存管理与清理)
9. [用户切换时的缓存隔离](#九用户切换时的缓存隔离)
10. [iOS 沙盒路径兼容](#十ios-沙盒路径兼容)
11. [关键文件索引](#十一关键文件索引)

---

## 一、设计原则

| 原则 | 说明 |
|-----|-----|
| **按业务与会话隔离** | 不同安全级别的资源物理隔离;私聊资源按 `conversation_id` 划分独立文件夹,实现 O(1) 极速清理 |
| **去参数化唯一标识** | 剥离 OSS 鉴权签名,以文件基础路径(`url.split('?').first`)作为唯一 Key,避免同一文件重复下载 |
| **按需 + 批量签名** | 严格控制网络并发,禁止在列表页对每条消息单独发起 HTTP 签名请求 |
| **本地永久有效** | 文件落盘后视为永久有效,仅受 LRU 淘汰或用户手动清理控制 |
| **三层同步清理** | L1 内存 + L2 索引 + L3 物理文件,缺一不可(详见第八节) |
| **系统临时文件剥离** | 图片压缩、语音录制等临时文件交由系统 `TemporaryDirectory` 管理,随用随删 |
| **SDK Adapter 原则** | 业务层禁止直接 `import flutter_cache_manager`,统一通过 `AppCacheService` 代理 |

---

## 二、整体目录结构

所有持久化缓存均落地于 `ApplicationDocumentsDirectory/app_cache/<uid>/`,按 UID 隔离:

```
ApplicationDocumentsDirectory/
└── app_cache/
    └── <uid>/               ← 按登录用户隔离(未登录时 uid = "public")
        ├── avatar/          ← 头像缓存
        ├── signal/          ← 信号资源(图片/语音)
        └── chat/            ← 私聊媒体
            ├── <conversation_id>/   ← 普通图片、语音(按会话物理隔离)
            │   ├── .nomedia         ← 防止系统相册扫描
            │   └── *.jpg / *.m4a
            └── flash/               ← 闪照专用目录
                ├── .nomedia
                └── *.jpg
```

**元数据索引**:SQLite 数据库 `app_cache_<uid>.db`,表 `LocalCacheInfo`

| 字段 | 类型 | 说明 |
|-----|------|-----|
| `key` | TEXT PRIMARY KEY | URL 去参数化后的基础路径(唯一标识) |
| `local_path` | TEXT | **相对路径**(相对于 `app_cache/<uid>/`),禁止存绝对路径 |
| `size` | INTEGER | 文件字节数 |
| `last_access_time` | INTEGER | 最后访问时间戳,用于 LRU 淘汰 |
| `category` | TEXT | `avatar` / `signal` / `chat` / `flash` |
| `conversation_id` | TEXT | 私聊资源必填,用于按会话 O(1) 清理 |

---

## 三、头像缓存

### 3.1 管理器(AvatarCacheManager)

基于 `flutter_cache_manager` 封装,通过 `AppCacheService` 统一初始化。

| 配置项 | 值 |
|-------|---|
| 缓存目录 | `app_cache/<uid>/avatar/` |
| 最大数量 | 100 个文件 |
| 有效期 | 30 天(`stalePeriod`) |
| 缓存 Key | `avatar_<uid>_CacheKey` |
| 索引 DB | `JsonCacheInfoRepository(databaseName: 'avatar_<uid>')` |

### 3.2 OSS 云端处理 & 双尺寸缓存

头像 URL 由 `OssAvatarHelper.getProcessedUrl()` 拼接 `x-oss-process` 参数,驱动 OSS 云端裁图。
两种尺寸的 URL 不同 → 独立 Key → **本地各存一份物理文件,互不干扰**。

| 规格 | OSS 参数 | 典型大小 | 使用场景 |
|-----|---------|---------|---------|
| `df`(小图) | `resize,m_fill,w_128,h_128/format,jpg` | ~10 KB | 列表、气泡、消息收件箱、信号收件箱 |
| `xxl`(大图) | `resize,w_750,m_lfit/format,jpg/interlace,1` | 100–500 KB | 个人主页大图预览 |

### 3.3 渲染流程(CustomAvatarWidget)

```
CustomAvatarWidget 构建
    ↓
OssAvatarHelper.getProcessedUrl(url, size) → 拼接尺寸参数
    ↓
CachedNetworkImageProvider(
  processedUrl,
  cacheManager: AvatarCacheManager.instance,
  memCacheWidth: 128,    ← 强制限制内存解码尺寸,防列表 OOM
  memCacheHeight: 128,
)
    ↓
Flutter 内存缓存查询
    ├─ 命中(wasSynchronouslyLoaded=true) → 直接渲染,无白屏闪烁
    └─ 未命中 → 磁盘缓存查询
            ├─ 命中 → 读取文件渲染
            └─ 未命中 → 从 OSS 下载 → 写入 avatar/ 目录 → 渲染

加载中占位(frameBuilder):
    - size == xxl:先渲染已缓存的 df 小图(渐进式加载,避免大图白屏)
    - 其余情况:首字母 + 哈希色彩兜底头像
```

### 3.4 首字母兜底规则

- 提取 `username` 首字符(英文强制大写,中文取首汉字)
- 颜色通过 `username.hashCode` 映射到护眼色盘(HSL,饱和度 45–65%,亮度 50–65%)
- **同一用户颜色全端固定不变**(哈希结果稳定)

### 3.5 LRU 淘汰

- 文件数量超过 100 → 按 `mtime` 升序淘汰(最久未访问的最先删)至 100 个以内
- `AppCacheService.init()` 启动时通过 `Future.microtask` 异步触发,不阻塞 UI

---

## 四、信号资源缓存

### 4.1 管理器(SignalCacheManager)

| 配置项 | 值 |
|-------|---|
| 缓存目录 | `app_cache/<uid>/signal/` |
| 最大数量 | 100 个文件 |
| 有效期 | 30 天 |
| 缓存 Key | `signal_<uid>_CacheKey` |
| 索引 DB | `JsonCacheInfoRepository(databaseName: 'signal_<uid>')` |

### 4.2 信号图片缓存流程

```
进入信号列表 / 详情页
    ↓
从消息体中提取 objectKey
    ↓
AppCacheService.isFileCached(objectKey) 查询本地 SQLite DB
    ├─ 命中 → getLocalPath() → Image.file 直接渲染(无网络请求)
    └─ 未命中 → 汇总本页所有未命中 key(禁止单条 HTTP 请求)
            ↓
        POST /oss/sign(批量获取临时签名 URL)
            ↓
        _stealthDownloadMedia() — 后台 fire-and-forget 下载
            ⚠️ 使用 pureDio(无 Authorization 拦截器)
               不能用 mainDio,Authorization 头会导致 OSS 预签名 URL 报 403
            ↓
        下载完成 → saveToCache(objectKey, tempPath, category: 'signal')
            ↓
        落盘至 signal/ 目录 + 写入 LocalCacheInfo DB
```

### 4.3 去参数化唯一 Key

```dart
// OSS 签名 URL 鉴权参数随时变化,但基础路径不变
// 确保同一文件不重复下载
String getCacheKey(String url) => url.split('?').first;
```

---

## 五、私聊媒体缓存

### 5.1 支持的消息类型

| 消息类型 | OpenIM 枚举 | 缓存 Category | 存储路径 |
|---------|-----------|-------------|---------|
| 普通图片 | `image` | `chat` | `chat/<conversation_id>/<filename>.jpg` |
| 多图 | `multiImage` | `chat` | `chat/<conversation_id>/<filename>.jpg` × N |
| 语音 | `voice` | `chat` | `chat/<conversation_id>/<filename>.m4a` |
| 闪照 | `flashImage` | `flash` | `chat/flash/<filename>.jpg` |

### 5.2 图片压缩规则(对齐规范第 10.8 节)

上传前**按顺序**执行:

1. 大小 ≤ 200KB → 跳过压缩,直接上传
2. 大小 > 200KB → `FlutterImageCompress.compressAndGetFile(minWidth: 1280, quality: 80, format: JPEG)`
   - 宽度 > 1280px 时按比例缩放;≤ 1280px 不放大
3. 压缩临时文件存 `getTemporaryDirectory()`,上传完成后立即删除

### 5.3 发送方:「先缓存,后上传」流程

```
用户选择图片 / 录音完成
    ↓
① 乐观 UI — 立即用本地路径创建 tempMsg 插入消息列表(用户即时看到内容)
    ↓
② 图片压缩(按 5.2 节规则,语音 .m4a 无需压缩直接上传)
    ↓
③ 上传 OSS:uploadFile(localPath, 'chat/image' | 'chat/audio' | 'chat/flash')
    回调 onProgress → 实时更新气泡上传进度条
    ↓
④ 上传成功后 saveToCache(uploadRes.objectKey, localPath, category, conversationId)
    → COPY 文件至 chat/<conversation_id>/ 持久化目录
    → 写入 LocalCacheInfo DB(key = objectKey 去参数化)
    ↓
⑤ 删除压缩临时文件(原始文件保留在用户相册)
    ↓
⑥ 通过 IM SDK 发送消息,customData 携带 objectKey / url(已签名)/ mime / size / w / h
    ↓
⑦ 合并 sentMsg 与 tempMsg 元数据(保留本地路径和尺寸),替换消息列表中的临时消息
```

### 5.4 接收方:「消息携带 URL → 后台下载 → 缓存」流程

发送方在 customData 中同时携带 `objectKey`(唯一 key)和 `url`(已签名 OSS URL),接收方无需额外签名请求。

```
收到新消息(_onSdkMessage)
    ↓
解析 customData.url(已签名 URL)和 objectKey
    ↓
AppCacheService.isFileCached(objectKey) 检查 SQLite DB
    ├─ 命中 → getLocalPath() → Image.file 直接渲染(零网络请求)
    └─ 未命中 → fire-and-forget 后台下载(_stealthDownloadMedia):
              - 使用 pureDio(无 Authorization 头,避免 OSS 403)
              - 下载至 TemporaryDirectory 临时文件
              - saveToCache(objectKey, tempPath, category, conversationId)
              - 落盘后删除临时文件

气泡混合渲染(Hybrid Loading):
    ① 优先:objectKey → AppCacheService.getLocalPath() → Image.file(即时渲染)
    ② 降级:本地未命中 → CachedNetworkImage(url)(网络展示,同步后台缓存)
```

> 多图消息:customData 额外携带 `multiUrls` 数组(与 `multiObjectKeys` 一一对应)。

### 5.5 多图消息

- **发送**:循环串行上传,进度公式 `(已完成数 + 当前图进度) / 总数`
- **列表展示**:仅显示前三张缩略图(叠层卡片:底层 α=0.2,中层 α=0.5,顶层 α=1.0),右下角"共 X 张"角标
- **内存保护**:仅加载前三张缩略图,避免一次性加载大量图片导致 OOM
- **大图预览**:点击后交由 `AppImageViewer` 展开完整序列;通过 `urls.indexOf` 精确匹配起始索引,防止乱序

---

## 六、闪照缓存

### 6.1 特殊规则

| 项目 | 规则 |
|-----|-----|
| 缓存目录 | `chat/flash/`(独立于普通聊天目录) |
| `.nomedia` | ✅ 已添加,系统相册不可见 |
| 已查看标记 | **MMKV** `StorageKeys.flashViewed(msgId)` = `'cache:flash_viewed:<msgId>'` |
| 查看后 UI | 本地标记 `isDestroyed = true`,气泡显示已销毁状态(闪烁图标) |
| 物理删除 | ✅ 用户查看后调用 `AppCacheService.deleteByKey(objectKey)`,物理删除文件并清除 DB 记录 |
| 服务端同步 | 接收方查看后,发送 `flashViewed` 信令消息通知发送方(不渲染,仅作状态同步) |

### 6.2 MMKV Key 规范

```dart
// lib/core/storage/storage_keys.dart
static String flashViewed(String msgId) => 'cache:flash_viewed:$msgId';

// 使用示例
AppStorage.setBool(StorageKeys.flashViewed(msgId), true);
AppStorage.getBool(StorageKeys.flashViewed(msgId));
```

### 6.3 闪照发送流程

```
uploadFile(path, 'chat/flash')
    ↓
saveToCache → 落盘至 chat/flash/<filename>.jpg
    ↓
发送消息,customData.type = 'flash_image'
```

### 6.4 闪照接收与查看

```
FlashImageCard 组件渲染
    ↓
检查 AppStorage.getBool(StorageKeys.flashViewed(msgId))
    ├─ true → 显示"已查看/已销毁"状态,不渲染图片
    └─ false → AppCacheService.getLocalPath(key) 检查本地文件
        ├─ 存在 → Image.file 显示图片
        └─ 不存在 → pureDio 下载 → 落盘 → Image.file

用户点击查看:
    ↓
① AppStorage.setBool(StorageKeys.flashViewed(msgId), true)
② 发送 flashViewed 信令给对方
③ 双方 isDestroyed = true,UI 同步刷新
④ AppCacheService.deleteByKey(objectKey) — 物理删除文件 + 清除 DB 记录
```

---

## 七、CacheImage 通用组件

`lib/core/cache/cache_image.dart` — 统一处理网络图片缓存注册的 `StatelessWidget`。

```dart
CacheImage(
  imageUrl: signedUrl,           // OSS 签名 URL(或任意网络 URL)
  category: CacheCategory.chat, // 或 avatar / signal / flash
  conversationId: convId,        // chat/flash 类型必传
  fit: BoxFit.cover,
  width: 120,
  height: 120,
  memCacheWidth: 128,            // 限制内存解码尺寸,防 OOM(头像必传)
  memCacheHeight: 128,
)
```

**工作原理**:
1. 使用对应 `CacheManager`(Avatar / Signal / Chat)加载网络图片
2. 在 `imageBuilder` 回调中(图片**确认加载成功后**)异步触发 `_registerToCache()`:从 `CacheManager` 取本地缓存文件路径,写入 `AppCacheService` SQLite DB
3. 这样图片自动纳入缓存统计 & 清理管理

> ⚠️ 历史 Bug:v2.0 在 `useEffect([imageUrl])` 中触发注册,但此时 `flutter_cache_manager` 尚未完成下载,`getFileFromCache()` 返回 null,导致图片无法注册到 AppCacheService。v3.0 改为 `imageBuilder` 回调触发,100% 时机准确。

---

## 八、缓存管理与清理

### 8.1 三种清理入口

| 操作 | 方法 | 效果 |
|-----|-----|-----|
| **按类别清理** | `AppCacheService.clearCategory(category)` | 删除该 category 所有物理文件 + SQLite DB 记录 |
| **按会话清理** | `AppCacheService.clearConversation(conversationId)` | 删除 `chat/<conversationId>/` 整个目录 + DB 记录;支持 `si_A_B` 格式 |
| **清空全部** | `AppCacheService.clearAll()` | 删除整个 `app_cache/<uid>/` 目录 + DB |

调用后,`CacheController` 负责同步清理对应的 CacheManager(见 8.2)。

### 8.2 三层同步(⚠️ 强制,缺一不可)

任何清理操作后**必须**同时执行以下三层,否则会出现"图片失效"白屏或无法触发重新下载:

```dart
// L1:内存缓存(ImageCache)
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// ↑ 已封装在 AppCacheService._clearMemoryCache(),清理方法内自动调用

// L2:索引(flutter_cache_manager 内部 DB)
await AvatarCacheManager().emptyCache();   // 头像
await SignalCacheManager().emptyCache();    // 信号
await ChatCacheManager().emptyCache();      // 聊天(含闪照)

// L3:物理文件
// 已在 clearCategory / clearConversation / clearAll 内部完成
```

**根因说明**:
- 只删物理文件 → L1 内存仍有旧引用,返回页面直接渲染旧数据
- 只清 L1 → L2 DB 索引仍显示"文件存在",`flutter_cache_manager` 跳过重下载,白屏
- 三层必须原子性同步

### 8.3 大小统计

| 方法 | 说明 |
|-----|-----|
| `AppCacheService.getTotalCacheSize()` | SQLite `SUM(size)` |
| `AppCacheService.getCategorySizes()` | 分类返回 `{avatar: N, signal: N, chat: N, flash: N}`(字节) |
| `AppCacheService.getConversationDetails()` | 每个 conversationId 下文件数量 + 总大小 |
| `AppCacheService.formatSize(bytes)` | 格式化为 `"12.5 MB"` 等可读字符串 |

### 8.4 LRU 全局淘汰

```
AppCacheService.runEviction()
    ↓
读取 MMKV StorageKeys.maxCacheSize(-1 = 不限制,默认 2GB)
    ↓
currentSize = getTotalCacheSize()
    ↓
currentSize > maxSize
    → 按 last_access_time ASC 逐条删除(最久未访问优先)
    → 直到 currentSize ≤ maxSize × 0.8(保留 20% 余量,避免频繁触发)
```

- `AppCacheService.init()` 时通过 `Future.microtask` 异步触发,不阻塞启动
- 用户可在缓存管理页自定义上限(1GB / 5GB / 10GB / 20GB / 不限制)

### 8.5 缓存管理页面(cache_management_page.dart)

`lib/features/settings/cache_management_page.dart`,页面从上到下分为以下区块:

#### ① 空间可视化仪表盘
- 环形图(`CustomPaint`)展示缓存占用百分比,正中显示已用大小
- 右侧数值卡:已用 / 总容量 / 可用(真实设备剩余空间)
- 可用空间通过 `AppCacheService.getAvailableDiskBytes()` 读取(MethodChannel `com.xiaopaix.app/storage`)

#### ② 分类管理区
- 三列卡片:**头像 / 信号 / 聊天**,分别显示图标、分类名称、占用大小
- 每张卡片右上角有「清理」按钮,0 字节时禁用;点击弹二次确认 `AppDialog.show`
- 聊天分类清理:同时执行 `clearCategory(chat)` + `clearCategory(flash)` + `ChatCacheManager().emptyCache()`(封装在 `CacheController.clearChatAndFlash()`)
- 全宽「清理全部缓存」按钮(`PrimaryActionButton`),弹确认后调 `CacheController.clearAll()`
- **AppBar 不再有操作按钮**,清理入口统一在此区块

#### ③ 最大缓存容量设置
- 标题行:左侧「最大缓存容量」,右侧以 accent 色展示当前选项(如「5G」/「不限制」)
- 无图标,无额外描述
- 展开后 Slider 控制(1G / 5G / 10G / 20G / 不限制),选中后写入 MMKV `StorageKeys.maxCacheSize`,立即触发 `runEviction()`

#### ④ 详细分析 Tab(3 Tab)
| Tab | 内容 |
|-----|------|
| **聊天** | 按会话分组,每项显示「会话 ID · 文件数 · 占用大小」,右侧垃圾桶图标,点击弹确认后调 `clearConversation()` |
| **头像** | 网格展示真实本地头像缩略图(`Image.file()`),底部「清除头像缓存」按钮 |
| **信号** | 列表展示信号缓存:图片文件显示缩略图,语音文件显示麦克风图标,底部「清除信号缓存」按钮 |

---

## 九、用户切换时的缓存隔离

```
登出 / 切换账号时:
    ↓
① AppCacheService.close()          — 关闭旧 SQLite DB 连接
    ↓
② AppCacheService.init(newUid)     — 切换至 app_cache/<newUid>/ 目录
    ↓
③ AvatarCacheManager.init(newUid)  — 新 Key = avatar_<newUid>_CacheKey
    ↓
④ SignalCacheManager.init(newUid)  — 新 Key = signal_<newUid>_CacheKey
```

- 未登录状态:`uid = "public"`,独立目录,登录后切换不污染已登录用户缓存
- 多账号切换时各 uid 目录完全隔离,不共享任何缓存数据

---

## 十、iOS 沙盒路径兼容

iOS 升级或应用更新后,`ApplicationDocumentsDirectory` 的沙盒 UUID **可能变化**。

**规则:**

- **严禁在 SQLite DB 中存储绝对路径**
- `local_path` 字段只存相对路径(相对于 `app_cache/<uid>/`)
- 读取时通过 `AppCacheService.getLocalPath(key)` 动态拼接当前 `_cacheRoot`
- 若 DB 中记录的文件实际不存在(UUID 迁移 / 手动删除),自动清除 DB 记录并触发重新下载

```dart
// ✅ 存相对路径
localPath = 'avatar/abc123.jpg';

// ❌ 禁止存绝对路径(iOS 更新后失效)
localPath = '/var/mobile/Containers/.../avatar/abc123.jpg';
```

---

## 十一、关键文件索引

| 文件 | 职责 |
|-----|-----|
| `lib/core/cache/app_cache_service.dart` | 核心缓存服务:目录管理、SQLite DB、saveToCache、deleteByKey、LRU 淘汰、三层清理、getAvailableDiskBytes |
| `lib/core/cache/cache_managers.dart` | `AvatarCacheManager` / `SignalCacheManager` / `ChatCacheManager` 配置 |
| `lib/core/cache/cache_image.dart` | 通用缓存图片组件,imageBuilder 回调后注册到 AppCacheService,支持 memCacheWidth/Height |
| `lib/core/storage/storage_keys.dart` | 所有 MMKV key 常量,含 `flashViewed(msgId)` / `maxCacheSize` |
| `lib/widgets/custom_avatar.dart` | 头像 Widget + `_OssHelper`(x-oss-process 缩略图)+ memCacheWidth/Height |
| `lib/features/chat/chat_controller.dart` | 私聊发送/接收时的缓存写入(`_stealthDownloadMedia`、`saveToCache`);消息携带 `url` 字段 |
| `lib/features/chat/widgets/chat_image_cards.dart` | 闪照 UI + MMKV 阅后即焚标记 + 物理抹除 |
| `lib/features/settings/cache_management_page.dart` | 缓存管理页面(真实剩余空间 + 可视化统计 + 分类清理入口) |
| `lib/features/settings/cache_controller.dart` | 缓存页 Riverpod Controller,协调 AppCacheService + CacheManagers |
| `lib/core/network/dio_client.dart` | `mainDio`(带鉴权)和 `pureDio`(纯净,OSS 下载专用)两个实例 |
| `ios/Runner/AppDelegate.swift` | `com.xiaopaix.app/storage` MethodChannel → iOS `attributesOfFileSystem` |
| `android/.../MainActivity.kt` | `com.xiaopaix.app/storage` MethodChannel → Android `StatFs` |

---

## 十二、已修复的历史 Bug(v3.0 变更说明)

| Bug | 根因 | 修复方案 |
|-----|-----|---------|
| 信号详情"开启聊天"后从聊天列表删除/清空再进,仍可看到旧消息 | `signal_detail_page.dart` 用 `targetUid.toString()` 作为会话 ID,与 IM SDK 实际会话 ID 格式(`si_tb_X_tb_Y`)不一致,清空操作传入了错误 ID | 在 `_openChat()` 中按 IM 规范动态计算:取双方 `tb_<uid>`,排序后拼接 `si_` 前缀 |
| 删除会话后,再次进入仍能加载旧媒体缓存 | `deleteConversation()` 只调用了 IM SDK 的删除,未同步清理 `AppCacheService.clearConversation()` 和 `ChatCacheManager` | 在 `conversation_list_controller.dart` 中补充 L2/L3 三层清理 |
| `CacheImage` 注册时序错误导致图片不纳入缓存统计 | `useEffect([imageUrl])` 在 Widget build 时触发,此时文件尚未下载,`getFileFromCache()` 返回 null | 改为在 `imageBuilder` 回调中触发,确保文件已落盘 |
| 闪照被查看后文件不删除 | `_markDestroyed()` 仅写 MMKV 标记,缺少物理删除 | 追加 `AppCacheService.deleteByKey(objectKey)` |
| MMKV 闪照标记 key 不一致 | `chat_image_cards.dart` 用硬编码 `'flash_dest_<id>'`,与 `StorageKeys.flashViewed(id)` = `'cache:flash_viewed:<id>'` 不同 | 统一改用 `StorageKeys.flashViewed(msgId)` |
| 接收方聊天图片无法显示(objectKey 非 URL) | `_mapSdkMessage` 将 `objectKey` 赋给 `imageUrl`,但 objectKey 不是可访问的 URL | 发送方 customData 额外传 `url`(已签名 URL),接收方解析使用 |
| 登出后 AppCacheService SQLite 连接未关闭 | `logout()` / `forceLogout()` 未调用 `AppCacheService.close()` | 在两个登出路径中追加 `close()` 调用 |

---

*文档版本:v3.0 · 对齐同伴 App 前端开发规范 v5*