feat(im): 统一消息读位置和回执状态模型

- 新增 im_conversation_read 会话读位置表,并补充消息存储推拉相关索引
- 群消息固化 receiver_user_ids 快照,按可见成员快照拉取和统计回执
- 统一消息 status 为 NORMAL/RECALL,新增私聊 receipt_status 并复用统一回执状态
- 前端改用 receiptStatus 展示私聊已读、群回执和频道已读态
- 补齐私聊、群聊、频道 WebSocket 已读同步和离线补偿逻辑
- 更新 IM 消息状态、回执状态字典和管理后台展示
- 调整相关单测和测试建表脚本
pull/884/MERGE
YunaiV 2026-06-14 09:34:16 +08:00
parent 8c796950f9
commit cf85fd4c86
18 changed files with 87 additions and 86 deletions

View File

@ -13,7 +13,7 @@ export interface ImManagerGroupMessageVO {
atUserIds?: number[]
// 与 atUserIds 同长度;后端对找不到 / 已删除的成员返回 nullUI 用 `?.[idx] || userId` 回退到 userId 渲染
atUserNicknames?: (string | null)[]
receiptStatus?: number
receiptStatus: number
sendTime: Date
createTime: Date
}

View File

@ -10,6 +10,7 @@ export interface ImManagerPrivateMessageVO {
type: number
content: string
status: number
receiptStatus: number
sendTime: Date
createTime: Date
}

View File

@ -7,8 +7,7 @@ export interface ImChannelMessageRespVO {
materialId: number
type: number
content: string
/** 当前用户已读态pull 时按 Redis 游标计算填充,多端同步使用 */
status?: number
receiptStatus?: number
sendTime: string
}

View File

@ -8,7 +8,8 @@ export interface ImPrivateMessageRespVO {
receiverId: number // 接收人编号
type: number // 消息类型
content: string // 消息内容JSON 格式)
status: number // 消息状态
status: number // 消息状态(正常 / 已撤回)
receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成),对齐 ImMessageReceiptStatus
sendTime: string // 发送时间
}
@ -18,6 +19,7 @@ export interface ImPrivateMessageSendReqVO {
receiverId: number // 接收人编号
type: number // 消息类型
content: string // 消息内容JSON 格式)
receipt?: boolean // 是否需要回执;不传后端默认 true普通私聊用户消息
}
// 私聊历史消息列表 Request VO

View File

@ -329,9 +329,8 @@ export enum DICT_TYPE {
// ========== IM - 即时通讯模块 ==========
IM_MESSAGE_TYPE = 'im_message_type', // IM 消息类型
IM_PRIVATE_MESSAGE_STATUS = 'im_private_message_status', // IM 私聊消息状态0=未读 / 2=已撤回 / 3=已读
IM_GROUP_MESSAGE_STATUS = 'im_group_message_status', // IM 群聊消息状态0=正常 / 2=已撤回
IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态
IM_MESSAGE_STATUS = 'im_message_status', // IM 消息状态0=正常 / 2=已撤回(私聊 / 群聊共用)
IM_MESSAGE_RECEIPT_STATUS = 'im_message_receipt_status', // IM 消息回执状态0=不需要 / 1=待完成 / 2=已完成
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态
IM_FRIEND_ADD_SOURCE = 'im_friend_add_source', // IM 好友添加来源
IM_FRIEND_REQUEST_HANDLE_RESULT = 'im_friend_request_handle_result', // IM 好友申请处理结果

View File

@ -76,6 +76,7 @@ export const useMessagePuller = () => {
type: message.type,
content: message.content,
status: message.status,
receiptStatus: message.receiptStatus,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
targetId: getPrivatePeerId(message),
@ -109,7 +110,8 @@ export const useMessagePuller = () => {
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status ?? ImMessageStatus.UNREAD,
status: ImMessageStatus.NORMAL, // 频道无撤回,恒为正常
receiptStatus: message.receiptStatus, // 频道已读态DONE 已读 / PENDING 未读
sendTime: new Date(message.sendTime).getTime(),
senderId: 0, // 系统下发,无发送人
targetId: message.channelId, // 会话归属到频道编号

View File

@ -53,7 +53,7 @@ interface SendExtOptions {
*
*
* 1. / conversation.type
* 2. insertMessage SENDING ackMessage UNREAD FAILED
* 2. insertMessage SENDING ackMessage NORMAL FAILED
* 3. WebSocket RECALL websocketStore 退
* 4.
*/
@ -134,7 +134,7 @@ export const useMessageSender = () => {
messageStore.insertMessage(conversationInfo, message)
}
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 NORMAL,失败更新为 FAILED
try {
if (conversation.type === ImConversationType.PRIVATE) {
const data = await apiSendPrivateMessage({
@ -147,6 +147,7 @@ export const useMessageSender = () => {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
receiptStatus: data.receiptStatus,
content: data.content
})
} else if (conversation.type === ImConversationType.GROUP) {
@ -222,9 +223,8 @@ export const useMessageSender = () => {
if (!conversation) {
return
}
// 本地标记已读:未读数清零 + 消息状态更新为 READUI 立刻响应)
// 本地标记已读:未读数清零UI 立刻响应)
conversationStore.markConversationRead(conversation.type, conversation.targetId)
messageStore.markConversationMessageListRead(conversation)
const maxMessageId = messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.reduce<number>(
@ -286,7 +286,7 @@ export const useMessageSender = () => {
if (!maxReadId) {
return
}
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息回执更新为 DONE
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,

View File

@ -149,7 +149,7 @@
v-else-if="privateReadLabel"
class="text-12px whitespace-nowrap"
:class="
message.status === ImMessageStatus.READ
message.receiptStatus === ImMessageReceiptStatus.DONE
? 'text-[#409eff]'
: 'text-[var(--el-text-color-secondary)]'
"
@ -202,7 +202,7 @@ import {
ImForwardMode,
ImMessageType,
ImMessageStatus,
ImGroupReceiptStatus,
ImMessageReceiptStatus,
ImConversationType,
ImFriendAddSource,
ImGroupMemberRole,
@ -527,10 +527,10 @@ const privateReadLabel = computed(() => {
if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) {
return ''
}
if (props.message.status === ImMessageStatus.READ) {
if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) {
return '已读'
}
if (props.message.status === ImMessageStatus.UNREAD) {
if (props.message.receiptStatus === ImMessageReceiptStatus.PENDING) {
return '未读'
}
return ''
@ -551,7 +551,7 @@ const showGroupReadStatus = computed(() => {
if (status === undefined || status === null) {
return false
}
return status !== ImGroupReceiptStatus.NO_RECEIPT
return status !== ImMessageReceiptStatus.NO_RECEIPT
})
/** 当前群成员(供 MessageReadStatus 计算未读名单;未加载完时兜底空数组不渲染) */

View File

@ -57,7 +57,7 @@ import { computed, ref } from 'vue'
import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/group'
import { CommonStatusEnum } from '@/utils/constants'
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
import { ImConversationType, ImMessageReceiptStatus } from '../../../../../utils/constants'
import type { Message } from '../../../../types'
import { useMessageStore } from '../../../../store/messageStore'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -88,7 +88,7 @@ const readUserIds = ref<number[]>([])
* - 其他readCount = 0 undefined且未到 DONE显示"未读"
*/
const label = computed(() => {
if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) {
if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) {
return '全部已读'
}
const readCount = props.message.readCount || 0
@ -147,15 +147,15 @@ async function loadReadUsers() {
})
readUserIds.value = userIds || []
const readCount = readUserIds.value.length
// flip DONE label ""
// readCountreceiptStatus PENDING / READING
// DONE label
// readCountreceiptStatus PENDING
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: props.groupId,
groupMessageId: props.message.id,
readCount,
receiptStatus: allRead ? ImGroupReceiptStatus.DONE : undefined
receiptStatus: allRead ? ImMessageReceiptStatus.DONE : undefined
})
} catch (error) {
console.error('[IM] 拉取群已读列表失败:', error)

View File

@ -391,9 +391,9 @@ export const useGroupStore = defineStore('imGroupStore', {
this.saveGroup(merged)
},
/** 本地移除由 WebSocket GROUP_DEL 事件触发 */
/** 本地移除群缓存和群会话;群解散GROUP_DEL、退群、被踢都复用 */
removeGroup(id: number) {
// 群解散是硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群
// 本地硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群
this.groups = this.groups.filter((g) => g.id !== id)
const conversationStore = useConversationStore()
conversationStore.removeGroupConversation(id)
@ -735,7 +735,7 @@ export const useGroupStore = defineStore('imGroupStore', {
senderId: message.senderId,
type: message.type,
content: message.content,
status: ImMessageStatus.UNREAD,
status: ImMessageStatus.NORMAL,
sendTime: new Date(message.sendTime).getTime(),
targetId: message.groupId || groupId,
selfSend: message.senderId === getCurrentUserId(),

View File

@ -4,6 +4,7 @@ import { store } from '@/store'
import {
IM_AT_ALL_USER_ID,
ImConversationType,
ImMessageReceiptStatus,
ImMessageStatus,
ImMessageType,
isGroupNotification,
@ -194,8 +195,7 @@ function syncConversationAtFlags(conversation: Conversation, message: Message):
message.selfSend ||
conversation.type !== ImConversationType.GROUP ||
!message.atUserIds ||
message.atUserIds.length === 0 ||
message.status === ImMessageStatus.READ
message.atUserIds.length === 0
) {
return
}
@ -518,7 +518,6 @@ export const useMessageStore = defineStore('imMessageStore', {
!message.selfSend &&
!isActive &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.READ &&
message.status !== ImMessageStatus.RECALL
) {
conversation.unreadCount++
@ -616,7 +615,6 @@ export const useMessageStore = defineStore('imMessageStore', {
!message.selfSend &&
!isActive &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.READ &&
message.status !== ImMessageStatus.RECALL
) {
conversation.unreadCount++
@ -779,9 +777,9 @@ export const useMessageStore = defineStore('imMessageStore', {
message.selfSend &&
message.id &&
message.id <= options.privateReadMaxId! &&
message.status !== ImMessageStatus.RECALL
message.receiptStatus === ImMessageReceiptStatus.PENDING
) {
message.status = ImMessageStatus.READ
message.receiptStatus = ImMessageReceiptStatus.DONE
changed.push(message)
}
})
@ -871,28 +869,6 @@ export const useMessageStore = defineStore('imMessageStore', {
conversationStore.saveConversation(conversation)
},
/** 当前会话标记已读 */
markConversationMessageListRead(conversation: Conversation) {
const messages = this.getMessageList(conversation.type, conversation.targetId)
const changed: Message[] = []
messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ
changed.push(message)
}
})
if (changed.length === 0) {
return
}
void getDb()
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of changed) {
await this.saveMessageRecord(message, conversation.type, tx)
}
})
.catch((e) => console.warn('[IM messageStore] 已读状态写入失败', e))
},
/** 删除会话全部消息 */
deleteConversationMessageList(conversationType: number, targetId: number) {
// 1. 清理内存消息和媒体资源

View File

@ -5,6 +5,7 @@ import { getCurrentUserId, getRefreshToken } from '@/utils/auth'
import {
ImWebSocketMessageType,
ImMessageStatus,
ImMessageReceiptStatus,
ImMessageType,
ImConversationType,
ImRtcCallMediaType,
@ -41,6 +42,7 @@ import {
} from './rtcStore'
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
import { readChannelMessages as apiReadChannelMessages } from '@/api/im/message/channel'
import type { ImChannelMessageRespVO } from '@/api/im/message/channel'
import { buildChannelConversationStub } from '../../utils/channel'
import type {
@ -106,6 +108,7 @@ const convertPrivateMessage = (
type: websocketMessage.type,
content: websocketMessage.content,
status: websocketMessage.status,
receiptStatus: websocketMessage.receiptStatus,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
targetId: getPrivateMessagePeerId(websocketMessage, currentUserId),
@ -334,19 +337,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
typeof websocketMessage.sendTime === 'number'
? websocketMessage.sendTime
: new Date(websocketMessage.sendTime).getTime()
messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
id: websocketMessage.id,
clientMessageId: '',
type: websocketMessage.type,
content: websocketMessage.content,
status: ImMessageStatus.UNREAD,
sendTime: sendTimeMs,
senderId: 0,
targetId: websocketMessage.channelId,
selfSend: false,
materialId: websocketMessage.materialId
})
// 非当前会话 + 未免打扰:响一下提示音
const conversation = conversationStore.getConversation(
ImConversationType.CHANNEL,
websocketMessage.channelId
@ -354,7 +344,28 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const isActive =
conversationStore.activeConversation?.type === ImConversationType.CHANNEL &&
conversationStore.activeConversation?.targetId === websocketMessage.channelId
if (!isActive && !conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 频道单向订阅receiptStatus 表达「我是否已读这条」:会话打开即已读 DONE否则 PENDING与 pull 口径一致)
messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
id: websocketMessage.id,
clientMessageId: '',
type: websocketMessage.type,
content: websocketMessage.content,
status: ImMessageStatus.NORMAL,
receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING,
sendTime: sendTimeMs,
senderId: 0,
targetId: websocketMessage.channelId,
selfSend: false,
materialId: websocketMessage.materialId
})
if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
conversationStore.markConversationRead(ImConversationType.CHANNEL, websocketMessage.channelId)
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 频道自动已读上报失败', e)
})
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音
playAudioTip()
}
},
@ -543,9 +554,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
conversationStore.markConversationRead(ImConversationType.PRIVATE, peerId)
if (conversation) {
useMessageStore().markConversationMessageListRead(conversation)
}
if (MESSAGE_PRIVATE_READ_ENABLED) {
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@ -673,9 +681,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
ImConversationType.GROUP,
websocketMessage.groupId
)
if (conversation) {
useMessageStore().markConversationMessageListRead(conversation)
}
if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)

View File

@ -15,6 +15,7 @@ export interface ImPrivateMessageDTO {
type: number // 消息类型
content: string // 消息内容
status: number // 消息状态
receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成)
sendTime: string // 发送时间
}
@ -93,7 +94,7 @@ export interface Message {
senderId: number // 发送人编号
atUserIds?: number[] // 群 @ 目标用户列表
receiverUserIds?: number[] // 群定向接收用户列表
receiptStatus?: number // 群回执状态,对齐 ImGroupReceiptStatus仅群消息
receiptStatus?: number // 回执状态,对齐 ImMessageReceiptStatus私聊 / 群 / 频道通用
readCount?: number // 群回执已读人数(仅群消息)
materialId?: number // 关联频道素材编号(仅频道消息 type=MATERIAL

View File

@ -13,7 +13,10 @@
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
</el-descriptions-item>
<el-descriptions-item v-if="MESSAGE_GROUP_READ_ENABLED" label="状态">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_STATUS" :value="detail.status" />
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
</el-descriptions-item>
<el-descriptions-item v-if="MESSAGE_GROUP_READ_ENABLED" label="回执">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS" :value="detail.receiptStatus" />
</el-descriptions-item>
<el-descriptions-item label="@用户" :span="2">
<template v-if="detail.atUserIds?.length">

View File

@ -120,7 +120,7 @@
width="110"
>
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_RECEIPT_STATUS" :value="row.receiptStatus" />
<dict-tag :type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS" :value="row.receiptStatus" />
</template>
</el-table-column>
<el-table-column
@ -131,7 +131,7 @@
width="100"
>
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_STATUS" :value="row.status" />
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100" fixed="right">

View File

@ -15,7 +15,10 @@
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
</el-descriptions-item>
<el-descriptions-item v-if="MESSAGE_PRIVATE_READ_ENABLED" label="状态">
<dict-tag :type="DICT_TYPE.IM_PRIVATE_MESSAGE_STATUS" :value="detail.status" />
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
</el-descriptions-item>
<el-descriptions-item v-if="MESSAGE_PRIVATE_READ_ENABLED" label="回执">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS" :value="detail.receiptStatus" />
</el-descriptions-item>
<el-descriptions-item label="发送时间" :span="2">
{{ formatDate(detail.sendTime) }}

View File

@ -102,7 +102,18 @@
width="100"
>
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_PRIVATE_MESSAGE_STATUS" :value="row.status" />
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column
v-if="MESSAGE_PRIVATE_READ_ENABLED"
label="回执"
align="center"
prop="receiptStatus"
width="110"
>
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS" :value="row.receiptStatus" />
</template>
</el-table-column>
<el-table-column

View File

@ -138,14 +138,13 @@ export function isMediaMessageType(type: number): boolean {
/**
* IM ImMessageStatusEnum SENDING + FAILED
*
* FAILED(-2) SENDING(-1) UNREAD(0) READ(3) / RECALL(2)
* FAILED(-2) SENDING(-1) NORMAL(0) RECALL(2)
*/
export const ImMessageStatus = {
FAILED: -2, // 发送失败(前端独有)
SENDING: -1, // 发送中(前端独有)
UNREAD: 0, // 未读
RECALL: 2, // 已撤回
READ: 3 // 已读
NORMAL: 0, // 正常
RECALL: 2 // 已撤回
} as const
/** IM 会话类型枚举 */
@ -233,8 +232,8 @@ export const ImWebSocketMessageType = {
CHANNEL_MESSAGE: 'im-channel-message' // 频道通道
} as const
/** IM 群回执状态枚举(对齐后端 ImGroupMessageReceiptStatusEnum */
export const ImGroupReceiptStatus = {
/** IM 消息回执状态枚举(对齐后端 ImMessageReceiptStatusEnum */
export const ImMessageReceiptStatus = {
NO_RECEIPT: 0, // 不需要回执
PENDING: 1, // 待完成
DONE: 2 // 已完成