暂存
This commit is contained in:
@@ -285,7 +285,11 @@ INSERT INTO sys.tb_sys_view (
|
||||
'route', NULL, 'workcase', NULL, 162, '工单操作日志', 'system', now(), false),
|
||||
|
||||
('VIEW-W109', 'view_workcase_admin_log_system', '系统日志', 'view_workcase_admin_log', '/admin/log/system', 'admin/log/systemLog/SystemLogView.vue', 'Settings', 1,
|
||||
'route', NULL, 'workcase', NULL, 163, '系统运行日志', 'system', now(), false);
|
||||
'route', NULL, 'workcase', NULL, 163, '系统运行日志', 'system', now(), false),
|
||||
|
||||
-- Jitsi视频会议独立页面(支持URL参数token认证,用于小程序和外部链接访问)
|
||||
('VIEW-W003', 'view_workcase_jitsi_meeting', 'Jitsi视频会议', NULL, '/meeting', 'public/JitsiMeeting/JitsiMeetingView.vue', 'Video', 1,
|
||||
'route', NULL, 'workcase', 'BlankLayout', 25, 'Jitsi视频会议独立页面,支持URL参数token认证', 'system', now(), false);
|
||||
|
||||
-- =============================
|
||||
-- 6. 角色权限关联(超级管理员拥有所有权限)
|
||||
@@ -426,7 +430,10 @@ INSERT INTO sys.tb_sys_view_permission (
|
||||
('VP-W106', 'view_workcase_admin_log', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||
('VP-W107', 'view_workcase_admin_log_knowledge', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||
('VP-W108', 'view_workcase_admin_log_workcase', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||
('VP-W109', 'view_workcase_admin_log_system', 'perm_workcase_log', 'system', NULL, now(), false);
|
||||
('VP-W109', 'view_workcase_admin_log_system', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||
|
||||
-- Jitsi视频会议页面关联会议权限
|
||||
('VP-W003', 'view_workcase_jitsi_meeting', 'perm_meeting_join', 'system', NULL, now(), false);
|
||||
|
||||
-- -- 用户管理视图关联用户权限(已注释,因为view_user被注释掉了)
|
||||
-- -- ('VP-0001', 'view_user', 'perm_user_view', 'system', NULL, now(), false),
|
||||
|
||||
@@ -89,4 +89,25 @@ public interface VideoMeetingService {
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
boolean isMemberOfRoom(String roomId, String userId);
|
||||
|
||||
/**
|
||||
* @description 通过token获取会议入口信息(用于小程序和外部链接访问)
|
||||
* 此接口在gateway白名单中,通过URL参数token进行认证
|
||||
* @param meetingId 会议ID
|
||||
* @param token 用户认证token
|
||||
* @return ResultDomain<VideoMeetingVO> 包含会议信息和用户专属的iframe URL
|
||||
* @author yslg
|
||||
* @since 2025-12-27
|
||||
*/
|
||||
ResultDomain<VideoMeetingVO> getMeetingEntryByToken(String meetingId, String token);
|
||||
|
||||
/**
|
||||
* @description 生成会议入口URL(用于分享给小程序用户)
|
||||
* @param meetingId 会议ID
|
||||
* @param baseUrl 基础URL(如 https://example.com/workcase)
|
||||
* @return ResultDomain<String> 完整的会议入口URL(包含token参数)
|
||||
* @author yslg
|
||||
* @since 2025-12-27
|
||||
*/
|
||||
ResultDomain<String> generateMeetingEntryUrl(String meetingId, String baseUrl);
|
||||
}
|
||||
|
||||
@@ -85,10 +85,13 @@ public class VideoMeetingVO extends BaseVO {
|
||||
|
||||
@Schema(description = "会议时长(格式化,如:1小时30分)")
|
||||
private String durationFormatted;
|
||||
|
||||
@Schema(description = "iframe嵌入URL")
|
||||
|
||||
@Schema(description = "会议页面URL(用于路由跳转)")
|
||||
private String iframeUrl;
|
||||
|
||||
|
||||
@Schema(description = "Jitsi真正的iframe URL(用于嵌入播放)")
|
||||
private String jitsiIframeUrl;
|
||||
|
||||
@Schema(description = "Jitsi配置项")
|
||||
private JSONObject config;
|
||||
}
|
||||
|
||||
@@ -188,6 +188,8 @@ auth:
|
||||
# ai 服务白名单
|
||||
- /urban-lifeline/ai/chat/**
|
||||
- /urban-lifeline/system/guest/identify
|
||||
# workcase 会议入口白名单(支持URL参数token认证)
|
||||
- /urban-lifeline/workcase/meeting/*/entry
|
||||
security:
|
||||
aes:
|
||||
secret-key: MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI= # Base64 编码,32字节(256位)
|
||||
|
||||
530
urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md
Normal file
530
urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Jitsi 会议独立页面实现方案
|
||||
|
||||
## 问题背景
|
||||
|
||||
1. **Web端问题**:ChatRoom 弹窗关闭按钮不应该触发 `endMeeting`,只有在 Jitsi iframe 中点击"结束会议"才应该结束会议
|
||||
2. **主持人问题**:主持人应该是数据库中的会议创建人,而不是第一个进入会议的人
|
||||
3. **小程序问题**:无法捕捉结束会议事件
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 1. 后端修改(已完成)
|
||||
|
||||
#### 1.1 新增路由配置 (`initDataPermission.sql`)
|
||||
|
||||
```sql
|
||||
-- Jitsi视频会议独立页面(支持URL参数token认证,用于小程序和外部链接访问)
|
||||
('VIEW-W003', 'view_workcase_jitsi_meeting', 'Jitsi视频会议', NULL, '/meeting/:meetingId',
|
||||
'public/Meeting/JitsiMeetingView.vue', 'Video', 1,
|
||||
'route', NULL, 'workcase', 'BlankLayout', 25,
|
||||
'Jitsi视频会议独立页面,支持token参数认证', 'system', now(), false);
|
||||
```
|
||||
|
||||
#### 1.2 新增 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/meeting/{meetingId}/entry?token=xxx` | GET | 会议入口(支持URL参数token认证) |
|
||||
| `/meeting/{meetingId}/share-url` | GET | 生成会议分享URL |
|
||||
|
||||
#### 1.3 Gateway 白名单
|
||||
|
||||
```yaml
|
||||
whitelist:
|
||||
- /urban-lifeline/workcase/meeting/*/entry
|
||||
```
|
||||
|
||||
#### 1.4 主持人逻辑
|
||||
|
||||
主持人判断逻辑已正确实现:`userId.equals(meeting.getCreator())`
|
||||
|
||||
### 2. 前端实现(需要在前端仓库实现)
|
||||
|
||||
#### 2.1 创建 `JitsiMeetingView.vue`
|
||||
|
||||
路径:`src/views/public/Meeting/JitsiMeetingView.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="jitsi-meeting-container">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在加载会议...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<h2>无法加入会议</h2>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="retry">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- Jitsi 会议容器 -->
|
||||
<div v-else id="jitsi-container" ref="jitsiContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getMeetingEntry, endMeeting } from '@/api/meeting'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const jitsiApi = ref(null)
|
||||
const meetingInfo = ref(null)
|
||||
|
||||
// 获取会议信息
|
||||
const fetchMeetingInfo = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const meetingId = route.params.meetingId
|
||||
// 优先从URL参数获取token,其次从store获取
|
||||
const token = route.query.token || userStore.token
|
||||
|
||||
if (!token) {
|
||||
// 如果没有token,尝试从localStorage恢复登录状态
|
||||
await tryRestoreLogin()
|
||||
}
|
||||
|
||||
const response = await getMeetingEntry(meetingId, token)
|
||||
if (response.success) {
|
||||
meetingInfo.value = response.data
|
||||
initJitsi()
|
||||
} else {
|
||||
error.value = response.message
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载会议失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从token恢复登录状态
|
||||
const tryRestoreLogin = async () => {
|
||||
const token = route.query.token
|
||||
if (token) {
|
||||
try {
|
||||
// 调用refresh接口验证token并获取用户信息
|
||||
const response = await refreshToken(token)
|
||||
if (response.success) {
|
||||
userStore.setLoginInfo(response.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复登录状态失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 Jitsi Meet
|
||||
const initJitsi = () => {
|
||||
if (!meetingInfo.value?.iframeUrl) {
|
||||
error.value = '会议URL无效'
|
||||
return
|
||||
}
|
||||
|
||||
// 解析iframe URL获取配置
|
||||
const url = new URL(meetingInfo.value.iframeUrl)
|
||||
const roomName = url.pathname.substring(1) // 去掉开头的 /
|
||||
const jwt = url.searchParams.get('jwt')
|
||||
|
||||
// 使用 Jitsi External API
|
||||
const domain = url.hostname + (url.port ? ':' + url.port : '')
|
||||
|
||||
const options = {
|
||||
roomName: roomName,
|
||||
jwt: jwt,
|
||||
parentNode: document.getElementById('jitsi-container'),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
configOverwrite: {
|
||||
startWithAudioMuted: false,
|
||||
startWithVideoMuted: false,
|
||||
enableWelcomePage: false,
|
||||
prejoinPageEnabled: false,
|
||||
disableDeepLinking: true,
|
||||
enableChat: true,
|
||||
enableScreenSharing: true,
|
||||
},
|
||||
interfaceConfigOverwrite: {
|
||||
SHOW_JITSI_WATERMARK: false,
|
||||
SHOW_WATERMARK_FOR_GUESTS: false,
|
||||
DISABLE_JOIN_LEAVE_NOTIFICATIONS: false,
|
||||
DEFAULT_BACKGROUND: '#474747',
|
||||
TOOLBAR_BUTTONS: [
|
||||
'microphone', 'camera', 'closedcaptions', 'desktop',
|
||||
'fullscreen', 'fodeviceselection', 'hangup', 'chat',
|
||||
'recording', 'livestreaming', 'etherpad', 'sharedvideo',
|
||||
'settings', 'raisehand', 'videoquality', 'filmstrip',
|
||||
'invite', 'feedback', 'stats', 'shortcuts', 'tileview',
|
||||
'videobackgroundblur', 'download', 'help', 'mute-everyone',
|
||||
'security'
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// 创建 Jitsi API 实例
|
||||
jitsiApi.value = new JitsiMeetExternalAPI(domain, options)
|
||||
|
||||
// 监听会议事件
|
||||
setupEventListeners()
|
||||
}
|
||||
|
||||
// 设置事件监听
|
||||
const setupEventListeners = () => {
|
||||
if (!jitsiApi.value) return
|
||||
|
||||
// 监听会议结束事件(主持人点击"结束会议")
|
||||
jitsiApi.value.addListener('readyToClose', async () => {
|
||||
console.log('会议准备关闭')
|
||||
await handleMeetingEnd()
|
||||
})
|
||||
|
||||
// 监听参与者离开事件
|
||||
jitsiApi.value.addListener('participantLeft', (participant) => {
|
||||
console.log('参与者离开:', participant)
|
||||
})
|
||||
|
||||
// 监听视频会议结束事件
|
||||
jitsiApi.value.addListener('videoConferenceLeft', async (data) => {
|
||||
console.log('离开视频会议:', data)
|
||||
// 只有主持人离开时才结束会议
|
||||
if (meetingInfo.value?.creator === userStore.userId) {
|
||||
await handleMeetingEnd()
|
||||
} else {
|
||||
// 普通参与者离开,直接关闭页面
|
||||
handleClose()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听挂断事件
|
||||
jitsiApi.value.addListener('hangup', async () => {
|
||||
console.log('挂断')
|
||||
// 检查是否是主持人
|
||||
if (meetingInfo.value?.creator === userStore.userId) {
|
||||
// 主持人挂断,询问是否结束会议
|
||||
const shouldEnd = confirm('是否结束会议?')
|
||||
if (shouldEnd) {
|
||||
await handleMeetingEnd()
|
||||
} else {
|
||||
handleClose()
|
||||
}
|
||||
} else {
|
||||
handleClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理会议结束
|
||||
const handleMeetingEnd = async () => {
|
||||
try {
|
||||
const meetingId = route.params.meetingId
|
||||
await endMeeting(meetingId)
|
||||
console.log('会议已结束')
|
||||
} catch (e) {
|
||||
console.error('结束会议失败:', e)
|
||||
} finally {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
// 如果是在iframe中打开的,发送消息给父窗口
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'MEETING_CLOSED', meetingId: route.params.meetingId }, '*')
|
||||
}
|
||||
|
||||
// 尝试关闭窗口
|
||||
window.close()
|
||||
|
||||
// 如果无法关闭(不是通过window.open打开的),则跳转回聊天室
|
||||
setTimeout(() => {
|
||||
router.push('/chatroom')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 重试
|
||||
const retry = () => {
|
||||
fetchMeetingInfo()
|
||||
}
|
||||
|
||||
// 清理
|
||||
const cleanup = () => {
|
||||
if (jitsiApi.value) {
|
||||
jitsiApi.value.dispose()
|
||||
jitsiApi.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载 Jitsi External API 脚本
|
||||
const script = document.createElement('script')
|
||||
script.src = `${import.meta.env.VITE_JITSI_SERVER_URL}/external_api.js`
|
||||
script.onload = fetchMeetingInfo
|
||||
script.onerror = () => {
|
||||
error.value = '加载Jitsi脚本失败'
|
||||
loading.value = false
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jitsi-meeting-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#jitsi-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid #333;
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 30px;
|
||||
background: #4a90d9;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
#### 2.2 API 接口定义
|
||||
|
||||
```typescript
|
||||
// src/api/meeting.ts
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取会议入口信息(支持token参数)
|
||||
export function getMeetingEntry(meetingId: string, token?: string) {
|
||||
return request({
|
||||
url: `/workcase/meeting/${meetingId}/entry`,
|
||||
method: 'get',
|
||||
params: { token },
|
||||
// 不使用默认的Authorization header
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
})
|
||||
}
|
||||
|
||||
// 结束会议
|
||||
export function endMeeting(meetingId: string) {
|
||||
return request({
|
||||
url: `/workcase/meeting/${meetingId}/end`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 生成会议分享URL
|
||||
export function generateMeetingShareUrl(meetingId: string, baseUrl?: string) {
|
||||
return request({
|
||||
url: `/workcase/meeting/${meetingId}/share-url`,
|
||||
method: 'get',
|
||||
params: { baseUrl }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 路由配置
|
||||
|
||||
```typescript
|
||||
// src/router/workcase.ts
|
||||
{
|
||||
path: '/meeting/:meetingId',
|
||||
name: 'JitsiMeeting',
|
||||
component: () => import('@/views/public/Meeting/JitsiMeetingView.vue'),
|
||||
meta: {
|
||||
layout: 'BlankLayout',
|
||||
requiresAuth: false, // 允许通过URL token认证
|
||||
title: '视频会议'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4 修改 ChatRoom 组件
|
||||
|
||||
在 ChatRoom 组件中,点击"进入会议"时:
|
||||
|
||||
```typescript
|
||||
// 进入会议
|
||||
const joinMeeting = async (meetingId: string) => {
|
||||
// 生成会议入口URL
|
||||
const response = await generateMeetingShareUrl(meetingId)
|
||||
if (response.success) {
|
||||
// 在新窗口打开会议页面
|
||||
window.open(response.data, '_blank', 'width=1200,height=800')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.5 Web 初始化逻辑修改
|
||||
|
||||
在 workcase 前端的初始化逻辑中,添加从 URL token 恢复登录状态的功能:
|
||||
|
||||
```typescript
|
||||
// src/utils/auth.ts
|
||||
export async function initAuth() {
|
||||
// 1. 检查 localStorage 中是否有登录信息
|
||||
const storedToken = localStorage.getItem('token')
|
||||
if (storedToken) {
|
||||
// 验证token是否有效
|
||||
const isValid = await validateToken(storedToken)
|
||||
if (isValid) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 URL 参数中是否有 token
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const urlToken = urlParams.get('token')
|
||||
if (urlToken) {
|
||||
try {
|
||||
// 调用 refresh 接口验证 token 并获取用户信息
|
||||
const response = await refreshToken(urlToken)
|
||||
if (response.success) {
|
||||
// 保存登录信息到 localStorage
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('loginDomain', JSON.stringify(response.data))
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('URL token 验证失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 小程序端实现
|
||||
|
||||
小程序端直接给出会议链接,用户在手机浏览器中打开:
|
||||
|
||||
```javascript
|
||||
// 小程序中获取会议链接
|
||||
const getMeetingUrl = async (meetingId) => {
|
||||
const token = wx.getStorageSync('token')
|
||||
const baseUrl = 'https://your-domain.com/workcase'
|
||||
|
||||
// 构建会议入口URL
|
||||
const meetingUrl = `${baseUrl}/meeting/${meetingId}?token=${token}`
|
||||
|
||||
// 复制到剪贴板或显示给用户
|
||||
wx.setClipboardData({
|
||||
data: meetingUrl,
|
||||
success: () => {
|
||||
wx.showToast({
|
||||
title: '会议链接已复制,请在浏览器中打开',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 关键流程
|
||||
|
||||
#### 4.1 Web 端进入会议流程
|
||||
|
||||
```
|
||||
用户点击"进入会议"
|
||||
↓
|
||||
调用 generateMeetingShareUrl 获取带token的URL
|
||||
↓
|
||||
window.open 打开 JitsiMeetingView.vue
|
||||
↓
|
||||
JitsiMeetingView 从URL获取token
|
||||
↓
|
||||
调用 /meeting/{id}/entry?token=xxx 获取会议信息
|
||||
↓
|
||||
初始化 Jitsi External API
|
||||
↓
|
||||
监听 readyToClose/hangup 事件
|
||||
↓
|
||||
主持人结束会议时调用 endMeeting API
|
||||
```
|
||||
|
||||
#### 4.2 小程序端进入会议流程
|
||||
|
||||
```
|
||||
用户点击"进入会议"
|
||||
↓
|
||||
获取当前用户token
|
||||
↓
|
||||
构建会议URL: /meeting/{id}?token=xxx
|
||||
↓
|
||||
复制URL到剪贴板
|
||||
↓
|
||||
用户在手机浏览器打开URL
|
||||
↓
|
||||
JitsiMeetingView 从URL获取token
|
||||
↓
|
||||
调用 /meeting/{id}/entry?token=xxx 获取会议信息
|
||||
↓
|
||||
初始化 Jitsi External API
|
||||
```
|
||||
|
||||
### 5. 注意事项
|
||||
|
||||
1. **主持人权限**:只有会议创建人(`meeting.creator`)才是主持人,JWT token 中的 `moderator` 字段会正确设置
|
||||
|
||||
2. **结束会议**:只有主持人点击"结束会议"或挂断时才会调用 `endMeeting` API
|
||||
|
||||
3. **Token 安全**:URL 中的 token 应该有时效性,建议使用短期 token 或一次性 token
|
||||
|
||||
4. **跨域问题**:确保 Jitsi 服务器配置了正确的 CORS 策略
|
||||
|
||||
5. **HTTPS**:生产环境必须使用 HTTPS,否则浏览器可能阻止摄像头/麦克风访问
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.xyzh.workcase.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -393,6 +394,53 @@ public class WorkcaseChatContorller {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "会议入口(支持URL参数token认证,用于小程序和外部链接)")
|
||||
@GetMapping("/meeting/{meetingId}/entry")
|
||||
public ResultDomain<VideoMeetingVO> getMeetingEntry(
|
||||
@PathVariable(value = "meetingId") String meetingId,
|
||||
@RequestParam(value = "token", required = false) String token,
|
||||
HttpServletRequest request) {
|
||||
// 优先从URL参数获取token,其次从Header获取
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
token = request.getHeader("Authorization");
|
||||
}
|
||||
|
||||
try {
|
||||
return videoMeetingService.getMeetingEntryByToken(meetingId, token);
|
||||
} catch (Exception e) {
|
||||
return ResultDomain.failure(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "生成会议入口URL(用于分享给小程序用户)")
|
||||
@PreAuthorize("hasAuthority('meeting:url:any')")
|
||||
@GetMapping("/meeting/{meetingId}/share-url")
|
||||
public ResultDomain<String> generateMeetingShareUrl(
|
||||
@PathVariable(value = "meetingId") String meetingId,
|
||||
@RequestParam(value = "baseUrl", defaultValue = "") String baseUrl,
|
||||
HttpServletRequest request) {
|
||||
// 如果没有提供baseUrl,则从请求中构建
|
||||
if (baseUrl == null || baseUrl.trim().isEmpty()) {
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if ((scheme.equals("http") && serverPort == 80) ||
|
||||
(scheme.equals("https") && serverPort == 443)) {
|
||||
baseUrl = scheme + "://" + serverName + contextPath + "/workcase";
|
||||
} else {
|
||||
baseUrl = scheme + "://" + serverName + ":" + serverPort + contextPath + "/workcase";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return videoMeetingService.generateMeetingEntryUrl(meetingId, baseUrl);
|
||||
} catch (Exception e) {
|
||||
return ResultDomain.failure(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= 微信客服消息回调 =========================
|
||||
|
||||
// @Operation(summary = "微信客服消息回调验证(GET)")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package org.xyzh.workcase.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.apache.dubbo.config.annotation.DubboReference;
|
||||
import org.apache.dubbo.config.annotation.DubboService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.xyzh.api.auth.service.AuthService;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO;
|
||||
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
|
||||
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||
@@ -51,6 +53,9 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
@Autowired
|
||||
private ChatRoomService chatRoomService;
|
||||
|
||||
@DubboReference(version = "1.0.0", group = "auth", timeout = 30000, retries = 0)
|
||||
private AuthService authService;
|
||||
|
||||
// 会议创建锁映射表:每个meetingId对应一个ReentrantLock
|
||||
private final ConcurrentHashMap<String, ReentrantLock> meetingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -326,7 +331,7 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
userName = member.getUserName();
|
||||
}
|
||||
|
||||
// 6. 生成用户专属JWT Token
|
||||
// 6. 生成用户专属JWT Token(用于Jitsi内部认证)
|
||||
String userJwtToken = jitsiTokenService.generateJwtToken(
|
||||
meeting.getJitsiRoomName(),
|
||||
userId,
|
||||
@@ -334,16 +339,24 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
isModerator
|
||||
);
|
||||
|
||||
// 7. 构建用户专属iframe URL
|
||||
String userIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||
// 7. 构建真正的Jitsi iframe URL
|
||||
String jitsiIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||
meeting.getJitsiRoomName(),
|
||||
userJwtToken,
|
||||
meeting.getConfig()
|
||||
);
|
||||
|
||||
// 8. 更新VO
|
||||
// 8. 构建会议页面URL(用于Web端路由跳转和小程序外部访问)
|
||||
// 获取当前用户的登录token(用于页面token认证)
|
||||
String userToken = LoginUtil.getToken();
|
||||
// 注意:URL不包含/workcase前缀,因为workcase应用的路由base已经是/workcase
|
||||
String meetingPageUrl = "/meeting?meetingId=" + meetingId +
|
||||
"&token=" + (userToken != null ? userToken : "");
|
||||
|
||||
// 9. 更新VO
|
||||
meeting.setJwtToken(userJwtToken);
|
||||
meeting.setIframeUrl(userIframeUrl);
|
||||
meeting.setJitsiIframeUrl(jitsiIframeUrl); // 真正的Jitsi URL
|
||||
meeting.setIframeUrl(meetingPageUrl); // 会议页面URL(用于router跳转)
|
||||
|
||||
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}, status={}",
|
||||
meetingId, userId, meeting.getStatus());
|
||||
@@ -534,4 +547,177 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||
// 时间段1的结束时间 > 时间段2的开始时间 AND 时间段1的开始时间 < 时间段2的结束时间
|
||||
return end1.after(start2) && start1.before(end2);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ResultDomain<VideoMeetingVO> getMeetingEntryByToken(String meetingId, String token) {
|
||||
logger.info("通过token获取会议入口: meetingId={}", meetingId);
|
||||
|
||||
try {
|
||||
// 1. 验证token并获取用户信息
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
logger.warn("token为空: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("认证token不能为空");
|
||||
}
|
||||
|
||||
// 去除Bearer前缀(如果有)
|
||||
if (token.startsWith("Bearer ")) {
|
||||
token = token.substring(7);
|
||||
}
|
||||
|
||||
ResultDomain<LoginDomain> loginResult = authService.getLoginByToken(token);
|
||||
if (!loginResult.getSuccess() || loginResult.getData() == null) {
|
||||
logger.warn("token验证失败: meetingId={}, error={}", meetingId, loginResult.getMessage());
|
||||
return ResultDomain.failure("认证失败: " + loginResult.getMessage());
|
||||
}
|
||||
|
||||
LoginDomain loginDomain = loginResult.getData();
|
||||
String userId = loginDomain.getUser().getUserId();
|
||||
logger.info("token验证成功: meetingId={}, userId={}", meetingId, userId);
|
||||
|
||||
// 2. 调用现有的generateUserMeetingUrl方法生成会议URL
|
||||
// 但需要先设置当前登录上下文(因为generateUserMeetingUrl可能依赖LoginUtil)
|
||||
// 这里直接复用generateUserMeetingUrl的核心逻辑
|
||||
|
||||
// 获取会议信息
|
||||
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||
filter.setMeetingId(meetingId);
|
||||
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||
|
||||
if (meetings == null || meetings.isEmpty()) {
|
||||
logger.warn("会议不存在: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("会议不存在");
|
||||
}
|
||||
|
||||
VideoMeetingVO meeting = meetings.get(0);
|
||||
|
||||
// 3. 验证访问权限(用户必须是聊天室成员)
|
||||
if (!isMemberOfRoom(meeting.getRoomId(), userId)) {
|
||||
logger.warn("用户无权访问会议: meetingId={}, userId={}", meetingId, userId);
|
||||
return ResultDomain.failure("您无权访问此会议");
|
||||
}
|
||||
|
||||
// 4. 检查会议状态和时间窗口
|
||||
if ("ended".equals(meeting.getStatus())) {
|
||||
logger.warn("会议已结束: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("会议已结束");
|
||||
}
|
||||
|
||||
if ("scheduled".equals(meeting.getStatus())) {
|
||||
Date now = new Date();
|
||||
|
||||
// 计算提前入会时间点
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(meeting.getStartTime());
|
||||
calendar.add(Calendar.MINUTE, -meeting.getAdvance());
|
||||
Date advanceTime = calendar.getTime();
|
||||
|
||||
if (now.before(advanceTime)) {
|
||||
logger.warn("会议未到入会时间: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("会议未到入会时间,请在 " + advanceTime + " 之后加入");
|
||||
}
|
||||
|
||||
if (now.after(meeting.getEndTime())) {
|
||||
logger.warn("会议已过期: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("会议已结束");
|
||||
}
|
||||
|
||||
// 首次入会时更新会议状态
|
||||
ReentrantLock lock = meetingLocks.computeIfAbsent(meetingId, k -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try {
|
||||
List<VideoMeetingVO> recheck = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||
if (recheck != null && !recheck.isEmpty() && "scheduled".equals(recheck.get(0).getStatus())) {
|
||||
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
|
||||
updateDTO.setMeetingId(meetingId);
|
||||
updateDTO.setStatus("ongoing");
|
||||
updateDTO.setActualStartTime(new Date());
|
||||
videoMeetingMapper.updateVideoMeeting(updateDTO);
|
||||
meeting.setStatus("ongoing");
|
||||
meeting.setActualStartTime(new Date());
|
||||
logger.info("会议状态已更新为进行中: meetingId={}", meetingId);
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
if (!lock.hasQueuedThreads()) {
|
||||
meetingLocks.remove(meetingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取用户信息
|
||||
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
|
||||
memberFilter.setRoomId(meeting.getRoomId());
|
||||
memberFilter.setUserId(userId);
|
||||
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
|
||||
|
||||
String userName = loginDomain.getUserInfo().getUsername();
|
||||
boolean isModerator = userId.equals(meeting.getCreator());
|
||||
|
||||
if (members != null && !members.isEmpty()) {
|
||||
userName = members.get(0).getUserName();
|
||||
}
|
||||
|
||||
// 6. 生成用户专属JWT Token
|
||||
String userJwtToken = jitsiTokenService.generateJwtToken(
|
||||
meeting.getJitsiRoomName(),
|
||||
userId,
|
||||
userName,
|
||||
isModerator
|
||||
);
|
||||
|
||||
// 7. 构建真正的Jitsi iframe URL
|
||||
String jitsiIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||
meeting.getJitsiRoomName(),
|
||||
userJwtToken,
|
||||
meeting.getConfig()
|
||||
);
|
||||
|
||||
// 8. 构建会议页面URL(用于Web端路由跳转和小程序外部访问)
|
||||
// 注意:使用提供的token参数而非LoginUtil.getToken()
|
||||
String meetingPageUrl = "/meeting?meetingId=" + meetingId +
|
||||
"&token=" + token;
|
||||
|
||||
// 9. 更新VO并返回
|
||||
meeting.setJwtToken(userJwtToken);
|
||||
meeting.setJitsiIframeUrl(jitsiIframeUrl); // 真正的Jitsi URL
|
||||
meeting.setIframeUrl(meetingPageUrl); // 会议页面URL(用于router跳转)
|
||||
|
||||
logger.info("通过token获取会议入口成功: meetingId={}, userId={}, isModerator={}",
|
||||
meetingId, userId, isModerator);
|
||||
return ResultDomain.success("获取会议入口成功", meeting);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("通过token获取会议入口异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||
return ResultDomain.failure("获取会议入口失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<String> generateMeetingEntryUrl(String meetingId, String baseUrl) {
|
||||
logger.info("生成会议入口URL: meetingId={}, baseUrl={}", meetingId, baseUrl);
|
||||
|
||||
try {
|
||||
// 获取当前用户token
|
||||
LoginDomain loginDomain = LoginUtil.getCurrentLogin();
|
||||
if (loginDomain == null || loginDomain.getToken() == null) {
|
||||
logger.warn("无法获取当前用户token: meetingId={}", meetingId);
|
||||
return ResultDomain.failure("无法获取当前用户认证信息");
|
||||
}
|
||||
|
||||
// 构建完整URL: {baseUrl}/meeting/{meetingId}?token={token}
|
||||
String entryUrl = String.format("%s/meeting/%s?token=%s",
|
||||
baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl,
|
||||
meetingId,
|
||||
loginDomain.getToken()
|
||||
);
|
||||
|
||||
logger.info("会议入口URL生成成功: meetingId={}", meetingId);
|
||||
return ResultDomain.success("生成会议入口URL成功", entryUrl);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("生成会议入口URL异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||
return ResultDomain.failure("生成会议入口URL失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user