fix(im): 修复发送方消息一直卡在"发送中"圆圈、下方不显示已读/未读的 Bug

现象
----
在单聊里发送新消息后,本人气泡左侧一直显示 loop 圆圈(_sending=true),
气泡下方看不到"已读 / 未读"标签;但对端能正常收到消息。刷新页面、
走 fetchHistory 之后才能恢复正常显示。

根因
----
1. services/websocket.js 的 _onMessage:一旦发现 event 以 ".ack" 结尾,
   就走 _handleAck(seq→pendingAcks) 处理完后 **直接 return**,不再 _emit。
   这样通过 on('im.message.send.ack') / on('im.message.read.ack')
   订阅 ACK 的 listener(chat store 的 _onSendACK / _onReadACK)
   **永远收不到回调**,导致 _sending 标记一直没被清、id 没被合入,
   模板里 getReadLabel() 因为 !msg.id / _sending=true 返回空字符串。
2. store/chat.js 的 _appendMessage:按 client_msg_id 命中旧临时消息时,
   只是 isDup=true 直接 return,把服务端权威字段(包括 id)丢弃了,
   没有形成有效的兜底路径。

修复
----
- websocket.js: .ack 报文在 _handleAck 之后 **继续走 _emit**,让 Promise
  通路(sendWithAck → pendingAcks)与订阅通路(on(...ack))并行分发,
  互不冲突。这是真正解决"卡圈"的主修复。
- chat.js _appendMessage: 把 dedup 改成 "就地合并" 语义:
  * 同 id 命中 → 真重复丢弃(不回退)
  * 同 client_msg_id 命中本地 _sending 临时消息 → 用服务端字段
    覆盖 id/created_at/..., 同时清 _sending/_failed
  * 其余情况才 push 新条
  作为广播帧先于 ACK 到达时的二级兜底,也保证后续再有 ACK/广播
  重复到达不会产生重复气泡。

影响面
------
- 单聊/群聊 发送消息后"已读 / 未读 / N人已读"标签恢复实时显示
- im.message.read.ack(对方读到某条)也能正常驱动 readStatusMap 更新
- meeting 等模块的 sendWithAck() 不受影响(走的是 pendingAcks Promise
  通路,本次只是在此之后多了一个 _emit 分发,对业务无副作用)

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 17:05:31 +08:00
parent ea28549a5f
commit 310ea03ce1
2 changed files with 38 additions and 9 deletions

View File

@@ -267,11 +267,17 @@ class WebSocketService {
_onMessage(data) { _onMessage(data) {
try { try {
const msg = JSON.parse(data) const msg = JSON.parse(data)
// Task 9ACK 报文event 以 ".ack" 结尾)走 pendingAcks 处理 // Task 9ACK 报文event 以 ".ack" 结尾)会先走 pendingAcks 处理
// 后端 ws.NewResponse 固定在原 event 后追加 ".ack" // 后端 ws.NewResponse 固定在原 event 后追加 ".ack"
//
// 修复2026-04-23历史实现在命中 _handleAck 后直接 return
// 导致通过 on('xxx.ack') 订阅 ACK 事件的 listenerchat store 的
// _onSendACK / _onReadACK永远收不到回调表现为
// "发送方消息一直卡在 _sending=true气泡下方不显示 已读/未读"。
// 两条通路互不冲突sendWithAck() 按 seq 命中 pending
// on('xxx.ack') 按 event 命中 listener。此处改为并行分发。
if (typeof msg.event === 'string' && msg.event.endsWith('.ack')) { if (typeof msg.event === 'string' && msg.event.endsWith('.ack')) {
this._handleAck(msg) this._handleAck(msg)
return
} }
this._emit(msg.event, msg) this._emit(msg.event, msg)
} catch (e) { } catch (e) {

View File

@@ -507,19 +507,42 @@ export const useChatStore = defineStore('chat', () => {
// ==================== 内部工具方法 ==================== // ==================== 内部工具方法 ====================
/** 追加消息到缓存(带去重检查,防止重连/ACK 导致重复) */ /**
* 追加消息到缓存
*
* 去重 + 临时消息合并2026-04-23 加固):
* - 若已存在同 id 的消息:真重复,直接丢弃
* - 若匹配到相同 client_msg_id 的本地临时消息_sending=true
* 就地把服务端权威字段id/created_at/...)合并进去,并清除
* _sending / _failed 标记;这样即使 ACK 丢失、但广播帧到了,
* 也能把发送方的"转圈"状态变成已入库可读/未读态,避免 UX 卡死
* - 否则追加一条新消息
*/
const _appendMessage = (conversationId, message) => { const _appendMessage = (conversationId, message) => {
if (!messagesMap.value[conversationId]) { if (!messagesMap.value[conversationId]) {
messagesMap.value[conversationId] = [] messagesMap.value[conversationId] = []
} }
const list = messagesMap.value[conversationId] const list = messagesMap.value[conversationId]
const isDup = list.some(m =>
(message.id && m.id === message.id) || if (message.id) {
(message.client_msg_id && m.client_msg_id === message.client_msg_id) const idIdx = list.findIndex(m => m.id === message.id)
) if (idIdx !== -1) return
if (!isDup) {
list.push(message)
} }
if (message.client_msg_id) {
const tmpIdx = list.findIndex(m => m.client_msg_id === message.client_msg_id)
if (tmpIdx !== -1) {
list[tmpIdx] = {
...list[tmpIdx],
...message,
_sending: false,
_failed: false
}
return
}
}
list.push(message)
} }
/** 更新会话列表中的预览信息 */ /** 更新会话列表中的预览信息 */