Commit ed85d9a7 by zhangxingmin

Merge remote-tracking branch 'origin/dev' into prod

parents 53cb6cfe 22b90e6a
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectStorage">
<option name="path" value="项目群组-&gt;银盾-微服务" />
<option name="projectId" value="4cc05386e898000" />
</component>
</project>
\ No newline at end of file
...@@ -17,6 +17,11 @@ ...@@ -17,6 +17,11 @@
<option name="url" value="http://139.224.145.34:8081/repository/yd-maven-public/" /> <option name="url" value="http://139.224.145.34:8081/repository/yd-maven-public/" />
</remote-repository> </remote-repository>
<remote-repository> <remote-repository>
<option name="id" value="aliyun-public" />
<option name="name" value="aliyun-public" />
<option name="url" value="http://139.224.145.34:8081/repository/yd-maven-public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" /> <option name="id" value="central" />
<option name="name" value="Maven Central repository" /> <option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" /> <option name="url" value="https://repo1.maven.org/maven2" />
......
# 基础镜像
FROM openjdk:8
# 维护人
LABEL maintainer="zxm<2060197959@qq.com>"
# 创建目录
RUN mkdir -p /home/app
# 拷贝项目jar - 使用可执行的 fat JAR
COPY target/yd-communication-api-1.0-SNAPSHOT-exec.jar /home/app/yd-communication-api.jar
# 执行命令启动jar,并设置JVM内存参数
ENTRYPOINT ["java","-Duser.timezone=Asia/Shanghai", "-Xmx256m", "-Xms128m", "-jar", "/home/app/yd-communication-api.jar"]
# 暴露端口
EXPOSE 9482
...@@ -29,12 +29,6 @@ ...@@ -29,12 +29,6 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- Spring Boot Starter WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId> <artifactId>spring-boot-starter</artifactId>
......
//package com.yd.communication.api.config;
//
//import lombok.Data;
//import org.springframework.boot.context.properties.ConfigurationProperties;
//import org.springframework.stereotype.Component;
//
//@Data
//@Component
//@ConfigurationProperties(prefix = "aliyun.rtc")
//public class AliyunRtcConfig {
// private String accessKeyId;
// private String accessKeySecret;
// private String regionId = "cn-shanghai";
// private String appId;
// private String ossBucket;
// private String ossEndpoint = "oss-cn-shanghai.aliyuncs.com";
//}
\ No newline at end of file
package com.yd.communication.api.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
@Bean(name = "communicationExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 核心线程数
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setThreadNamePrefix("communication-"); // 线程名前缀
executor.initialize();
return executor;
}
}
\ No newline at end of file
package com.yd.communication.api.config; package com.yd.communication.api.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
@Slf4j
@Configuration @Configuration
public class RestTemplateConfig { public class RestTemplateConfig {
@Bean @Bean
public RestTemplate restTemplate() { public RestTemplate restTemplate() {
return new RestTemplate(); RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(Collections.singletonList((request, body, execution) -> {
log.info("【RestTemplate】请求URI: {}", request.getURI());
log.info("【RestTemplate】请求方法: {}", request.getMethod());
log.info("【RestTemplate】请求头: {}", request.getHeaders());
log.info("【RestTemplate】请求体: {}", new String(body, StandardCharsets.UTF_8));
ClientHttpResponse response = execution.execute(request, body);
log.info("【RestTemplate】响应状态码: {}", response.getStatusCode());
// 如果状态码异常,打印响应体(注意只能读取一次,这里简单处理,读一次打印,但不影响后续)
// 这里不消费流,因为流可能后续还需要使用
return response;
}));
return restTemplate;
} }
} }
\ No newline at end of file
package com.yd.communication.api.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
\ No newline at end of file
package com.yd.communication.api.controller;
import com.yd.common.result.Result;
import com.yd.communication.api.service.ApiCoDesensitizationRuleService;
import com.yd.communication.feign.client.ApiCoDesensitizationRuleFeignClient;
import com.yd.communication.feign.response.desensitization.ApiCoDesensitizationRuleResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 脱敏信息
*
* @author zxm
* @since 2026-07-28
*/
@Slf4j
@RestController
@RequestMapping("/desensitization")
@Validated
public class ApiCoDesensitizationRuleController implements ApiCoDesensitizationRuleFeignClient {
@Resource
private ApiCoDesensitizationRuleService apiCoDesensitizationRuleService;
/**
* 根据 ID 查询单条规则
* @param bizId
* @return
*/
@Override
public Result<ApiCoDesensitizationRuleResponse> getByBizId(String bizId) {
return apiCoDesensitizationRuleService.getByBizId(bizId);
}
/**
* 根据资源类型和资源ID获取生效的脱敏规则列表(供脱敏引擎调用)
* @param resourceType
* @param resourceId
* @return
*/
@Override
public Result<List<ApiCoDesensitizationRuleResponse>> getEnabledRules(String resourceType, String resourceId) {
return apiCoDesensitizationRuleService.getEnabledRules(resourceType,resourceId);
}
}
\ No newline at end of file
package com.yd.communication.api.controller;
import com.yd.common.result.Result;
import com.yd.communication.api.service.ApiCoSessionService;
import com.yd.communication.feign.client.ApiCoSessionFeignClient;
import com.yd.communication.feign.request.*;
import com.yd.communication.feign.response.*;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* 会话信息
*
* @author zxm
* @since 2026-07-28
*/
@RestController
@RequestMapping("/coSession")
@Validated
public class ApiCoSessionController implements ApiCoSessionFeignClient {
@Resource
private ApiCoSessionService apiCoSessionService;
/**
* 创建会话
* @param request 创建会话请求
* @return
*/
@Override
public Result<CreateResponse> create(CreateRequest request) {
return apiCoSessionService.create(request);
}
/**
* 加入会话
* @param request
* @return
*/
public Result<JoinResponse> join(JoinRequest request) {
return apiCoSessionService.join(request);
}
/**
* 获取会话状态
* @param request
* @return
*/
@Override
public Result<GetStatusResponse> getStatus(GetStatusRequest request) {
return apiCoSessionService.getStatus(request);
}
/**
* 获取会话详情
* @param bizId
* @return
*/
public Result<SessionDetailResponse> get(String bizId) {
return apiCoSessionService.get(bizId);
}
/**
* 结束协同会话(关闭共享,仅客户可调用)
* @return
*/
public Result<CommonResponse> end(EndSessionRequest request) {
return apiCoSessionService.end(request);
}
/**
* 切换控制权(仅参与者(顾问)可调用)
* @param request
* @return
*/
public Result<CommonResponse> transferControl(TransferControlRequest request) {
return apiCoSessionService.transferControl(request);
}
}
package com.yd.communication.api.controller;
import com.yd.common.result.Result;
import com.yd.communication.api.service.ApiRecordingTaskService;
import com.yd.communication.feign.client.ApiRecordingTaskFeignClient;
import com.yd.communication.feign.request.recording.ApiStartRecordingRequest;
import com.yd.communication.feign.request.recording.ApiStopRecordingRequest;
import com.yd.communication.feign.response.recording.ApiQueryRecordingResponse;
import com.yd.communication.service.service.IRecordingTaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.Map;
/**
* 录制信息
*
* @author zxm
* @since 2026-07-28
*/
@Slf4j
@RestController
@RequestMapping("/recordingTask")
public class ApiRecordingTaskController implements ApiRecordingTaskFeignClient {
@Resource
private ApiRecordingTaskService apiRecordingTaskService;
/**
* 开始录制
* @param request
* @return
*/
public Result<String> startRecording(ApiStartRecordingRequest request) {
return apiRecordingTaskService.startRecording(request);
}
/**
* 停止录制并上传视频
* @param taskId 录制任务ID
* @return
*/
public Result<Map<String, String>> stopRecording(String taskId) {
return apiRecordingTaskService.stopRecording(taskId);
}
/**
* 查询录制信息
* @param taskId
* @return
*/
public Result<ApiQueryRecordingResponse> queryRecording(String taskId) {
return apiRecordingTaskService.queryRecording(taskId);
}
}
package com.yd.communication.api.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* <p>
* 协同-脱敏设置表(通用) 前端控制器
* </p>
*
* @author zxm
* @since 2026-07-28
*/
@RestController
@RequestMapping("/coDesensitizationRule")
public class CoDesensitizationRuleController {
}
package com.yd.communication.api.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* <p>
* 协同-会话表(通用) 前端控制器
* </p>
*
* @author zxm
* @since 2026-07-28
*/
@RestController
@RequestMapping("/coSession")
public class CoSessionController {
}
package com.yd.communication.api.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* <p>
* 录制任务表(通用) 前端控制器
* </p>
*
* @author zxm
* @since 2026-07-28
*/
@RestController
@RequestMapping("/recordingTask")
public class RecordingTaskController {
}
//package com.yd.communication.api.record;
//
//import com.alibaba.fastjson2.JSONObject;
//import com.aliyuncs.CommonRequest;
//import com.aliyuncs.CommonResponse;
//import com.aliyuncs.DefaultAcsClient;
//import com.aliyuncs.IAcsClient;
//import com.aliyuncs.http.MethodType;
//import com.aliyuncs.live.model.v20161101.StopRtcCloudRecordingRequest;
//import com.aliyuncs.live.model.v20161101.StopRtcCloudRecordingResponse;
//import com.aliyuncs.profile.DefaultProfile;
//import com.yd.communication.api.config.AliyunRtcConfig;
//import lombok.extern.slf4j.Slf4j;
//import org.springframework.stereotype.Service;
//import javax.annotation.Resource;
//import java.util.HashMap;
//import java.util.Map;
//
//@Service
//@Slf4j
//public class AliyunRtcRecordService {
//
// @Resource
// private AliyunRtcConfig rtcConfig;
//
// private IAcsClient getClient() {
// DefaultProfile profile = DefaultProfile.getProfile(
// rtcConfig.getRegionId(),
// rtcConfig.getAccessKeyId(),
// rtcConfig.getAccessKeySecret()
// );
// return new DefaultAcsClient(profile);
// }
//
// public String startRecording(String channelId) {
// try {
// IAcsClient client = getClient();
// CommonRequest request = new CommonRequest();
// request.setSysMethod(MethodType.POST);
// request.setSysDomain("live.aliyuncs.com");
// request.setSysVersion("2016-11-01");
// request.setSysAction("StartRtcCloudRecording");
//
// request.putQueryParameter("AppId", rtcConfig.getAppId());
// request.putQueryParameter("ChannelId", channelId);
// // 订阅所有用户(不传订阅参数则默认订阅所有)
// request.putQueryParameter("RecordParams.RecordMode", "1");
// request.putQueryParameter("StorageParams.StorageType", "1");
// request.putQueryParameter("StorageParams.OSSParams.OSSEndpoint", rtcConfig.getOssEndpoint());
// request.putQueryParameter("StorageParams.OSSParams.OSSBucket", rtcConfig.getOssBucket());
//
// CommonResponse response = client.getCommonResponse(request);
// String responseData = response.getData();
// JSONObject jsonObject = JSONObject.parseObject(responseData);
// JSONObject body = jsonObject.getJSONObject("Body");
// String taskId = body.getString("TaskId");
// if (taskId == null || taskId.isEmpty()) {
// throw new BusinessException("启动录制失败,未返回 TaskId,响应:" + responseData);
// }
// log.info("启动录制成功, TaskId: {}", taskId);
// return taskId;
// } catch (Exception e) {
// log.error("启动录制失败", e);
// throw new BusinessException("启动云端录制失败: " + e.getMessage(), e);
// }
// }
//
// public void stopRecording(String taskId) {
// try {
// IAcsClient client = getClient();
// StopRtcCloudRecordingRequest request = new StopRtcCloudRecordingRequest();
// request.setTaskId(taskId);
// StopRtcCloudRecordingResponse response = client.getAcsResponse(request);
// log.info("停止录制成功, TaskId: {}", taskId);
// } catch (Exception e) {
// log.error("停止录制失败", e);
// throw new BusinessException("停止云端录制失败: " + e.getMessage(), e);
// }
// }
//
// /**
// * 查询录制任务详情(文件信息)
// * @param taskId
// * @return
// */
// public Map<String, String> queryRecordingInfo(String taskId) {
// try {
// IAcsClient client = getClient();
// CommonRequest request = new CommonRequest();
// request.setSysMethod(MethodType.POST);
// request.setSysDomain("live.aliyuncs.com");
// request.setSysVersion("2016-11-01");
// request.setSysAction("DescribeCloudRecording");
// request.putQueryParameter("TaskId", taskId);
//
// CommonResponse response = client.getCommonResponse(request);
// String responseData = response.getData();
// log.info("查询录制信息响应: {}", responseData);
//
// JSONObject jsonObject = JSONObject.parseObject(responseData);
// JSONObject body = jsonObject.getJSONObject("Body");
// if (body == null) {
// return new HashMap<>();
// }
//
// Map<String, String> info = new HashMap<>();
// // 根据实际返回字段解析,常见字段名
// if (body.containsKey("FileUrl")) {
// info.put("fileUrl", body.getString("FileUrl"));
// } else if (body.containsKey("fileUrl")) {
// info.put("fileUrl", body.getString("fileUrl"));
// }
// if (body.containsKey("Duration")) {
// info.put("duration", body.getString("Duration"));
// } else if (body.containsKey("duration")) {
// info.put("duration", body.getString("duration"));
// }
// if (body.containsKey("FileSize")) {
// info.put("size", body.getString("FileSize"));
// } else if (body.containsKey("fileSize")) {
// info.put("size", body.getString("fileSize"));
// }
// // 可能还有其他字段,如 RecordingStartTime, RecordingEndTime 等
// return info;
// } catch (Exception e) {
// log.error("查询录制信息失败", e);
// throw new BusinessException("查询录制信息失败: " + e.getMessage(), e);
// }
// }
//}
\ No newline at end of file
package com.yd.communication.api.service;
import com.yd.common.result.Result;
import com.yd.communication.feign.response.desensitization.ApiCoDesensitizationRuleResponse;
import java.util.List;
public interface ApiCoDesensitizationRuleService {
Result<ApiCoDesensitizationRuleResponse> getByBizId(String bizId);
Result<List<ApiCoDesensitizationRuleResponse>> getEnabledRules(String resourceType, String resourceId);
}
package com.yd.communication.api.service;
import com.yd.common.result.Result;
import com.yd.communication.feign.dto.RoomRedisInfoDTO;
import com.yd.communication.feign.request.*;
import com.yd.communication.feign.response.*;
import com.yd.communication.service.model.CoSession;
public interface ApiCoSessionService {
Result<CreateResponse> create(CreateRequest request);
Result<JoinResponse> join(JoinRequest request);
Result<GetStatusResponse> getStatus(GetStatusRequest request);
Result<SessionDetailResponse> get(String bizId);
Result<CommonResponse> end(EndSessionRequest request);
Result<CommonResponse> transferControl(TransferControlRequest request);
RoomRedisInfoDTO getCurrentController(String roomId);
void updateCurrentPage(String roomId, String newPageJson, String operatorId);
CoSession getByRoomId(String roomId);
}
package com.yd.communication.api.service;
import com.yd.common.result.Result;
import com.yd.communication.feign.request.recording.ApiStartRecordingRequest;
import com.yd.communication.feign.response.recording.ApiQueryRecordingResponse;
import java.util.Map;
public interface ApiRecordingTaskService {
Result<String> startRecording(ApiStartRecordingRequest request);
Result<Map<String, String>> stopRecording(String taskId);
Result<ApiQueryRecordingResponse> queryRecording(String taskId);
}
package com.yd.communication.api.service.impl;
import com.alibaba.fastjson2.JSON;
import com.yd.common.exception.BusinessException;
import com.yd.common.result.Result;
import com.yd.communication.api.service.ApiCoDesensitizationRuleService;
import com.yd.communication.feign.response.desensitization.ApiCoDesensitizationRuleResponse;
import com.yd.communication.service.model.CoDesensitizationRule;
import com.yd.communication.service.service.ICoDesensitizationRuleService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
public class ApiCoDesensitizationRuleServiceImpl implements ApiCoDesensitizationRuleService {
@Resource
private ICoDesensitizationRuleService ruleService;
/**
* 根据 ID 查询单条规则
* @param bizId
* @return
*/
@Override
public Result<ApiCoDesensitizationRuleResponse> getByBizId(String bizId) {
CoDesensitizationRule rule = ruleService.lambdaQuery()
.eq(CoDesensitizationRule::getRuleBizId,bizId)
.last(" limit 1 ")
.one();
if (rule == null) {
throw new BusinessException("脱敏信息不存在");
}
ApiCoDesensitizationRuleResponse response = new ApiCoDesensitizationRuleResponse();
BeanUtils.copyProperties(rule,response);
return Result.success(response);
}
/**
* 根据资源类型和资源ID获取生效的脱敏规则列表(供脱敏引擎调用)
* @param resourceType
* @param resourceId
* @return
*/
@Override
public Result<List<ApiCoDesensitizationRuleResponse>> getEnabledRules(String resourceType, String resourceId) {
log.info("【根据资源类型和资源ID获取生效的脱敏规则列表】入参值, resourceType={}", resourceType);
log.info("【根据资源类型和资源ID获取生效的脱敏规则列表】入参值, resourceId={}", resourceId);
List<CoDesensitizationRule> rules = ruleService.getEnabledRulesByResource(resourceType, resourceId);
log.info("【根据资源类型和资源ID获取生效的脱敏规则列表】查询列表, rules={}", JSON.toJSONString(rules));
if (CollectionUtils.isEmpty(rules)) {
return Result.success();
}
List<ApiCoDesensitizationRuleResponse> responses = rules.stream().map(dto -> {
ApiCoDesensitizationRuleResponse response = new ApiCoDesensitizationRuleResponse();
BeanUtils.copyProperties(dto,response);
return response;
}).collect(Collectors.toList());
return Result.success(responses);
}
}
package com.yd.communication.api.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yd.common.enums.CommonEnum;
import com.yd.common.enums.ResultCode;
import com.yd.common.exception.BusinessException;
import com.yd.common.result.JsonResult;
import com.yd.common.result.Result;
import com.yd.common.utils.ByteArrayMultipartFile;
import com.yd.common.utils.RandomStringGenerator;
import com.yd.common.utils.RedisUtil;
import com.yd.communication.api.service.ApiCoSessionService;
import com.yd.communication.api.utils.ClientInfoUtils;
import com.yd.communication.api.utils.QRCodeUtils;
import com.yd.communication.feign.dto.RoomRedisInfoDTO;
import com.yd.communication.feign.enums.CoSessionStatusEnum;
import com.yd.communication.feign.enums.ControlHolderTypeEnum;
import com.yd.communication.feign.enums.RedisEnum;
import com.yd.communication.feign.request.*;
import com.yd.communication.feign.request.http.GenerateTokenRequest;
import com.yd.communication.feign.response.*;
import com.yd.communication.feign.response.http.GenerateTokenResponse;
import com.yd.communication.service.model.CoSession;
import com.yd.communication.service.service.ICoOperationLogService;
import com.yd.communication.service.service.ICoSessionService;
import com.yd.communication.service.service.IRecordingTaskService;
import com.yd.communication.service.utils.RandomUtil;
import com.yd.oss.feign.client.ApiOssFeignClient;
import com.yd.oss.feign.request.ApiUploadFileRequest;
import com.yd.oss.feign.response.ApiUploadResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.UUID;
@Slf4j
@Service
public class ApiCoSessionServiceImpl implements ApiCoSessionService {
@Resource
private ICoSessionService iCoSessionService;
@Resource
private IRecordingTaskService recordingService;
@Resource
private ICoOperationLogService operationLogService;
@Resource
private RedisUtil redisUtil;
@Resource
private ApiOssFeignClient apiOssFeignClient;
@Value("${co.room.base-url:https://mdev.zuihuibi.cn/repotKYC/#/pages/launch/index?visitType=1&targetPage=showcasePage}")
private String roomBaseUrl;
@Resource
private RestTemplate restTemplate;
@Value("${sfp.api.base-url:https://mdev.anjibao.cn/sfpApi}")
private String sfpApiBaseUrl;
/**
* 客户创建会话
* @param scope
* @param resourceType
* @param resourceId
* @param resourceInit
* @param ownerId
* @param ownerType
* @param userId
* @return
*/
@Transactional(rollbackFor = Exception.class)
public CoSession createSession(String scope, String resourceType, String resourceId,
String resourceInit, String ownerId, String ownerType,
String userId) {
log.info("【创建会话-内部】开始创建, scope={}, resourceType={}, resourceId={}, ownerId={}, ownerType={}, userId={}",
scope, resourceType, resourceId, ownerId, ownerType, userId);
try {
// 创建会话
CoSession session = new CoSession();
session.setCoSessionBizId(RandomStringGenerator.generateBizId16(CommonEnum.UID_TYPE_CO_SESSION.getCode()));
session.setCoSessionNo("S" + System.currentTimeMillis());
session.setScope(scope);
session.setResourceType(resourceType);
session.setResourceId(resourceId);
session.setResourceInit(resourceInit);
session.setOwnerId(ownerId);
session.setOwnerType(ownerType);
// 房间号
String roomId = "room_" + UUID.randomUUID().toString().substring(0, 8);
String roomPwd = RandomUtil.generateNumericCode(6);
session.setRoomId(roomId);
session.setRoomPwd(roomPwd);
session.setChannelPrefix("co");
// 初始化控制权为所有者(客户)
session.setControlHolderType(ControlHolderTypeEnum.OWNER.getItemValue());
session.setControlHolderId(ownerId);
// 待开始状态
session.setStatus(CoSessionStatusEnum.DKS.getItemValue());
session.setCurrentPage(resourceInit);
session.setPageHistory("[" + resourceInit + "]");
session.setCreatorId(ownerId);
session.setUpdaterId(ownerId);
iCoSessionService.save(session);
log.info("【创建会话-内部】数据库保存成功, id={}", session.getId());
// 获取 Token(调用 /user/generate/token)
String token = null;
GenerateTokenResponse tokenResp =null;
try {
// 构建请求体
GenerateTokenRequest tokenRequest = new GenerateTokenRequest();
// 注意:userId 是 String,转为 Long。若 userId 可能为空,需做判空处理
if (StringUtils.isNotBlank(userId)) {
tokenRequest.setSfpUserId(Long.valueOf(userId));
} else {
// 如果 userId 为空,可以尝试用 ownerId 或抛出业务异常,根据业务定
throw new BusinessException("userId 不能为空,无法生成 Token");
}
// 发起 POST 请求
String url = sfpApiBaseUrl + "/user/generate/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<GenerateTokenRequest> entity = new HttpEntity<>(tokenRequest, headers);
log.info("【Token生成】sfpApiBaseUrl = {}", sfpApiBaseUrl);
log.info("【Token生成】完整URL = {}", sfpApiBaseUrl + "/user/generate/token");
log.info("【Token生成】请求体GenerateTokenRequest = {}", JSONObject.toJSONString(tokenRequest));
log.info("【Token生成】请求体entity = {}", JSONObject.toJSONString(entity));
ResponseEntity<JsonResult> responseEntity = restTemplate.postForEntity(url, entity, JsonResult.class);
log.info("【Token生成】返回体responseEntity = {}", JSONObject.toJSONString(responseEntity));
if (responseEntity.getStatusCode().is2xxSuccessful()) {
JsonResult body = responseEntity.getBody();
if (body != null && body.isSuccess()) {
// 假设 body.getData() 返回的是 LinkedHashMap,需要转为 GenerateTokenResponse
// 或者直接使用 fastjson 转换
Object data = body.getData();
if (data != null) {
tokenResp = JSONObject.parseObject(JSONObject.toJSONString(data), GenerateTokenResponse.class);
token = tokenResp.getToken();
}
}
}
if (StringUtils.isBlank(token)) {
log.error("【创建会话】获取Token失败,userId={}", userId);
throw new BusinessException("生成会话 Token 失败,请稍后重试");
}
log.info("【创建会话】获取Token成功,token前缀={}", token.substring(0, Math.min(10, token.length())));
} catch (Exception e) {
log.error("【创建会话】调用生成Token接口异常", e);
throw new BusinessException("生成会话 Token 异常:" + e.getMessage());
}
// 存入 Redis
RoomRedisInfoDTO roomRedisInfoDTO = new RoomRedisInfoDTO();
if (tokenResp != null) {
roomRedisInfoDTO.setName(tokenResp.getName());
roomRedisInfoDTO.setMobile(tokenResp.getMobile());
}
roomRedisInfoDTO.setToken(token); // 设置 token
roomRedisInfoDTO.setUserId(userId);
roomRedisInfoDTO.setControlHolderType(session.getControlHolderType());
roomRedisInfoDTO.setControlHolderId(session.getControlHolderId());
roomRedisInfoDTO.setRoomId(roomId);
roomRedisInfoDTO.setRoomPwd(roomPwd);
redisUtil.setCacheObject(RedisEnum.ROOM.getPrefix() + roomId + ":" + roomPwd, roomRedisInfoDTO,
RedisEnum.ROOM.getTimeout(), RedisEnum.ROOM.getTimeUnit());
log.info("【创建会话-内部】Redis缓存已设置, key={}", RedisEnum.ROOM.getPrefix() + roomId);
// 自动开启录制(当前注释)
if (autoStartRecording()) {
// recordingService.startRecording(session.getCoSessionBizId(), roomId);
log.info("【创建会话-内部】自动录制已触发(未实际启动)");
}
log.info("【创建会话-内部】创建成功, roomId={}, roomPwd={}", roomId, roomPwd);
//生成协同操作日志
operationLogService.log(
session.getCoSessionBizId(),
ownerId,
ownerType,
ownerId,
"create_session",
String.format("创建会话 roomId=%s, roomPwd=%s", session.getRoomId(), session.getRoomPwd()),
ClientInfoUtils.getDevice(),
ClientInfoUtils.getClientIp()
);
return session;
} catch (Exception e) {
log.error("【创建会话-内部】异常", e);
throw e;
}
}
/**
* 创建会话
* @param request
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<CreateResponse> create(CreateRequest request) {
log.info("【创建会话】收到创建请求, request={}", request);
try {
// 1. 创建会话
CoSession session = createSession(
request.getScope(),
request.getResourceType(),
request.getResourceId(),
request.getResourceInit(),
request.getOwnerId(),
request.getOwnerType(),
request.getUserId()
);
if (session == null) {
log.warn("【创建会话-生成二维码】创建会话失败");
return Result.success();
}
// 2. 构建房间链接(从配置读取)
String roomLink = roomBaseUrl + "&roomId=" + session.getRoomId() + "&pwd=" + session.getRoomPwd();
log.info("【创建会话】生成房间链接: {}", roomLink);
// 3. 生成二维码图片字节数组(PNG格式)
byte[] qrCodeBytes = QRCodeUtils.generateQRCode(roomLink, 300, 300);
// 4. 将字节数组转换为 MultipartFile
String fileName = session.getCoSessionBizId() + ".png";
MultipartFile multipartFile = new ByteArrayMultipartFile(
qrCodeBytes, // 图片字节数组
"file", // 字段名,与 Feign @RequestPart("file") 匹配
fileName, // 原始文件名
"image/png" // 内容类型
);
// 5. 构建 OSS 上传请求参数
ApiUploadFileRequest ossRequest = new ApiUploadFileRequest();
ossRequest.setObjectBizId(session.getCoSessionBizId()); // 必填,使用会话业务ID
ossRequest.setObjectType("co_session_qr"); // 自定义对象类型
ossRequest.setObjectName("协同会话二维码");
ossRequest.setProjectBizId("");
ossRequest.setTenantBizId("");
String requestJson = JSONObject.toJSONString(ossRequest);
// 6. 调用 Feign 上传
Result<ApiUploadResponse> uploadResult = apiOssFeignClient.uploadFileBodyWithJson(multipartFile, requestJson);
if (uploadResult == null || uploadResult.getCode() != 200 || uploadResult.getData() == null) {
log.error("【创建会话】OSS上传失败,result={}", uploadResult);
throw new BusinessException("二维码上传失败:" + (uploadResult != null ? uploadResult.getMsg() : "未知错误"));
}
// 7. 获取文件访问 URL(假设 ApiUploadResponse 包含 url 字段)
ApiUploadResponse uploadData = uploadResult.getData();
String roomQrCodeUrl = uploadData.getUrl(); // 若字段名为 fileUrl 则对应调整
log.info("【创建会话】OSS上传成功,文件URL: {}", roomQrCodeUrl);
//8. 更新会话信息表数据
session.setRoomQrCode(roomQrCodeUrl);
session.setRoomLink(roomLink);
iCoSessionService.saveOrUpdate(session);
//设置房间链接失效时间(用于扫码和链接直接访问,加载页面后调用校验接口判断是否失效)
//设置房间链接失效时间(用于扫码和链接直接访问)
redisUtil.setCacheObject(RedisEnum.ROOM_LINK.getPrefix() + session.getRoomId() + ":" + session.getRoomPwd(),
session.getRoomLink(),
RedisEnum.ROOM_LINK.getTimeout(),
RedisEnum.ROOM_LINK.getTimeUnit());
// 计算链接失效时间点(当前时间 + 缓存过期时长)
long expireMillis = RedisEnum.ROOM_LINK.getTimeUnit().toMillis(RedisEnum.ROOM_LINK.getTimeout());
Date expirationDate = new Date(System.currentTimeMillis() + expireMillis);
// 组装响应
CreateResponse response = new CreateResponse();
response.setRoomId(session.getRoomId());
response.setRoomPwd(session.getRoomPwd());
response.setSessionBizId(session.getCoSessionBizId());
response.setStatus(session.getStatus());
response.setRoomLink(roomLink);
response.setRoomQrCode(roomQrCodeUrl);
response.setExpirationTime(expirationDate); // 设置过期时间
log.info("【创建会话】成功,roomId={}, sessionBizId={}", session.getRoomId(), session.getCoSessionBizId());
return Result.success(response);
} catch (Exception e) {
log.error("【创建会话】异常", e);
throw new BusinessException("创建会话失败:" + e.getMessage());
}
}
/**
* 加入协同会话
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<JoinResponse> join(JoinRequest request) {
log.info("【加入会话】收到加入请求, request={}", request);
try {
CoSession session = joinSession(
request.getRoomId(),
request.getRoomPwd(),
request.getParticipantId(),
request.getParticipantType()
);
if (session == null) {
log.warn("【加入会话】加入失败,返回空会话");
return Result.success();
}
JoinResponse joinResponse = new JoinResponse();
joinResponse.setControlHolderId(session.getControlHolderId());
joinResponse.setControlHolderType(session.getControlHolderType());
joinResponse.setResourceInit(session.getResourceInit());
joinResponse.setCurrentPage(session.getCurrentPage());
joinResponse.setRoomId(session.getRoomId());
joinResponse.setSessionBizId(session.getCoSessionBizId());
// 获取资源所有者缓存中的登录信息
RoomRedisInfoDTO roomRedisInfoDTO = redisUtil.getCacheObject(RedisEnum.ROOM.getPrefix() + session.getRoomId() + ":" + session.getRoomPwd(),RoomRedisInfoDTO.class);
if (roomRedisInfoDTO == null) {
log.error("【加入会话】会话发起者缓存信息不存在,roomId={}", session.getRoomId());
throw new BusinessException("会话发起者登录信息失效,建议联系会话发起者再次发起");
}
joinResponse.setUserId(roomRedisInfoDTO.getUserId());
joinResponse.setToken(roomRedisInfoDTO.getToken());
joinResponse.setName(roomRedisInfoDTO.getName());
joinResponse.setMobile(roomRedisInfoDTO.getMobile());
log.info("【加入会话】成功, roomId={}, participantId={}, controlHolderType={}",
session.getRoomId(), session.getParticipantId(), session.getControlHolderType());
//删除房间链接缓存(只能访问一次,下次重新生成)
redisUtil.deleteObject(RedisEnum.ROOM_LINK.getPrefix() + session.getRoomId() + ":" + session.getRoomPwd());
return Result.success(joinResponse);
} catch (Exception e) {
log.error("【加入会话】异常", e);
throw e;
}
}
/**
* 顾问加入会话
* @param roomId
* @param roomPwd
* @param participantId
* @param participantType
* @return
*/
@Transactional(rollbackFor = Exception.class)
public CoSession joinSession(String roomId,String roomPwd, String participantId, String participantType) {
log.info("【加入会话-内部】开始, roomId={}, roomPwd={}, participantId={}, participantType={}", roomId, roomPwd, participantId, participantType);
try {
//校验房间链接是否失效
String roomLink = redisUtil.getCacheObject(RedisEnum.ROOM_LINK.getPrefix() + roomId + ":" + roomPwd);
if (StringUtils.isBlank(roomLink)) {
throw new BusinessException(ResultCode.LINK_INVALID.getCode(),"访问链接已失效,请重新生成");
}
LambdaQueryWrapper<CoSession> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoSession::getRoomId, roomId)
.eq(CoSession::getRoomPwd, roomPwd)
.last(" limit 1 ");
CoSession session = iCoSessionService.getOne(wrapper);
if (session == null) {
log.error("【加入会话-内部】未找到会话,roomId={}, roomPwd={}", roomId, roomPwd);
throw new BusinessException("房间号或者密码错误,无法加入");
}
log.info("【加入会话-内部】找到会话, roomId={}, status={}, currentHolder={}:{}",
session.getRoomId(), session.getStatus(), session.getControlHolderType(), session.getControlHolderId());
if (CoSessionStatusEnum.YJS.getItemValue().equals(session.getStatus())) {
log.warn("【加入会话-内部】会话已结束,roomId={}", session.getRoomId());
throw new BusinessException("会话已结束,不能再次加入房间");
}
// if (StringUtils.isNotBlank(session.getParticipantId()) && !session.getParticipantId().equals(participantId)) {
// log.warn("【加入会话-内部】房间被占用,当前参与者={}, 新参与者={}",
// session.getParticipantId(), participantId);
// throw new BusinessException("当前房间被占用,不能加入到房间");
// }
// 若为待开始状态,设置开始时间并转移控制权
if (CoSessionStatusEnum.DKS.getItemValue().equals(session.getStatus())) {
if (session.getStartTime() == null) {
session.setStartTime(LocalDateTime.now());
log.info("【加入会话-内部】设置开始时间={}", session.getStartTime());
}
// 待开始状态下,控制权自动移交给参与者(顾问)
session.setControlHolderType(ControlHolderTypeEnum.PARTICIPANT.getItemValue());
session.setControlHolderId(participantId);
log.info("【加入会话-内部】待开始状态,控制权移交给参与者={}", participantId);
} else {
// 如果会话已经是进行中,但可能控制权不在顾问,此处强制转移给顾问(根据业务需求,可调整)
// 如果不希望强制转移,可注释掉以下行
session.setControlHolderType(ControlHolderTypeEnum.PARTICIPANT.getItemValue());
session.setControlHolderId(participantId);
log.info("【加入会话-内部】强制控制权移交给参与者={}", participantId);
}
// 更新会话状态为进行中
session.setStatus(CoSessionStatusEnum.JXZ.getItemValue());
session.setParticipantId(participantId);
session.setParticipantType(participantType);
session.setUpdaterId(participantId);
iCoSessionService.updateById(session);
log.info("【加入会话-内部】数据库更新成功,新状态={}", session.getStatus());
// 更新 Redis 缓存
RoomRedisInfoDTO roomRedisInfoDTO = redisUtil.getCacheObject(RedisEnum.ROOM.getPrefix() + session.getRoomId() + ":" + session.getRoomPwd(),RoomRedisInfoDTO.class);
if (roomRedisInfoDTO != null) {
roomRedisInfoDTO.setControlHolderType(session.getControlHolderType());
roomRedisInfoDTO.setControlHolderId(session.getControlHolderId());
redisUtil.setCacheObject(RedisEnum.ROOM.getPrefix() + session.getRoomId() + ":" + session.getRoomPwd(), roomRedisInfoDTO,
RedisEnum.ROOM.getTimeout(), RedisEnum.ROOM.getTimeUnit());
log.info("【加入会话-内部】Redis缓存更新成功,新控制者={}:{}",
session.getControlHolderType(), session.getControlHolderId());
} else {
log.warn("【加入会话-内部】Redis缓存不存在,可能已过期,roomId={}", session.getRoomId());
}
//生成协同操作日志
operationLogService.log(
session.getCoSessionBizId(),
participantId,
participantType,
participantId,
"join_session",
String.format("加入会话 roomId=%s, 控制权转移至%s", session.getRoomId(), session.getControlHolderType()),
ClientInfoUtils.getDevice(),
ClientInfoUtils.getClientIp()
);
return session;
} catch (Exception e) {
log.error("【加入会话-内部】异常", e);
throw e;
}
}
/**
* 获取会话状态
* @param request
* @return
*/
@Override
public Result<GetStatusResponse> getStatus(GetStatusRequest request) {
CoSession coSession = iCoSessionService.lambdaQuery()
.eq(CoSession::getCoSessionBizId,request.getSessionBizId())
.last(" limit 1 ")
.one();
if (coSession == null) {
throw new BusinessException("会话信息不存在");
}
GetStatusResponse response = new GetStatusResponse();
String roomLink = redisUtil.getCacheObject(RedisEnum.ROOM_LINK.getPrefix() + coSession.getRoomId() + ":" + coSession.getRoomPwd());
if (StringUtils.isBlank(roomLink)) {
//2-已失效
response.setStatus(2);
}else {
response.setStatus(1);
}
return Result.success(response);
}
/**
* 获取会话详情
*/
@Override
public Result<SessionDetailResponse> get(String bizId) {
log.info("【获取会话详情】bizId={}", bizId);
try {
CoSession session = iCoSessionService.getByBizId(bizId);
if (session == null) {
log.warn("【获取会话详情】未找到会话,bizId={}", bizId);
return Result.success();
}
SessionDetailResponse response = new SessionDetailResponse();
BeanUtils.copyProperties(session, response);
log.info("【获取会话详情】成功,roomId={}", session.getRoomId());
return Result.success(response);
} catch (Exception e) {
log.error("【获取会话详情】异常", e);
throw e;
}
}
/**
* 结束会话(客户调用)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<CommonResponse> end(EndSessionRequest request) {
log.info("【结束会话】收到请求, roomId={}", request.getRoomId());
try {
CoSession coSession = iCoSessionService.lambdaQuery()
.eq(CoSession::getRoomId, request.getRoomId())
.last(" limit 1")
.one();
if (coSession == null) {
log.error("【结束会话】会话不存在,roomId={}", request.getRoomId());
throw new BusinessException("会话不存在");
}
log.info("【结束会话】找到会话,当前状态={}", coSession.getStatus());
// 结束会话关闭共享,更新信息
coSession.setStatus(CoSessionStatusEnum.YJS.getItemValue());
coSession.setEndTime(LocalDateTime.now());
iCoSessionService.saveOrUpdate(coSession);
log.info("【结束会话】数据库更新成功,状态已结束");
// 销毁redis房间缓存信息
redisUtil.deleteObject(RedisEnum.ROOM.getPrefix() + coSession.getRoomId() + ":" + coSession.getRoomPwd());
log.info("【结束会话】Redis缓存已删除");
// 添加操作日志,协同-操作日志表
operationLogService.log(
coSession.getCoSessionBizId(),
null,
null,
null,
"end_session",
String.format("结束会话 roomId=%s", coSession.getRoomId()),
ClientInfoUtils.getDevice(),
ClientInfoUtils.getClientIp()
);
CommonResponse response = new CommonResponse();
response.setMessage("会话已结束");
log.info("【结束会话】成功, roomId={}", request.getRoomId());
return Result.success(response);
} catch (Exception e) {
log.error("【结束会话】异常", e);
throw e;
}
}
/**
* 切换控制权(顾问调用)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<CommonResponse> transferControl(TransferControlRequest request) {
log.info("【切换控制权】收到请求, oprType={}, roomId={}", request.getOprType(), request.getRoomId());
try {
transferControlUp(request.getOprType(), request.getRoomId());
CommonResponse response = new CommonResponse();
response.setMessage("控制权已切换");
log.info("【切换控制权】成功, roomId={}", request.getRoomId());
return Result.success(response);
} catch (Exception e) {
log.error("【切换控制权】异常", e);
throw e;
}
}
/**
* 切换控制权(内部方法)
*/
@Transactional(rollbackFor = Exception.class)
public void transferControlUp(Integer oprType, String roomId) {
log.info("【切换控制权-内部】开始, oprType={}, roomId={}", oprType, roomId);
try {
// 根据房间号查询会话信息
LambdaQueryWrapper<CoSession> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoSession::getRoomId, roomId).last(" limit 1 ");
CoSession session = iCoSessionService.getOne(wrapper);
if (session == null) {
log.error("【切换控制权-内部】会话不存在, roomId={}", roomId);
throw new BusinessException("会话不存在");
}
log.info("【切换控制权-内部】找到会话, 当前控制者={}:{}", session.getControlHolderType(), session.getControlHolderId());
// 移交控制权
if (oprType == 1) {
// 1-开启客户操作,控制权移交给资源所有者(客户)
session.setControlHolderType(ControlHolderTypeEnum.OWNER.getItemValue());
session.setControlHolderId(session.getOwnerId());
log.info("【切换控制权-内部】开启客户操作,控制权移交给所有者={}", session.getOwnerId());
} else if (oprType == 2) {
// 2-关闭客户操作,控制权移交给参与者(顾问)
session.setControlHolderType(ControlHolderTypeEnum.PARTICIPANT.getItemValue());
session.setControlHolderId(session.getParticipantId());
log.info("【切换控制权-内部】关闭客户操作,控制权移交给参与者={}", session.getParticipantId());
} else {
log.warn("【切换控制权-内部】未知oprType={}, 忽略", oprType);
return;
}
session.setUpdaterId(session.getParticipantId());
iCoSessionService.updateById(session);
log.info("【切换控制权-内部】数据库更新成功,新控制者={}:{}",
session.getControlHolderType(), session.getControlHolderId());
// 更新房间缓存redis信息
RoomRedisInfoDTO roomRedisInfoDTO = redisUtil.getCacheObject(RedisEnum.ROOM.getPrefix() + roomId + ":" +session.getRoomPwd(),RoomRedisInfoDTO.class);
if (roomRedisInfoDTO != null) {
roomRedisInfoDTO.setControlHolderId(session.getControlHolderId());
roomRedisInfoDTO.setControlHolderType(session.getControlHolderType());
redisUtil.setCacheObject(RedisEnum.ROOM.getPrefix() + roomId + ":" + session.getRoomPwd(), roomRedisInfoDTO,
RedisEnum.ROOM.getTimeout(), RedisEnum.ROOM.getTimeUnit());
log.info("【切换控制权-内部】Redis缓存更新成功");
} else {
log.warn("【切换控制权-内部】Redis缓存不存在,可能已过期");
}
// 添加操作日志,协同-操作日志表
operationLogService.log(
session.getCoSessionBizId(),
session.getParticipantId(),
ControlHolderTypeEnum.PARTICIPANT.getItemValue(),
session.getParticipantId(),
"transfer_control",
String.format("切换控制权至 %s:%s", session.getControlHolderType(), session.getControlHolderId()),
ClientInfoUtils.getDevice(),
ClientInfoUtils.getClientIp()
);
} catch (Exception e) {
log.error("【切换控制权-内部】异常", e);
throw e;
}
}
/**
* 获取当前控制者
*/
@Override
public RoomRedisInfoDTO getCurrentController(String roomId) {
log.info("【获取当前控制者】roomId={}", roomId);
try {
RoomRedisInfoDTO roomRedisInfoDTO = redisUtil.getCacheObject(RedisEnum.ROOM.getPrefix() + roomId);
if (roomRedisInfoDTO == null ||
(roomRedisInfoDTO != null && StringUtils.isBlank(roomRedisInfoDTO.getControlHolderId()))) {
log.info("【获取当前控制者】Redis缓存不存在或控制者ID为空,从数据库查询");
LambdaQueryWrapper<CoSession> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoSession::getRoomId, roomId).last(" limit 1 ");
CoSession session = iCoSessionService.getOne(wrapper);
if (session == null) {
log.error("【获取当前控制者】会话不存在, roomId={}", roomId);
throw new BusinessException("会话不存在");
}
if (roomRedisInfoDTO == null) {
roomRedisInfoDTO = new RoomRedisInfoDTO();
}
roomRedisInfoDTO.setControlHolderType(session.getControlHolderType());
roomRedisInfoDTO.setControlHolderId(session.getControlHolderId());
// 查询出来的信息更新回缓存里面
redisUtil.setCacheObject(RedisEnum.ROOM.getPrefix() + roomId + ":" + session.getRoomPwd(), roomRedisInfoDTO,
RedisEnum.ROOM.getTimeout(), RedisEnum.ROOM.getTimeUnit());
log.info("【获取当前控制者】从数据库加载并更新缓存,控制者={}:{}",
session.getControlHolderType(), session.getControlHolderId());
} else {
log.info("【获取当前控制者】从Redis获取,控制者={}:{}",
roomRedisInfoDTO.getControlHolderType(), roomRedisInfoDTO.getControlHolderId());
}
return roomRedisInfoDTO;
} catch (Exception e) {
log.error("【获取当前控制者】异常", e);
throw e;
}
}
/**
* 更新当前页面
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateCurrentPage(String roomId, String newPageJson, String operatorId) {
log.info("【更新当前页面】roomId={}, operatorId={}, newPageJson={}", roomId, operatorId, newPageJson);
try {
LambdaQueryWrapper<CoSession> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoSession::getRoomId, roomId).last(" limit 1 ");
CoSession session = iCoSessionService.getOne(wrapper);
if (session == null) {
log.error("【更新当前页面】会话不存在, roomId={}", roomId);
throw new BusinessException("会话不存在");
}
String history = session.getPageHistory();
if (history == null || history.equals("[]") || history.isEmpty()) {
history = "[" + newPageJson + "]";
} else {
// 简单追加(生产环境建议用JSONArray处理)
history = history.substring(0, history.length() - 1) + "," + newPageJson + "]";
}
iCoSessionService.updateCurrentPageAndHistory(roomId, newPageJson, history, operatorId);
log.info("【更新当前页面】成功, 新历史={}", history);
} catch (Exception e) {
log.error("【更新当前页面】异常", e);
throw e;
}
}
/**
* 根据房间号获取会话信息
*/
@Override
public CoSession getByRoomId(String roomId) {
log.info("【根据房间号获取会话】roomId={}", roomId);
try {
CoSession session = iCoSessionService.getByRoomId(roomId);
if (session == null) {
log.warn("【根据房间号获取会话】未找到会话, roomId={}", roomId);
} else {
log.info("【根据房间号获取会话】找到会话, controlHolder={}:{}",
session.getControlHolderType(), session.getControlHolderId());
}
return session;
} catch (Exception e) {
log.error("【根据房间号获取会话】异常", e);
throw e;
}
}
private boolean autoStartRecording() {
return true; // 从配置读取
}
}
\ No newline at end of file
package com.yd.communication.api.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yd.common.enums.CommonEnum;
import com.yd.common.exception.BusinessException;
import com.yd.common.result.Result;
import com.yd.common.utils.RandomStringGenerator;
import com.yd.communication.api.service.ApiRecordingTaskService;
import com.yd.communication.api.utils.ClientInfoUtils;
import com.yd.communication.feign.request.recording.ApiStartRecordingRequest;
import com.yd.communication.feign.response.recording.ApiQueryRecordingResponse;
import com.yd.communication.service.model.RecordingTask;
import com.yd.communication.service.service.ICoOperationLogService;
import com.yd.communication.service.service.IRecordingTaskService;
import com.yd.oss.feign.client.ApiChunkedUploadContextFeignClient;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Service
@Slf4j
public class ApiRecordingTaskServiceImpl implements ApiRecordingTaskService {
@Resource
private IRecordingTaskService iRecordingTaskService;
@Resource
private ApiChunkedUploadContextFeignClient contextFeignClient;
@Resource
private ICoOperationLogService operationLogService;
/**
* 开始录制
* @param request
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<String> startRecording(ApiStartRecordingRequest request) {
log.info("开始录制: bizType={}, bizId={}", request.getBizType(), request.getBizId());
// 检查是否有未结束的任务
LambdaQueryWrapper<RecordingTask> checkWrapper = new LambdaQueryWrapper<>();
checkWrapper.eq(RecordingTask::getBizId, request.getBizId())
.eq(RecordingTask::getStatus, "2");
RecordingTask task = new RecordingTask();
task.setRecordingTaskBizId(RandomStringGenerator.generateBizId16(CommonEnum.UID_TYPE_RECORDING_TASK.getCode()));
task.setTaskNo("R" + System.currentTimeMillis());
task.setTaskId(UUID.randomUUID().toString().replace("-", ""));
task.setBizType(request.getBizType());
task.setBizId(request.getBizId());
task.setStatus("2"); // 录制中
task.setStartTime(LocalDateTime.now());
task.setCreatorId("system");
task.setIsDeleted(0);
task.setRecordingMode("screen");
iRecordingTaskService.save(task);
log.info("录制任务创建成功,taskId={}", task.getTaskId());
//生成协同操作日志
operationLogService.log(
request.getBizId(),
"system",
"system",
"system",
"start_recording",
String.format("开始录制 taskId=%s, bizType=%s", task.getTaskId(), request.getBizType()),
ClientInfoUtils.getDevice(),
ClientInfoUtils.getClientIp()
);
return Result.success(task.getTaskId());
}
/**
* 停止录制并上传视频文件到 OSS
* @param taskId 录制任务ID
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, String>> stopRecording(String taskId) {
log.info("停止录制并上传文件: taskId={}", taskId);
// 查询录制任务
LambdaQueryWrapper<RecordingTask> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RecordingTask::getTaskId, taskId);
RecordingTask task = iRecordingTaskService.getOne(wrapper);
if (task == null) {
throw new BusinessException("录制任务不存在,taskId=" + taskId);
}
// 远程调用 OSS 微服务,完成分片上传(合并)
String projectBizId = "";
Result<Map<String, Object>> finishResult = contextFeignClient.finishChunks(taskId, projectBizId);
if (finishResult == null || finishResult.getCode() != 200) {
String errorMsg = finishResult != null ? finishResult.getMsg() : "OSS服务返回空";
log.error("完成分片上传失败: {}", errorMsg);
throw new BusinessException("录制文件上传失败: " + errorMsg);
}
// 提取上传结果
Map<String, Object> data = finishResult.getData();
if (data == null) {
throw new BusinessException("上传成功但未返回文件信息");
}
String fileUrl = (String) data.get("fileUrl");
// 安全获取 fileSize(可能是 Integer 或 Long)
Number fileSizeNumber = (Number) data.get("fileSize");
Long fileSize = fileSizeNumber != null ? fileSizeNumber.longValue() : null;
String fileKey = (String) data.get("fileKey");
// 更新录制任务
task.setFileUrl(fileUrl);
task.setFileSize(fileSize);
task.setStatus("3"); // 已录制
task.setFileFormat("webm");
task.setStopTime(LocalDateTime.now());
iRecordingTaskService.updateById(task);
// 返回结果
Map<String, String> result = new HashMap<>();
result.put("taskId", task.getTaskId());
result.put("fileUrl", fileUrl);
result.put("fileKey", fileKey);
log.info("录制文件上传成功,fileUrl={}", fileUrl);
// 生成协同操作日志
operationLogService.log(
task.getBizId(),
"system",
"system",
"system",
"stop_recording",
String.format("停止录制 taskId=%s, fileUrl=%s", task.getTaskId(), fileUrl),
ClientInfoUtils.getDevice(),
ClientInfoUtils.getClientIp()
);
return Result.success(result);
}
/**
* 查询录制信息
* @param taskId
* @return
*/
@Override
public Result<ApiQueryRecordingResponse> queryRecording(String taskId) {
if (StringUtils.isBlank(taskId)) {
throw new BusinessException("任务ID不能为空");
}
ApiQueryRecordingResponse response = new ApiQueryRecordingResponse();
RecordingTask recordingTask = iRecordingTaskService.lambdaQuery()
.eq(RecordingTask::getTaskId,taskId)
.last(" limit 1 ")
.one();
if (recordingTask == null) {
throw new BusinessException("录制任务信息不存在");
}
BeanUtils.copyProperties(recordingTask,response);
return Result.success(response);
}
}
\ No newline at end of file
package com.yd.communication.api.utils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* 客户端信息工具类(IP、设备号等)
* 优先从 HTTP 请求获取,若不存在则从 IpContextHolder 获取(WebSocket 场景)
*
* @author zxm
* @date 2026-08-03
*/
public class ClientInfoUtils {
/**
* 获取客户端真实 IP
* 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr > IpContextHolder
*
* @return 客户端 IP
*/
public static String getClientIp() {
// 1. 从 HTTP 请求获取
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
if (request != null) {
// 获取 X-Forwarded-For(经过代理时)
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.isBlank(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (StringUtils.isBlank(ip)) {
ip = request.getRemoteAddr();
}
// X-Forwarded-For 可能包含多个 IP,取第一个
if (StringUtils.isNotBlank(ip) && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
if (StringUtils.isNotBlank(ip)) {
return ip;
}
}
}
} catch (Exception e) {
// 忽略异常,继续回退
}
// 2. 回退到 IpContextHolder(WebSocket 场景由 CoWebSocketServer 设置)
return IpContextHolder.getIp();
}
/**
* 获取设备号(User-Agent)
* 优先级:HTTP 请求头 > IpContextHolder
*
* @return 设备号(User-Agent)
*/
public static String getDevice() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
if (request != null) {
return request.getHeader("User-Agent");
}
}
} catch (Exception e) {
// 忽略异常
}
return IpContextHolder.getDevice();
}
/**
* 获取完整客户端信息(IP + 设备)
* @return 格式:IP | User-Agent
*/
public static String getClientInfo() {
return getClientIp() + " | " + getDevice();
}
}
\ No newline at end of file
package com.yd.communication.api.utils;
/**
* 用于在 WebSocket 等非 HTTP 环境下传递客户端 IP 和设备信息
* <p>
* 使用 ThreadLocal 存储,避免参数透传,确保线程安全。
* 通常在 WebSocket 的 @OnMessage 方法中设置,在业务 Service 中获取,
* 并在 finally 块中清除,防止内存泄漏。
* </p>
*
* @author zxm
* @date 2026-08-03
*/
public class IpContextHolder {
private static final ThreadLocal<String> IP_HOLDER = new ThreadLocal<>();
private static final ThreadLocal<String> DEVICE_HOLDER = new ThreadLocal<>();
/**
* 设置当前线程的客户端 IP
*/
public static void setIp(String ip) {
IP_HOLDER.set(ip);
}
/**
* 获取当前线程的客户端 IP
*/
public static String getIp() {
return IP_HOLDER.get();
}
/**
* 设置当前线程的设备号(User-Agent 等)
*/
public static void setDevice(String device) {
DEVICE_HOLDER.set(device);
}
/**
* 获取当前线程的设备号
*/
public static String getDevice() {
return DEVICE_HOLDER.get();
}
/**
* 清除当前线程的上下文(防止内存泄漏)
*/
public static void clear() {
IP_HOLDER.remove();
DEVICE_HOLDER.remove();
}
}
\ No newline at end of file
package com.yd.communication.api.utils;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayOutputStream;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class QRCodeUtils {
/**
* 生成二维码图片的字节数组(PNG格式)
*
* @param content 二维码内容(如URL)
* @param width 图片宽度(像素)
* @param height 图片高度(像素)
* @return PNG图片的字节数组
*/
public static byte[] generateQRCode(String content, int width, int height) {
try {
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.MARGIN, 1); // 边距
QRCodeWriter writer = new QRCodeWriter();
BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, width, height, hints);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos);
return baos.toByteArray();
} catch (Exception e) {
log.error("生成二维码失败,content={}, width={}, height={}", content, width, height, e);
throw new RuntimeException("生成二维码失败", e);
}
}
}
\ No newline at end of file
package com.yd.communication.api.websocket;
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yd.communication.api.service.ApiCoSessionService;
import com.yd.communication.api.utils.IpContextHolder;
import com.yd.communication.feign.dto.RoomRedisInfoDTO;
import com.yd.communication.feign.enums.ControlHolderTypeEnum;
import com.yd.communication.feign.request.EndSessionRequest;
import com.yd.communication.feign.request.TransferControlRequest;
import com.yd.communication.service.model.CoSession;
import com.yd.communication.service.service.ICoOperationLogService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RTopic;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.TimeUnit;
/**
* 协同 WebSocket 服务端(支持多节点部署)
* <p>
* 设计思路:
* 1. 本地内存 Map 管理当前节点的 Session(无法跨节点共享)
* 2. Redis 存储房间成员信息、当前状态(跨节点共享)
* 3. Redis Pub/Sub 实现跨节点实时消息广播
* 4. 新节点接入时,从 Redis 拉取最新状态,实现"状态同步"而非"历史重放"
* </p>
*
* @author zxm
* @date 2026-07-28
*/
@Component // 将当前类注入 Spring 容器,使其成为 Bean
@ServerEndpoint("/ws/{roomId}") // 声明 WebSocket 端点,路径为 /ws/{roomId},{roomId} 是路径参数
@Slf4j
public class CoWebSocketServer {
// ==================== 本地内存(当前节点) ====================
/**
* 房间 -> 当前节点内的 WebSocket 会话集合
* key: 房间号 (roomId)
* value: 该房间内当前节点上的所有会话(Session)
* 使用 ConcurrentHashMap 保证多线程安全,CopyOnWriteArraySet 保证读写分离
*/
private static final Map<String, Set<Session>> ROOMS = new ConcurrentHashMap<>();
/**
* 会话 -> 房间号 反向映射
* key: WebSocket Session 对象
* value: 该会话所在的房间号
* 用于在收到消息时快速查找房间号
*/
private static final Map<Session, String> SESSION_ROOM = new ConcurrentHashMap<>();
/**
* 会话 -> 用户ID 映射
* key: WebSocket Session 对象
* value: 该会话对应的用户ID
*/
private static final Map<Session, String> SESSION_USER_ID = new ConcurrentHashMap<>();
/**
* 会话 -> 用户类型 映射
* key: WebSocket Session 对象
* value: 用户类型 (owner: 客户, participant: 顾问)
*/
private static final Map<Session, String> SESSION_USER_TYPE = new ConcurrentHashMap<>();
/**
* 房间 -> 当前投屏者用户类型
* key: 房间号 (roomId)
* value: "owner" 或 "participant"
*/
private static final Map<String, String> PRESENTER_MAP = new ConcurrentHashMap<>();
// ==================== Spring Bean 静态注入 ====================
/**
* API 层会话服务(静态化,供 WebSocket 生命周期方法使用)
* 因为 WebSocket 端点不由 Spring 直接管理,需要静态注入
*/
private static ApiCoSessionService sessionService;
/**
* 操作日志服务(静态化)
*/
private static ICoOperationLogService operationLogService;
/**
* Redis 模板(静态化),用于跨节点通信和状态存储
*/
private static RedisTemplate<String, String> redisTemplate;
/**
* 注入会话服务
* @param service ApiCoSessionService 实例
*/
@Autowired
public void setSessionService(ApiCoSessionService service) {
CoWebSocketServer.sessionService = service;
}
/**
* 注入操作日志服务
* @param service ICoOperationLogService 实例
*/
@Autowired
public void setOperationLogService(ICoOperationLogService service) {
CoWebSocketServer.operationLogService = service;
}
/**
* 注入 Redis 模板
* @param redisTemplate RedisTemplate 实例
*/
@Autowired
public void setRedisTemplate(RedisTemplate<String, String> redisTemplate) {
CoWebSocketServer.redisTemplate = redisTemplate;
}
private static RedissonClient redissonClient;
@Autowired
public void setRedissonClient(RedissonClient redissonClient) {
CoWebSocketServer.redissonClient = redissonClient;
}
// ==================== Redis 键名常量 ====================
/**
* 房间成员信息 Redis Hash 键模板
* 实际键名:room:members:{roomId}
* 存储结构:Hash,field 为 sessionId,value 为成员信息 JSON
*/
private static final String ROOM_MEMBERS_KEY = "room:members:%s";
/**
* 房间当前页面状态 Redis String 键模板
* 实际键名:room:state:{roomId}:currentPage
* 存储内容:当前页面的 JSON 字符串
*/
private static final String ROOM_STATE_PAGE_KEY = "room:state:%s:currentPage";
/**
* 房间广播频道 Redis Pub/Sub 频道模板
* 实际频道名:room:channel:{roomId}
* 用于跨节点消息广播
*/
private static final String ROOM_BROADCAST_CHANNEL = "room:channel:%s";
// ==================== 新增:节点标识 ====================
/**
* 当前节点的唯一标识(IP:端口 或 UUID)
* 用于区分消息是否为本节点发出,避免回环
*/
private static final String NODE_ID = UUID.randomUUID().toString() + "@" + System.currentTimeMillis();
// ==================== 跨节点订阅(每个节点启动时执行) ====================
@EventListener(ApplicationReadyEvent.class)
public void startRedisSubscriber() {
log.info("应用已完全启动,开始初始化 Redisson RTopic 订阅...");
new Thread(() -> {
// 订阅全局频道
RTopic topic = redissonClient.getTopic("coordination:channel");
topic.addListener(String.class, (channel, msg) -> {
log.debug("收到跨节点消息: {}", msg);
handleCrossNodeMessage(msg);
});
log.info("Redisson RTopic 订阅已启动,频道: coordination:channel");
// 保持线程存活(监听在后台异步进行,线程无需阻塞,但保留以防止退出)
while (true) {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
break;
}
}
}).start();
}
/**
* Bean 初始化完成后执行,启动 Redis 订阅线程
* 每个节点启动时都会订阅通配符频道 "room:channel:*"
* 用于接收其他节点发来的跨节点广播消息
*/
// @PostConstruct
// public void init() {
// // 启动一个独立的后台线程,避免阻塞主线程
// new Thread(() -> {
// // 无限循环,支持断线自动重连
// while (true) {
// try {
// // 通过 Redis 连接执行订阅命令
// redisTemplate.execute((connection) -> {
// // 订阅所有以 "room:channel:" 开头的频道
// // 第二个参数是监听器:收到消息时调用 handleCrossNodeMessage 处理
// connection.subscribe(
// (message, pattern) -> handleCrossNodeMessage(new String(message.getBody())),
// "room:channel:*".getBytes()
// );
// // 返回 null,因为 subscribe 是阻塞方法,执行到这里说明订阅已结束(异常断开)
// return null;
// }, true); // true 表示使用事务(此处无实际影响)
// } catch (Exception e) {
// // 订阅断开(如 Redis 连接超时、网络抖动),记录错误日志
// log.error("Redis 订阅断开,5秒后重试...", e);
// try {
// // 等待 5 秒后重连
// Thread.sleep(5000);
// } catch (InterruptedException ex) {
// // 线程被中断,退出循环
// Thread.currentThread().interrupt();
// break;
// }
// }
// }
// }).start(); // 启动线程
// log.info("Redis 跨节点广播订阅已启动,当前节点ID: {}", NODE_ID);
// }
/**
* 处理跨节点广播消息(由 Redis 订阅触发)
* 当其他节点向 Redis 频道发布消息时,此方法会被调用
* 注意:此方法仅负责将消息转发给本节点的 Session,不处理状态同步
*
* @param body Redis 消息体(JSON 字符串)
*/
private void handleCrossNodeMessage(String body) {
try {
// 创建 Jackson 对象映射器,解析 JSON
ObjectMapper mapper = new ObjectMapper();
// 将 JSON 字符串解析为树节点
JsonNode json = mapper.readTree(body);
// 提取消息中的节点ID
String sourceNodeId = json.has("sourceNodeId") ? json.get("sourceNodeId").asText() : null;
// 如果消息是本节点发出的,直接忽略,避免回环
if (NODE_ID.equals(sourceNodeId)) {
log.debug("忽略本节点发出的消息,sourceNodeId={}", sourceNodeId);
return;
}
// 提取房间号
String roomId = json.get("roomId").asText();
// 提取消息内容
String msg = json.get("message").asText();
// 提取需要排除的会话 ID(即原始发送者,避免消息回环)
String excludeSessionId = json.has("excludeSessionId") ? json.get("excludeSessionId").asText() : null;
// 从本地内存中获取该房间在本节点的所有会话
Set<Session> sessions = ROOMS.get(roomId);
// 如果本节点没有该房间的会话,直接忽略(其他节点会处理)
if (sessions == null || sessions.isEmpty()) {
return;
}
// 遍历该房间在本节点的所有会话
for (Session s : sessions) {
// 跳过需要排除的会话(即消息发送者所在的节点已处理,本节点不再转发给同一个人)
if (excludeSessionId != null && excludeSessionId.equals(s.getId())) {
continue;
}
// 检查会话是否还处于打开状态
if (s.isOpen()) {
// 发送消息给客户端
s.getBasicRemote().sendText(msg);
}
}
} catch (Exception e) {
// 处理消息异常,记录日志但不影响其他节点
log.error("处理跨节点消息异常", e);
}
}
// ==================== WebSocket 生命周期 ====================
/**
* Jackson 对象映射器,用于解析 JSON 消息
*/
private final ObjectMapper objectMapper = new ObjectMapper();
@OnOpen
public void onOpen(Session session, @PathParam("roomId") String roomId) {
log.info("========== WebSocket 连接请求开始 ==========");
log.info("房间号: {}, 会话ID: {}", roomId, session.getId());
// 1. 解析 URL 查询参数
String queryString = session.getQueryString();
log.info("查询字符串: {}", queryString);
Map<String, String> params = parseQueryString(queryString);
String userId = params.getOrDefault("userId", "unknown");
String userType = params.getOrDefault("userType", "unknown");
log.info("解析到的 userId: {}, userType: {}", userId, userType);
// 2. 检查关键依赖是否注入成功
log.info("检查依赖注入 - sessionService: {}, redisTemplate: {}, redissonClient: {}",
sessionService == null ? "NULL" : "OK",
redisTemplate == null ? "NULL" : "OK",
redissonClient == null ? "NULL" : "OK"
);
if (sessionService == null || redisTemplate == null || redissonClient == null) {
log.error("依赖注入失败,无法处理连接!");
try {
session.close(new CloseReason(CloseReason.CloseCodes.UNEXPECTED_CONDITION, "服务未就绪"));
} catch (IOException e) {
log.error("关闭session失败", e);
}
return;
}
try {
// 3. 加入本地内存
log.info("将会话加入本地内存 ROOMS...");
ROOMS.computeIfAbsent(roomId, k -> new CopyOnWriteArraySet<>()).add(session);
SESSION_ROOM.put(session, roomId);
SESSION_USER_ID.put(session, userId);
SESSION_USER_TYPE.put(session, userType);
log.info("本地内存添加成功,当前房间 {} 的会话数: {}", roomId, ROOMS.get(roomId).size());
// 4. 将成员信息存入 Redis
String memberKey = String.format(ROOM_MEMBERS_KEY, roomId);
Map<String, String> memberInfo = new HashMap<>();
memberInfo.put("userId", userId);
memberInfo.put("userType", userType);
memberInfo.put("sessionId", session.getId());
log.info("准备将成员信息存入 Redis, key: {}, info: {}", memberKey, memberInfo);
try {
redisTemplate.opsForHash().put(memberKey, session.getId(), JSON.toJSONString(memberInfo));
redisTemplate.expire(memberKey, 60, TimeUnit.MINUTES);
log.info("Redis 成员信息存储成功");
} catch (Exception e) {
log.error("Redis 成员信息存储失败", e);
throw e; // 继续抛出以便外层捕获
}
// 5. 状态同步
log.info("开始状态同步...");
try {
// 5.1 获取控制权
RoomRedisInfoDTO dto = sessionService.getCurrentController(roomId);
String controlHolderType = dto.getControlHolderType();
String controlHolderId = dto.getControlHolderId();
log.info("获取控制权成功: holderType={}, holderId={}", controlHolderType, controlHolderId);
// 5.2 获取当前页面状态
String stateKey = String.format(ROOM_STATE_PAGE_KEY, roomId);
String currentPageJson = redisTemplate.opsForValue().get(stateKey);
log.info("从Redis获取当前页面状态: {}", currentPageJson);
if (StringUtils.isBlank(currentPageJson)) {
CoSession coSession = sessionService.getByRoomId(roomId);
currentPageJson = coSession != null ? coSession.getCurrentPage() : "{}";
if (StringUtils.isNotBlank(currentPageJson)) {
redisTemplate.opsForValue().set(stateKey, currentPageJson, 60, TimeUnit.MINUTES);
log.info("从数据库获取并回填Redis状态: {}", currentPageJson);
} else {
log.warn("未找到当前页面状态,使用默认空对象");
}
}
// 5.3 发送初始化消息
String initMsg = String.format(
"{\"action\":\"init_sync\",\"holderType\":\"%s\",\"holderId\":\"%s\",\"currentPage\":%s}",
controlHolderType, controlHolderId, currentPageJson
);
session.getBasicRemote().sendText(initMsg);
log.info("发送 init_sync 消息: {}", initMsg);
String controlMsg = String.format(
"{\"action\":\"control_transfer\",\"holderType\":\"%s\",\"holderId\":\"%s\"}",
controlHolderType, controlHolderId
);
session.getBasicRemote().sendText(controlMsg);
log.info("发送 control_transfer 消息: {}", controlMsg);
log.info("用户 {} 加入房间 {},状态同步完成", userId, roomId);
} catch (Exception e) {
log.error("状态同步过程中发生异常", e);
// 状态同步失败不影响连接建立,但应告知客户端(可选)
// 这里重新抛出以便外层统一处理
throw e;
}
log.info("========== WebSocket 连接处理完成 ==========");
} catch (Exception e) {
log.error("WebSocket 连接处理失败,房间号: {}, 用户: {}", roomId, userId, e);
// 发送错误消息给客户端(可选)
try {
session.getBasicRemote().sendText("{\"error\":\"服务器处理异常: " + e.getMessage() + "\"}");
} catch (IOException ex) {
log.error("发送错误消息失败", ex);
}
// 关闭连接
try {
session.close(new CloseReason(CloseReason.CloseCodes.UNEXPECTED_CONDITION, "服务器错误: " + e.getMessage()));
} catch (IOException ex) {
log.error("关闭session失败", ex);
}
}
}
/**
* WebSocket 连接建立时触发
* 1. 解析 URL 查询参数(userId, userType)
* 2. 将当前会话加入本地内存(ROOMS, SESSION_ROOM 等)
* 3. 将成员信息存入 Redis(跨节点共享)
* 4. 从 Redis 拉取当前房间状态并同步给新连接的用户(状态同步)
*
* @param session 当前 WebSocket 会话对象
* @param roomId 路径参数:房间号
*/
// @OnOpen
// public void onOpen(Session session, @PathParam("roomId") String roomId) {
// // 1. 获取 URL 查询字符串(如 ?userId=123&userType=owner)
// String queryString = session.getQueryString();
// // 解析查询参数为 Map
// Map<String, String> params = parseQueryString(queryString);
// // 获取用户ID,默认为 "unknown"
// String userId = params.getOrDefault("userId", "unknown");
// // 获取用户类型,默认为 "unknown"(owner: 客户, participant: 顾问)
// String userType = params.getOrDefault("userType", "unknown");
//
// // 2. 将当前会话加入本地内存
// // computeIfAbsent: 如果房间不存在则创建新的 Set 集合
// ROOMS.computeIfAbsent(roomId, k -> new CopyOnWriteArraySet<>()).add(session);
// // 记录会话对应的房间号
// SESSION_ROOM.put(session, roomId);
// // 记录会话对应的用户ID
// SESSION_USER_ID.put(session, userId);
// // 记录会话对应的用户类型
// SESSION_USER_TYPE.put(session, userType);
//
// // 3. 将成员信息存入 Redis(供跨节点查询使用)
// // 构造 Redis Hash 的键名:room:members:{roomId}
// String memberKey = String.format(ROOM_MEMBERS_KEY, roomId);
// // 构造成员信息 Map
// Map<String, String> memberInfo = new HashMap<>();
// memberInfo.put("userId", userId);
// memberInfo.put("userType", userType);
// memberInfo.put("sessionId", session.getId()); // sessionId 作为唯一标识
// // 以 sessionId 为 field,成员信息 JSON 为 value,存入 Redis Hash
// redisTemplate.opsForHash().put(memberKey, session.getId(), JSON.toJSONString(memberInfo));
// // 设置 Hash 过期时间为 60 分钟(与会话超时一致)
// redisTemplate.expire(memberKey, 60, TimeUnit.MINUTES);
//
// // 4. 状态同步(核心功能):向新用户推送当前房间的完整状态
// try {
// // 4.1 获取当前控制权信息(从 Redis 缓存中读取)
// RoomRedisInfoDTO dto = sessionService.getCurrentController(roomId);
// // 控制权持有者类型:owner 或 participant
// String controlHolderType = dto.getControlHolderType();
// // 控制权持有者ID
// String controlHolderId = dto.getControlHolderId();
//
// // 4.2 获取当前页面状态(从 Redis 中读取,由翻页操作实时更新)
// String stateKey = String.format(ROOM_STATE_PAGE_KEY, roomId);
// String currentPageJson = redisTemplate.opsForValue().get(stateKey);
//
// // 如果 Redis 中没有页面状态,则从数据库读取最新状态
// if (StringUtils.isBlank(currentPageJson)) {
// // 根据房间号查询数据库中的会话记录
// CoSession coSession = sessionService.getByRoomId(roomId);
// // 如果会话存在,取其中的当前页面 JSON
// currentPageJson = coSession != null ? coSession.getCurrentPage() : "{}";
// // 将数据库中的状态回填到 Redis,供后续新节点快速同步
// if (StringUtils.isNotBlank(currentPageJson)) {
// redisTemplate.opsForValue().set(stateKey, currentPageJson, 60, TimeUnit.MINUTES);
// }
// }
//
// // 4.3 组装初始化同步消息(包含控制权和当前页面)
// // 消息格式:{"action":"init_sync","holderType":"xxx","holderId":"xxx","currentPage":{...}}
// String initMsg = String.format(
// "{\"action\":\"init_sync\",\"holderType\":\"%s\",\"holderId\":\"%s\",\"currentPage\":%s}",
// controlHolderType, controlHolderId, currentPageJson
// );
// // 发送初始化同步消息给刚连接的客户端
// session.getBasicRemote().sendText(initMsg);
//
// // 4.4 单独再推送一次控制权消息(兼容前端只监听 control_transfer 的情况)
// String controlMsg = String.format(
// "{\"action\":\"control_transfer\",\"holderType\":\"%s\",\"holderId\":\"%s\"}",
// controlHolderType, controlHolderId
// );
// session.getBasicRemote().sendText(controlMsg);
//
// // 记录日志:用户已加入并完成状态同步
// log.info("用户 {} 加入房间 {},已同步状态", userId, roomId);
// } catch (Exception e) {
// // 状态同步失败,记录错误但不影响连接建立
// log.error("状态同步失败,用户 {} 可能无法恢复最新状态", userId, e);
// }
//
// // 记录连接建立日志,包含当前节点该房间的会话数
// log.info("用户 {} ({}) 加入房间 {}", userId, userType, roomId);
// }
/**
* 接收客户端发送的 WebSocket 消息(核心业务分发器)
* 根据 action 类型分发到不同的业务处理逻辑
*
* @param session 当前会话
* @param message 客户端发送的 JSON 字符串
*/
@OnMessage
public void onMessage(Session session, String message) {
// 获取 IP
String remoteIp = getClientIp(session);
IpContextHolder.setIp(remoteIp);
IpContextHolder.setDevice(null);
// 根据会话查询对应的房间号
String roomId = SESSION_ROOM.get(session);
if (roomId == null) {
log.warn("【WebSocket】会话未关联房间,忽略消息");
return;
}
// 打印收到的原始消息
log.info("【WebSocket】收到消息,房间号: {}, 消息内容: {}", roomId, message);
try {
// 1. 解析 JSON 消息
JsonNode json = objectMapper.readTree(message);
String action = json.get("action").asText();
String userId = SESSION_USER_ID.get(session);
String userType = SESSION_USER_TYPE.get(session);
log.info("【WebSocket】解析结果: action={}, userId={}, userType={}", action, userId, userType);
// 2. 获取会话业务ID(用于操作日志记录)
CoSession coSession = sessionService.getByRoomId(roomId);
String bizId = coSession != null ? coSession.getCoSessionBizId() : null;
// ========== 业务逻辑分发 ==========
// --- 操作1:控制权切换(仅顾问可操作) ---
if ("control_transfer".equals(action)) {
// 打印开始处理日志,包含当前操作用户类型(owner/participant)
log.info("【控制权切换】开始处理,当前用户类型: {}", userType);
// 校验权限:只有参与者(顾问)才能切换控制权
if (!"participant".equals(userType)) {
log.warn("【控制权切换】权限不足,非顾问用户尝试切换,userType={}", userType);
sendError(session, "只有顾问可以切换控制权");
return;
}
// 从WebSocket消息中解析目标控制权持有者类型(owner/participant)
// 注意:前端可能不传递 holderId 或传递空值,因此不能直接信任
String newHolderType = json.get("holderType").asText();
log.info("【控制权切换】目标控制者类型: holderType={}", newHolderType);
// 构造控制权切换请求
TransferControlRequest req = new TransferControlRequest();
req.setRoomId(roomId);
// 根据目标类型确定操作类型:1=授权客户(owner),2=收回控制权(participant)
Integer oprType = ControlHolderTypeEnum.OWNER.getItemValue().equals(newHolderType) ? 1 : 2;
req.setOprType(oprType);
log.info("【控制权切换】操作类型: {}", oprType);
// 调用服务层切换控制权(更新数据库 + Redis)
try {
sessionService.transferControl(req);
log.info("【控制权切换】服务层调用成功,数据库和Redis已更新");
} catch (Exception e) {
log.error("【控制权切换】服务层调用失败", e);
sendError(session, "切换控制权失败:" + e.getMessage());
return;
}
// 重新从 Redis(或数据库)获取最新的控制者信息,确保准确性
// 因为服务层已经更新了控制权,此时 Redis 中的值是最新的
RoomRedisInfoDTO latest = sessionService.getCurrentController(roomId);
String correctHolderType = latest.getControlHolderType();
String correctHolderId = latest.getControlHolderId();
log.info("【控制权切换】最新控制者: holderType={}, holderId={}", correctHolderType, correctHolderId);
// 构造包含正确 holderId 的广播消息,避免前端因空 ID 无法匹配
String correctMsg = String.format(
"{\"action\":\"control_transfer\",\"holderType\":\"%s\",\"holderId\":\"%s\"}",
correctHolderType, correctHolderId
);
// 广播正确的控制权变更消息给房间内所有用户(包括自己,以便本端也能更新状态)
broadcast(roomId, correctMsg, session);
log.info("【控制权切换】已广播正确的控制权消息: {}", correctMsg);
// 记录操作日志(用于合规审计)
if (bizId != null) {
operationLogService.log(bizId, userId, userType, userId, action, correctMsg, IpContextHolder.getDevice(), IpContextHolder.getIp());
}
log.info("控制权切换成功: roomId={}, holder={}:{}", roomId, correctHolderType, correctHolderId);
return;
}
// --- 操作2:结束共享(仅客户可操作) ---
if ("end_sharing".equals(action)) {
log.info("【结束共享】开始处理,当前用户类型: {}", userType);
// 校验权限:只有所有者(客户)才能结束共享
if (!"owner".equals(userType)) {
log.warn("【结束共享】权限不足,非客户用户尝试结束共享,userType={}", userType);
sendError(session, "只有客户可以结束共享");
return;
}
// 构造结束会话请求
EndSessionRequest req = new EndSessionRequest();
req.setRoomId(roomId);
log.info("【结束共享】调用服务层结束会话");
sessionService.end(req);
// 广播结束消息给所有用户
broadcast(roomId, "{\"action\":\"end_sharing\"}", null);
log.info("【结束共享】结束消息已广播");
// 清理 Redis 中的页面状态
redisTemplate.delete(String.format(ROOM_STATE_PAGE_KEY, roomId));
log.info("【结束共享】Redis页面状态已清理");
// 关闭房间所有连接并清理资源
closeRoom(roomId);
log.info("共享已结束: roomId={}", roomId);
if (bizId != null) {
operationLogService.log(
bizId,
userId,
userType,
userId,
"end_sharing",
"结束共享",
IpContextHolder.getDevice(),
IpContextHolder.getIp()
);
}
return;
}
// --- 操作3:脱敏开关(客户操作,全房间生效) ---
if ("desensitization_switch".equals(action)) {
log.info("【脱敏开关】收到请求,enabled={}", json.get("enabled").asBoolean());
// 广播脱敏状态给所有人(同步显示脱敏效果)
broadcast(roomId, message, session);
log.info("【脱敏开关】广播消息已发送");
// 记录操作日志
if (bizId != null) {
operationLogService.log(bizId, userId, userType, userId, action, message, IpContextHolder.getDevice(), IpContextHolder.getIp());
}
return;
}
// --- 操作4:同步操作(翻页/滚动/缩放)——仅控制权持有者可操作 ---
if ("turn_the_page".equals(action) || "scroll".equals(action) || "scaling".equals(action)) {
log.info("【同步操作】开始处理,action={}, 当前用户类型={}", action, userType);
// 4.1 获取当前控制权持有者信息
RoomRedisInfoDTO dto = sessionService.getCurrentController(roomId);
String holderType = dto.getControlHolderType();
String holderId = dto.getControlHolderId();
log.info("【同步操作】当前控制权: holderType={}, holderId={}", holderType, holderId);
// 4.2 校验:当前用户是否持有控制权
boolean isController = holderType.equals(userType) && holderId.equals(userId);
log.info("【同步操作】是否持有控制权: {}", isController);
if (!isController) {
log.warn("【同步操作】用户无控制权,拒绝操作,userType={}, userId={}", userType, userId);
sendError(session, "您没有控制权,无法操作");
return;
}
// 4.3 如果是翻页或滚动操作,需要更新数据库和 Redis 状态
if ("turn_the_page".equals(action) || "scroll".equals(action)) {
// 获取当前页面 JSON(从消息中提取)
String currentPage = json.has("currentPage") ? json.get("currentPage").toString() : "{}";
log.info("【同步操作】更新页面状态: {}", currentPage);
// 更新数据库中的当前页面和页面历史轨迹
sessionService.updateCurrentPage(roomId, currentPage, userId);
log.info("【同步操作】数据库已更新");
// 更新 Redis 状态(供新节点连接时同步)
String stateKey = String.format(ROOM_STATE_PAGE_KEY, roomId);
redisTemplate.opsForValue().set(stateKey, currentPage, 60, TimeUnit.MINUTES);
log.info("【同步操作】Redis状态已更新");
}
// 4.4 广播操作指令给房间内其他用户(排除自己,避免回环)
broadcast(roomId, message, session);
log.info("【同步操作】广播消息已发送");
// 4.5 记录操作日志(合规审计)
if (bizId != null) {
operationLogService.log(bizId, userId, userType, userId, action, message, IpContextHolder.getDevice(), IpContextHolder.getIp());
}
return;
}
// --- 操作5:WebRTC 信令转发(Offer/Answer/ICE) ---
if ("webrtc_offer".equals(action) || "webrtc_answer".equals(action) || "webrtc_ice_candidate".equals(action)) {
log.info("【WebRTC信令】转发 {} 消息, roomId={}, 发送者={}", action, roomId, userId);
// 打印信令内容摘要
if ("webrtc_offer".equals(action) || "webrtc_answer".equals(action)) {
String sdpType = json.has("sdp") ? json.get("sdp").get("type").asText() : "unknown";
log.info("【WebRTC信令】SDP类型: {}", sdpType);
} else {
String candidate = json.has("candidate") ? json.get("candidate").get("candidate").asText() : "unknown";
log.info("【WebRTC信令】ICE候选: {}", candidate);
}
// 广播给房间内其他人(不包含发送者)
broadcast(roomId, message, session);
return;
}
if ("request_offer".equals(action)) {
log.info("收到 request_offer 请求,转发给房间内其他成员");
// 广播给房间内其他人(不包括发送者)
broadcast(roomId, message, session);
return;
}
// ========== 投屏切换 ==========
if ("presenter_change".equals(action) || "presenter_invite".equals(action) ||
"presenter_response".equals(action) || "presenter_revoke".equals(action)) {
if ("presenter_invite".equals(action)) {
// 强制发送给客户(owner),因为投屏邀请的目标一定是客户
String targetUserType = "owner";
log.info("投屏邀请定向发送给: {}", targetUserType);
sendToUserType(roomId, targetUserType, message);
} else if ("presenter_response".equals(action)) {
boolean accept = json.has("accept") && json.get("accept").asBoolean();
if (accept) {
String target = json.has("target") ? json.get("target").asText() : null;
if (target != null) {
String mappedTarget = "customer".equals(target) ? "owner" :
"consultant".equals(target) ? "participant" : target;
PRESENTER_MAP.put(roomId, mappedTarget);
log.info("房间 {} 投屏者更新为: {} (来自 presenter_response 接受)", roomId, mappedTarget);
}
}
broadcast(roomId, message, session);
} else if ("presenter_change".equals(action)) {
String from = json.has("from") ? json.get("from").asText() : null;
String target = json.has("target") ? json.get("target").asText() : null;
if (target != null) {
String mappedTarget = "customer".equals(target) ? "owner" :
"consultant".equals(target) ? "participant" : target;
// 如果 from 为空,或 from == target(自己发起),或当前无人投屏,则更新
if (from == null || from.equals(target) || PRESENTER_MAP.get(roomId) == null) {
PRESENTER_MAP.put(roomId, mappedTarget);
log.info("房间 {} 投屏者更新为: {} (来自 presenter_change)", roomId, mappedTarget);
}
}
broadcast(roomId, message, session);
} else if ("presenter_revoke".equals(action)) {
PRESENTER_MAP.remove(roomId);
log.info("房间 {} 投屏者已清除", roomId);
broadcast(roomId, message, session);
}
return;
}
// ========== 远程控制 ==========
if ("request_remote_control".equals(action) || "remote_control_response".equals(action) ||
"remote_control_stop".equals(action)) {
if ("request_remote_control".equals(action)) {
String presenterType = PRESENTER_MAP.get(roomId);
if (presenterType == null) {
// 后备:从数据库加载控制者
CoSession sessionFromDb = sessionService.getByRoomId(roomId);
if (sessionFromDb != null) {
String holderType = sessionFromDb.getControlHolderType();
if (holderType != null) {
presenterType = holderType;
PRESENTER_MAP.put(roomId, presenterType);
log.info("从数据库加载投屏者: roomId={}, holderType={}", roomId, presenterType);
}
}
}
if (presenterType != null) {
sendToUserType(roomId, presenterType, message);
} else {
log.warn("房间 {} 无人投屏,忽略远程控制请求", roomId);
sendError(session, "当前无人投屏,无法请求远程控制");
}
} else {
// response 或 stop 广播给所有人
broadcast(roomId, message, session);
}
return;
}
// --- 未知操作:记录警告日志 ---
log.warn("未知 action: {}", action);
} catch (Exception e) {
// 处理消息异常,向客户端返回错误信息
log.error("处理WebSocket消息异常,消息内容: {}", message, e);
sendError(session, "服务器处理异常");
} finally {
//清除上下文,防止内存泄漏
IpContextHolder.clear();
}
}
/**
* WebSocket 连接关闭时触发
* 1. 从本地内存中移除该会话
* 2. 从 Redis 中移除该成员信息
* 3. 如果房间为空,清理本地房间映射
*
* @param session 关闭的会话
*/
@OnClose
public void onClose(Session session) {
// 从反向映射中移除会话,获取该会话对应的房间号
String roomId = SESSION_ROOM.remove(session);
if (roomId != null) {
// 从本地内存中获取该房间的会话集合
Set<Session> sessions = ROOMS.get(roomId);
if (sessions != null) {
// 从集合中移除当前会话
sessions.remove(session);
// 如果集合为空,移除该房间的映射,释放内存
if (sessions.isEmpty()) {
ROOMS.remove(roomId);
}
}
// 从 Redis 中移除该成员信息
String memberKey = String.format(ROOM_MEMBERS_KEY, roomId);
redisTemplate.opsForHash().delete(memberKey, session.getId());
}
// 移除会话对应的用户ID映射
SESSION_USER_ID.remove(session);
// 移除会话对应的用户类型映射
SESSION_USER_TYPE.remove(session);
// 记录连接关闭日志
log.info("WebSocket 关闭: sessionId={}", session.getId());
}
/**
* WebSocket 发生异常时触发
*
* @param session 发生异常的会话
* @param error 异常信息
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("WebSocket 错误", error);
}
// ==================== 广播方法 ====================
/**
* 向房间内所有成员广播消息(支持跨节点)
* 1. 本节点直接发送:遍历本地 ROOMS 中的会话,直接发送消息
* 2. 跨节点广播:将消息发布到 Redis,其他节点的订阅者收到后会转发给各自的客户端
*
* @param roomId 房间号
* @param message JSON 消息内容
* @param exclude 本节点需要排除的会话(通常为消息发送者,避免回环)
*/
private void broadcast(String roomId, String message, Session exclude) {
// 1. 本节点直接发送消息给当前节点内的所有会话
Set<Session> localSessions = ROOMS.get(roomId);
if (localSessions != null && !localSessions.isEmpty()) {
for (Session s : localSessions) {
// 跳过需要排除的会话(发送者自己)
if (s != exclude && s.isOpen()) {
try {
// 发送消息
s.getBasicRemote().sendText(message);
} catch (IOException e) {
// 发送失败,忽略(日志不打印,避免刷屏)
}
}
}
}
// 2. 跨节点广播(发布到 Redisson RTopic)
Map<String, String> data = new HashMap<>();
data.put("roomId", roomId);
data.put("message", message);
data.put("sourceNodeId", NODE_ID);
if (exclude != null) {
data.put("excludeSessionId", exclude.getId());
}
String jsonMsg = JSON.toJSONString(data);
RTopic topic = redissonClient.getTopic("coordination:channel");
topic.publish(jsonMsg);
}
/**
* 向指定会话发送错误消息
*
* @param session 目标会话
* @param errorMsg 错误描述信息
*/
private void sendError(Session session, String errorMsg) {
try {
// 发送 JSON 格式的错误消息
session.getBasicRemote().sendText("{\"error\":\"" + errorMsg + "\"}");
} catch (IOException e) {
// 发送失败,忽略
}
}
/**
* 关闭房间所有连接并清理 Redis 状态
* 在结束共享时调用
*
* @param roomId 房间号
*/
private void closeRoom(String roomId) {
// 1. 从本地内存移除该房间并获取所有会话
Set<Session> sessions = ROOMS.remove(roomId);
if (sessions != null) {
// 遍历所有会话,逐个关闭连接
for (Session s : sessions) {
try {
// 正常关闭连接,附带关闭原因
s.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "会话结束"));
} catch (IOException e) {
// 关闭失败,忽略
}
}
}
PRESENTER_MAP.remove(roomId);
// 2. 清理 Redis 中的成员信息 Hash
redisTemplate.delete(String.format(ROOM_MEMBERS_KEY, roomId));
// 3. 清理 Redis 中的页面状态
redisTemplate.delete(String.format(ROOM_STATE_PAGE_KEY, roomId));
// 注意:控制权缓存由 sessionService.end() 负责清理
}
// ==================== 工具方法 ====================
/**
* 解析 URL 查询参数字符串
* 示例输入: "userId=123&userType=owner"
* 示例输出: {"userId": "123", "userType": "owner"}
*
* @param queryString 原始查询字符串(不含问号)
* @return 解析后的参数键值对 Map
*/
private Map<String, String> parseQueryString(String queryString) {
Map<String, String> result = new HashMap<>();
// 如果查询字符串为空,返回空 Map
if (queryString == null || queryString.isEmpty()) {
return result;
}
// 按 & 符号分割多个参数
for (String param : queryString.split("&")) {
// 按 = 分割键和值
String[] pair = param.split("=", 2);
// 解码键名
String key = pair.length > 0 ? decode(pair[0]) : "";
// 解码值
String value = pair.length > 1 ? decode(pair[1]) : "";
// 如果键不为空,存入 Map
if (!key.isEmpty()) {
result.put(key, value);
}
}
return result;
}
/**
* URL 解码(UTF-8)
*
* @param value 需要解码的字符串
* @return 解码后的字符串,如果解码失败则返回原值
*/
private String decode(String value) {
try {
// 使用 UTF-8 进行 URL 解码
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
} catch (Exception e) {
// 解码失败,返回原值
return value;
}
}
// ==================== 获取客户端 IP 的工具方法 ====================
/**
* 获取客户端 IP(由于 javax.websocket.Session 接口没有 getRemoteAddress,通过反射调用)
*/
private String getClientIp(Session session) {
try {
// 尝试直接通过反射调用 getRemoteAddress(Tomcat 等实现类有该方法)
Method method = session.getClass().getMethod("getRemoteAddress");
Object result = method.invoke(session);
if (result instanceof InetSocketAddress) {
InetSocketAddress address = (InetSocketAddress) result;
return address.getAddress().getHostAddress();
}
} catch (Exception e) {
log.debug("通过反射获取 getRemoteAddress 失败,尝试其他方式");
}
// 备选:从请求参数中获取(前端可传 ?ip=xxx)
Map<String, List<String>> params = session.getRequestParameterMap();
if (params != null && params.containsKey("ip")) {
List<String> ips = params.get("ip");
if (ips != null && !ips.isEmpty()) {
return ips.get(0);
}
}
// 再备选:从 getUserProperties 中获取(如由 configurator 存储)
Object storedIp = session.getUserProperties().get("remoteIp");
if (storedIp != null) {
return storedIp.toString();
}
return "unknown";
}
/**
* 向房间内指定用户类型的所有用户发送消息(定向发送)
*/
private void sendToUserType(String roomId, String targetUserType, String message) {
// 将前端传入的 customer/consultant 映射为 owner/participant
String mappedType = targetUserType;
if ("customer".equals(targetUserType)) {
mappedType = "owner";
} else if ("consultant".equals(targetUserType)) {
mappedType = "participant";
}
log.info("定向发送: roomId={}, 目标类型={} (映射为 {}), 消息={}", roomId, targetUserType, mappedType, message);
Set<Session> sessions = ROOMS.get(roomId);
if (sessions == null || sessions.isEmpty()) {
log.warn("房间 {} 无会话,无法定向发送", roomId);
return;
}
int count = 0;
for (Session s : sessions) {
String userType = SESSION_USER_TYPE.get(s);
if (mappedType.equals(userType) && s.isOpen()) {
try {
s.getBasicRemote().sendText(message);
count++;
} catch (IOException e) {
log.warn("向用户 {} 发送消息失败: {}", s.getId(), e.getMessage());
}
}
}
log.info("定向发送给 {} (映射为 {}),共 {} 个会话", targetUserType, mappedType, count);
}
}
\ No newline at end of file
${AnsiColor.GREEN}
_ _ _ __ __ ____ _ _ _ _
| | (_)_ __ | | _\ \ / /__ / ___| |__ __ _| |_ / \ _ __ (_)
| | | | '_ \| |/ /\ \ /\ / / _ \ | | '_ \ / _` | __| / _ \ | '_ \| |
| |___| | | | | < \ V V / __/ |___| | | | (_| | |_ / ___ \| |_) | |
|_____|_|_| |_|_|\_\ \_/\_/ \___|\____|_| |_|\__,_|\__/_/ \_\ .__/|_|
|_|
${AnsiColor.BRIGHT_WHITE}
Spring Boot Version: ${spring-boot.version}
\ No newline at end of file
spring:
profiles:
active: test
# active: '@spring.profiles.active@'
---
spring:
application:
name: yd-communication-api
profiles: dev
main:
allow-bean-definition-overriding: true
allow-circular-references: true
cloud:
nacos:
# 配置中心
config:
# 命名空间id(此处不用public,因public初始化的空间, id为空) 4e237601-cea8-414d-b7b9-d7adc8cbcf95
namespace: 22f9d61e-9011-4d45-88cb-24f9857e3eec
# nacos的ip地址和端口 120.79.64.17:10848
server-addr: 127.0.0.1:8848
# 这个就表示 在我们nacos命名空间id为 dev中 有一个data-id 为 demo-service.yml 的配置文件 读取这个里面的配置
file-extension: yml
config-retry-time: 300000
# 共享配置, 可以把公共配置放在同个命名空间下,然后创建一个 common.yml 文件 ,里面可以放共用的配置
shared-configs[0]:
dataId: linkwe-common.yml
refresh: true
# 发布到注册中心 (如果没有使用可以不配)
discovery:
# 命名空间id(此处不用public,因public初始化的空间, id为空)
namespace: ${spring.cloud.nacos.config.namespace}
# nacos的ip地址和端口
server-addr: ${spring.cloud.nacos.config.server-addr}
---
spring:
application:
name: yd-communication-api
profiles: test
main:
allow-bean-definition-overriding: true
allow-circular-references: true
cloud:
nacos:
# 配置中心
config:
# 命名空间id(此处不用public,因public初始化的空间, id为空)
namespace: b3b01715-eb85-4242-992a-5aff03d864d4
# nacos的ip地址和端口
server-addr: 139.224.145.34:8848
# 这个就表示 在我们nacos命名空间id为 dev中 有一个data-id 为 demo-service.yml 的配置文件 读取这个里面的配置
file-extension: yml
config-retry-time: 300000
# 共享配置, 可以把公共配置放在同个命名空间下,然后创建一个 common.yml 文件 ,里面可以放共用的配置
shared-configs[0]:
dataId: yd-common.yml
group: YD_GROUP
refresh: true
extension-configs: # 扩展配置
- data-id: yd-communication-api.yml
group: YD_GROUP
refresh: true
# 发布到注册中心 (如果没有使用可以不配)
discovery:
# 命名空间id(此处不用public,因public初始化的空间, id为空)
namespace: ${spring.cloud.nacos.config.namespace}
# nacos的ip地址和端口
server-addr: ${spring.cloud.nacos.config.server-addr}
group: YD_GROUP
---
spring:
profiles: prod
application:
name: yd-communication-api
server:
port: 9482
main:
allow-bean-definition-overriding: true
allow-circular-references: true
cloud:
nacos:
# 配置中心
config:
# 命名空间id(此处不用public,因public初始化的空间, id为空)
namespace: cb587d6d-d3b2-45ca-a3ef-5b5c80ece5b3
# nacos的ip地址和端口
server-addr: 139.224.150.79:8848
# 这个就表示 在我们nacos命名空间id为 dev中 有一个data-id 为 demo-service.yml 的配置文件 读取这个里面的配置
file-extension: yml
config-retry-time: 300000
# 共享配置, 可以把公共配置放在同个命名空间下,然后创建一个 common.yml 文件 ,里面可以放共用的配置
shared-configs[0]:
dataId: yd-common.yml
group: YD_GROUP
refresh: true
extension-configs: # 扩展配置
- data-id: yd-communication-api.yml
group: YD_GROUP
refresh: true
# 发布到注册中心 (如果没有使用可以不配)
discovery:
# 命名空间id(此处不用public,因public初始化的空间, id为空)
namespace: ${spring.cloud.nacos.config.namespace}
# nacos的ip地址和端口
server-addr: ${spring.cloud.nacos.config.server-addr}
group: YD_GROUP
#3.2.1\u4EE5\u4E0A\u4F7F\u7528
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
#3.2.1\u4EE5\u4E0B\u4F7F\u7528\u6216\u8005\u4E0D\u914D\u7F6E
#modulelist=com.p6spy.engine.logging.P6LogFactory,com.p6spy.engine.outage.P6OutageFactory
# \u81EA\u5B9A\u4E49\u65E5\u5FD7\u6253\u5370
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
#\u65E5\u5FD7\u8F93\u51FA\u5230\u63A7\u5236\u53F0
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# \u4F7F\u7528\u65E5\u5FD7\u7CFB\u7EDF\u8BB0\u5F55 sql
#appender=com.p6spy.engine.spy.appender.Slf4JLogger
# \u8BBE\u7F6E p6spy driver \u4EE3\u7406
deregisterdrivers=true
# \u53D6\u6D88JDBC URL\u524D\u7F00
useprefix=true
# \u914D\u7F6E\u8BB0\u5F55 Log \u4F8B\u5916,\u53EF\u53BB\u6389\u7684\u7ED3\u679C\u96C6\u6709error,info,batch,debug,statement,commit,rollback,result,resultset.
excludecategories=info,debug,result,commit,resultset
# \u65E5\u671F\u683C\u5F0F
dateformat=yyyy-MM-dd HH:mm:ss
# \u5B9E\u9645\u9A71\u52A8\u53EF\u591A\u4E2A
#driverlist=org.h2.Driver
# \u662F\u5426\u5F00\u542F\u6162SQL\u8BB0\u5F55
outagedetection=true
# \u6162SQL\u8BB0\u5F55\u6807\u51C6 2 \u79D2
outagedetectioninterval=2
...@@ -24,6 +24,11 @@ ...@@ -24,6 +24,11 @@
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.yd</groupId>
<artifactId>yd-oss-feign</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId> <artifactId>jackson-annotations</artifactId>
</dependency> </dependency>
......
package com.yd.communication.feign.client;
import com.yd.common.result.Result;
import com.yd.communication.feign.fallback.ApiCoDesensitizationRuleFeignFallbackFactory;
import com.yd.communication.feign.response.desensitization.ApiCoDesensitizationRuleResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotBlank;
import java.util.List;
/**
* 通信服务-脱敏信息Feign 客户端
*
* @author zxm
* @date 2026-07-28
*/
@FeignClient(name = "yd-communication-api", path = "/communication/api/desensitization", fallbackFactory = ApiCoDesensitizationRuleFeignFallbackFactory.class)
public interface ApiCoDesensitizationRuleFeignClient {
/**
* 根据业务ID查询单条规则
*/
@GetMapping("/{bizId}")
Result<ApiCoDesensitizationRuleResponse> getByBizId(@PathVariable @NotBlank(message = "业务ID不能为空") String bizId);
/**
* 根据资源类型和资源ID获取生效的脱敏规则列表(供脱敏引擎调用)
*/
@GetMapping("/enabled")
Result<List<ApiCoDesensitizationRuleResponse>> getEnabledRules(
@RequestParam(value = "resourceType",required = false) String resourceType,
@RequestParam(value = "resourceId",required = false) String resourceId);
}
package com.yd.communication.feign.client;
import com.yd.common.result.Result;
import com.yd.communication.feign.fallback.ApiCoSessionFeignFallbackFactory;
import com.yd.communication.feign.request.*;
import com.yd.communication.feign.response.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* 通信服务-协同会话信息 Feign 客户端
*
* @author zxm
* @date 2026-07-28
*/
@FeignClient(name = "yd-communication-api", path = "/communication/api/coSession", fallbackFactory = ApiCoSessionFeignFallbackFactory.class)
public interface ApiCoSessionFeignClient {
/**
* 创建会话
*
* @param request 创建会话请求
* @return 会话信息
*/
@PostMapping("/create")
Result<CreateResponse> create(@Validated @RequestBody CreateRequest request);
/**
* 加入会话
*
* @param request 加入会话请求
* @return 会话详情
*/
@PostMapping("/join")
Result<JoinResponse> join(@Validated @RequestBody JoinRequest request);
/**
* 获取会话状态
* @param request
* @return
*/
@PostMapping("/status")
Result<GetStatusResponse> getStatus(@Validated @RequestBody GetStatusRequest request);
/**
* 获取协同会话详情
*
* @param bizId 会话业务ID
* @return 完整会话信息
*/
@GetMapping("/{bizId}")
Result<SessionDetailResponse> get(@PathVariable("bizId") String bizId);
/**
* 结束协同会话(关闭共享,仅客户可调用)
*
*/
@PostMapping("/end")
Result<CommonResponse> end(@Validated @RequestBody EndSessionRequest request);
/**
* 切换控制权(仅参与者(顾问)可调用)
* @param request
* @return
*/
@PostMapping("/control/transfer")
Result<CommonResponse> transferControl(@Validated @RequestBody TransferControlRequest request);
}
\ No newline at end of file
package com.yd.communication.feign.client;
import com.yd.common.result.Result;
import com.yd.communication.feign.fallback.ApiRecordingTaskFeignFallbackFactory;
import com.yd.communication.feign.request.recording.ApiStartRecordingRequest;
import com.yd.communication.feign.response.recording.ApiQueryRecordingResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 通信服务-录制信息 Feign 客户端
*
* @author zxm
* @date 2026-07-28
*/
@FeignClient(name = "yd-communication-api", path = "/communication/api/recordingTask", fallbackFactory = ApiRecordingTaskFeignFallbackFactory.class)
public interface ApiRecordingTaskFeignClient {
/**
* 开始录制
* @return
*/
@PostMapping("/start")
Result<String> startRecording(@Validated @RequestBody ApiStartRecordingRequest request);
/**
* 停止录制并上传视频
* @param taskId 录制任务ID
* @return
*/
@PostMapping("/stop")
Result<Map<String, String>> stopRecording(@RequestParam("taskId") String taskId);
/**
* 查询录制信息
* @param taskId
* @return
*/
@GetMapping("/query/{taskId}")
Result<ApiQueryRecordingResponse> queryRecording(@PathVariable String taskId);
}
package com.yd.communication.feign.constant;
/**
* redis的key前缀常量
*/
public class RedisConstants {
/**
* 协同房间缓存信息redis前缀
*/
public static final String ROOM_KEY_PREFIX = "room:";
public static final String ROOM_LINK_KEY_PREFIX = "room:link:";
}
package com.yd.communication.feign.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 存储当前房间内的一些缓存字段信息
*/
@Data
public class RoomRedisInfoDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 协同房间号(房间ID)
*/
private String roomId;
/**
* 协同房间密码(输入密码进入房间,协同共享码,进入这个协同房间号的密码)
*/
private String roomPwd;
/**
* 控制权持有者类型:owner(资源所有者类型)/participant(参与者类型)
*/
private String controlHolderType;
/**
* 控制权持有者ID(具体人的ID)
*/
private String controlHolderId;
/**
* 资源所有者的登录用户ID
*/
private String userId;
/**
* 资源所有者的登录token信息
*/
private String token;
/**
* 姓名
*/
private String name;
/**
* 手机号
*/
private String mobile;
}
package com.yd.communication.feign.enums;
/**
* 协同会话状态枚举
*/
public enum CoSessionStatusEnum {
DKS("待开始","1"),
JXZ("进行中","2"),
YJS("已结束","3"),
YCS("已超时","4"),
;
//字典项标签(名称)
private String itemLabel;
//字典项值
private String itemValue;
//构造函数
CoSessionStatusEnum(String itemLabel, String itemValue) {
this.itemLabel = itemLabel;
this.itemValue = itemValue;
}
public String getItemLabel() {
return itemLabel;
}
public String getItemValue() {
return itemValue;
}
}
package com.yd.communication.feign.enums;
/**
* 控制权持有者类型枚举
*/
public enum ControlHolderTypeEnum {
OWNER("资源所有者类型","owner"),
PARTICIPANT("参与者类型","participant"),
;
//字典项标签(名称)
private String itemLabel;
//字典项值
private String itemValue;
//构造函数
ControlHolderTypeEnum(String itemLabel, String itemValue) {
this.itemLabel = itemLabel;
this.itemValue = itemValue;
}
public String getItemLabel() {
return itemLabel;
}
public String getItemValue() {
return itemValue;
}
}
package com.yd.communication.feign.enums;
import com.yd.communication.feign.constant.RedisConstants;
import java.util.concurrent.TimeUnit;
/**
* redis枚举
*/
public enum RedisEnum {
//协同房间缓存信息redis参数
ROOM(RedisConstants.ROOM_KEY_PREFIX,120,TimeUnit.MINUTES),
//房间链接缓存信息redis参数
ROOM_LINK(RedisConstants.ROOM_LINK_KEY_PREFIX,120,TimeUnit.MINUTES),
;
//缓存key前缀
private String prefix;
//缓存过期时长
private Integer timeout;
//缓存过期时长单位
private TimeUnit timeUnit;
RedisEnum(String prefix, Integer timeout, TimeUnit timeUnit) {
this.prefix = prefix;
this.timeout = timeout;
this.timeUnit = timeUnit;
}
public String getPrefix() {
return prefix;
}
public Integer getTimeout() {
return timeout;
}
public TimeUnit getTimeUnit() {
return timeUnit;
}
}
package com.yd.communication.feign.fallback;
import com.yd.common.result.Result;
import com.yd.communication.feign.client.ApiCoDesensitizationRuleFeignClient;
import com.yd.communication.feign.response.desensitization.ApiCoDesensitizationRuleResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 通信服务-脱敏信息Feign降级处理
*/
@Slf4j
@Component
public class ApiCoDesensitizationRuleFeignFallbackFactory implements FallbackFactory<ApiCoDesensitizationRuleFeignClient> {
@Override
public ApiCoDesensitizationRuleFeignClient create(Throwable cause) {
return new ApiCoDesensitizationRuleFeignClient() {
@Override
public Result<ApiCoDesensitizationRuleResponse> getByBizId(String bizId) {
return null;
}
@Override
public Result<List<ApiCoDesensitizationRuleResponse>> getEnabledRules(String resourceType, String resourceId) {
return null;
}
};
}
}
package com.yd.communication.feign.fallback;
import com.yd.common.result.Result;
import com.yd.communication.feign.client.ApiCoSessionFeignClient;
import com.yd.communication.feign.request.*;
import com.yd.communication.feign.response.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* 通信服务-协同会话信息Feign降级处理
*/
@Slf4j
@Component
public class ApiCoSessionFeignFallbackFactory implements FallbackFactory<ApiCoSessionFeignClient> {
@Override
public ApiCoSessionFeignClient create(Throwable cause) {
return new ApiCoSessionFeignClient() {
@Override
public Result<CreateResponse> create(CreateRequest request) {
return null;
}
@Override
public Result<JoinResponse> join(JoinRequest request) {
return null;
}
@Override
public Result<GetStatusResponse> getStatus(GetStatusRequest request) {
return null;
}
@Override
public Result<SessionDetailResponse> get(String bizId) {
return null;
}
@Override
public Result<CommonResponse> end(EndSessionRequest request) {
return null;
}
@Override
public Result<CommonResponse> transferControl(TransferControlRequest request) {
return null;
}
};
}
}
package com.yd.communication.feign.fallback;
import com.yd.common.result.Result;
import com.yd.communication.feign.client.ApiRecordingTaskFeignClient;
import com.yd.communication.feign.request.recording.ApiStartRecordingRequest;
import com.yd.communication.feign.response.recording.ApiQueryRecordingResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 通信服务-录制信息Feign降级处理
*/
@Slf4j
@Component
public class ApiRecordingTaskFeignFallbackFactory implements FallbackFactory<ApiRecordingTaskFeignClient> {
@Override
public ApiRecordingTaskFeignClient create(Throwable cause) {
return new ApiRecordingTaskFeignClient() {
@Override
public Result<String> startRecording(ApiStartRecordingRequest request) {
return null;
}
@Override
public Result<Map<String, String>> stopRecording(String taskId) {
return null;
}
@Override
public Result<ApiQueryRecordingResponse> queryRecording(String taskId) {
return null;
}
};
}
}
package com.yd.communication.feign.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 创建协同会话请求对象
* 客户调用此接口生成共享码,开启协同讲解
*/
@Data
public class CreateRequest {
/**
* 协同作用域
* single: 单个资源(如单份报告、资讯)
* global: 全局协同(如小程序全局协同,切换页面自动跟随)
*/
@NotBlank(message = "协同作用域不能为空")
private String scope;
/**
* 资源类型
* 用于区分不同的业务资源类型
* 可选值:report(报告)、news(资讯)、mini_program(小程序)等
*/
@NotBlank(message = "资源类型不能为空")
private String resourceType;
/**
* 资源业务ID
* 具体资源的唯一标识
* 如:report-报告ID、news-资讯ID、mini_program-小程序应用标识
*/
@NotBlank(message = "资源业务ID不能为空")
private String resourceId;
/**
* 资源初始化JSON串
* 创建会话时记录当前所在页面的完整信息,后续不做修改,用于历史追溯
* 示例:{"url":"https://mini.xxx.com/pages/index/index?userId=xxx"}
*/
@NotBlank(message = "资源初始化JSON串不能为空")
private String resourceInit;
/**
* 资源所有者ID(即客户ID)
* 报告/资源的归属人,通常为发起协同的客户
*/
@NotBlank(message = "资源所有者ID不能为空")
private String ownerId;
/**
* 资源所有者类型
* 默认:customer(客户)
* 可扩展:member(会员)、user(普通用户)等
*/
@NotBlank(message = "资源所有者类型不能为空")
private String ownerType;
/**
* 资源所有者的登录用户ID
*/
@NotBlank(message = "资源所有者的登录用户ID不能为空")
private String userId;
}
\ No newline at end of file
package com.yd.communication.feign.request;
import lombok.Data;
@Data
public class EndSessionRequest {
/**
* 房间号不能为空
*/
private String roomId;
}
package com.yd.communication.feign.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class GetStatusRequest {
/**
* 会话唯一业务ID
*/
@NotBlank(message = "会话唯一业务ID不能为空")
private String sessionBizId;
}
package com.yd.communication.feign.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 加入协同会话请求对象
* 顾问输入房间号和共享码加入协同
*/
@Data
public class JoinRequest {
/**
* 协同房间号(房间ID)
* 客户创建会话时生成的唯一房间标识
* 示例:room_abc12345
*/
private String roomId;
/**
* 协同房间密码(共享码)
* 客户生成共享会话时返回的6位数字密码
* 顾问需要输入此密码才能加入协同
*/
@NotBlank(message = "房间密码不能为空")
private String roomPwd;
/**
* 参与者ID(即顾问ID)
* 加入协同的顾问/专家的唯一标识
*/
@NotBlank(message = "参与者ID不能为空")
private String participantId;
/**
* 参与者类型
* 默认:consultant(顾问)
* 可扩展:expert(专家)、trainer(培训师)等
*/
@NotBlank(message = "参与者类型不能为空")
private String participantType;
}
\ No newline at end of file
package com.yd.communication.feign.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class TransferControlRequest {
/**
* 操作类型:1-开启客户操作 2-关闭客户操作
*/
@NotNull(message = "操作类型不能为空")
private Integer oprType;
/**
* 房间号
*/
@NotBlank(message = "房间号不能为空")
private String roomId;
}
package com.yd.communication.feign.request.http;
import lombok.Data;
@Data
public class GenerateTokenRequest {
/**
* 商城用户ID
*/
private Long sfpUserId;
/**
* 分销用户ID
*/
private Long cffpUserId;
}
package com.yd.communication.feign.request.recording;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class ApiStartRecordingRequest {
/**
* 业务类型:co_session/meeting
*/
@NotBlank(message = "业务类型不能为空")
private String bizType;
/**
* 业务ID(如co_session_biz_id(协同会话表唯一业务ID)、会议ID)
*/
@NotBlank(message = "业务ID不能为空")
private String bizId;
}
package com.yd.communication.feign.request.recording;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class ApiStopRecordingRequest {
/**
* 第三方RTC录制任务ID
*/
@NotBlank(message = "录制任务ID不能为空")
private String taskId;
}
package com.yd.communication.feign.response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 通用操作响应对象
* 用于结束会话、切换控制权等无需返回业务数据的操作
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CommonResponse {
/**
* 操作结果消息
*/
private String message;
}
\ No newline at end of file
package com.yd.communication.feign.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 创建协同会话响应对象
*/
@Data
public class CreateResponse {
/**
* 会话唯一业务ID
*/
private String sessionBizId;
/**
* 房间号
*/
private String roomId;
/**
* 共享码(6位数字)
*/
private String roomPwd;
/**
* 会话状态:0-进行中,1-已结束,2-已超时
*/
private String status;
/**
* 协同房间二维码(扫码进入房间)
*/
private String roomQrCode;
/**
* 协同房间链接(访问链接进入房间)
*/
private String roomLink;
/**
* 链接失效时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")
private Date expirationTime;
}
\ No newline at end of file
package com.yd.communication.feign.response;
import lombok.Data;
@Data
public class GetStatusResponse {
/**
* 1-未失效 2-已失效
*/
private Integer status;
}
package com.yd.communication.feign.response;
import lombok.Data;
/**
* 加入协同会话响应对象
*/
@Data
public class JoinResponse {
/**
* 会话唯一业务ID
*/
private String sessionBizId;
/**
* 房间号
*/
private String roomId;
/**
* 资源初始化JSON(用于初始加载)
*/
private String resourceInit;
/**
* 当前操作页面(实时更新,示例:{url:https://mini.xxx.com/pages/my/index?userId=xxx})
*/
private String currentPage;
/**
* 控制权持有者类型:owner-客户,participant-顾问
*/
private String controlHolderType;
/**
* 控制权持有者ID
*/
private String controlHolderId;
/**
* 资源所有者的登录用户ID
*/
private String userId;
/**
* 资源所有者的登录token信息
*/
private String token;
/**
* 姓名
*/
private String name;
/**
* 手机号
*/
private String mobile;
}
\ No newline at end of file
package com.yd.communication.feign.response;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 协同会话详情响应对象
*/
@Data
public class SessionDetailResponse {
/**
* 数据库主键
*/
private Long id;
/**
* 协同-会话表唯一业务ID
*/
private String coSessionBizId;
/**
* 会话编号
*/
private String coSessionNo;
/**
* 协同作用域:single-单资源,global-全域
*/
private String scope;
/**
* 资源类型:report/news/mini_program
*/
private String resourceType;
/**
* 资源业务ID(如报告ID、小程序标识等)
*/
private String resourceId;
/**
* 资源初始化JSON(创建时页面快照)
*/
private String resourceInit;
/**
* 所有者类型:customer-客户
*/
private String ownerType;
/**
* 所有者ID(客户ID)
*/
private String ownerId;
/**
* 参与者类型:consultant-顾问
*/
private String participantType;
/**
* 参与者ID(顾问ID)
*/
private String participantId;
/**
* 房间号
*/
private String roomId;
/**
* 控制权持有者类型:owner-客户,participant-顾问
*/
private String controlHolderType;
/**
* 控制权持有者ID
*/
private String controlHolderId;
/**
* 会话状态:0-进行中,1-已结束,2-已超时
*/
private String status;
/**
* 会话开始时间
*/
private LocalDateTime startTime;
/**
* 会话结束时间
*/
private LocalDateTime endTime;
/**
* 当前页面JSON(实时更新)
*/
private String currentPage;
/**
* 页面访问历史轨迹(JSON数组)
*/
private String pageHistory;
}
\ No newline at end of file
package com.yd.communication.feign.response.desensitization;
import lombok.Data;
@Data
public class ApiCoDesensitizationRuleResponse {
/**
* 主键
*/
private Long id;
/**
* 脱敏规则唯一业务ID
*/
private String ruleBizId;
/**
* 规则名称(如:报告-客户姓名脱敏)
*/
private String ruleName;
/**
* 资源类型:report/news/mini_program
*/
private String resourceType;
/**
* 资源业务ID(NULL表示全局规则,适用于该类型下所有资源)
*/
private String resourceId;
/**
* 脱敏字段路径(JSON Path,如:$.customer.name)
*/
private String fieldPath;
/**
* 字段显示名称(冗余,便于运营配置)
*/
private String fieldName;
/**
* 脱敏方式:mask-掩码(如:张**)、hide-完全隐藏(****)、replace-替换(***)、partial-部分显示(如:138****8000)
*/
private String maskType;
/**
* 脱敏配置JSON(如:{"prefix":1,"suffix":1,"replace_char":"*"})
*/
private String maskConfig;
/**
* 规则是否启用:0-停用,1-启用
*/
private Integer enabled;
/**
* 是否为默认规则:0-否,1-是(资源未配置时使用默认)
*/
private Integer isDefault;
/**
* 排序顺序
*/
private Integer sortOrder;
}
package com.yd.communication.feign.response.http;
import lombok.Data;
@Data
public class GenerateTokenResponse {
/**
* SFP的token
*/
private String token;
/**
* SFP商城用户ID
*/
private Long sfpUserId;
/**
* SFP姓名
*/
private String name;
/**
* SFP手机号
*/
private String mobile;
/**
* CFFP分销用户ID
*/
private Long cffpUserId;
}
package com.yd.communication.feign.response.recording;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ApiQueryRecordingResponse {
/**
* 主键
*/
private Long id;
/**
* 录制任务表唯一业务ID
*/
private String recordingTaskBizId;
/**
* 任务编号
*/
private String taskNo;
/**
* 第三方RTC录制任务ID
*/
private String taskId;
/**
* 业务类型:co_session/meeting
*/
private String bizType;
/**
* 业务ID(如co_session_biz_id(协同会话表唯一业务ID)、会议ID)
*/
private String bizId;
/**
* RTC房间ID
*/
private String roomId;
/**
* RTC频道名
*/
private String channel;
/**
* 录制模式:mix-混合, single-单流, screen-屏幕
*/
private String recordingMode;
/**
* 布局:grid/speaker/custom
*/
private String layout;
/**
* 1-初始化,2-录制中,3-已停止,4-失败,5-已归档
*/
private String status;
/**
* 录制开始时间
*/
private LocalDateTime startTime;
/**
* 录制结束时间
*/
private LocalDateTime stopTime;
/**
* 文件地址
*/
private String fileUrl;
/**
* 视频时长(秒)
*/
private Integer fileDuration;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* 文件MD5
*/
private String fileMd5;
/**
* 文件格式
*/
private String fileFormat;
/**
* 回放地址(带鉴权)
*/
private String playbackUrl;
/**
* 存储类型:oss/cos/minio/local
*/
private String storageType;
/**
* 存储桶
*/
private String storageBucket;
/**
* 存储路径
*/
private String storagePath;
/**
* 错误信息
*/
private String errorMsg;
/**
* 扩展信息
*/
private String extInfo;
/**
* 通用备注
*/
private String remark;
/**
* 删除标识: 0-正常, 1-删除
*/
private Integer isDeleted;
/**
* 创建人ID
*/
private String creatorId;
/**
* 更新人ID
*/
private String updaterId;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
...@@ -50,6 +50,12 @@ ...@@ -50,6 +50,12 @@
<artifactId>freemarker</artifactId> <artifactId>freemarker</artifactId>
</dependency> </dependency>
<!-- Spring Boot Starter WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.yd</groupId> <groupId>com.yd</groupId>
<artifactId>yd-communication-feign</artifactId> <artifactId>yd-communication-feign</artifactId>
...@@ -67,6 +73,29 @@ ...@@ -67,6 +73,29 @@
<artifactId>yd-framework</artifactId> <artifactId>yd-framework</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
</dependency>
<!-- &lt;!&ndash; 阿里云 Java SDK 核心库 &ndash;&gt;-->
<!-- <dependency>-->
<!-- <groupId>com.aliyun</groupId>-->
<!-- <artifactId>aliyun-java-sdk-core</artifactId>-->
<!-- <version>4.6.3</version>-->
<!-- </dependency>-->
<!-- &lt;!&ndash; 阿里云视频直播 SDK(包含 RTC 云端录制 API) &ndash;&gt;-->
<!-- <dependency>-->
<!-- <groupId>com.aliyun</groupId>-->
<!-- <artifactId>aliyun-java-sdk-live</artifactId>-->
<!-- <version>3.9.76</version>-->
<!-- </dependency>-->
</dependencies> </dependencies>
</project> </project>
\ No newline at end of file
...@@ -2,6 +2,10 @@ package com.yd.communication.service.dao; ...@@ -2,6 +2,10 @@ package com.yd.communication.service.dao;
import com.yd.communication.service.model.CoSession; import com.yd.communication.service.model.CoSession;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/** /**
* <p> * <p>
...@@ -13,4 +17,14 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; ...@@ -13,4 +17,14 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
*/ */
public interface CoSessionMapper extends BaseMapper<CoSession> { public interface CoSessionMapper extends BaseMapper<CoSession> {
@Update("UPDATE co_session SET status = #{status}, end_time = #{endTime} WHERE room_id = #{roomId}")
int updateStatusAndEndTimeByRoomId(@Param("roomId") String roomId,
@Param("status") Integer status,
@Param("endTime") LocalDateTime endTime);
@Update("UPDATE co_session SET current_page = #{currentPage}, page_history = #{pageHistory}, updater_id = #{updaterId} WHERE room_id = #{roomId}")
int updateCurrentPageAndHistory(@Param("roomId") String roomId,
@Param("currentPage") String currentPage,
@Param("pageHistory") String pageHistory,
@Param("updaterId") String updaterId);
} }
...@@ -97,12 +97,24 @@ public class CoSession implements Serializable { ...@@ -97,12 +97,24 @@ public class CoSession implements Serializable {
private String roomId; private String roomId;
/** /**
* 协同房间密码(协同共享码,进入这个协同房间号的密码) * 协同房间密码(输入密码进入房间,协同共享码,进入这个协同房间号的密码)
*/ */
@TableField("room_pwd") @TableField("room_pwd")
private String roomPwd; private String roomPwd;
/** /**
* 协同房间二维码(扫码进入房间)
*/
@TableField("room_qr_code")
private String roomQrCode;
/**
* 协同房间链接(访问链接进入房间)
*/
@TableField("room_link")
private String roomLink;
/**
* RTC频道前缀,便于区分业务 * RTC频道前缀,便于区分业务
*/ */
@TableField("channel_prefix") @TableField("channel_prefix")
...@@ -121,10 +133,10 @@ public class CoSession implements Serializable { ...@@ -121,10 +133,10 @@ public class CoSession implements Serializable {
private String controlHolderId; private String controlHolderId;
/** /**
* 0-进行中,1-已结束,2-已超时 * 1-待开始,2-进行中,3-已结束,4-已超时
*/ */
@TableField("status") @TableField("status")
private Integer status; private String status;
/** /**
* 开始时间 * 开始时间
...@@ -185,4 +197,5 @@ public class CoSession implements Serializable { ...@@ -185,4 +197,5 @@ public class CoSession implements Serializable {
*/ */
@TableField("update_time") @TableField("update_time")
private LocalDateTime updateTime; private LocalDateTime updateTime;
} }
...@@ -49,7 +49,7 @@ public class RecordingTask implements Serializable { ...@@ -49,7 +49,7 @@ public class RecordingTask implements Serializable {
private String taskId; private String taskId;
/** /**
* 业务类型:co-session/meeting * 业务类型:co_session/meeting
*/ */
@TableField("biz_type") @TableField("biz_type")
private String bizType; private String bizType;
...@@ -85,10 +85,10 @@ public class RecordingTask implements Serializable { ...@@ -85,10 +85,10 @@ public class RecordingTask implements Serializable {
private String layout; private String layout;
/** /**
* 0-初始化,1-录制中,2-已停止,3-失败,4-已归档 * 1-初始化,2-录制中,3-已停止,4-失败,5-已归档
*/ */
@TableField("status") @TableField("status")
private Integer status; private String status;
/** /**
* 录制开始时间 * 录制开始时间
......
package com.yd.communication.service.service; package com.yd.communication.service.service;
import com.yd.communication.service.model.CoDesensitizationRule; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.yd.communication.service.model.CoDesensitizationRule;
import java.util.List;
/** /**
* <p> * 协同-脱敏设置表 服务类
* 协同-脱敏设置表(通用) 服务类
* </p>
*
* @author zxm
* @since 2026-07-28
*/ */
public interface ICoDesensitizationRuleService extends IService<CoDesensitizationRule> { public interface ICoDesensitizationRuleService extends IService<CoDesensitizationRule> {
} /**
* 分页查询脱敏规则列表
*/
IPage<CoDesensitizationRule> pageList(Page<CoDesensitizationRule> page, String resourceType, String resourceId, Integer enabled);
/**
* 根据资源类型和资源ID获取生效的脱敏规则列表
*/
List<CoDesensitizationRule> getEnabledRulesByResource(String resourceType, String resourceId);
/**
* 启用/停用规则
*/
void toggleEnabled(Long id, Integer enabled);
/**
* 逻辑删除规则
*/
void deleteById(Long id);
}
\ No newline at end of file
...@@ -13,4 +13,7 @@ import com.baomidou.mybatisplus.extension.service.IService; ...@@ -13,4 +13,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
*/ */
public interface ICoOperationLogService extends IService<CoOperationLog> { public interface ICoOperationLogService extends IService<CoOperationLog> {
void log(String bizId, String operatorId, String operatorType,
String operatorName, String action, String content,
String deviceNumber, String ip);
} }
...@@ -2,6 +2,7 @@ package com.yd.communication.service.service; ...@@ -2,6 +2,7 @@ package com.yd.communication.service.service;
import com.yd.communication.service.model.CoSession; import com.yd.communication.service.model.CoSession;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import org.springframework.transaction.annotation.Transactional;
/** /**
* <p> * <p>
...@@ -13,4 +14,15 @@ import com.baomidou.mybatisplus.extension.service.IService; ...@@ -13,4 +14,15 @@ import com.baomidou.mybatisplus.extension.service.IService;
*/ */
public interface ICoSessionService extends IService<CoSession> { public interface ICoSessionService extends IService<CoSession> {
/**
* 根据房间ID获取会话(WebSocket 使用)
*/
CoSession getByRoomId(String roomId);
/**
* 根据业务ID获取会话
*/
CoSession getByBizId(String bizId);
int updateCurrentPageAndHistory(String roomId, String currentPage, String pageHistory, String updaterId);
} }
...@@ -13,4 +13,7 @@ import com.baomidou.mybatisplus.extension.service.IService; ...@@ -13,4 +13,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
*/ */
public interface IRecordingTaskService extends IService<RecordingTask> { public interface IRecordingTaskService extends IService<RecordingTask> {
String startRecording(String bizId, String roomId);
void stopRecording(String taskId);
} }
package com.yd.communication.service.service.impl; package com.yd.communication.service.service.impl;
import com.yd.communication.service.model.CoDesensitizationRule; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yd.common.enums.CommonEnum;
import com.yd.common.exception.BusinessException;
import com.yd.common.utils.RandomStringGenerator;
import com.yd.communication.service.dao.CoDesensitizationRuleMapper; import com.yd.communication.service.dao.CoDesensitizationRuleMapper;
import com.yd.communication.service.model.CoDesensitizationRule;
import com.yd.communication.service.service.ICoDesensitizationRuleService; import com.yd.communication.service.service.ICoDesensitizationRuleService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/** @Slf4j
* <p>
* 协同-脱敏设置表(通用) 服务实现类
* </p>
*
* @author zxm
* @since 2026-07-28
*/
@Service @Service
public class CoDesensitizationRuleServiceImpl extends ServiceImpl<CoDesensitizationRuleMapper, CoDesensitizationRule> implements ICoDesensitizationRuleService { public class CoDesensitizationRuleServiceImpl
extends ServiceImpl<CoDesensitizationRuleMapper, CoDesensitizationRule>
implements ICoDesensitizationRuleService {
@Override
public IPage<CoDesensitizationRule> pageList(Page<CoDesensitizationRule> page,
String resourceType,
String resourceId,
Integer enabled) {
LambdaQueryWrapper<CoDesensitizationRule> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoDesensitizationRule::getIsDeleted, 0);
if (StringUtils.isNotBlank(resourceType)) {
wrapper.eq(CoDesensitizationRule::getResourceType, resourceType);
}
if (StringUtils.isNotBlank(resourceId)) {
wrapper.eq(CoDesensitizationRule::getResourceId, resourceId);
}
if (enabled != null) {
wrapper.eq(CoDesensitizationRule::getEnabled, enabled);
}
wrapper.orderByAsc(CoDesensitizationRule::getSortOrder)
.orderByDesc(CoDesensitizationRule::getCreateTime);
return this.page(page, wrapper);
}
@Override
public List<CoDesensitizationRule> getEnabledRulesByResource(String resourceType, String resourceId) {
LambdaQueryWrapper<CoDesensitizationRule> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoDesensitizationRule::getIsDeleted, 0)
.eq(CoDesensitizationRule::getEnabled, 1)
.eq(StringUtils.isNotBlank(resourceType),CoDesensitizationRule::getResourceType, resourceType)
.eq(StringUtils.isNotBlank(resourceId),CoDesensitizationRule::getResourceId,resourceId)
.orderByAsc(CoDesensitizationRule::getSortOrder);
return this.list(wrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void toggleEnabled(Long id, Integer enabled) {
CoDesensitizationRule rule = this.getById(id);
if (rule == null || rule.getIsDeleted() == 1) {
throw new BusinessException("规则不存在或已删除");
}
rule.setEnabled(enabled);
rule.setUpdaterId("system");
rule.setUpdateTime(LocalDateTime.now());
this.updateById(rule);
log.info("脱敏规则 {} 状态已切换为: {}", id, enabled == 1 ? "启用" : "停用");
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteById(Long id) {
CoDesensitizationRule rule = this.getById(id);
if (rule == null || rule.getIsDeleted() == 1) {
throw new BusinessException("规则不存在或已删除");
}
rule.setIsDeleted(1);
rule.setUpdaterId("system");
rule.setUpdateTime(LocalDateTime.now());
this.updateById(rule);
log.info("脱敏规则 {} 已逻辑删除", id);
}
} /**
* 保存或更新前的公共逻辑
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveOrUpdate(CoDesensitizationRule entity) {
if (entity == null) {
return false;
}
if (entity.getId() == null) {
// 新增
entity.setRuleBizId(RandomStringGenerator.generateBizId16(CommonEnum.UID_TYPE_DESENSITIZATION_RULE.getCode()));
entity.setIsDeleted(0);
entity.setCreateTime(LocalDateTime.now());
entity.setUpdateTime(LocalDateTime.now());
if (StringUtils.isBlank(entity.getCreatorId())) {
entity.setCreatorId("system");
}
if (StringUtils.isBlank(entity.getUpdaterId())) {
entity.setUpdaterId("system");
}
// 默认启用
if (entity.getEnabled() == null) {
entity.setEnabled(1);
}
} else {
// 更新
CoDesensitizationRule exist = this.getById(entity.getId());
if (exist == null || exist.getIsDeleted() == 1) {
throw new BusinessException("规则不存在或已删除");
}
entity.setUpdateTime(LocalDateTime.now());
if (StringUtils.isBlank(entity.getUpdaterId())) {
entity.setUpdaterId("system");
}
// 不允许修改 ruleBizId 和 isDeleted
entity.setRuleBizId(null);
entity.setIsDeleted(null);
entity.setCreateTime(null);
}
return super.saveOrUpdate(entity);
}
}
\ No newline at end of file
...@@ -4,6 +4,8 @@ import com.yd.communication.service.model.CoOperationLog; ...@@ -4,6 +4,8 @@ import com.yd.communication.service.model.CoOperationLog;
import com.yd.communication.service.dao.CoOperationLogMapper; import com.yd.communication.service.dao.CoOperationLogMapper;
import com.yd.communication.service.service.ICoOperationLogService; import com.yd.communication.service.service.ICoOperationLogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
...@@ -14,7 +16,29 @@ import org.springframework.stereotype.Service; ...@@ -14,7 +16,29 @@ import org.springframework.stereotype.Service;
* @author zxm * @author zxm
* @since 2026-07-28 * @since 2026-07-28
*/ */
@Slf4j
@Service @Service
public class CoOperationLogServiceImpl extends ServiceImpl<CoOperationLogMapper, CoOperationLog> implements ICoOperationLogService { public class CoOperationLogServiceImpl extends ServiceImpl<CoOperationLogMapper, CoOperationLog> implements ICoOperationLogService {
@Override
@Async("communicationExecutor")
public void log(String bizId, String operatorId, String operatorType,
String operatorName, String action, String content,
String deviceNumber, String ip) {
CoOperationLog log = new CoOperationLog();
log.setBizType("co_session");
log.setBizId(bizId);
log.setOperatorId(operatorId);
log.setOperatorType(operatorType);
log.setOperatorName(operatorName);
log.setAction(action);
log.setActionCategory("control");
log.setContent(content);
log.setOperatorDeviceNumber(deviceNumber);
log.setOperatorIp(ip);
log.setCreatorId(operatorId);
log.setUpdaterId(operatorId);
this.save(log);
}
} }
...@@ -2,9 +2,11 @@ package com.yd.communication.service.service.impl; ...@@ -2,9 +2,11 @@ package com.yd.communication.service.service.impl;
import com.yd.communication.service.model.CoSession; import com.yd.communication.service.model.CoSession;
import com.yd.communication.service.dao.CoSessionMapper; import com.yd.communication.service.dao.CoSessionMapper;
import com.yd.communication.service.service.ICoSessionService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yd.communication.service.service.ICoSessionService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
/** /**
* <p> * <p>
...@@ -15,6 +17,34 @@ import org.springframework.stereotype.Service; ...@@ -15,6 +17,34 @@ import org.springframework.stereotype.Service;
* @since 2026-07-28 * @since 2026-07-28
*/ */
@Service @Service
@Slf4j
public class CoSessionServiceImpl extends ServiceImpl<CoSessionMapper, CoSession> implements ICoSessionService { public class CoSessionServiceImpl extends ServiceImpl<CoSessionMapper, CoSession> implements ICoSessionService {
/**
* 根据房间ID获取会话(WebSocket 使用)
*/
@Override
public CoSession getByRoomId(String roomId) {
LambdaQueryWrapper<CoSession> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CoSession::getRoomId, roomId)
.eq(CoSession::getIsDeleted, 0);
return this.getOne(wrapper);
}
/**
* 根据业务ID获取会话
*/
@Override
public CoSession getByBizId(String bizId) {
return this.lambdaQuery()
.eq(CoSession::getCoSessionBizId,bizId)
.last(" limit 1 ")
.one();
}
@Override
public int updateCurrentPageAndHistory(String roomId,String currentPage,String pageHistory,String updaterId){
return baseMapper.updateCurrentPageAndHistory(roomId,currentPage,pageHistory,updaterId);
}
} }
package com.yd.communication.service.service.impl; package com.yd.communication.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yd.common.enums.CommonEnum;
import com.yd.common.exception.BusinessException;
import com.yd.common.utils.RandomStringGenerator;
import com.yd.communication.service.model.RecordingTask; import com.yd.communication.service.model.RecordingTask;
import com.yd.communication.service.dao.RecordingTaskMapper; import com.yd.communication.service.dao.RecordingTaskMapper;
import com.yd.communication.service.service.IRecordingTaskService; import com.yd.communication.service.service.IRecordingTaskService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* <p> * <p>
...@@ -14,7 +21,72 @@ import org.springframework.stereotype.Service; ...@@ -14,7 +21,72 @@ import org.springframework.stereotype.Service;
* @author zxm * @author zxm
* @since 2026-07-28 * @since 2026-07-28
*/ */
@Slf4j
@Service @Service
public class RecordingTaskServiceImpl extends ServiceImpl<RecordingTaskMapper, RecordingTask> implements IRecordingTaskService { public class RecordingTaskServiceImpl extends ServiceImpl<RecordingTaskMapper, RecordingTask> implements IRecordingTaskService {
/**
* 初始化录制信息(协同生成共享码的时候就初始化信息)
* @param bizId
* @param roomId
* @return
*/
@Override
public String startRecording(String bizId, String roomId) {
//任务ID
String taskId = "agora_" + System.currentTimeMillis();
RecordingTask task = new RecordingTask();
//录制任务表唯一业务ID
task.setRecordingTaskBizId(RandomStringGenerator.generateBizId16(CommonEnum.UID_TYPE_RECORDING_TASK.getCode()));
//任务编号
task.setTaskNo("R" + System.currentTimeMillis());
task.setTaskId(taskId);
//关联的任务类型: 协同会话
task.setBizType("co_session");
//关联的任务类型表的ID: 协同会话表唯一业务ID
task.setBizId(bizId);
//房间号
task.setRoomId(roomId);
//RTC频道名
task.setChannel("channel_" + roomId);
//录制模式
task.setRecordingMode("mix");
//布局
task.setLayout("grid");
//1-初始化
task.setStatus("1");
task.setStartTime(LocalDateTime.now());
task.setCreatorId("system");
this.save(task);
log.info("启动录制成功, taskId={}, roomId={}", taskId, roomId);
return taskId;
}
@Override
public void stopRecording(String taskId) {
LambdaQueryWrapper<RecordingTask> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RecordingTask::getTaskId, taskId);
RecordingTask task = this.getOne(wrapper);
if (task == null) {
throw new BusinessException("录制任务不存在");
}
// 模拟停止录制
task.setStatus("3");
task.setStopTime(LocalDateTime.now());
task.setFileUrl("https://oss.example.com/recordings/" + taskId + ".mp4");
task.setFileDuration(120);
task.setFileSize(1024000L);
task.setFileMd5(UUID.randomUUID().toString().substring(0, 32));
task.setFileFormat("mp4");
task.setStorageType("oss");
task.setStorageBucket("coordination-recordings");
task.setStoragePath("/recordings/" + taskId + ".mp4");
task.setUpdaterId("system");
this.updateById(task);
log.info("停止录制成功, taskId={}", taskId);
}
} }
package com.yd.communication.service.utils;
import java.security.SecureRandom;
public class RandomUtil {
private static final SecureRandom random = new SecureRandom();
public static String generateNumericCode(int length) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(random.nextInt(10));
}
return sb.toString();
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment