← API | 列表 | swift_聊天模块实现
提示信息
# 同伴 App — Swift 聊天模块实现

> 业务规格 + 完整 SwiftUI 实现。对应 Flutter `features/chat/` 目录。
> IM SDK 封装层见 `swift_IM适配器与推送.md`(已实现)。

---

## 一、数据模型

```swift
// ChatModels.swift

// ── 会话 ────────────────────────────────────────────────────────────────
struct Conversation: Identifiable, Hashable, Sendable {
    let id: String              // conversationId,格式:si_{imUid1}_{imUid2}
    let targetUid: Int
    var nickname: String
    var avatar: String
    var isOnline: Bool = false

    // 信号上下文(来自信号详情页 push 时携带)
    var fromSignal: Bool = false
    var signalId: Int? = nil
    var receiveId: String? = nil
    var signalReply: String? = nil     // 对方的回复文字(首条消息预填)
    var signalContent: String? = nil   // 原始信号内容

    // 会话状态
    var isImOpened: Bool = false       // 是否已发送 signalQuote
    var isPinned: Bool = false
    var draftText: String? = nil

    // 列表展示
    var unreadCount: Int = 0
    var lastMessagePreview: String = ""
    var lastMessageTime: Int = 0       // Unix 毫秒

    func hash(into hasher: inout Hasher) { hasher.combine(id) }
    static func == (lhs: Conversation, rhs: Conversation) -> Bool { lhs.id == rhs.id }
}

// ── 消息类型 ────────────────────────────────────────────────────────────
enum ChatMessageType: String, Sendable {
    case text
    case image
    case flashImage
    case multiImage
    case voice
    case signalQuote
    case flashViewed
    case revoked
    case time           // 纯 UI 时间分隔符
    case unknown
}

// ── 消息 ────────────────────────────────────────────────────────────────
struct ChatMessage: Identifiable, Sendable {
    let id: String              // clientMsgId(乐观更新 key)
    var serverId: String?
    let senderId: String
    let receiverId: String
    var type: ChatMessageType
    var content: Any            // 不同类型含义不同

    let createTime: Int         // 毫秒时间戳
    var isRead: Bool
    let isMe: Bool

    // 媒体字段
    var imageUrl: String?
    var urlExpireAt: Int?       // Unix 秒,0=永不过期
    var objectKey: String?
    var multiObjectKeys: [String]?
    var durationSec: Int?
    var width: Double?
    var height: Double?
    var isFlash: Bool = false

    // 生命周期
    var isRecalled: Bool = false
    var isDestroyed: Bool = false       // 闪照已查看销毁
    var uploadProgress: Double = 0
    var status: String = "success"      // "sending" | "success" | "failed"
    var errorReason: String?

    // 信号引用(type=signalQuote 时有值)
    var signalQuoteId: Int?
    var signalQuoteContent: String?
    var signalQuoteReply: String?

    var isURLExpired: Bool {
        guard let exp = urlExpireAt, exp > 0 else { return false }
        return Int(Date().timeIntervalSince1970) > exp
    }
}
```

---

## 二、会话 ID 工具

```swift
// ConversationIDHelper.swift
enum ConversationIDHelper {
    /// 生成 si_tb_{小uid}_tb_{大uid} 格式的会话 ID
    static func make(myUid: Int, targetUid: Int) -> String {
        let a = "tb_\(myUid)"
        let b = "tb_\(targetUid)"
        let sorted = [a, b].sorted()
        return "si_\(sorted[0])_\(sorted[1])"
    }
}
```

---

## 三、MediaCacheService(媒体缓存)

`actor MediaCacheService` 位于 `Tongban/Features/Chat/MediaCacheService.swift`,对外暴露以下核心接口:

### 目录结构

```
Library/Caches/
└── media_<uid>/              ← 按登录用户隔离
    ├── <filename>.jpg        ← 平铺根目录(接收方 prefetch 下载的文件)
    └── <conversationId>/     ← 按会话子目录(发送方上传后 save 的文件)
        └── <filename>.jpg
```

文件名规则:`objectKey.replacingOccurrences(of: "/", with: "_")`,例如
`chat/image/2026/03/20/abc.jpg` → `chat_image_2026_03_20_abc.jpg`

> **⚠️ 与 Flutter 规范差异**:iOS 采用 `Library/Caches/` 目录(系统在低存储时自动清理),Flutter 采用 `ApplicationDocumentsDirectory`(持久存储)。iOS 版暂不实现 SQLite 索引,改用内存字典 `memoryCache` + 文件系统扫描。

### 关键方法

| 方法 | 说明 |
|-----|-----|
| `initialize(userId:)` | 登录后初始化缓存目录;登出时需重新调用(切换账号隔离) |
| `localURL(for:)` | 先查内存缓存,再扫描磁盘(平铺根 + 所有子目录) |
| `save(data:for:conversationId:)` | 有 `conversationId` → 存子目录;无 → 存根目录 |
| `prefetch(url:objectKey:)` | 后台下载并缓存;URL 过期时自动重签;返回新鲜 URL 供调用方回写气泡 |
| `resolveMediaURLs(for:)` | 批量策略:本地命中 → file:// URL;未命中且已过期 → batchSignURLs 重签;未命中且未过期 → 原 URL 不动(由 prefetchMedia 后台缓存) |

### 媒体 URL 解析流程(resolveMediaURLs)

```
for each message:
  if 本地缓存命中 (localURL != nil):
      result[key] = file:// URL   ← 零网络请求
  else if URL 为空 or isURLExpired:
      needSign.append(key)         ← 收集待重签 key
  else:
      保留原签名 URL,由 prefetchMedia 后台缓存
      ↓ (不加入 result)

if needSign 非空:
    batchSignURLs(needSign) → 新签名 URL
    for each new URL:
        result[key] = newURL
        Task { prefetch(url:objectKey:) }  ← 后台下载缓存
```

### ATS 要求

OSS 签名 URL 格式:`http://metch.oss.xiaopai.vip/<objectKey>?OSSAccessKeyId=...`

**`metch.oss.xiaopai.vip` 必须加入 `Info.plist` 的 `NSExceptionDomains`**,否则 URLSession 和 Kingfisher 均无法下载(ATS 默认阻断 HTTP)。已配置:

```xml
<key>metch.oss.xiaopai.vip</key>
<dict>
    <key>NSExceptionAllowsInsecureHTTPLoads</key>
    <true/>
    <key>NSIncludesSubdomains</key>
    <true/>
</dict>
```

### multiImageUrls 更新注意事项

`refreshExpiredMediaURLs(in:msgs:inout)` 更新多图 URL 时,**必须先提取数组到临时变量再写回**,不能用可选链 `msgs[idx].multiImageUrls?[ki] = url`(Swift `inout` + 可选链下标赋值存在写回不确定性):

```swift
// ✅ 正确
var updatedURLs = msgs[idx].multiImageUrls ?? Array(repeating: "", count: keys.count)
updatedURLs[ki] = url
msgs[idx].multiImageUrls = updatedURLs

// ❌ 避免
msgs[idx].multiImageUrls?[ki] = url
```

---

## 四、ChatRepository(消息发送串行队列)

```swift
// ChatRepository.swift
// 串行队列保证慢上传不被后续消息超越
actor ChatRepository {
    static let shared = ChatRepository()

    private var sendQueue: [() async -> Void] = []
    private var isSending = false

    // ── 消息发送入口(加入串行队列) ─────────────────────────────────
    func enqueue(_ task: @escaping () async -> Void) {
        sendQueue.append(task)
        if !isSending { Task { await drainQueue() } }
    }

    private func drainQueue() async {
        isSending = true
        while !sendQueue.isEmpty {
            let task = sendQueue.removeFirst()
            await task()
        }
        isSending = false
    }

    // ── 发送文字消息 ──────────────────────────────────────────────────
    func sendText(
        conversationId: String,
        text: String,
        receiveId: String?,
        updateMsg: @escaping @Sendable (String, String) -> Void,   // (clientMsgId, status)
        updateServerId: @escaping @Sendable (String, String) -> Void
    ) async {
        do {
            let resp = try await callSendAPI(
                conversationId: conversationId,
                content: text,
                contentType: 101,
                receiveId: receiveId,
                pushDesc: text
            )
            updateServerId(resp.clientMsgID, resp.serverMsgID)
            updateMsg(resp.clientMsgID, "success")
        } catch let e as AppAPIError {
            updateMsg("", "failed")
            _ = e
        }
    }

    // ── 发送自定义消息(图片/语音/闪照/信号引用) ─────────────────────
    func sendCustom(
        conversationId: String,
        payload: [String: Any],
        receiveId: String?,
        pushDesc: String,
        clientMsgId: String,
        updateMsg: @escaping @Sendable (String, String) -> Void,
        updateServerId: @escaping @Sendable (String, String) -> Void
    ) async {
        guard let jsonData = try? JSONSerialization.data(withJSONObject: payload),
              let jsonStr  = String(data: jsonData, encoding: .utf8) else { return }
        do {
            let resp = try await callSendAPI(
                conversationId: conversationId,
                content: jsonStr,
                contentType: 110,
                receiveId: receiveId,
                pushDesc: pushDesc
            )
            updateServerId(resp.clientMsgID, resp.serverMsgID)
            updateMsg(resp.clientMsgID, "success")
        } catch {
            updateMsg(clientMsgId, "failed")
        }
    }

    // ── 撤回消息 ──────────────────────────────────────────────────────
    func revokeMessage(conversationId: String, clientMsgId: String) async throws {
        try await APIClient.shared.post("/chat/message/revoke",
                                         body: ["conversation_id": conversationId,
                                                "client_msg_id": clientMsgId] as [String: Any])
    }

    // ── REST 发送接口 ─────────────────────────────────────────────────
    private struct SendResponse: Decodable, Sendable {
        let serverMsgID: String
        let clientMsgID: String
        let sendTime: Int
    }

    private func callSendAPI(
        conversationId: String,
        content: String,
        contentType: Int,
        receiveId: String?,
        pushDesc: String
    ) async throws -> SendResponse {
        var body: [String: Any] = [
            "recvID": conversationId,
            "content": content,
            "contentType": contentType,
            "clientMsgId": UUID().uuidString,
            "offlinePushInfo": ["title": "新消息", "desc": pushDesc] as [String: Any]
        ]
        if let rid = receiveId { body["receive_id"] = rid }
        return try await APIClient.shared.post("/chat/message/send",
                                                body: body as [String: Any],
                                                as: SendResponse.self)
    }
}
```

---

## 五、会话列表(ConversationListView)

### ViewModel

```swift
// ConversationListViewModel.swift
@MainActor
@Observable
final class ConversationListViewModel {
    var conversations: [Conversation] = []
    var isLoading = false

    private var imTask: Task<Void, Never>?

    func onAppear() async {
        // 先展示 MMKV 缓存
        loadFromCache()

        // 从 IM SDK 拉取
        isLoading = true
        await refreshFromIM()
        isLoading = false

        // 启动实时监听
        startListening()
    }

    private func loadFromCache() {
        guard let str  = AppStorage.getString(StorageKeys.convListCache),
              let data = str.data(using: .utf8),
              let cached = try? JSONDecoder().decode([CachedConversation].self, from: data)
        else { return }
        // 只用缓存填充空列表,避免闪烁覆盖 IM 数据
        if conversations.isEmpty {
            conversations = cached.map { $0.toConversation() }
        }
    }

    private func refreshFromIM() async {
        let summaries = await IMAdapter.shared.getConversationList()
        var updated = summaries.map { makeConversation(from: $0) }
        await enrichUserProfiles(conversations: &updated)
        conversations = sorted(updated)
        saveToCache(updated)
    }

    // 监听 IM 会话变更(实时刷新)
    private func startListening() {
        imTask = Task {
            for await summaries in await IMAdapter.shared.conversationChangedStream {
                let changed = summaries.map { makeConversation(from: $0) }
                mergeChanges(changed)
            }
        }
    }

    private func mergeChanges(_ changed: [Conversation]) {
        for conv in changed {
            if let idx = conversations.firstIndex(where: { $0.id == conv.id }) {
                conversations[idx] = conv
            } else {
                conversations.append(conv)
            }
        }
        conversations = sorted(conversations)
    }

    // ── 会话操作 ──────────────────────────────────────────────────────
    func pinConversation(id: String, isPinned: Bool) async {
        try? await IMAdapter.shared.pinConversation(conversationID: id, isPinned: isPinned)
        if let idx = conversations.firstIndex(where: { $0.id == id }) {
            conversations[idx].isPinned = isPinned
            conversations = sorted(conversations)
        }
    }

    func deleteConversation(id: String) async {
        try? await IMAdapter.shared.deleteConversation(conversationID: id)
        conversations.removeAll { $0.id == id }
    }

    // ── 辅助 ──────────────────────────────────────────────────────────
    private func sorted(_ list: [Conversation]) -> [Conversation] {
        list.sorted {
            if $0.isPinned != $1.isPinned { return $0.isPinned }
            return $0.lastMessageTime > $1.lastMessageTime
        }
    }

    private func makeConversation(from summary: IMConversationSummary) -> Conversation {
        Conversation(
            id: summary.conversationId,
            targetUid: Int(summary.userId?.replacingOccurrences(of: "tb_", with: "") ?? "0") ?? 0,
            nickname: summary.showName ?? "",
            avatar: summary.faceURL ?? "",
            isPinned: summary.isPinned,
            draftText: summary.draftText,
            unreadCount: summary.unreadCount,
            lastMessagePreview: summary.lastMessagePreview,
            lastMessageTime: summary.lastMessageTime
        )
    }

    private func enrichUserProfiles(conversations: inout [Conversation]) async {
        let uids = conversations.map(\.targetUid)
        let cached = (try? await UserProfileCache.shared.getProfiles(uids)) ?? []
        let cachedMap = Dictionary(uniqueKeysWithValues: cached.map { ($0.uid, $0) })

        let missingUids = uids.filter { cachedMap[$0] == nil }
        var fetchedMap: [Int: UserBasicInfo] = [:]

        if !missingUids.isEmpty {
            let fetched = (try? await APIClient.shared.post(
                "/user/profile/openim-batch",
                body: ["user_ids": missingUids, "scene": "list"] as [String: Any],
                as: [UserBasicInfo].self
            )) ?? []
            try? await UserProfileCache.shared.saveProfiles(fetched)
            fetchedMap = Dictionary(uniqueKeysWithValues: fetched.map { ($0.uid, $0) })
        }

        for idx in conversations.indices {
            let uid = conversations[idx].targetUid
            if let info = cachedMap[uid] ?? fetchedMap[uid] {
                conversations[idx].nickname = info.username
                conversations[idx].avatar   = info.avatar
            }
        }
    }

    private func saveToCache(_ convs: [Conversation]) {
        let cached = convs.prefix(50).map { CachedConversation(from: $0) }
        if let data = try? JSONEncoder().encode(cached),
           let str  = String(data: data, encoding: .utf8) {
            AppStorage.setString(StorageKeys.convListCache, str)
        }
    }
}

// 用于 MMKV 序列化的精简结构
private struct CachedConversation: Codable {
    let id: String; let targetUid: Int; let nickname: String; let avatar: String
    let isPinned: Bool; let unreadCount: Int
    let lastMessagePreview: String; let lastMessageTime: Int

    init(from c: Conversation) {
        id = c.id; targetUid = c.targetUid; nickname = c.nickname; avatar = c.avatar
        isPinned = c.isPinned; unreadCount = c.unreadCount
        lastMessagePreview = c.lastMessagePreview; lastMessageTime = c.lastMessageTime
    }

    func toConversation() -> Conversation {
        Conversation(id: id, targetUid: targetUid, nickname: nickname, avatar: avatar,
                     isPinned: isPinned, unreadCount: unreadCount,
                     lastMessagePreview: lastMessagePreview, lastMessageTime: lastMessageTime)
    }
}
```

### View

```swift
// ConversationListView.swift
struct ConversationListView: View {
    @State private var vm = ConversationListViewModel()
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            Group {
                if vm.isLoading && vm.conversations.isEmpty {
                    listSkeleton
                } else if vm.conversations.isEmpty {
                    ContentUnavailableView(
                        "暂无消息",
                        systemImage: "message",
                        description: Text("通过信号互动后,会话将显示在这里")
                    )
                } else {
                    List {
                        ForEach(vm.conversations) { conv in
                            ConversationRow(conversation: conv)
                                .listRowInsets(EdgeInsets())
                                .listRowSeparator(.hidden)
                                .contentShape(Rectangle())
                                .onTapGesture {
                                    navigationPath.append(conv)
                                }
                                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                                    Button(role: .destructive) {
                                        Task { await vm.deleteConversation(id: conv.id) }
                                    } label: {
                                        Label("删除", systemImage: "trash")
                                    }
                                }
                                .swipeActions(edge: .leading) {
                                    Button {
                                        Task { await vm.pinConversation(id: conv.id, isPinned: !conv.isPinned) }
                                    } label: {
                                        Label(conv.isPinned ? "取消置顶" : "置顶",
                                              systemImage: conv.isPinned ? "pin.slash" : "pin")
                                    }
                                    .tint(.orange)
                                }
                        }
                    }
                    .listStyle(.plain)
                    .refreshable { await vm.refreshFromIM() }
                }
            }
            .navigationTitle("消息")
            .navigationDestination(for: Conversation.self) { conv in
                ConversationView(conversation: conv)
            }
        }
        .task { await vm.onAppear() }
    }

    private var listSkeleton: some View {
        List(0..<8, id: \.self) { _ in
            HStack(spacing: 12) {
                Circle().frame(width: 52, height: 52)
                VStack(alignment: .leading, spacing: 8) {
                    RoundedRectangle(cornerRadius: 4).frame(width: 100, height: 14)
                    RoundedRectangle(cornerRadius: 4).frame(width: 180, height: 12)
                }
                Spacer()
                RoundedRectangle(cornerRadius: 4).frame(width: 36, height: 10)
            }
            .padding(.vertical, 8)
            .redacted(reason: .placeholder)
            .listRowSeparator(.hidden)
        }
        .listStyle(.plain)
        .allowsHitTesting(false)
    }
}

struct ConversationRow: View {
    let conversation: Conversation

    var body: some View {
        HStack(spacing: 12) {
            ZStack(alignment: .topTrailing) {
                AsyncImage(url: URL(string: conversation.avatar)) { img in
                    img.resizable().scaledToFill()
                } placeholder: {
                    Circle().fill(.secondary.opacity(0.2))
                }
                .frame(width: 52, height: 52)
                .clipShape(Circle())

                if conversation.unreadCount > 0 {
                    Text(conversation.unreadCount > 99 ? "99+" : "\(conversation.unreadCount)")
                        .font(.caption2.bold())
                        .padding(.horizontal, 5).padding(.vertical, 2)
                        .background(.red, in: Capsule())
                        .foregroundStyle(.white)
                        .offset(x: 4, y: -4)
                }
            }

            VStack(alignment: .leading, spacing: 4) {
                HStack {
                    if conversation.isPinned {
                        Image(systemName: "pin.fill").font(.caption).foregroundStyle(.orange)
                    }
                    Text(conversation.nickname).font(.subheadline.bold()).lineLimit(1)
                    Spacer()
                    Text(formattedTime(conversation.lastMessageTime))
                        .font(.caption).foregroundStyle(.secondary)
                }

                Text(conversation.draftText.map { "[草稿] \($0)" } ?? conversation.lastMessagePreview)
                    .font(.caption)
                    .foregroundStyle(conversation.draftText != nil ? .red : .secondary)
                    .lineLimit(1)
            }

            Spacer(minLength: 0)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
    }

    private func formattedTime(_ ms: Int) -> String {
        let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
        let cal  = Calendar.current
        if cal.isDateInToday(date) {
            let fmt = DateFormatter(); fmt.dateFormat = "HH:mm"
            return fmt.string(from: date)
        } else if cal.isDateInYesterday(date) {
            return "昨天"
        } else {
            let fmt = DateFormatter(); fmt.dateFormat = "M/d"
            return fmt.string(from: date)
        }
    }
}
```

---

## 六、私聊页面(ConversationView)

### ViewModel

```swift
// ConversationViewModel.swift
@MainActor
@Observable
final class ConversationViewModel {
    let conversation: Conversation

    var messages: [ChatMessage] = []
    var isLoading = false
    var hasMoreHistory = true
    var newMessageBannerCount = 0   // 底部"N条新消息"悬浮
    var isAtBottom = true

    private var myUID: String = ""
    private var listenTask: Task<Void, Never>?
    private var oldestMsg: OIMMessageInfo?

    init(conversation: Conversation) {
        self.conversation = conversation
    }

    func onAppear() async {
        myUID = AppStorage.getString(StorageKeys.userId).map { "tb_\($0)" } ?? ""

        // 1. 加载历史消息
        isLoading = true
        await loadHistory()
        isLoading = false

        // 2. 首次进入自动发送 signalQuote
        if conversation.fromSignal && !conversation.isImOpened {
            await sendSignalQuote()
        }

        // 3. 监听新消息
        startListening()

        // 4. 标记已读
        markConversationRead()
    }

    func onDisappear() {
        // 保存草稿
        listenTask?.cancel()
    }

    // ── 历史消息 ──────────────────────────────────────────────────────
    private func loadHistory(startMsg: OIMMessageInfo? = nil) async {
        let rawMsgs = await IMAdapter.shared.getMessageHistory(
            conversationId: conversation.id,
            count: 40,
            startMsg: startMsg
        )
        guard !rawMsgs.isEmpty else {
            hasMoreHistory = false
            return
        }
        oldestMsg = rawMsgs.first

        var mapped = rawMsgs.map { IMMessageMapper.map($0, currentUID: myUID) }
        mapped = insertTimeMarkers(into: mapped)

        // 批量预加载媒体 URL
        await refreshExpiredMediaURLs(in: &mapped)

        if startMsg == nil {
            messages = mapped
        } else {
            messages = mapped + messages
        }
        hasMoreHistory = rawMsgs.count >= 40

        // 后台预下载未缓存媒体
        prefetchMedia(in: mapped)
    }

    func loadMoreHistory() async {
        guard hasMoreHistory, let startMsg = oldestMsg else { return }
        await loadHistory(startMsg: startMsg)
    }

    // ── 新消息监听 ────────────────────────────────────────────────────
    private func startListening() {
        listenTask = Task {
            for await raw in await IMAdapter.shared.newMessageStream {
                guard raw.recvID == conversation.id || raw.sendID == myUID else { continue }
                let msg = IMMessageMapper.map(raw, currentUID: myUID)
                handleIncoming(msg)
            }
        }
    }

    private func handleIncoming(_ msg: ChatMessage) {
        switch msg.type {
        case .revoked:
            // 更新对应消息为撤回状态
            if let id = msg.content as? String,
               let idx = messages.firstIndex(where: { $0.id == id }) {
                messages[idx].isRecalled = true
            }
        case .flashViewed:
            if let id = msg.content as? String,
               let idx = messages.firstIndex(where: { $0.id == id }) {
                messages[idx].isDestroyed = true
            }
        default:
            var incoming = msg
            // 如果 URL 过期则刷新
            Task {
                if incoming.isURLExpired, let key = incoming.objectKey,
                   let refreshed = try? await UploadClient.shared.batchSignURLs([key]),
                   let newURL = refreshed[key] {
                    incoming.imageUrl = newURL
                }
                messages.insert(incoming, at: 0)
                if !isAtBottom { newMessageBannerCount += 1 }
                prefetchMedia(in: [incoming])
            }
        }
    }

    // ── 发送消息 ──────────────────────────────────────────────────────
    func sendText(_ text: String) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }

        let msgId = UUID().uuidString
        var msg = ChatMessage(
            id: msgId, senderId: myUID, receiverId: conversation.id,
            type: .text, content: trimmed,
            createTime: Int(Date().timeIntervalSince1970 * 1000),
            isRead: false, isMe: true
        )
        msg.status = "sending"
        messages.insert(msg, at: 0)

        let convId   = conversation.id
        let receiveId = conversation.receiveId

        ChatRepository.shared.enqueue {
            await ChatRepository.shared.sendText(
                conversationId: convId, text: trimmed, receiveId: receiveId,
                updateMsg: { [weak self] id, status in
                    await MainActor.run {
                        if let idx = self?.messages.firstIndex(where: { $0.id == msgId }) {
                            self?.messages[idx].status = status
                        }
                    }
                },
                updateServerId: { [weak self] _, serverId in
                    await MainActor.run {
                        if let idx = self?.messages.firstIndex(where: { $0.id == msgId }) {
                            self?.messages[idx].serverId = serverId
                        }
                    }
                }
            )
        }
    }

    func sendImage(fileURL: URL, isFlash: Bool = false) {
        let msgId = UUID().uuidString
        let type: ChatMessageType = isFlash ? .flashImage : .image
        var msg = ChatMessage(
            id: msgId, senderId: myUID, receiverId: conversation.id,
            type: type, content: "",
            createTime: Int(Date().timeIntervalSince1970 * 1000),
            isRead: false, isMe: true
        )
        msg.imageUrl = fileURL.absoluteString
        msg.status   = "sending"
        msg.isFlash  = isFlash
        messages.insert(msg, at: 0)

        let convId    = conversation.id
        let receiveId = conversation.receiveId

        ChatRepository.shared.enqueue {
            do {
                // 压缩图片
                let compressed = try self.compressImage(at: fileURL)
                // 上传
                let result = try await UploadClient.shared.uploadFile(
                    at: compressed,
                    type: isFlash ? "chat_image" : "chat_image"
                ) { [weak self] progress in
                    await MainActor.run {
                        if let idx = self?.messages.firstIndex(where: { $0.id == msgId }) {
                            self?.messages[idx].uploadProgress = progress
                        }
                    }
                }

                // 签名 URL
                let signed = try? await UploadClient.shared.batchSignURLs([result.objectKey])
                let signedURL = signed?[result.objectKey]

                await MainActor.run {
                    if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                        self.messages[idx].objectKey = result.objectKey
                        self.messages[idx].imageUrl  = signedURL ?? fileURL.absoluteString
                    }
                }

                let payload: [String: Any] = isFlash
                    ? ["type": "flash_image", "objectKey": result.objectKey]
                    : ["type": "image", "objectKey": result.objectKey,
                       "url": signedURL ?? "", "expireAt": 0]

                await ChatRepository.shared.sendCustom(
                    conversationId: convId, payload: payload,
                    receiveId: receiveId,
                    pushDesc: isFlash ? "[闪照]" : "[图片]",
                    clientMsgId: msgId,
                    updateMsg: { id, status in
                        await MainActor.run {
                            if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                                self.messages[idx].status = status
                            }
                        }
                    },
                    updateServerId: { _, sid in
                        await MainActor.run {
                            if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                                self.messages[idx].serverId = sid
                            }
                        }
                    }
                )

                // 缓存本地
                if let data = try? Data(contentsOf: compressed), let key = result.objectKey as String? {
                    await MediaCacheService.shared.save(data: data, for: key)
                }

            } catch {
                await MainActor.run {
                    if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                        self.messages[idx].status = "failed"
                    }
                }
            }
        }
    }

    func sendVoice(fileURL: URL, duration: Int) {
        let msgId = UUID().uuidString
        var msg = ChatMessage(
            id: msgId, senderId: myUID, receiverId: conversation.id,
            type: .voice, content: "",
            createTime: Int(Date().timeIntervalSince1970 * 1000),
            isRead: false, isMe: true
        )
        msg.durationSec = duration
        msg.status = "sending"
        messages.insert(msg, at: 0)

        let convId    = conversation.id
        let receiveId = conversation.receiveId

        ChatRepository.shared.enqueue {
            do {
                let result = try await UploadClient.shared.uploadFile(at: fileURL, type: "chat_audio")
                let payload: [String: Any] = [
                    "type": "audio",
                    "objectKey": result.objectKey,
                    "durationSec": duration
                ]
                await ChatRepository.shared.sendCustom(
                    conversationId: convId, payload: payload,
                    receiveId: receiveId, pushDesc: "[语音]",
                    clientMsgId: msgId,
                    updateMsg: { _, status in
                        await MainActor.run {
                            if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                                self.messages[idx].status = status
                            }
                        }
                    },
                    updateServerId: { _, sid in
                        await MainActor.run {
                            if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                                self.messages[idx].serverId = sid
                            }
                        }
                    }
                )
            } catch {
                await MainActor.run {
                    if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
                        self.messages[idx].status = "failed"
                    }
                }
            }
        }
    }

    // ── 信号引用首条消息 ──────────────────────────────────────────────
    private func sendSignalQuote() async {
        guard let reply   = conversation.signalReply,
              let signalId = conversation.signalId else { return }

        let payload: [String: Any] = [
            "type": "signal_quote",
            "signalId": signalId,
            "replyContent": reply,
            "signalContent": conversation.signalContent ?? ""
        ]

        let msgId     = UUID().uuidString
        let convId    = conversation.id
        let receiveId = conversation.receiveId

        await ChatRepository.shared.sendCustom(
            conversationId: convId, payload: payload,
            receiveId: receiveId, pushDesc: "[信号回复]",
            clientMsgId: msgId,
            updateMsg: { _, _ in },
            updateServerId: { _, _ in }
        )

        // 本地记录已开启
        AppStorage.setBool(StorageKeys.imOpened(convId), true)
        // 调后端 open-im 接口
        try? await SignalRepository.shared.openIM(receiveId: Int(receiveId ?? "0") ?? 0)
    }

    // ── 撤回消息 ──────────────────────────────────────────────────────
    func revokeMessage(msgId: String) async {
        guard let idx = messages.firstIndex(where: { $0.id == msgId }) else { return }
        let snapshot = messages[idx]
        messages[idx].isRecalled = true
        do {
            try await ChatRepository.shared.revokeMessage(
                conversationId: conversation.id, clientMsgId: msgId
            )
        } catch {
            messages[idx] = snapshot
        }
    }

    // ── 闪照已查看 ────────────────────────────────────────────────────
    func markFlashViewed(msgId: String) {
        if let idx = messages.firstIndex(where: { $0.id == msgId }) {
            messages[idx].isDestroyed = true
        }
        let convId    = conversation.id
        let receiveId = conversation.receiveId
        ChatRepository.shared.enqueue {
            let payload: [String: Any] = ["type": "flash_viewed", "msgId": msgId]
            await ChatRepository.shared.sendCustom(
                conversationId: convId, payload: payload,
                receiveId: receiveId, pushDesc: "",
                clientMsgId: UUID().uuidString,
                updateMsg: { _, _ in }, updateServerId: { _, _ in }
            )
        }
    }

    // ── 辅助 ──────────────────────────────────────────────────────────
    private func insertTimeMarkers(into msgs: [ChatMessage]) -> [ChatMessage] {
        guard !msgs.isEmpty else { return [] }
        var result: [ChatMessage] = []
        var lastTimestamp: Int = 0

        for msg in msgs.reversed() {
            let diff = msg.createTime - lastTimestamp
            if diff > 5 * 60 * 1000 {   // 超 5 分钟插时间分隔
                let fmt = DateFormatter()
                fmt.dateFormat = "MM-dd HH:mm"
                let timeStr = fmt.string(from: Date(timeIntervalSince1970: TimeInterval(msg.createTime) / 1000))
                var marker = ChatMessage(
                    id: "time_\(msg.createTime)", senderId: "", receiverId: "",
                    type: .time, content: timeStr,
                    createTime: msg.createTime, isRead: true, isMe: false
                )
                result.append(marker)
                lastTimestamp = msg.createTime
            }
            result.append(msg)
        }
        return result.reversed()
    }

    private func refreshExpiredMediaURLs(in msgs: inout [ChatMessage]) async {
        let newURLs = await MediaCacheService.shared.refreshExpiredURLs(for: msgs)
        for idx in msgs.indices {
            if let key = msgs[idx].objectKey, let url = newURLs[key] {
                msgs[idx].imageUrl = url
            }
        }
    }

    private func prefetchMedia(in msgs: [ChatMessage]) {
        for msg in msgs {
            guard let key = msg.objectKey, let url = msg.imageUrl else { continue }
            if msg.isFlash { continue }   // 闪照不缓存
            Task { await MediaCacheService.shared.prefetch(url: url, objectKey: key) }
        }
    }

    private func markConversationRead() {
        // OpenIM SDK 标记已读(内部实现,无需业务层显式调用)
    }

    private func compressImage(at url: URL) throws -> URL {
        guard let image = UIImage(contentsOfFile: url.path) else {
            throw AppAPIError(code: -1, message: "图片加载失败")
        }
        let maxSize = 200 * 1024   // 200KB
        var data    = image.jpegData(compressionQuality: 0.8) ?? Data()
        if data.count > maxSize {
            var q: CGFloat = 0.7
            while data.count > maxSize && q > 0.1 {
                data = image.jpegData(compressionQuality: q) ?? data
                q -= 0.1
            }
        }
        let out = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).jpg")
        try data.write(to: out)
        return out
    }
}
```

### View

```swift
// ConversationView.swift
struct ConversationView: View {
    let conversation: Conversation
    @State private var vm: ConversationViewModel
    @State private var scrollProxy: ScrollViewProxy?
    @Environment(\.dismiss) private var dismiss

    init(conversation: Conversation) {
        self.conversation = conversation
        _vm = State(initialValue: ConversationViewModel(conversation: conversation))
    }

    var body: some View {
        VStack(spacing: 0) {
            // 消息列表(reverse: 最新在底部,列表倒序排列)
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(spacing: 0) {
                        // 加载更多
                        if vm.hasMoreHistory {
                            Button {
                                Task { await vm.loadMoreHistory() }
                            } label: {
                                Text("加载更多").font(.caption).foregroundStyle(.secondary)
                                    .frame(maxWidth: .infinity).padding(.vertical, 12)
                            }
                        }

                        ForEach(vm.messages.reversed()) { msg in
                            MessageBubble(
                                message: msg,
                                onRevoke: { Task { await vm.revokeMessage(msgId: msg.id) } },
                                onFlashViewed: { vm.markFlashViewed(msgId: msg.id) }
                            )
                            .id(msg.id)
                            .padding(.vertical, 2)
                        }
                    }
                    .padding(.horizontal, 12)
                }
                .onAppear { scrollProxy = proxy }
                .defaultScrollAnchor(.bottom)
            }

            // 新消息悬浮提示
            if vm.newMessageBannerCount > 0 {
                Button {
                    vm.newMessageBannerCount = 0
                    vm.isAtBottom = true
                } label: {
                    Text("\(vm.newMessageBannerCount) 条新消息 ↓")
                        .font(.caption.weight(.medium))
                        .padding(.horizontal, 12).padding(.vertical, 6)
                        .glassEffect()
                }
                .padding(.bottom, 4)
            }

            Divider()

            // 输入栏
            ChatInputBar(
                conversationId: conversation.id,
                onSendText:  { vm.sendText($0) },
                onSendImage: { vm.sendImage(fileURL: $0) },
                onSendFlash: { vm.sendImage(fileURL: $0, isFlash: true) },
                onSendVoice: { url, dur in vm.sendVoice(fileURL: url, duration: dur) }
            )
        }
        .navigationBarTitleDisplayMode(.inline)
        .navigationBarBackButtonHidden(false)
        .toolbar {
            ToolbarItem(placement: .principal) {
                ChatNavBar(conversation: conversation)
            }
            ToolbarItem(placement: .topBarTrailing) {
                NavigationLink(destination: ChatSettingsView(conversation: conversation)) {
                    Image(systemName: "ellipsis.circle")
                }
            }
        }
        .task { await vm.onAppear() }
        .onDisappear { vm.onDisappear() }
    }
}

// ── 导航栏 ────────────────────────────────────────────────────────────────
struct ChatNavBar: View {
    let conversation: Conversation

    var body: some View {
        HStack(spacing: 8) {
            AsyncImage(url: URL(string: conversation.avatar)) { img in
                img.resizable().scaledToFill()
            } placeholder: {
                Circle().fill(.secondary.opacity(0.2))
            }
            .frame(width: 32, height: 32).clipShape(Circle())
            .overlay(alignment: .bottomTrailing) {
                if conversation.isOnline {
                    Circle().fill(.green).frame(width: 9, height: 9)
                }
            }

            VStack(alignment: .leading, spacing: 1) {
                Text(conversation.nickname).font(.subheadline.weight(.semibold))
                if conversation.isOnline {
                    Text("在线").font(.caption2).foregroundStyle(.green)
                }
            }
        }
    }
}
```

---

## 七、ChatInputBar(输入栏)

```swift
// ChatInputBar.swift
struct ChatInputBar: View {
    let conversationId: String
    let onSendText:  (String) -> Void
    let onSendImage: (URL) -> Void
    let onSendFlash: (URL) -> Void
    let onSendVoice: (URL, Int) -> Void

    @State private var text = ""
    @State private var isVoiceMode = false
    @State private var showMediaPicker = false
    @State private var isRecording = false
    @State private var recordedURL: URL?
    @State private var recordDuration = 0

    private let recorder = VoiceRecordingSession()

    var body: some View {
        HStack(alignment: .bottom, spacing: 8) {
            // 语音 / 键盘 切换
            Button {
                isVoiceMode.toggle()
            } label: {
                Image(systemName: isVoiceMode ? "keyboard" : "waveform")
                    .font(.title3)
                    .frame(width: 36, height: 36)
            }
            .foregroundStyle(.secondary)

            if isVoiceMode {
                // 长按录音按钮
                VoicePressButton { event in
                    switch event {
                    case .began:   recorder.start()
                    case .ended:   if let (url, dur) = recorder.stop() { onSendVoice(url, dur) }
                    case .cancelled: recorder.cancel()
                    }
                }
                .frame(maxWidth: .infinity)
            } else {
                // 文字输入
                TextField("发送消息", text: $text, axis: .vertical)
                    .lineLimit(1...5)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit { sendText() }
            }

            // 媒体 / 发送 按钮
            if text.isEmpty && !isVoiceMode {
                Button {
                    showMediaPicker = true
                } label: {
                    Image(systemName: "photo")
                        .font(.title3)
                        .frame(width: 36, height: 36)
                }
                .foregroundStyle(.secondary)
            } else if !text.isEmpty {
                Button {
                    sendText()
                } label: {
                    Image(systemName: "paperplane.fill")
                        .font(.title3)
                        .frame(width: 36, height: 36)
                }
                .foregroundStyle(.accent)
            }
        }
        .padding(.horizontal, 12)
        .padding(.vertical, 8)
        .background(.background)
        .sheet(isPresented: $showMediaPicker) {
            MediaPickerSheet(
                onPickNormal: { url in onSendImage(url) },
                onPickFlash:  { url in onSendFlash(url) }
            )
            .presentationDetents([.medium])
        }
    }

    private func sendText() {
        let t = text
        text = ""
        onSendText(t)
    }
}

// 媒体选择 Sheet(普通图片 / 闪照 / 多图)
struct MediaPickerSheet: View {
    let onPickNormal: (URL) -> Void
    let onPickFlash:  (URL) -> Void
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            List {
                Button {
                    // PHPickerViewController → 普通图片
                    dismiss()
                } label: {
                    Label("图片", systemImage: "photo")
                }
                Button {
                    // PHPickerViewController → 闪照
                    dismiss()
                } label: {
                    Label("闪照(阅后即焚)", systemImage: "eye.slash")
                }
            }
            .navigationTitle("发送媒体")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("取消") { dismiss() }
                }
            }
        }
    }
}
```

---

## 八、消息气泡(MessageBubble)

```swift
// MessageBubble.swift
struct MessageBubble: View {
    let message: ChatMessage
    let onRevoke: () -> Void
    let onFlashViewed: () -> Void

    var body: some View {
        HStack(alignment: .bottom, spacing: 8) {
            if message.isMe { Spacer(minLength: 40) }

            if !message.isMe && message.type != .time {
                AsyncImage(url: nil) { _ in EmptyView() } placeholder: {
                    Circle().fill(.secondary.opacity(0.2))
                }
                .frame(width: 32, height: 32).clipShape(Circle())
            }

            bubbleContent
                .contextMenu {
                    if message.isMe && !message.isRecalled && message.type != .time {
                        Button(role: .destructive, action: onRevoke) {
                            Label("撤回", systemImage: "arrow.uturn.left")
                        }
                    }
                }

            if !message.isMe { Spacer(minLength: 40) }
        }
        .padding(.vertical, 2)
    }

    @ViewBuilder
    private var bubbleContent: some View {
        switch message.type {
        case .time:
            Text(message.content as? String ?? "")
                .font(.caption2).foregroundStyle(.secondary)
                .frame(maxWidth: .infinity)
                .padding(.vertical, 8)

        case .revoked, _ where message.isRecalled:
            Text("消息已撤回")
                .font(.caption).foregroundStyle(.secondary)
                .padding(.horizontal, 12).padding(.vertical, 8)
                .background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))

        case .text:
            TextBubble(text: message.content as? String ?? "", isMe: message.isMe,
                       status: message.status)

        case .image:
            ImageBubble(url: message.imageUrl, isExpired: message.isURLExpired,
                        progress: message.uploadProgress, status: message.status)

        case .flashImage:
            FlashImageBubble(
                url: message.imageUrl,
                isMe: message.isMe,
                isDestroyed: message.isDestroyed,
                onViewed: onFlashViewed
            )

        case .voice:
            VoiceMessageBubble(
                url: message.imageUrl,
                duration: message.durationSec ?? 0,
                isMe: message.isMe
            )

        case .signalQuote:
            SignalQuoteBubble(message: message)

        default:
            Text("[未知消息类型]")
                .font(.caption).foregroundStyle(.secondary)
        }
    }
}

// ── 文字气泡 ──────────────────────────────────────────────────────────────
struct TextBubble: View {
    let text: String; let isMe: Bool; let status: String

    var body: some View {
        HStack(alignment: .bottom, spacing: 4) {
            if isMe {
                statusIcon
                Text(text)
                    .padding(.horizontal, 12).padding(.vertical, 8)
                    .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 16))
                    .foregroundStyle(.white)
            } else {
                Text(text)
                    .padding(.horizontal, 12).padding(.vertical, 8)
                    .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
                statusIcon
            }
        }
    }

    @ViewBuilder
    private var statusIcon: some View {
        switch status {
        case "sending": ProgressView().scaleEffect(0.6)
        case "failed":  Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red)
        default:        EmptyView()
        }
    }
}

// ── 图片气泡 ──────────────────────────────────────────────────────────────
struct ImageBubble: View {
    let url: String?; let isExpired: Bool
    let progress: Double; let status: String

    var body: some View {
        ZStack {
            if isExpired {
                RoundedRectangle(cornerRadius: 12)
                    .fill(.secondary.opacity(0.1))
                    .frame(width: 180, height: 180)
                    .overlay {
                        VStack(spacing: 8) {
                            Image(systemName: "photo.slash").font(.title2).foregroundStyle(.secondary)
                            Text("图片已过期").font(.caption).foregroundStyle(.secondary)
                        }
                    }
            } else {
                AsyncImage(url: url.flatMap(URL.init)) { img in
                    img.resizable().scaledToFill()
                } placeholder: {
                    Rectangle().fill(.secondary.opacity(0.2))
                }
                .frame(width: 180, height: 180)
                .clipShape(RoundedRectangle(cornerRadius: 12))

                if status == "sending" && progress > 0 {
                    RoundedRectangle(cornerRadius: 12)
                        .fill(.black.opacity(0.4))
                        .frame(width: 180, height: 180)
                        .overlay {
                            VStack(spacing: 8) {
                                ProgressView(value: progress)
                                    .tint(.white).frame(width: 120)
                                Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.white)
                            }
                        }
                }
            }
        }
    }
}

// ── 闪照气泡 ──────────────────────────────────────────────────────────────
struct FlashImageBubble: View {
    let url: String?; let isMe: Bool
    let isDestroyed: Bool; let onViewed: () -> Void

    var body: some View {
        if isDestroyed {
            Text(isMe ? "闪照已被查看" : "闪照已查看")
                .font(.caption).foregroundStyle(.secondary)
                .padding(.horizontal, 12).padding(.vertical, 8)
                .background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
        } else if isMe {
            // 发送方:显示缩略图
            AsyncImage(url: url.flatMap(URL.init)) { img in
                img.resizable().scaledToFill()
            } placeholder: {
                Rectangle().fill(.secondary.opacity(0.2))
            }
            .frame(width: 120, height: 120)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .overlay { Image(systemName: "eye.slash.fill").foregroundStyle(.white.opacity(0.8)) }
        } else {
            // 接收方:点击才可查看
            Button(action: onViewed) {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.accentColor.opacity(0.15))
                    .frame(width: 120, height: 120)
                    .overlay {
                        VStack(spacing: 8) {
                            Image(systemName: "eye").font(.title2).foregroundStyle(.accent)
                            Text("点击查看闪照").font(.caption2).foregroundStyle(.accent)
                        }
                    }
            }
        }
    }
}

// ── 语音气泡 ──────────────────────────────────────────────────────────────
struct VoiceMessageBubble: View {
    let url: String?; let duration: Int; let isMe: Bool
    @State private var isPlaying = false

    var body: some View {
        Button {
            togglePlay()
        } label: {
            HStack(spacing: 8) {
                Image(systemName: isPlaying ? "stop.fill" : "play.fill")
                    .font(.subheadline)
                Image(systemName: "waveform")
                    .symbolEffect(.variableColor, isActive: isPlaying)
                Text("\(duration)″").font(.caption.monospacedDigit())
            }
            .padding(.horizontal, 12).padding(.vertical, 8)
            .background(isMe ? Color.accentColor : Color.secondary.opacity(0.15),
                        in: RoundedRectangle(cornerRadius: 20))
            .foregroundStyle(isMe ? .white : .primary)
        }
    }

    private func togglePlay() {
        // 使用 AVPlayer 播放
        isPlaying.toggle()
    }
}

// ── 信号引用气泡 ──────────────────────────────────────────────────────────
struct SignalQuoteBubble: View {
    let message: ChatMessage

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("信号引用").font(.caption.weight(.semibold)).foregroundStyle(.accent)
            if let content = message.signalQuoteContent {
                Text(content).font(.caption).foregroundStyle(.secondary).lineLimit(2)
            }
            if let reply = message.signalQuoteReply {
                Divider()
                Text(reply).font(.caption)
            }
        }
        .padding(12)
        .background(.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
        .overlay {
            RoundedRectangle(cornerRadius: 12)
                .strokeBorder(.accent.opacity(0.3), lineWidth: 1)
        }
        .frame(maxWidth: 240, alignment: .leading)
    }
}
```

---

## 九、聊天设置(ChatSettingsView)

```swift
// ChatSettingsView.swift
struct ChatSettingsView: View {
    let conversation: Conversation
    @State private var showClearConfirm = false
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        List {
            Section {
                // 对方资料
                NavigationLink(destination: UserProfileView(uid: conversation.targetUid)) {
                    HStack(spacing: 12) {
                        AsyncImage(url: URL(string: conversation.avatar)) { img in
                            img.resizable().scaledToFill()
                        } placeholder: {
                            Circle().fill(.secondary.opacity(0.2))
                        }
                        .frame(width: 40, height: 40).clipShape(Circle())

                        Text(conversation.nickname).font(.body)
                    }
                }
            }

            Section {
                Button("清空聊天记录", role: .destructive) {
                    showClearConfirm = true
                }
            }
        }
        .navigationTitle("聊天设置")
        .navigationBarTitleDisplayMode(.inline)
        .alert("清空聊天记录", isPresented: $showClearConfirm) {
            Button("清空", role: .destructive) {
                Task {
                    try? await IMAdapter.shared.clearHistory(conversationID: conversation.id)
                    dismiss()
                }
            }
            Button("取消", role: .cancel) { }
        } message: {
            Text("此操作将清除本地聊天记录,不可恢复。")
        }
    }
}
```

---

## 十、IM 协议速查(OpenIM contentType 与自定义消息格式)

> 实现在 `IMMessageMapper`(见 `swift_IM适配器与推送.md`)。此处仅备查业务规格。

| contentType | 含义 |
|---|---|
| `101` | 文字(`textElem.content`) |
| `110` | 自定义(`customElem.data`:JSON 字符串) |
| `2101` | 撤回通知 |

**自定义消息 type 字段值:**

| type | 说明 | 关键字段 |
|---|---|---|
| `image` | 单张图片 | `objectKey`, `url`, `expireAt`, `w`, `h` |
| `flash_image` | 闪照 | `objectKey`, `url`, `expireAt` |
| `flash_viewed` | 闪照已查看 | `msgId`(目标消息的 clientMsgId) |
| `multi_image` | 多图 | `multiObjectKeys: [String]`, `multiUrls: [String]`, `expireAt` |
| `audio` | 语音 | `objectKey`, `url`, `expireAt`, `durationSec` |
| `signal_quote` | 信号引用首条消息 | `signalId`, `replyContent`, `signalContent` |

**会话 ID 规则:**
```
imUid = "tb_" + 业务UID
conversationId = "si_" + sorted([myImUid, targetImUid]).joined("_")
```

---

## 十一、路由注册

```swift
// MainTabView — 消息 Tab 挂载 ConversationListView
// ConversationListView → NavigationDestination(for: Conversation.self)
// SignalDetailView → enterChat() 时 push ConversationView(conversation:)
// ChatSettingsView → ConversationView 导航栏右上角跳转
```

---

## 十二、已修复问题记录

| 问题 | 根因 | 修复 |
|-----|------|------|
| 多图消息无法加载(接收方首次查看) | `metch.oss.xiaopai.vip` HTTP 未在 `Info.plist` 中豁免 ATS,URLSession 和 Kingfisher 均被系统拒绝 | `Info.plist` 新增 `NSExceptionDomains` 条目 |
| 多图 URL 写回不生效(历史消息 re-resolve) | `msgs[idx].multiImageUrls?[ki] = url` 在 `inout` 数组上可选链赋值有写回风险 | 改为提取临时变量修改后整体赋值 |
| 多图气泡崩溃(`multiImageUrls` 为空时) | `MultiImageBubble.card(at: 0)` 无条件执行 `urls[0]`,数组为空时越界 | `body` 顶部加 `if urls.isEmpty` 占位分支 |
| 消息重复出现 | 乐观插入和 OpenIM 回显各生成不同 `clientMsgId` | `ChatRepository.sendText/sendCustom` 接收调用方传入的 `clientMsgId` 而非内部重新生成 |
| 撤回无效 | `IMAdapter.revokeMessage` 基于 OpenIM SDK 调用,但消息通过 REST API 发送,OpenIM 不认识该消息 | 改用 `ChatRepository.revokeMessage` 调用后端 `/chat/message/revoke` |
| 消息列表顺序(最新消息应在底部) | `defaultScrollAnchor(.bottom)` 在异步加载后失效 | ScrollView + 每条消息均旋转 `180°`,镜像 Flutter `ListView(reverse: true)` 行为 |