This commit is contained in:
2025-12-27 15:36:40 +08:00
parent 7c6fbc5ebe
commit 55801fa0ec
17 changed files with 1728 additions and 229 deletions

View File

@@ -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")

View File

@@ -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());
}
}
}