311 lines
9.7 KiB
Markdown
311 lines
9.7 KiB
Markdown
|
|
# 功能实现:视频会议预约模式(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. 新增导入:`TbChatRoomMessageDTO`、`ChatRoomService`、`JSONObject`
|
|||
|
|
2. 注入依赖:`ChatRoomService`
|
|||
|
|
3. 修改 `createMeeting()` 方法:插入数据库成功后调用 `sendMeetingNotification()`
|
|||
|
|
4. 新增私有方法:`sendMeetingNotification()`
|
|||
|
|
|
|||
|
|
## 详细实现
|
|||
|
|
|
|||
|
|
### 1. 依赖注入
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
@Autowired
|
|||
|
|
private ChatRoomService chatRoomService;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 调用时机
|
|||
|
|
|
|||
|
|
在 `createMeeting()` 方法中,会议记录插入数据库成功后:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
// 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)**
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
/**
|
|||
|
|
* 发送会议通知消息到聊天室
|
|||
|
|
* @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 | 消息ID(UUID) |
|
|||
|
|
| 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 详细信息
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"meetingId": "会议ID",
|
|||
|
|
"meetingName": "会议名称",
|
|||
|
|
"jitsiRoomName": "Jitsi房间名",
|
|||
|
|
"iframeUrl": "会议URL",
|
|||
|
|
"maxParticipants": 10,
|
|||
|
|
"creatorName": "创建者名称",
|
|||
|
|
"workcaseId": "工单ID"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 前端渲染建议
|
|||
|
|
|
|||
|
|
### Vue Web 端
|
|||
|
|
|
|||
|
|
在 `ChatRoom.vue` 中添加会议消息卡片渲染:
|
|||
|
|
|
|||
|
|
```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` 中添加会议消息卡片:
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<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. 会议创建测试
|
|||
|
|
```bash
|
|||
|
|
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 可根据需要添加更多字段
|
|||
|
|
- 建议前端做好字段缺失的容错处理
|
|||
|
|
|
|||
|
|
## 相关文档
|
|||
|
|
|
|||
|
|
- [Jitsi Meet 视频会议集成总结](./项目总结-Jitsi-Meet视频会议功能.md)
|
|||
|
|
- [Docker 部署指南](./Jitsi-Meet-Docker部署指南.md)
|
|||
|
|
- [代码重构 - 视频会议API规范化](./代码重构-视频会议API规范化.md)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**实现人员**:Claude Code
|
|||
|
|
**审核状态**:待审核
|
|||
|
|
**版本**:v1.0
|