对接腾讯ai通话回调,包括房间回调和AI任务回调
参考文档:
ai对话回调
https://cloud.tencent.com/document/product/647/115502
房间回调
https://cloud.tencent.com/document/product/647/51586
/** * 通话时长 */ @Service @Slf4j public class AiRoomCallbackService { @Autowired private TencentSecretConfig tencentSecretConfig; @Autowired private CallRecordRepo callRecordRepo; // 存储会话ID与开始时间的映射(线程安全) @Autowired private RedisUtil redisUtil; @Autowired private AiMessageService aiMessageService; private static final String SESSION_START_TIME_REDIS_KEY = "AIVIDEO:SESSIONSTARTTIME:"; private static final String ENTER_ROOM_SUFFIX_KEY = "ENTER_ROOM"; private static final String CREATE_ROOM_SUFFIX_KEY = "CREATE_ROOM"; private static final String CREATE_ROOM_REDIS_KEY = "AIVIDEO:CREATE_ROOM:"; public R aiCallback(String sign, // 腾讯云请求头中的签名 String sdkAppId, // 应用ID byte[] rawBody ) // 原始请求体 { try { log.info("收到的sdkAppId:{}",sdkAppId); if (!StringUtils.equals(sdkAppId, tencentSecretConfig.getSdkAiAppId())){ log.info("sdkAppId:{} 不匹配",sdkAppId); return R.ok().setCode(0); } String bodyStr = new String(rawBody, StandardCharsets.UTF_8); log.info("收到的body信息:{}",bodyStr); //验签 boolean signValid = verifySign(rawBody, sign, tencentSecretConfig.getCallbackKey()); if (!signValid) { log.info("签名验证失败"); //签名验证失败 return R.ok().setCode(0); } //解析请求体(根据腾讯云回调事件格式处理) JSONObject callbackData = JSONObject.parseObject(bodyStr); if (callbackData.isEmpty()){ log.info("请求体解析失败"); return R.ok().setCode(0); } // String eventGroupId = callbackData.getString("EventGroupId"); // if (!StringUtils.equals(eventGroupId,"1")){ // log.info("过滤ai通话回调事件"); // return R.ok().setCode(0); // } Integer eventType = callbackData.getInteger("EventType"); String eventInfoStr = callbackData.getString("EventInfo"); EventInfo eventInfo = JSONObject.parseObject(eventInfoStr, EventInfo.class); log.info("收到的eventInfo信息:{}",eventInfo); //处理回调事件(包含时长计算逻辑) handleCallback(eventType,eventInfo); // sessionStartTimeMap.remove(buildSessionId(eventInfo.getUserId(), eventInfo.getRoomId(), eventInfo.getEventTs())); //返回成功响应(腾讯云要求必须返回JSON格式的成功响应) return R.ok().setCode(0); } catch (Exception e) { // 异常处理:返回错误响应 return R.error(e.getMessage()); } } /** * 签名验证方法(遵循腾讯云签名规则) * 签名算法:HMAC-SHA256(rawBody, callbackKey) → Base64编码 → 与请求头的Sign对比 */ private boolean verifySign(byte[] rawBody, String receivedSign, String key) throws Exception { // 1. 初始化HMAC-SHA256算法 Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(secretKey); // 2. 计算签名 byte[] signatureBytes = mac.doFinal(rawBody); String calculatedSign = Base64.getEncoder().encodeToString(signatureBytes); // 3. 对比签名(注意:需使用常量时间比较,防止时序攻击) return MessageDigest.isEqual(calculatedSign.getBytes(), receivedSign.getBytes()); } /** * 处理回调事件 * * @param eventType 事件类型枚举 * @param eventInfo */ public void handleCallback(Integer eventType, EventInfo eventInfo) { RoomEventType roomEventType = RoomEventType.fromValue(eventType); if (roomEventType == null){ log.error("无效的eventType:{}", eventType); return; } if (eventInfo == null) { log.error("回调数据EventInfo为空"); return; } log.info("handleCallback start...,eventType:{},callbackData:{}", eventType, eventInfo); // 从回调数据中提取会话ID(根据腾讯云实际字段调整) // 提取事件时间戳(毫秒) Long eventMsTs = eventInfo.getEventMsTs(); //用户id String userId = eventInfo.getUserId(); //房间 ID String roomId = eventInfo.getRoomId(); String sessionId = buildSessionId(userId, roomId,eventType); if (redisUtil.exists(sessionId)){ log.info("事件重复,已过滤"); return; } //事件发生事件 单位s log.info("事件{}回调时间戳为:{},eventInfo:{}", roomEventType.getValue(), roomEventType, eventMsTs); String session = redisUtil.get(sessionId); switch (roomEventType) { case AI_TASK_END: //记录聊天记录,主要是推送环信消息并记录语音通话的内容 log.info("收到EventType=903消息,开始处理AI消息"); aiMessageService.handleEventType903(eventInfo); return; //不会重复 case EVENT_TYPE_CREATE_ROOM: log.info("创建EVENT_TYPE_CREATE_ROOM事件"); String createRoomKey = buildCreateRoomKey(userId, roomId, eventType); log.info("createRoomKey is :{}", createRoomKey); redisUtil.setex(createRoomKey, eventMsTs.toString(), 120); break; //可能重复 case EVENT_TYPE_ENTER_ROOM: log.info("进入ENTER_ROOM事件"); //处理开始事件,记录开始时间 handleStartEvent(sessionId, eventMsTs); log.info("ENTER_ROOM sessionStartTimeMap {}",session); break; //可能重复 case EVENT_TYPE_EXIT_ROOM: log.info("进入EXIT_ROOM事件,sessionStartTimeMap is {}",session); String lockValue = UUID.randomUUID().toString(); // 唯一值防止误删 String lockKey = "user:leave:lock:" + roomId; // 以频道ID作为锁键 try { // 获取分布式锁(设置5秒超时避免死锁) boolean locked = redisUtil.setNX(lockKey, lockValue) == 1; redisUtil.expire(lockKey, 5); if (!locked) { log.warn("Failed to acquire lock for channel {}, skipping: {}", roomId, lockKey); return; } handleExitRoomEvent(userId,roomId,eventInfo, eventMsTs); }catch (Exception e){ log.error("处理EXIT_ROOM事件失败: {}", e.getMessage(), e); throw e; // 重新抛出异常 }finally { // 安全释放锁 String storedValue = redisUtil.get(lockKey); if (lockValue.equals(storedValue)) { redisUtil.delStr(lockKey); log.info("Lock released for channel: {}", roomId); } } break; } } public String buildCreateRoomKey(String userId, String roomId,Integer eventType) { String sessionId = userId + roomId + CREATE_ROOM_SUFFIX_KEY+ eventType; return CREATE_ROOM_REDIS_KEY+sessionId; } public String buildSessionId(String userId, String roomId,Integer eventType) { String sessionId = userId + roomId + ENTER_ROOM_SUFFIX_KEY+ eventType; return SESSION_START_TIME_REDIS_KEY+sessionId; } public void handleStartEvent(String sessionId, Long eventMsTs) { log.info("handleStartEvent: sessionId={}, eventMsTs={}", sessionId, eventMsTs); redisUtil.setex(sessionId, eventMsTs.toString(), 120); } public void handleExitRoomEvent(String userId,String roomId,EventInfo eventInfo, Long eventMsTs) { //roomId拼接 agent.getShortId() + "-" + userDetail.getGagaNo() + "-" + new Date().getTime(); String[] roomIdArr = roomId.split("-"); //接听方 智能体 String agentNo = roomIdArr[0]; //发起方 真人 String gagaNo = roomIdArr[1]; Integer reason = eventInfo.getReason(); String leaveRoomSessionId = buildSessionId(userId, roomId,103); String leaveRoomSession = redisUtil.get(leaveRoomSessionId); String createRoomKey = buildCreateRoomKey(userId, roomId, 101); if (!redisUtil.exists(createRoomKey)){ log.info("用户{}创建房间{}失败,请检查",userId,roomId); return; } String createRoomSession = redisUtil.get(createRoomKey); log.info("leaveRoomSession is {},createRoomSession is {}",leaveRoomSession,createRoomSession); Long creatRoomTime = Long.parseLong(createRoomSession); Long startTime = Long.parseLong(leaveRoomSession); long durationSeconds = eventMsTs - startTime; long durationMines = (long) Math.ceil(durationSeconds / 1000.0); // 秒数向上取整 CallRecord callRecord = new CallRecord(); callRecord.setCallerMember(gagaNo); callRecord.setAnswerMember(agentNo); callRecord.setDurationSeconds(durationMines); callRecord.setStartTimestamps(startTime); callRecord.setEndTimestamps(eventMsTs); callRecord.setChannelCtime(creatRoomTime); //分钟向上取整 long billableDuration = AgoraServiceImpl.calculateBillableDuration(durationMines); callRecord.setDurationMinutes(billableDuration); callRecord.setEndCallReason(reason.toString()); callRecord.setChannelName(roomId); callRecord.setCallType("2"); CallRecord save = callRecordRepo.save(callRecord); log.info("保存通话记录成功,CallRecord:{}",save); redisUtil.delStr(createRoomKey); redisUtil.delStr(leaveRoomSessionId); } }
实现功能主要是监听房间回调
计算真人和ai的通话时长
call_record表结构
-- mixmix.call_record definition CREATE TABLE `call_record` ( `call_id` bigint(20) NOT NULL AUTO_INCREMENT, `channel_name` varchar(100) NOT NULL COMMENT '频道名称', `duration_seconds` bigint(20) NOT NULL COMMENT '实际通话时长(s)', `duration_minutes` bigint(20) DEFAULT NULL COMMENT '通话时长(分钟)', `start_time` bigint(20) NOT NULL COMMENT '通话开始时间戳(毫秒)', `end_time` bigint(20) NOT NULL COMMENT '通话结束时间戳(毫秒)', `version` int(11) NOT NULL DEFAULT '1' COMMENT '版本号', `end_call_reason` char(1) DEFAULT NULL COMMENT 'NORMAL_LEAVE(1, "主播正常离开频道"),\r\n CONNECTION_TIMEOUT(2, "客户端与声网业务服务器连接超时"),\r\n PERMISSION_ISSUE(3, "权限问题"),\r\n SERVER_ADJUSTMENT(4, "声网业务服务器因负载调整"),\r\n DEVICE_CHANGE(5, "主播切换新设备"),\r\n MULTIPLE_IP_ADDRESSES(9, "由于客户端有多个 IP 地址"),\r\n NETWORK_CONNECTION_ISSUE(10, "由于网络连接问题"),\r\n TOKEN_ERROR(12, "Token 错误或者过期"),\r\n UNKNOWN_NETWORK_ISSUE(99, "SDK 因未知的网络问题与声网业务服务器断开连接"),\r\n ABNORMAL_USER(999, "异常用户");', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `channel_ctime` bigint(20) NOT NULL COMMENT '频道创建时间', `caller_member` varchar(100) NOT NULL COMMENT '发起人', `answer_member` varchar(100) DEFAULT NULL COMMENT '接听人', `call_type` char(1) DEFAULT '1' COMMENT '1-真人通话 2-ai通话', PRIMARY KEY (`call_id`) ) ENGINE=InnoDB AUTO_INCREMENT=67 DEFAULT CHARSET=utf8mb4 COMMENT='通话记录表';
浙公网安备 33010602011771号