From 55801fa0ec609eb635b63653cca484333fedfc54 Mon Sep 17 00:00:00 2001 From: wangys <3401275564@qq.com> Date: Sat, 27 Dec 2025 15:36:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .../postgres/sql/initDataPermission.sql | 11 +- .../workcase/service/VideoMeetingService.java | 21 + .../xyzh/api/workcase/vo/VideoMeetingVO.java | 9 +- .../src/main/resources/application.yml | 2 + .../workcase/Jitsi会议独立页面实现方案.md | 530 ++++++++++++++++++ .../controller/WorkcaseChatContorller.java | 48 ++ .../service/VideoMeetingServiceImpl.java | 196 ++++++- .../workcase/localSharedImportMap.js | 35 ++ .../packages/workcase/src/router/index.ts | 71 ++- .../workcase/src/types/workcase/chatRoom.ts | 5 +- .../views/public/ChatRoom/ChatRoomView.vue | 47 +- .../public/ChatRoom/chatRoom/ChatRoom.vue | 133 +---- .../public/JitsiMeeting/JitsiMeetingView.scss | 81 +++ .../public/JitsiMeeting/JitsiMeetingView.vue | 365 ++++++++++++ .../WorkcaseDetail/WorkcaseDetail.vue | 236 ++++++-- .../pages/chatRoom/chatRoom/chatRoom.uvue | 165 +++++- 17 files changed, 1728 insertions(+), 229 deletions(-) create mode 100644 urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md create mode 100644 urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.scss create mode 100644 urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.vue diff --git a/.gitignore b/.gitignore index 0202f052..42eae94a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ *.zip *.tar.gz *.rar - +.tmp # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* diff --git a/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql b/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql index 260ffc86..c84ce017 100644 --- a/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql +++ b/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql @@ -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), diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/VideoMeetingService.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/VideoMeetingService.java index 4946c4e9..bbcc1fed 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/VideoMeetingService.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/VideoMeetingService.java @@ -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 包含会议信息和用户专属的iframe URL + * @author yslg + * @since 2025-12-27 + */ + ResultDomain getMeetingEntryByToken(String meetingId, String token); + + /** + * @description 生成会议入口URL(用于分享给小程序用户) + * @param meetingId 会议ID + * @param baseUrl 基础URL(如 https://example.com/workcase) + * @return ResultDomain 完整的会议入口URL(包含token参数) + * @author yslg + * @since 2025-12-27 + */ + ResultDomain generateMeetingEntryUrl(String meetingId, String baseUrl); } diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java index 8d075d19..285823c6 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java @@ -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; } diff --git a/urbanLifelineServ/gateway/src/main/resources/application.yml b/urbanLifelineServ/gateway/src/main/resources/application.yml index 6fcb5312..acf9fac1 100644 --- a/urbanLifelineServ/gateway/src/main/resources/application.yml +++ b/urbanLifelineServ/gateway/src/main/resources/application.yml @@ -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位) diff --git a/urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md b/urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md new file mode 100644 index 00000000..c02a5f3d --- /dev/null +++ b/urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md @@ -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 + + + + + +``` + +#### 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,否则浏览器可能阻止摄像头/麦克风访问 diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java index 58c66500..e74bbbe3 100644 --- a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java @@ -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 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 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)") diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java index 20793b9f..4d7c5cd3 100644 --- a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java @@ -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 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 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 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 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 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 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 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()); + } + } } diff --git a/urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js b/urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js index a3e0d379..8ddf622b 100644 --- a/urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js +++ b/urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js @@ -4,6 +4,11 @@ import {loadShare} from "@module-federation/runtime"; const importMap = { + "axios": async () => { + let pkg = await import("__mf__virtual/workcase__prebuild__axios__prebuild__.js"); + return pkg; + } + , "element-plus": async () => { let pkg = await import("__mf__virtual/workcase__prebuild__element_mf_2_plus__prebuild__.js"); return pkg; @@ -22,6 +27,36 @@ } const usedShared = { + "axios": { + name: "axios", + version: "1.13.2", + scope: ["default"], + loaded: false, + from: "workcase", + async get () { + if (false) { + throw new Error(`Shared module '${"axios"}' must be provided by host`); + } + usedShared["axios"].loaded = true + const {"axios": pkgDynamicImport} = importMap + const res = await pkgDynamicImport() + const exportModule = {...res} + // All npm packages pre-built by vite will be converted to esm + Object.defineProperty(exportModule, "__esModule", { + value: true, + enumerable: false + }) + return function () { + return exportModule + } + }, + shareConfig: { + singleton: false, + requiredVersion: "^1.13.2", + + } + } + , "element-plus": { name: "element-plus", version: "2.12.0", diff --git a/urbanLifelineWeb/packages/workcase/src/router/index.ts b/urbanLifelineWeb/packages/workcase/src/router/index.ts index 0acc4840..4dd8b458 100644 --- a/urbanLifelineWeb/packages/workcase/src/router/index.ts +++ b/urbanLifelineWeb/packages/workcase/src/router/index.ts @@ -18,45 +18,84 @@ const router = createRouter({ let dynamicRoutesLoaded = false // 路由守卫 -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { console.log('[Workcase Router] 路由守卫触发:', { to: to.path, from: from.path, - meta: to.meta + meta: to.meta, + query: to.query }) - + // 设置页面标题 if (to.meta.title) { document.title = `${to.meta.title} - 工单管理系统` } - + + // 检查URL参数中是否有token(用于外部链接和小程序访问) + const tokenParam = to.query.token as string | undefined + + // 如果URL中有token,但localStorage中没有loginDomain,使用refresh接口验证 + if (tokenParam && !localStorage.getItem('loginDomain')) { + console.log('[Workcase Router] 检测到token参数,尝试验证登录状态...') + try { + const response = await fetch('/api/urban-lifeline/auth/refresh', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${tokenParam}`, + 'Content-Type': 'application/json' + } + }) + + if (response.ok) { + const result = await response.json() + if (result.success && result.data) { + const loginDomain = result.data + const newToken = loginDomain.token + + // 保存到localStorage + localStorage.setItem('token', newToken) + localStorage.setItem('loginDomain', JSON.stringify(loginDomain)) + TokenManager.setToken(newToken) + + console.log('[Workcase Router] Token验证成功,登录状态已保存') + } else { + console.warn('[Workcase Router] Token验证失败:', result.message) + } + } else { + console.warn('[Workcase Router] Token验证请求失败:', response.status) + } + } catch (error) { + console.error('[Workcase Router] Token验证异常:', error) + } + } + // 检查是否需要登录 const requiresAuth = to.meta.requiresAuth !== false const hasToken = TokenManager.hasToken() - + console.log('[Workcase Router] 认证检查:', { requiresAuth, hasToken, tokenValue: localStorage.getItem('token') }) - + // 其他页面:检查是否需要登录 if (requiresAuth && !hasToken) { // 需要登录但未登录,重定向到 platform 的登录页 // 重要:必须使用完整URL(包含origin),避免被workcase的路由拦截造成循环 const currentUrl = window.location.href const origin = window.location.origin - + // 构建platform登录页的完整URL const loginUrl = `${origin}/login?redirect=${encodeURIComponent(currentUrl)}` - + console.log('[Workcase Router] 未登录,重定向到Platform登录页:', loginUrl) - + // 使用完整URL跳转,跳出workcase的路由系统 window.location.href = loginUrl return } - + // 如果已登录且动态路由未加载,先加载动态路由 if (hasToken && !dynamicRoutesLoaded) { console.log('[Workcase Router] 开始加载动态路由...') @@ -64,14 +103,14 @@ router.beforeEach((to, from, next) => { loginDomain: localStorage.getItem('loginDomain'), token: localStorage.getItem('token') }) - + dynamicRoutesLoaded = true const loaded = loadRoutesFromStorage?.() - + console.log('[Workcase Router] 动态路由加载结果:', loaded) console.log('[Workcase Router] 当前路径:', to.path) console.log('[Workcase Router] 所有路由:', router.getRoutes().map(r => r.path)) - + if (loaded) { if (to.path === '/') { // 访问根路径,重定向到第一个可用路由 @@ -103,7 +142,7 @@ router.beforeEach((to, from, next) => { console.warn('[Workcase Router] 动态路由加载失败') } } - + // 如果已登录且访问根路径,但动态路由已加载,重定向到第一个可用路由 if (hasToken && to.path === '/' && dynamicRoutesLoaded) { const firstRoute = getFirstAvailableRoute() @@ -114,7 +153,7 @@ router.beforeEach((to, from, next) => { return } } - + // 如果访问 /admin,重定向到第一个 admin 路由 if (hasToken && to.path === '/admin' && dynamicRoutesLoaded) { const firstAdminRoute = getFirstAdminRoute() @@ -124,7 +163,7 @@ router.beforeEach((to, from, next) => { return } } - + console.log('[Workcase Router] 继续正常导航') next() }) diff --git a/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts b/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts index 07fcee7e..5b06e4ec 100644 --- a/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts +++ b/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts @@ -240,7 +240,7 @@ export interface VideoMeetingVO extends BaseVO { participantCount?: number maxParticipants?: number // 预定开始时间 - startTime?: string + startTime?: string // 预定结束时间 endTime?: string // 提前入会时间(分钟) @@ -249,7 +249,10 @@ export interface VideoMeetingVO extends BaseVO { actualEndTime?: string durationSeconds?: number durationFormatted?: string + /** 会议页面URL(用于路由跳转) */ iframeUrl?: string + /** Jitsi真正的iframe URL(用于嵌入播放) */ + jitsiIframeUrl?: string config?: Record } diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue index 51be90d6..cb1b2690 100644 --- a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue @@ -92,8 +92,6 @@ :room-id="currentRoomId" :workcase-id="currentWorkcaseId" :room-name="currentRoom?.roomName" - :meeting-url="currentMeetingUrl" - :show-meeting="showMeetingIframe" :file-download-url="FILE_DOWNLOAD_URL" :has-more="hasMore" :loading-more="loadingMore" @@ -145,8 +143,13 @@ title="工单详情" width="800px" class="workcase-dialog" + destroy-on-close > - + @@ -155,9 +158,14 @@ title="创建工单" width="800px" class="workcase-dialog" + destroy-on-close > - -
工单创建表单
+ @@ -247,11 +255,6 @@ const showWorkcaseDetail = ref(false) // 工单创建对话框 const showWorkcaseCreator = ref(false) -// Jitsi Meet会议相关 -const currentMeetingUrl = ref('') -const showMeetingIframe = ref(false) -const currentMeetingId = ref(null) - // ChatRoom组件引用 const chatRoomRef = ref | null>(null) @@ -508,13 +511,15 @@ const onWorkcaseCreated = (workcaseId: string) => { if (currentRoom.value) { currentRoom.value.workcaseId = workcaseId } + // 刷新聊天室列表 + fetchChatRooms() ElMessage.success('工单创建成功') } // 发起会议 const startMeeting = async () => { if (!currentRoomId.value) return - + try { // 先检查是否有活跃会议 const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value) @@ -523,31 +528,33 @@ const startMeeting = async () => { currentMeetingId.value = activeResult.data.meetingId! const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) if (joinResult.success && joinResult.data?.iframeUrl) { - currentMeetingUrl.value = joinResult.data.iframeUrl - showMeetingIframe.value = true + // 使用router跳转到JitsiMeetingView页面,附加roomId参数用于返回 + const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}` + router.push(meetingUrl) } else { ElMessage.error(joinResult.message || '加入会议失败') } return } - + // 没有活跃会议,创建新会议 const createResult = await workcaseChatAPI.createVideoMeeting({ roomId: currentRoomId.value, meetingName: currentRoom.value?.roomName || '视频会议' }) - + if (createResult.success && createResult.data) { currentMeetingId.value = createResult.data.meetingId! - + // 开始会议 await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!) - - // 加入会议获取iframe URL + + // 加入会议获取会议页面URL const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) if (joinResult.success && joinResult.data?.iframeUrl) { - currentMeetingUrl.value = joinResult.data.iframeUrl - showMeetingIframe.value = true + // 使用router跳转到JitsiMeetingView页面,附加roomId参数用于返回 + const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}` + router.push(meetingUrl) ElMessage.success('会议已创建') } else { ElMessage.error(joinResult.message || '获取会议链接失败') diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue index fd535448..b8c47552 100644 --- a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue @@ -79,11 +79,11 @@ @@ -137,48 +137,20 @@ :workcase-id="workcaseId || ''" @success="handleMeetingCreated" /> - - - -
-
-
- - -
- - -
-
-
- -
-
-
-
- - -
-
diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.scss b/urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.scss new file mode 100644 index 00000000..88893865 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.scss @@ -0,0 +1,81 @@ +.jitsi-meeting-view { + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + background: #000; + overflow: hidden; +} + +.loading-container, +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #fff; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-text { + margin-top: 20px; + font-size: 16px; +} + +.error-icon { + font-size: 64px; + margin-bottom: 20px; +} + +.error-title { + font-size: 24px; + font-weight: bold; + margin-bottom: 10px; +} + +.error-message { + font-size: 16px; + color: #ccc; + margin-bottom: 30px; +} + +.error-btn { + padding: 10px 30px; + font-size: 16px; + color: #fff; + background: #409eff; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s; + + &:hover { + background: #66b1ff; + } +} + +.meeting-container { + width: 100%; + height: 100%; + + // Jitsi External API 会在这个容器内创建 iframe + #jitsi-meet-container { + width: 100%; + height: 100%; + } +} \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.vue b/urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.vue new file mode 100644 index 00000000..c2cb8686 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/JitsiMeeting/JitsiMeetingView.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue b/urbanLifelineWeb/packages/workcase/src/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue index bb704d2e..09350021 100644 --- a/urbanLifelineWeb/packages/workcase/src/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue +++ b/urbanLifelineWeb/packages/workcase/src/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue @@ -51,7 +51,9 @@
故障类型
- + + + {{ formData.type || '-' }}
@@ -141,22 +143,24 @@ -
+
处理记录
-
-
-
+
+
+
- {{ item.title.split(' ')[0] }} - {{ item.title.split(' ').slice(1).join(' ') }} + {{ getTime(item.createTime) }} + {{ getDate(item.createTime) }} +
+
+ {{ getActionText(item.action) }}: + {{ item.message }}
-
{{ item.desc }}
-
{{ item.time }}
@@ -180,27 +184,25 @@