# 功能实现：视频会议预约模式（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
