temp jitsi

This commit is contained in:
2025-12-26 10:37:52 +08:00
parent e39dc03f92
commit c2b37503fc
22 changed files with 1710 additions and 416 deletions

View File

@@ -268,6 +268,100 @@ public class WorkcaseChatContorller {
return chatRoomService.assignCustomerService(roomId);
}
// ========================= 视频会议管理Jitsi Meet =========================
@Autowired
private org.xyzh.api.workcase.service.VideoMeetingService videoMeetingService;
@Operation(summary = "创建视频会议")
@PreAuthorize("hasAuthority('workcase:room:meeting')")
@PostMapping("/meeting/create")
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> createVideoMeeting(
@RequestBody org.xyzh.api.workcase.dto.TbVideoMeetingDTO meetingDTO) {
ValidationResult vr = ValidationUtils.validate(meetingDTO, Arrays.asList(
ValidationUtils.requiredString("roomId", "聊天室ID")
));
if (!vr.isValid()) {
return ResultDomain.failure(vr.getAllErrors());
}
try {
return videoMeetingService.createMeeting(meetingDTO);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
@Operation(summary = "获取会议信息")
@PreAuthorize("hasAuthority('workcase:room:meeting')")
@GetMapping("/meeting/{meetingId}")
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> getMeetingInfo(
@PathVariable(value = "meetingId") String meetingId) {
String userId = LoginUtil.getCurrentUserId();
try {
return videoMeetingService.getMeetingInfo(meetingId, userId);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
@Operation(summary = "加入会议生成用户专属JWT")
@PreAuthorize("hasAuthority('workcase:room:meeting')")
@PostMapping("/meeting/{meetingId}/join")
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> joinMeeting(
@PathVariable(value = "meetingId") String meetingId) {
String userId = LoginUtil.getCurrentUserId();
// 验证加入权限
ResultDomain<Boolean> accessCheck = videoMeetingService.validateMeetingAccess(meetingId, userId);
if (!accessCheck.getSuccess() || !accessCheck.getData()) {
return ResultDomain.failure("您无权加入此会议");
}
// 生成用户专属的iframe URL包含JWT Token
try {
return videoMeetingService.generateUserMeetingUrl(meetingId, userId);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
@Operation(summary = "开始会议")
@PreAuthorize("hasAuthority('workcase:room:meeting')")
@PostMapping("/meeting/{meetingId}/start")
public ResultDomain<Boolean> startMeeting(@PathVariable(value = "meetingId") String meetingId) {
try {
return videoMeetingService.startMeeting(meetingId);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
@Operation(summary = "结束会议")
@PreAuthorize("hasAuthority('workcase:room:meeting')")
@PostMapping("/meeting/{meetingId}/end")
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> endMeeting(
@PathVariable(value = "meetingId") String meetingId) {
try {
return videoMeetingService.endMeeting(meetingId);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
@Operation(summary = "获取聊天室当前活跃会议")
@PreAuthorize("hasAuthority('workcase:room:meeting')")
@GetMapping("/meeting/room/{roomId}/active")
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> getActiveMeetingByRoom(
@PathVariable(value = "roomId") String roomId) {
try {
return videoMeetingService.getActiveMeetingByRoom(roomId);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
// ========================= 微信客服消息回调 =========================
// @Operation(summary = "微信客服消息回调验证GET")

View File

@@ -0,0 +1,145 @@
package org.xyzh.workcase.service;
import com.alibaba.fastjson2.JSONObject;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.dubbo.config.annotation.DubboService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.xyzh.api.workcase.service.JitsiTokenService;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @description Jitsi Meet JWT Token服务实现类
* @filename JitsiTokenServiceImpl.java
* @author claude
* @copyright xyzh
* @since 2025-12-25
*/
@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0)
public class JitsiTokenServiceImpl implements JitsiTokenService {
private static final Logger logger = LoggerFactory.getLogger(JitsiTokenServiceImpl.class);
@Value("${jitsi.app.id:urbanLifeline}")
private String jitsiAppId;
@Value("${jitsi.app.secret:your-secret-key-change-in-production}")
private String jitsiAppSecret;
@Value("${jitsi.server.url:https://meet.jit.si}")
private String jitsiServerUrl;
@Value("${jitsi.token.expiration:7200000}")
private Long tokenExpiration; // 默认2小时
@Override
public String generateJwtToken(String roomName, String userId, String userName, boolean isModerator) {
logger.info("生成Jitsi JWT Token: roomName={}, userId={}, userName={}, isModerator={}",
roomName, userId, userName, isModerator);
try {
long now = System.currentTimeMillis();
long exp = now + tokenExpiration;
// 构建用户上下文
Map<String, Object> userContext = new HashMap<>();
userContext.put("id", userId);
userContext.put("name", userName);
userContext.put("moderator", isModerator);
// 构建JWT claims
Map<String, Object> claims = new HashMap<>();
claims.put("context", Map.of("user", userContext));
claims.put("room", roomName);
claims.put("iss", jitsiAppId);
claims.put("aud", "jitsi");
claims.put("sub", jitsiServerUrl);
claims.put("exp", exp / 1000); // 秒级时间戳
claims.put("nbf", now / 1000);
// 生成JWT Token
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(now))
.setExpiration(new Date(exp))
.signWith(SignatureAlgorithm.HS256, jitsiAppSecret.getBytes())
.compact();
logger.info("JWT Token生成成功: roomName={}", roomName);
return token;
} catch (Exception e) {
logger.error("生成JWT Token失败: roomName={}, error={}", roomName, e.getMessage(), e);
throw new RuntimeException("生成JWT Token失败: " + e.getMessage());
}
}
@Override
public boolean validateJwtToken(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(jitsiAppSecret.getBytes())
.parseClaimsJws(token)
.getBody();
// 检查过期时间
Date expiration = claims.getExpiration();
return expiration.after(new Date());
} catch (Exception e) {
logger.warn("JWT Token验证失败: error={}", e.getMessage());
return false;
}
}
@Override
public String buildIframeUrl(String roomName, String jwtToken, JSONObject config) {
logger.info("构建Jitsi iframe URL: roomName={}", roomName);
StringBuilder url = new StringBuilder();
url.append(jitsiServerUrl).append("/").append(roomName);
// 添加JWT Token
url.append("?jwt=").append(jwtToken);
// 添加默认配置
url.append("&config.startWithAudioMuted=false");
url.append("&config.startWithVideoMuted=false");
url.append("&config.enableWelcomePage=false");
url.append("&config.prejoinPageEnabled=false");
url.append("&config.disableDeepLinking=true");
url.append("&config.enableChat=true");
url.append("&config.enableScreenSharing=true");
// 界面配置
url.append("&interfaceConfig.SHOW_JITSI_WATERMARK=false");
url.append("&interfaceConfig.SHOW_WATERMARK_FOR_GUESTS=false");
url.append("&interfaceConfig.DISABLE_JOIN_LEAVE_NOTIFICATIONS=false");
url.append("&interfaceConfig.DEFAULT_BACKGROUND=#474747");
// 添加自定义配置
if (config != null && !config.isEmpty()) {
config.forEach((key, value) -> {
url.append("&config.").append(key).append("=").append(value);
});
}
String iframeUrl = url.toString();
logger.info("iframe URL构建成功: url={}", iframeUrl);
return iframeUrl;
}
@Override
public String generateRoomName(String workcaseId) {
// 格式: workcase_{workcaseId}_{timestamp}
String roomName = String.format("workcase_%s_%d",
workcaseId != null ? workcaseId : "default",
System.currentTimeMillis());
logger.info("生成Jitsi房间名: roomName={}", roomName);
return roomName;
}
}

View File

@@ -0,0 +1,386 @@
package org.xyzh.workcase.service;
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.workcase.dto.TbChatRoomMemberDTO;
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
import org.xyzh.api.workcase.service.JitsiTokenService;
import org.xyzh.api.workcase.service.VideoMeetingService;
import org.xyzh.api.workcase.vo.ChatMemberVO;
import org.xyzh.api.workcase.vo.VideoMeetingVO;
import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.utils.id.IdUtil;
import org.xyzh.workcase.mapper.TbChatRoomMemberMapper;
import org.xyzh.workcase.mapper.TbVideoMeetingMapper;
import java.util.Date;
import java.util.List;
/**
* @description 视频会议服务实现类
* @filename VideoMeetingServiceImpl.java
* @author claude
* @copyright xyzh
* @since 2025-12-25
*/
@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0)
public class VideoMeetingServiceImpl implements VideoMeetingService {
private static final Logger logger = LoggerFactory.getLogger(VideoMeetingServiceImpl.class);
@Autowired
private TbVideoMeetingMapper videoMeetingMapper;
@Autowired
private TbChatRoomMemberMapper chatRoomMemberMapper;
@Autowired
private JitsiTokenService jitsiTokenService;
@Override
@Transactional
public ResultDomain<VideoMeetingVO> createMeeting(TbVideoMeetingDTO meetingDTO) {
// 获取当前用户ID
String userId = LoginUtil.getCurrentUserId();
logger.info("创建视频会议: roomId={}, workcaseId={}, userId={}",
meetingDTO.getRoomId(), meetingDTO.getWorkcaseId(), userId);
try {
// 1. 验证用户是否为聊天室成员
if (!isMemberOfRoom(meetingDTO.getRoomId(), userId)) {
logger.warn("用户不是聊天室成员,无法创建会议: roomId={}, userId={}",
meetingDTO.getRoomId(), userId);
return ResultDomain.failure("您不是聊天室成员,无法创建会议");
}
// 2. 检查聊天室是否已有进行中的会议
TbVideoMeetingDTO existingMeetingFilter = new TbVideoMeetingDTO();
existingMeetingFilter.setRoomId(meetingDTO.getRoomId());
existingMeetingFilter.setStatus("ongoing");
List<VideoMeetingVO> existingMeetings = videoMeetingMapper.selectVideoMeetingList(existingMeetingFilter);
if (existingMeetings != null && !existingMeetings.isEmpty()) {
logger.warn("聊天室已有进行中的会议: roomId={}", meetingDTO.getRoomId());
return ResultDomain.failure("聊天室已有进行中的会议,请稍后再试");
}
// 3. 生成会议ID和房间名
String meetingId = IdUtil.generateUUID();
String jitsiRoomName = jitsiTokenService.generateRoomName(meetingDTO.getWorkcaseId());
// 4. 获取用户信息(从聊天室成员表)
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
memberFilter.setRoomId(meetingDTO.getRoomId());
memberFilter.setUserId(userId);
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
String userName = "用户";
String userType = "guest";
if (members != null && !members.isEmpty()) {
ChatMemberVO member = members.get(0);
userName = member.getUserName();
userType = member.getUserType();
}
// 5. 生成创建者的JWT Token创建者默认为主持人
String jwtToken = jitsiTokenService.generateJwtToken(
jitsiRoomName,
userId,
userName,
true // 创建者为主持人
);
// 6. 构建iframe URL
String iframeUrl = jitsiTokenService.buildIframeUrl(jitsiRoomName, jwtToken, meetingDTO.getConfig());
// 7. 填充会议信息
meetingDTO.setMeetingId(meetingId);
meetingDTO.setJitsiRoomName(jitsiRoomName);
meetingDTO.setJwtToken(jwtToken); // 存储创建者的token可选
meetingDTO.setIframeUrl(iframeUrl);
meetingDTO.setStatus("scheduled");
meetingDTO.setCreatorId(userId);
meetingDTO.setCreatorType(userType);
meetingDTO.setCreatorName(userName);
meetingDTO.setParticipantCount(0);
meetingDTO.setOptsn(IdUtil.getOptsn());
if (meetingDTO.getMaxParticipants() == null) {
meetingDTO.setMaxParticipants(10);
}
// 8. 插入数据库
int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO);
if (rows > 0) {
logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}",
meetingId, jitsiRoomName);
// 9. 返回VO
VideoMeetingVO meetingVO = new VideoMeetingVO();
BeanUtils.copyProperties(meetingDTO, meetingVO);
return ResultDomain.success("创建会议成功", meetingVO);
} else {
logger.error("插入会议记录失败: meetingId={}", meetingId);
return ResultDomain.failure("创建会议失败");
}
} catch (Exception e) {
logger.error("创建视频会议异常: roomId={}, error={}",
meetingDTO.getRoomId(), e.getMessage(), e);
return ResultDomain.failure("创建会议失败: " + e.getMessage());
}
}
@Override
public ResultDomain<VideoMeetingVO> getMeetingInfo(String meetingId, String userId) {
logger.info("获取会议信息: meetingId={}, userId={}", meetingId, userId);
try {
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);
// 验证访问权限
ResultDomain<Boolean> accessCheck = validateMeetingAccess(meetingId, userId);
if (!accessCheck.getSuccess() || !accessCheck.getData()) {
logger.warn("用户无权访问会议: meetingId={}, userId={}", meetingId, userId);
return ResultDomain.failure("您无权访问此会议");
}
logger.info("获取会议信息成功: meetingId={}", meetingId);
return ResultDomain.success("获取会议信息成功", meeting);
} catch (Exception e) {
logger.error("获取会议信息异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
return ResultDomain.failure("获取会议信息失败: " + e.getMessage());
}
}
@Override
public ResultDomain<Boolean> validateMeetingAccess(String meetingId, String userId) {
logger.info("验证会议访问权限: meetingId={}, userId={}", meetingId, userId);
try {
// 1. 获取会议信息
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
filter.setMeetingId(meetingId);
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
if (meetings == null || meetings.isEmpty()) {
logger.warn("会议不存在: meetingId={}", meetingId);
return ResultDomain.success("会议不存在", false);
}
VideoMeetingVO meeting = meetings.get(0);
// 2. 检查用户是否为聊天室成员
boolean isMember = isMemberOfRoom(meeting.getRoomId(), userId);
logger.info("会议访问权限验证结果: meetingId={}, userId={}, hasAccess={}",
meetingId, userId, isMember);
return ResultDomain.success("会议访问权限验证成功", isMember);
} catch (Exception e) {
logger.error("验证会议访问权限异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
return ResultDomain.failure("验证访问权限失败: " + e.getMessage());
}
}
@Override
public ResultDomain<VideoMeetingVO> generateUserMeetingUrl(String meetingId, String userId) {
logger.info("生成用户专属会议URL: meetingId={}, userId={}", meetingId, userId);
try {
// 1. 获取会议信息
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);
// 2. 验证访问权限
ResultDomain<Boolean> accessCheck = validateMeetingAccess(meetingId, userId);
if (!accessCheck.getSuccess() || !accessCheck.getData()) {
logger.warn("用户无权访问会议: meetingId={}, userId={}", meetingId, userId);
return ResultDomain.failure("您无权访问此会议");
}
// 3. 获取用户信息
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
memberFilter.setRoomId(meeting.getRoomId());
memberFilter.setUserId(userId);
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
String userName = "用户";
boolean isModerator = false;
if (members != null && !members.isEmpty()) {
ChatMemberVO member = members.get(0);
userName = member.getUserName();
// 客服人员设为主持人
isModerator = "agent".equals(member.getUserType());
}
// 4. 生成用户专属JWT Token
String userJwtToken = jitsiTokenService.generateJwtToken(
meeting.getJitsiRoomName(),
userId,
userName,
isModerator
);
// 5. 构建用户专属iframe URL
String userIframeUrl = jitsiTokenService.buildIframeUrl(
meeting.getJitsiRoomName(),
userJwtToken,
meeting.getConfig()
);
// 6. 更新VO
meeting.setJwtToken(userJwtToken);
meeting.setIframeUrl(userIframeUrl);
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}", meetingId, userId);
return ResultDomain.success("生成用户专属会议URL成功", meeting);
} catch (Exception e) {
logger.error("生成用户专属会议URL异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
return ResultDomain.failure("生成会议URL失败: " + e.getMessage());
}
}
@Override
@Transactional
public ResultDomain<Boolean> startMeeting(String meetingId) {
logger.info("开始会议: meetingId={}", meetingId);
try {
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
updateDTO.setMeetingId(meetingId);
updateDTO.setStatus("ongoing");
updateDTO.setActualStartTime(new Date());
int rows = videoMeetingMapper.updateVideoMeeting(updateDTO);
if (rows > 0) {
logger.info("会议开始成功: meetingId={}", meetingId);
return ResultDomain.success("会议开始成功", true);
} else {
logger.warn("会议开始失败(可能不存在): meetingId={}", meetingId);
return ResultDomain.failure("会议不存在或已开始");
}
} catch (Exception e) {
logger.error("开始会议异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
return ResultDomain.failure("开始会议失败: " + e.getMessage());
}
}
@Override
@Transactional
public ResultDomain<VideoMeetingVO> endMeeting(String meetingId) {
logger.info("结束会议: meetingId={}", meetingId);
try {
// 1. 获取会议信息
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);
// 2. 计算会议时长
Integer durationSeconds = null;
if (meeting.getActualStartTime() != null) {
long duration = (System.currentTimeMillis() - meeting.getActualStartTime().getTime()) / 1000;
durationSeconds = (int) duration;
}
// 3. 更新会议状态
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
updateDTO.setMeetingId(meetingId);
updateDTO.setStatus("ended");
updateDTO.setActualEndTime(new Date());
updateDTO.setDurationSeconds(durationSeconds);
int rows = videoMeetingMapper.updateVideoMeeting(updateDTO);
if (rows > 0) {
// 4. 更新VO
meeting.setStatus("ended");
meeting.setActualEndTime(new Date());
meeting.setDurationSeconds(durationSeconds);
logger.info("会议结束成功: meetingId={}, duration={}秒", meetingId, durationSeconds);
return ResultDomain.success("会议结束成功", meeting);
} else {
logger.warn("会议结束失败: meetingId={}", meetingId);
return ResultDomain.failure("结束会议失败");
}
} catch (Exception e) {
logger.error("结束会议异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
return ResultDomain.failure("结束会议失败: " + e.getMessage());
}
}
@Override
public ResultDomain<VideoMeetingVO> getActiveMeetingByRoom(String roomId) {
logger.info("获取聊天室活跃会议: roomId={}", roomId);
try {
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
filter.setRoomId(roomId);
filter.setStatus("ongoing");
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
if (meetings == null || meetings.isEmpty()) {
logger.info("聊天室无活跃会议: roomId={}", roomId);
return ResultDomain.failure("无活跃会议");
}
VideoMeetingVO meeting = meetings.get(0);
logger.info("找到活跃会议: roomId={}, meetingId={}", roomId, meeting.getMeetingId());
return ResultDomain.success("找到活跃会议", meeting);
} catch (Exception e) {
logger.error("获取活跃会议异常: roomId={}, error={}", roomId, e.getMessage(), e);
return ResultDomain.failure("获取活跃会议失败: " + e.getMessage());
}
}
@Override
public boolean isMemberOfRoom(String roomId, String userId) {
logger.debug("检查用户是否为聊天室成员: roomId={}, userId={}", roomId, userId);
try {
TbChatRoomMemberDTO filter = new TbChatRoomMemberDTO();
filter.setRoomId(roomId);
filter.setUserId(userId);
filter.setStatus("active");
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(filter);
boolean isMember = members != null && !members.isEmpty();
logger.debug("用户成员检查结果: roomId={}, userId={}, isMember={}",
roomId, userId, isMember);
return isMember;
} catch (Exception e) {
logger.error("检查用户成员身份异常: roomId={}, userId={}, error={}",
roomId, userId, e.getMessage(), e);
return false;
}
}
}

View File

@@ -1,11 +1,26 @@
package org.xyzh.workcase.service;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import javax.imageio.ImageIO;
import org.apache.dubbo.config.annotation.DubboReference;
import org.apache.dubbo.config.annotation.DubboService;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.xyzh.api.file.dto.TbSysFileDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
@@ -20,10 +35,23 @@ import org.xyzh.workcase.enums.WorkcaseProcessAction;
import org.xyzh.workcase.mapper.TbWorkcaseDeviceMapper;
import org.xyzh.workcase.mapper.TbWorkcaseMapper;
import org.xyzh.workcase.mapper.TbWorkcaseProcessMapper;
import org.xyzh.api.file.service.FileService;
import org.xyzh.api.workcase.dto.TbChatRoomDTO;
import org.xyzh.api.workcase.service.ChatRoomService;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.Result;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
@DubboService(version = "1.0.0",group = "workcase",timeout = 30000,retries = 0)
public class WorkcaseServiceImpl implements WorkcaseService {
@@ -41,6 +69,9 @@ public class WorkcaseServiceImpl implements WorkcaseService {
@Autowired
private ChatRoomService chatRoomService;
@DubboReference(version = "1.0.0",group = "file",timeout = 30000,retries = 0)
private FileService fileService;
// ====================== 工单管理 ======================
@Override
@@ -68,7 +99,10 @@ public class WorkcaseServiceImpl implements WorkcaseService {
}
// 统一由后端从登录态设置 creator避免前端传入不可信
workcase.setCreator(LoginUtil.getCurrentUserId());
// 解析设备铭牌二维码
// ResultDomain<TbWorkcaseDeviceDTO> deviceResult = anylizeQrCode(workcase.getDeviceNamePlateImg());
int rows = workcaseMapper.insertWorkcase(workcase);
if (rows > 0) {
// 创建工单处理记录
@@ -80,7 +114,7 @@ public class WorkcaseServiceImpl implements WorkcaseService {
process.setMessage("工单创建");
process.setCreator(workcase.getCreator());
workcaseProcessMapper.insertWorkcaseProcess(process);
// 如果是新创建的聊天室,更新聊天室的 workcaseId
logger.info("更新聊天室的工单ID: roomId={}, workcaseId={}", workcase.getRoomId(), workcase.getWorkcaseId());
@@ -89,9 +123,24 @@ public class WorkcaseServiceImpl implements WorkcaseService {
updateRoom.setWorkcaseId(workcase.getWorkcaseId());
chatRoomService.updateChatRoom(updateRoom);
syncWorkcaseToCrm(workcase);
// 插入设备文件记录到数据库
// if (deviceResult.getSuccess() && deviceResult.getData() != null) {
// List<TbWorkcaseDeviceDTO> deviceList = (List<TbWorkcaseDeviceDTO>) deviceResult.getData();
// for (TbWorkcaseDeviceDTO deviceDTO : deviceList) {
// deviceDTO.setWorkcaseId(workcase.getWorkcaseId());
// try {
// workcaseDeviceMapper.insertWorkcaseDevice(deviceDTO);
// logger.info("设备文件记录插入成功: workcaseId={}, fileName={}",
// workcase.getWorkcaseId(), deviceDTO.getFileName());
// } catch (Exception e) {
// logger.error("设备文件记录插入失败: " + deviceDTO.getFileName(), e);
// }
// }
// }
syncWorkcaseToCrm(workcase);
return ResultDomain.success("创建成功", workcase);
// return ResultDomain.success(deviceResult.getSuccess() ? "创建成功" : "设备铭牌二维码解析失败", workcase);
}
return ResultDomain.failure("创建失败");
}
@@ -445,4 +494,349 @@ public class WorkcaseServiceImpl implements WorkcaseService {
return ResultDomain.success("查询成功", pageDomain);
}
/**
* @description 扫描设备铭牌二维码,并下载其中的文件,构建WorkDeviceDTO列表
* @param qrcodeFileId 二维码文件id
* @return WorkDeviceDTO列表
* @author yslg
* @since 2025-12-25
*/
private ResultDomain<TbWorkcaseDeviceDTO> anylizeQrCode(String qrcodeFileId){
List<TbWorkcaseDeviceDTO> workDeviceList = new ArrayList<>();
try {
logger.info("开始解析设备铭牌二维码: qrcodeFileId={}", qrcodeFileId);
// 1. 从 FileService 获取二维码图片
ResultDomain<byte[]> downloadResult = fileService.downloadFile(qrcodeFileId);
if (!downloadResult.getSuccess()) {
logger.error("下载二维码图片失败: {}", downloadResult.getMessage());
return ResultDomain.failure("下载二维码图片失败: " + downloadResult.getMessage());
}
byte[] qrcodeImageBytes = downloadResult.getData();
logger.info("二维码图片下载成功,大小: {} bytes", qrcodeImageBytes.length);
// 2. 解析二维码内容
String qrcodeContent = decodeQRCode(qrcodeImageBytes);
if (qrcodeContent == null || qrcodeContent.isEmpty()) {
logger.error("二维码解析失败或内容为空");
return ResultDomain.failure("二维码解析失败或内容为空");
}
logger.info("二维码解析成功,内容: {}", qrcodeContent);
// 3. 判断二维码内容类型并解析
String deviceCode = null;
String device = null;
JSONArray files = null;
// 尝试判断是 URL 还是 JSON
if (qrcodeContent.startsWith("http://") || qrcodeContent.startsWith("https://")) {
// 二维码包含 URL需要访问 H5 页面解析
logger.info("检测到二维码包含URL开始访问页面: {}", qrcodeContent);
JSONObject pageData = parseDevicePageFromUrl(qrcodeContent);
if (pageData == null) {
logger.error("访问URL并解析页面失败");
return ResultDomain.failure("访问设备信息页面失败");
}
deviceCode = pageData.getString("deviceCode");
device = pageData.getString("device");
files = pageData.getJSONArray("files");
} else {
// 二维码直接包含 JSON 数据
logger.info("检测到二维码包含JSON数据直接解析");
JSONObject qrcodeData = JSON.parseObject(qrcodeContent);
deviceCode = qrcodeData.getString("deviceCode");
device = qrcodeData.getString("device");
files = qrcodeData.getJSONArray("files");
}
if (files == null || files.isEmpty()) {
logger.warn("二维码中没有文件信息");
return ResultDomain.success("二维码中没有文件信息", workDeviceList);
}
logger.info("设备编号: {}, 设备名称: {}, 文件数量: {}", deviceCode, device, files.size());
// 4. 下载并存储每个文件
for (int i = 0; i < files.size(); i++) {
JSONObject fileInfo = files.getJSONObject(i);
String fileName = fileInfo.getString("fileName");
String fileUrl = fileInfo.getString("url");
logger.info("开始下载文件 [{}/{}]: {}", i + 1, files.size(), fileName);
try {
// 下载文件
byte[] fileBytes = downloadFileFromUrl(fileUrl);
if (fileBytes == null || fileBytes.length == 0) {
logger.error("文件下载失败: {}", fileName);
continue;
}
logger.info("文件下载成功: {}, 大小: {} bytes", fileName, fileBytes.length);
// 推断文件类型
String contentType = getContentTypeFromFileName(fileName);
// 通过 FileService 存储文件
ResultDomain<TbSysFileDTO> uploadResult = fileService.uploadFileBytes(
fileBytes,
fileName,
contentType,
"workcase",
deviceCode
);
if (uploadResult.getSuccess()) {
TbSysFileDTO uploadedFile = uploadResult.getData();
logger.info("文件上传成功: fileId={}, fileName={}", uploadedFile.getFileId(), fileName);
// 创建 TbWorkcaseDeviceDTO 对象
TbWorkcaseDeviceDTO deviceDTO = new TbWorkcaseDeviceDTO();
deviceDTO.setDevice(device);
deviceDTO.setDeviceCode(deviceCode);
deviceDTO.setFileId(uploadedFile.getFileId());
deviceDTO.setFileName(fileName);
deviceDTO.setFileRootId(uploadedFile.getFileRootId());
deviceDTO.setOptsn(IdUtil.getOptsn());
workDeviceList.add(deviceDTO);
} else {
logger.error("文件上传失败: {}, 原因: {}", fileName, uploadResult.getMessage());
}
} catch (Exception e) {
logger.error("处理文件失败: " + fileName, e);
}
}
logger.info("二维码解析完成,成功处理 {} 个文件", workDeviceList.size());
return ResultDomain.success("解析成功,处理了 " + workDeviceList.size() + " 个文件", workDeviceList);
} catch (Exception e) {
logger.error("解析设备铭牌二维码失败", e);
return ResultDomain.failure("解析设备铭牌二维码失败: " + e.getMessage());
}
}
/**
* 从 URL 解析设备页面信息
*/
private JSONObject parseDevicePageFromUrl(String url) {
try {
logger.info("开始访问设备信息页面: {}", url);
// 访问 URL 并获取 HTML 内容
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.timeout(10000)
.followRedirects(true)
.get();
logger.info("页面访问成功,开始解析内容");
// 解析设备编号和型号(从页面标题或特定元素中)
String deviceInfo = doc.select("body").text();
// 尝试匹配设备编号模式:数字格式如 202508012
String deviceCode = null;
String device = null;
// 从页面内容中提取设备编号和型号
if (deviceInfo.contains("(") && deviceInfo.contains(")")) {
// 例如: "202508012 (THHM1800PL)"
int start = deviceInfo.indexOf("(");
int end = deviceInfo.indexOf(")", start);
if (start > 0 && end > start) {
// 提取括号前的设备编号
String beforeBracket = deviceInfo.substring(Math.max(0, start - 20), start).trim();
String[] parts = beforeBracket.split("\\s+");
if (parts.length > 0) {
deviceCode = parts[parts.length - 1].trim();
}
// 提取括号内的型号
device = deviceInfo.substring(start + 1, end).trim();
}
}
logger.info("提取的设备信息 - 编号: {}, 型号: {}", deviceCode, device);
// 解析文件列表
JSONArray files = new JSONArray();
// 查找所有下载链接(通常是 PDF、图片等文件
Elements links = doc.select("a[href]");
for (Element link : links) {
String href = link.attr("abs:href"); // 获取绝对 URL
String text = link.text().trim();
// 过滤出文件下载链接(包含文件扩展名)
if (isFileLink(href) && !text.isEmpty()) {
JSONObject fileInfo = new JSONObject();
fileInfo.put("fileName", text);
fileInfo.put("url", href);
files.add(fileInfo);
logger.info("发现文件: {} -> {}", text, href);
}
}
// 如果没找到链接,尝试从 script 标签中查找 JSON 数据
if (files.isEmpty()) {
Elements scripts = doc.select("script");
for (Element script : scripts) {
String scriptContent = script.html();
// 尝试查找 JSON 数据
if (scriptContent.contains("files") || scriptContent.contains("fileName")) {
try {
// 尝试解析嵌入的 JSON
int jsonStart = scriptContent.indexOf("{");
int jsonEnd = scriptContent.lastIndexOf("}") + 1;
if (jsonStart >= 0 && jsonEnd > jsonStart) {
String jsonStr = scriptContent.substring(jsonStart, jsonEnd);
JSONObject embedded = JSON.parseObject(jsonStr);
if (embedded.containsKey("files")) {
files = embedded.getJSONArray("files");
if (deviceCode == null) {
deviceCode = embedded.getString("deviceCode");
}
if (device == null) {
device = embedded.getString("device");
}
break;
}
}
} catch (Exception e) {
// 忽略解析错误,继续下一个 script
}
}
}
}
if (deviceCode == null || files.isEmpty()) {
logger.error("无法从页面中提取完整的设备信息");
return null;
}
JSONObject result = new JSONObject();
result.put("deviceCode", deviceCode);
result.put("device", device != null ? device : "Unknown");
result.put("files", files);
logger.info("页面解析成功,设备编号: {}, 文件数量: {}", deviceCode, files.size());
return result;
} catch (Exception e) {
logger.error("解析设备页面失败: " + url, e);
return null;
}
}
/**
* 判断是否为文件下载链接
*/
private boolean isFileLink(String url) {
if (url == null || url.isEmpty()) {
return false;
}
String lowerUrl = url.toLowerCase();
return lowerUrl.endsWith(".pdf") ||
lowerUrl.endsWith(".jpg") ||
lowerUrl.endsWith(".jpeg") ||
lowerUrl.endsWith(".png") ||
lowerUrl.endsWith(".doc") ||
lowerUrl.endsWith(".docx") ||
lowerUrl.endsWith(".xls") ||
lowerUrl.endsWith(".xlsx") ||
lowerUrl.contains("/download") ||
lowerUrl.contains("/file/") ||
lowerUrl.contains(".pdf?") ||
lowerUrl.contains(".jpg?");
}
/**
* 解码二维码图片
*/
private String decodeQRCode(byte[] imageBytes) {
try {
InputStream inputStream = new ByteArrayInputStream(imageBytes);
BufferedImage bufferedImage = ImageIO.read(inputStream);
if (bufferedImage == null) {
logger.error("无法读取图片");
return null;
}
BinaryBitmap binaryBitmap = new BinaryBitmap(
new HybridBinarizer(
new BufferedImageLuminanceSource(bufferedImage)
)
);
MultiFormatReader reader = new MultiFormatReader();
java.util.Map<DecodeHintType, Object> hints = new java.util.HashMap<>();
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
Result result = reader.decode(binaryBitmap, hints);
return result.getText();
} catch (Exception e) {
logger.error("解码二维码失败", e);
return null;
}
}
/**
* 从URL下载文件
*/
private byte[] downloadFileFromUrl(String fileUrl) {
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpGet httpGet = new HttpGet(URI.create(fileUrl));
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
if (response.getCode() == 200) {
return EntityUtils.toByteArray(response.getEntity());
} else {
logger.error("下载文件失败HTTP状态码: {}", response.getCode());
return null;
}
}
} catch (Exception e) {
logger.error("下载文件失败: " + fileUrl, e);
return null;
}
}
/**
* 根据文件名推断 Content-Type
*/
private String getContentTypeFromFileName(String fileName) {
if (fileName == null) {
return "application/octet-stream";
}
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".pdf")) {
return "application/pdf";
} else if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
return "image/jpeg";
} else if (lowerFileName.endsWith(".png")) {
return "image/png";
} else if (lowerFileName.endsWith(".doc")) {
return "application/msword";
} else if (lowerFileName.endsWith(".docx")) {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if (lowerFileName.endsWith(".xls")) {
return "application/vnd.ms-excel";
} else if (lowerFileName.endsWith(".xlsx")) {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
} else {
return "application/octet-stream";
}
}
}