从零用 Java 实现小红书 SpringBoot Vue UniApp(16)消息体系:私信、群聊与视频通话
>## [**移动端演示 http://8.146.211.120:8081/#/**](https://gitee.com/link?target=http%3A%2F%2F8.146.211.120%3A8081%2F%23%2F) ## [**管理端演示 http://8.146.211.120:8088/#/**](https://gitee.com/link?target=http%3A%2F%2F8.146.211.120%3A8088%2F%23%2F) ## [**项目整体介绍及演示**](https://gitee.com/link?target=https%3A%2F%2Fblog.csdn.net%2Fqq_35238367%2Farticle%2Fdetails%2F145126222) **前言** 前十五章已经走通内容、商城,以及评论 / IM 基础图文能力。但真正让用户愿意留下来的,往往不是商品页,而是**人与人之间更完整的连接**。 本章在此基础上补齐完整消息体系: - 私信多媒体(文字 / 图片 / 语音 / 视频 / 笔记卡片) - 群聊(创建、广场、禁言、撤回、在线人数) - 会话聚合 Inbox(置顶、未读) - 社交互动通知(赞藏、关注、评论@) - 1v1 音视频通话(腾讯云 TRTC) 形成「内容社区 + IM」双引擎。 ----       ### 相对原教程,本章新增了什么 |能力|早期教程|当前工程| |:-:|:-:|:-:| |私信|文本为主|文本/图/语音/视频/笔记/通话记录/直播邀请卡| |群聊|无|完整群生命周期 + 广场发现| |会话列表|简单列表|Inbox 聚合、置顶、未读角标| |实时通道|有限|全局 WebSocket 单连接多业务复用| |音视频通话|无|TRTC 发起/接听/拒绝/挂断全流程| ---- ### 一、整体架构 消息体系拆成四条线: 1. **会话线**:Inbox 汇总私信会话 + 群会话,负责未读与置顶 2. **内容线**:统一 `contentType + content`,多媒体用 JSON 存 payload 3. **实时线**:WebSocket 推送新消息、已读、在线人数、来电 4. **通话线**:RTC 信令走业务接口,媒体走 TRTC ``` 消息 Tab ├─ 赞藏 / 关注 / 评论@ 通知入口 └─ Inbox 会话列表(私信 + 群) ↓ 点进会话 chat.vue / group-chat.vue ↓ 发送 POST /member/dm/message/send ↓ 落库 + WebSocket 推对端 ``` ---- ### 二、消息类型协议(核心) 后端在 `MemberDmServiceImpl` 中定义类型常量,前后端约定同一套数字: ``` private static final int TYPE_TEXT = 1; private static final int TYPE_IMAGE = 2; private static final int TYPE_VOICE = 3; private static final int TYPE_NOTE = 4; private static final int TYPE_VIDEO = 5; /** 语音/视频通话记录 */ private static final int TYPE_CALL = 6; /** 直播分享卡片 */ private static final int TYPE_LIVE = 7; /** 音视频聊天室分享卡片 */ private static final int TYPE_VOICE_ROOM = 8; ``` 发送时做白名单校验,非法类型回落为文本: ``` int contentType = reqVO.getContentType() == null ? TYPE_TEXT : reqVO.getContentType(); if (contentType != TYPE_TEXT && contentType != TYPE_IMAGE && contentType != TYPE_VOICE && contentType != TYPE_NOTE && contentType != TYPE_VIDEO && contentType != TYPE_LIVE && contentType != TYPE_VOICE_ROOM) { contentType = TYPE_TEXT; } ``` 会话列表摘要按类型简化展示(和早期「评论/IM 加图片」同一思路): ``` private String buildSummary(int contentType, String content) { if (contentType == TYPE_IMAGE) { return "[图片]"; } if (contentType == TYPE_VOICE) { return "[语音]"; } if (contentType == TYPE_VIDEO) { return "[视频]"; } if (contentType == TYPE_NOTE) { return "[笔记]" + extractNoteTitle(content); } if (contentType == TYPE_LIVE) { return "[直播]" + extractNoteTitle(content); } if (contentType == TYPE_VOICE_ROOM) { return "[聊天室]" + extractNoteTitle(content); } // ... return StrUtil.maxLength(content, 80); } ``` 前端类型常量与组装工具(`sheep/helper/dmContent.js`): ``` export const DM_TYPE = { TEXT: 1, IMAGE: 2, VOICE: 3, NOTE: 4, VIDEO: 5, }; export function buildVoicePayload(url, durationSec) { return JSON.stringify({ url, duration: Math.max(1, Math.round(durationSec || 1)), }); } export function buildNotePayload(note) { const cover = note.coverUrl || (note.picUrls && note.picUrls.length ? note.picUrls[0] : '') || ''; const summary = (note.content || note.title || '').replace(/\s+/g, ' ').trim().slice(0, 80); return JSON.stringify({ noteId: note.id, title: note.title || summary || '笔记', coverUrl: cover, nickname: note.nickname || '', avatar: note.avatar || '', summary, noteType: Number(note.noteType || 0), likeCount: Number(note.likeCount || 0), }); } ``` 发送接口: ``` // sheep/api/member/dm.js sendMessage: (data) => { return request({ url: '/member/dm/message/send', method: 'POST', data, custom: { auth: true, showLoading: false }, }); }, ``` ---- ### 三、私信气泡:按 contentType 条件渲染 `pages/message/chat.vue` 里根据类型切换 UI(笔记卡 / 语音条 / 视频封面 / 图片 / 文本): ``` {{ notePayload(item)?.title || '笔记' }} {{ voiceDuration(item) }}" {{ videoDurationText(item) }} {{ item.content }} ``` 发送封装: ``` async function sendContent(content, contentType = DM_TYPE.TEXT) { const payload = contentType === DM_TYPE.TEXT ? (content || '').trim() : content || ''; const { code, data } = await DmApi.sendMessage({ peerId: state.peerId, contentType, content: payload, }); // 成功后 push 到本地 messageList,并滚到底部 } ``` ---- ### 四、WebSocket:一条连接多业务复用 类型常量集中在 `WebSocketMessageTypeConstants`: ``` /** 私信新消息 */ String MEMBER_DM_MESSAGE = "member_dm_message_type"; /** 群聊新消息 */ String MEMBER_GROUP_CHAT_MESSAGE = "member_group_chat_message_type"; /** 消息未读总数变更 */ String MEMBER_UNREAD_COUNT = "member_unread_count_type"; /** 视频通话邀请 / 接听 / 拒绝 / 取消 / 挂断 */ String MEMBER_RTC_CALL_INVITE = "member_rtc_call_invite_type"; String MEMBER_RTC_CALL_ACCEPTED = "member_rtc_call_accepted_type"; String MEMBER_RTC_CALL_REJECTED = "member_rtc_call_rejected_type"; String MEMBER_RTC_CALL_CANCELLED = "member_rtc_call_cancelled_type"; String MEMBER_RTC_CALL_HANGUP = "member_rtc_call_hangup_type"; ``` 前端 `sheep/helper/wsGlobal.js` 订阅未读与来电;进聊天页再处理具体消息推送。这样消息 Tab 角标、聊天页气泡、来电弹窗共用同一条长连接。 ---- ### 五、群聊要点 群聊覆盖运营侧常见能力: - 创建群、群广场发现、申请加入 - 群公告、成员列表、邀请入群 - 群主禁言、踢人、消息撤回 - 群在线人数实时订阅(`MEMBER_GROUP_CHAT_ONLINE_*`) 消息体同样走 `contentType`,与私信协议对齐,前端 `group-chat.vue` 复用气泡渲染思路。 ---- ### 六、1v1 音视频通话 通话信令接口(`AppMemberRtcController`): ``` @PostMapping("/call/create") @Operation(summary = "发起 1v1 视频通话") public CommonResult create(@Valid @RequestBody AppMemberRtcCallCreateReqVO reqVO) { return success(rtcService.createCall(getLoginUserId(), reqVO)); } @PostMapping("/call/accept") public CommonResult accept(@RequestParam("roomId") String roomId) { return success(rtcService.acceptCall(getLoginUserId(), roomId)); } @PostMapping("/call/reject") public CommonResult reject(@RequestParam("roomId") String roomId) { rtcService.rejectCall(getLoginUserId(), roomId); return success(true); } ``` 创建通话后通过 WebSocket 推送来电;接听返回 TRTC 凭证,媒体进独立通话页。结束后写一条 `TYPE_CALL` 气泡到私信会话。 流程: ``` 发起方 createCall → 对端 WS 收到 INVITE → accept / reject / timeout → TRTC 进房通话 → hangup → 私信写入通话记录气泡 ``` ---- ### 关键代码位置 **后端** - 私信:`yudao-module-member/.../service/dm/MemberDmServiceImpl.java` - 群聊:`.../controller/app/groupchat/` - 会话 Inbox:`.../controller/app/inbox/` - 通话:`.../controller/app/rtc/AppMemberRtcController.java` - WS 常量:`.../enums/WebSocketMessageTypeConstants.java` **移动端** - 消息 Tab:`pages/index/message.vue` - 私信:`pages/message/chat.vue` - 内容协议:`sheep/helper/dmContent.js` - 全局 WS:`sheep/helper/wsGlobal.js` ---- ### 总结 1. 用统一 `contentType` 扩展多媒体,避免每种消息各建一套表 2. Inbox + WebSocket 解决多入口未读不一致 3. 信令与媒体分离:业务状态走后端,音视频走 TRTC #### 下一章预告 有了 IM 通道之后,下一章接入 **TRTC 直播**:笔记开播、公屏弹幕、心跳关播、体验时长倒计时。 ---- >**演示地址** 移动端:[http://8.146.211.120:8081/#/](https://gitee.com/link?target=http%3A%2F%2F8.146.211.120%3A8081%2F%23%2F) 管理端:[http://8.146.211.120:8088/#/](https://gitee.com/link?target=http%3A%2F%2F8.146.211.120%3A8088%2F%23%2F) **代码地址** https://gitee.com/ddeatrr/springboot_vue_xhs