对接腾讯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='通话记录表';

 

posted @ 2025-08-26 11:52  Fyy发大财  阅读(3)  评论(0)    收藏  举报