Files
urbanLifeline/docs/功能实现-会议通知消息.md
2025-12-26 18:55:54 +08:00

9.7 KiB
Raw Permalink Blame History

功能实现视频会议预约模式Reservation Model

实现日期

2025-12-26

功能概述

实现视频会议"预约+按需创建"架构用户创建会议预约时不立即创建Jitsi会议室仅在首个用户在允许入会时间窗口内加入时通过Redis双检锁机制创建Jitsi会议室。

架构模型

预约模式 (Reservation Model)

用户创建会议 → 保存预约信息scheduled→ 发送会议通知消息meetingId
                                             ↓
首个用户加入 → 时间窗口校验 → Redis分布式锁 → 创建Jitsi会议室 → 更新状态ongoing
                                             ↓
后续用户加入 → 直接获取已创建的会议室URL

时间窗口规则

  • 提前入会时间: start_time - advance 分钟
  • 允许入会窗口: [提前入会时间, end_time]
  • 默认advance: 5分钟

实现文件

后端修改

VideoMeetingServiceImpl.java

  • 位置:urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/
  • 修改内容:
    1. 新增导入:TbChatRoomMessageDTOChatRoomServiceJSONObject
    2. 注入依赖:ChatRoomService
    3. 修改 createMeeting() 方法:插入数据库成功后调用 sendMeetingNotification()
    4. 新增私有方法:sendMeetingNotification()

详细实现

1. 依赖注入

@Autowired
private ChatRoomService chatRoomService;

2. 调用时机

createMeeting() 方法中,会议记录插入数据库成功后:

// 8. 插入数据库
int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO);
if (rows > 0) {
    logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}",
        meetingId, jitsiRoomName);

    // 9. 发送会议通知消息到聊天室
    sendMeetingNotification(meetingDTO, userName);

    // 10. 返回VO
    VideoMeetingVO meetingVO = new VideoMeetingVO();
    BeanUtils.copyProperties(meetingDTO, meetingVO);
    return ResultDomain.success("创建会议成功", meetingVO);
}

3. 消息发送实现

sendMeetingNotification() 方法 (VideoMeetingServiceImpl.java:396-442)

/**
 * 发送会议通知消息到聊天室
 * @param meetingDTO 会议信息
 * @param creatorName 创建者名称
 */
private void sendMeetingNotification(TbVideoMeetingDTO meetingDTO, String creatorName) {
    try {
        logger.info("发送会议通知消息: roomId={}, meetingId={}",
            meetingDTO.getRoomId(), meetingDTO.getMeetingId());

        // 构建消息内容
        TbChatRoomMessageDTO message = new TbChatRoomMessageDTO();
        message.setMessageId(IdUtil.generateUUID());
        message.setRoomId(meetingDTO.getRoomId());
        message.setSenderId(meetingDTO.getCreator());
        message.setSenderType(meetingDTO.getCreatorType());
        message.setSenderName(creatorName);
        message.setMessageType("meet"); // 会议类型消息
        message.setContent(meetingDTO.getIframeUrl()); // 会议URL作为内容
        message.setStatus("sent");
        message.setReadCount(0);
        message.setSendTime(new Date());

        // 构建扩展信息(会议详情)
        JSONObject contentExtra = new JSONObject();
        contentExtra.put("meetingId", meetingDTO.getMeetingId());
        contentExtra.put("meetingName", meetingDTO.getMeetingName());
        contentExtra.put("jitsiRoomName", meetingDTO.getJitsiRoomName());
        contentExtra.put("iframeUrl", meetingDTO.getIframeUrl());
        contentExtra.put("maxParticipants", meetingDTO.getMaxParticipants());
        contentExtra.put("creatorName", creatorName);
        contentExtra.put("workcaseId", meetingDTO.getWorkcaseId());
        message.setContentExtra(contentExtra);

        // 发送消息
        ResultDomain<TbChatRoomMessageDTO> sendResult = chatRoomService.sendMessage(message);
        if (sendResult.getSuccess()) {
            logger.info("会议通知消息发送成功: messageId={}", message.getMessageId());
        } else {
            logger.warn("会议通知消息发送失败: {}", sendResult.getMessage());
        }
    } catch (Exception e) {
        // 消息发送失败不影响会议创建
        logger.error("发送会议通知消息异常: roomId={}, error={}",
            meetingDTO.getRoomId(), e.getMessage(), e);
    }
}

消息数据结构

消息字段

字段 类型 说明
messageId String 消息IDUUID
roomId String 聊天室ID
senderId String 发送者ID会议创建者
senderType String 发送者类型guest/agent
senderName String 发送者名称
messageType String "meet"(会议消息类型)
content String 会议iframe URL
contentExtra JSONObject 会议详细信息(见下表)
status String "sent"
readCount Integer 0
sendTime Date 发送时间

contentExtra 详细信息

{
    "meetingId": "会议ID",
    "meetingName": "会议名称",
    "jitsiRoomName": "Jitsi房间名",
    "iframeUrl": "会议URL",
    "maxParticipants": 10,
    "creatorName": "创建者名称",
    "workcaseId": "工单ID"
}

前端渲染建议

Vue Web 端

ChatRoom.vue 中添加会议消息卡片渲染:

<template>
    <div v-if="message.messageType === 'meet'" class="meeting-card">
        <div class="meeting-card-header">
            <Video :size="20" />
            <span>{{ message.contentExtra.meetingName }}</span>
        </div>
        <div class="meeting-card-body">
            <p>发起人{{ message.contentExtra.creatorName }}</p>
            <p>最多参与人数{{ message.contentExtra.maxParticipants }}</p>
        </div>
        <div class="meeting-card-footer">
            <button @click="joinMeeting(message.contentExtra.meetingId)">
                加入会议
            </button>
        </div>
    </div>
</template>

<script setup>
import { Video } from 'lucide-vue-next'

const joinMeeting = async (meetingId) => {
    // 调用加入会议API
    const res = await workcaseChatAPI.joinVideoMeeting(meetingId)
    if (res.code === 0) {
        // 打开会议iframe
        meetingUrl.value = res.data.iframeUrl
        showMeeting.value = true
    }
}
</script>

UniApp 小程序端

chatRoom.uvue 中添加会议消息卡片:

<template>
    <view v-if="message.messageType === 'meet'" class="meeting-card">
        <view class="meeting-card-header">
            <text class="icon-video">📹</text>
            <text>{{ message.contentExtra.meetingName }}</text>
        </view>
        <view class="meeting-card-body">
            <text>发起人{{ message.contentExtra.creatorName }}</text>
            <text>最多 {{ message.contentExtra.maxParticipants }} </text>
        </view>
        <button @tap="joinMeeting(message.contentExtra.meetingId)" class="join-btn">
            加入会议
        </button>
    </view>
</template>

<script setup>
function joinMeeting(meetingId: string) {
    // 调用加入会议API
    workcaseChatAPI.joinVideoMeeting(meetingId).then(res => {
        if (res.success && res.data) {
            // 跳转到会议页面
            uni.navigateTo({
                url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(res.data.iframeUrl)}&meetingId=${meetingId}`
            })
        }
    })
}
</script>

设计考虑

1. 异步发送

消息发送在独立的 try-catch 块中,失败不影响会议创建流程

2. 完整信息

contentExtra 包含会议所有关键信息,前端可灵活使用

3. 类型明确

messageType 使用 "meet" 标识会议消息,方便前端过滤和渲染

4. URL 即内容

content 字段直接存储会议URL方便快速访问

5. 日志追踪

完整的日志记录,便于问题排查

测试要点

1. 会议创建测试

POST /urban-lifeline/workcase/chat/meeting/create
{
    "roomId": "test-room-123",
    "workcaseId": "WC001",
    "meetingName": "技术支持会议",
    "maxParticipants": 10
}

预期结果

  • 返回会议创建成功
  • 数据库 tb_video_meeting 表插入会议记录
  • 数据库 tb_chat_room_message 表插入类型为 "meet" 的消息
  • 消息的 content 字段包含会议URL
  • 消息的 contentExtra 包含会议详细信息

2. 前端卡片渲染测试

  • 聊天消息列表中显示会议卡片
  • 卡片展示会议名称、发起人、参与人数等信息
  • 点击"加入会议"按钮能正确跳转

3. 多人加入测试

  • 创建者加入会议(主持人权限)
  • 其他成员通过卡片加入会议(普通权限)
  • 非聊天室成员无法加入

4. 异常情况测试

  • 消息发送失败不影响会议创建
  • 会议创建失败不会发送消息

注意事项

  1. 消息类型messageType 为 "meet",而非 "meeting"(根据用户需求)

  2. 权限控制

    • 只有聊天室成员才能创建会议
    • 只有聊天室成员才能加入会议
  3. 事务处理

    • 会议创建在事务中(@Transactional
    • 消息发送在独立的 try-catch失败不回滚会议创建
  4. 前端适配

    • Web端和小程序端需分别实现会议卡片渲染
    • 建议使用统一的样式和交互
  5. 扩展性

    • contentExtra 可根据需要添加更多字段
    • 建议前端做好字段缺失的容错处理

相关文档


实现人员Claude Code 审核状态:待审核 版本v1.0