对接声网(环信)rtc视频通话sdk,webhook回调实现步骤
1.注册声网账号,完成后一个月免费赠送1万分钟通话时长 https://console.shengwang.cn/packages/minPackage?packageProduct=rtc&type=subscribe

2.开通完成后会生成一个APPID 和APP证书,声网提供 App 证书用以生成 Token。若同时存在无证书和主要/次要证书,则你既可以仅使用 App ID 鉴权,也可以使用主要/次要证书生成的 Token 鉴权。
3.https://gitee.com/agoraio-community/API-Examples-Web 下载web端demo ;根据自己的技术栈,也可以下载其它版本的demo。

下载完成之后,vscode打开项目,运行前端项目,可以选择不同的版本,随便进入到标记的文件夹 执行 npm install,执行完install就可以运行了 npm run dev;

访问页面就长这个样子

4.生成token
里面要填一个token 这个token正常是在服务端生成的,参考https://doc.shengwang.cn/doc/rtc/android/basic-features/token-authentication;声网也提供了快速生成测试token的入口如图:

把生成的token appid填入本地启动的demo中,channel name就是创建的房间号 这里随便填。
5.后续 会实现 第三方app 1v1通话,生成通话记录。
6.上代码
/** * 接收声网 Webhook 通知的主接口 * @param requestBody 原始请求体(未反序列化的字节数组) * @param signatureV1 HMAC-SHA1 签名(请求头 Agora-Signature) * @param signatureV2 HMAC-SHA256 签名(请求头 Agora-Signature-V2) * @return 符合要求的响应 */ @PostMapping("/webhook") public R handleWebhook( @RequestBody(required = false) byte[] requestBody, // 原始字节数组 @RequestHeader(value = "Agora-Signature", required = false) String signatureV1, @RequestHeader(value = "Agora-Signature-V2", required = false) String signatureV2) { return agoraService.handleWebhook(requestBody, signatureV1, signatureV2); }
@Slf4j @Service public class AgoraServiceImpl implements AgoraService { //配置中心配置 todo private static final String APP_ID = "xx"; private static final String APP_CERTIFICATE = "xxx"; private static final String CUSTOMER_KEY = "xxx"; // 客户 ID private static final String CUSTOMER_SECRET = "xxxx"; // 客户密钥 private static final String KICK_API_URL = "https://api.sd-rtn.com/dev/v1/kicking-rule"; // 获取频道内用户列表的 API URL private static final String API_URL = "https://api.sd-rtn.com/dev/v1/channel/user/"; //查询用户通话状态 private static final String API_URL_V2 = "https://api.sd-rtn.com/dev/v1/channel/user/property/"; private static final String API_URL_V3 = "https://api.sd-rtn.com/dev/v1/channel/"; private static final int TOKEN_EXPIRATION_IN_SECONDS = 7200;//2 小时 // 所有的权限的有效时间,单位秒,声网建议将该参数和 Token 的有效时间设为一致 private static final int PRIVILEGE_EXPIRATION_IN_SECONDS = 7200; private static final String WEBHOOK_SECRET = "xxxx"; private static final String REDIS_CHANNEL_CREATE = "agora:channel:create:"; private static final String REDIS_CALL_START = "agora:call:start:"; private static final String REDIS_CALL_JOIN = "agora:call:join:"; private static final String REDIS_CHANNEL_NAME = "agora:channel:name:"; private static final String REDIS_CHANNEL_ANSWER = "agora:call:answer:"; @Autowired protected RedisUtil redisUtil; @Autowired private CallRecordRepo callRecordRepo; @Autowired private UserGoodsVideoRepo userGoodsVideoRepo; @Autowired private GoodsVideoRepo goodsVideoRepo; @Autowired private CommissionService commissionService; /** * 获取 Agora RTC Token * * @return 生成的 Token */ public String generateRtcToken(GenerateTokenRequest request) { RtcTokenBuilder2 tokenBuilder = new RtcTokenBuilder2(); return tokenBuilder.buildTokenWithUid( APP_ID, APP_CERTIFICATE, request.getChannelName(), Integer.parseInt(request.getUid()), RtcTokenBuilder2.Role.ROLE_PUBLISHER, TOKEN_EXPIRATION_IN_SECONDS, PRIVILEGE_EXPIRATION_IN_SECONDS ); } /** * 获取 Agora HTTP 请求所需的 Authorization Header * * @param customerKey Agora 客户端 ID * @param customerSecret Agora 客户端密钥 * @return Basic Auth Header 字符串 */ public String getAuthorizationHeader(String customerKey, String customerSecret) { if (customerKey == null || customerKey.isEmpty() || customerSecret == null || customerSecret.isEmpty()) { throw new IllegalArgumentException("Customer Key and Secret must not be empty."); } String plainCredentials = customerKey + ":" + customerSecret; String base64Credentials = Base64.getEncoder().encodeToString(plainCredentials.getBytes()); return "Basic " + base64Credentials; } @Transactional public R handleWebhook(byte[] requestBody, String signatureV1, String signatureV2) { log.info("Agora-Signature is:{},Agora-Signature-V2 is:{}", signatureV1, signatureV2); if (StringUtils.isAnyBlank(signatureV1, signatureV2)) { return R.error("Invalid request"); } // 1. 验证签名 boolean isValid = HmacUtil.validateSignature(requestBody, signatureV1, signatureV2, WEBHOOK_SECRET); if (!isValid) { return R.error("Signature verification failed"); } // 2. 解析 requestBody 获取 eventType ObjectMapper objectMapper = new ObjectMapper(); JsonNode jsonNode; int eventType = -1; String noticeId; String channelName = null; try { jsonNode = objectMapper.readTree(requestBody); eventType = jsonNode.get("eventType").asInt(); noticeId = jsonNode.get("noticeId").asText(); // 获取频道名称(大多数事件都包含在 payload 中) if (jsonNode.has("payload") && jsonNode.get("payload").has("channelName")) { channelName = jsonNode.get("payload").get("channelName").asText(); } } catch (Exception e) { log.error("Failed to parse requestBody: {}", e.getMessage()); return R.error("Failed to parse requestBody"); } // 检查是否为关键事件类型(101, 103, 104, 102) // boolean isCriticalEvent = // eventType == EventEnum.CHANNEL_CREATE.getEventType() || // 101 // eventType == EventEnum.BROADCASTER_JOIN_CHANNEL.getEventType() || // 103 // eventType == EventEnum.BROADCASTER_LEAVE_CHANNEL.getEventType() || // 104 // eventType == EventEnum.CHANNEL_DESTROY.getEventType(); // 102 EventEnum eventEnum = EventEnum.fromEventType(eventType); // 允许 CHANNEL_DESTROY 事件重复处理(因为可能需要强制清理该频道的所有redis) boolean processed = redisUtil.set(noticeId, "processed", 5 * 60); if (!processed) { log.info("Duplicate event ignored: noticeId={}", noticeId); return R.ok(); // 重复事件直接返回成功 } // 3. 事件顺序验证(仅针对关键事件) // if (isCriticalEvent && channelName != null) { // // 获取当前频道的事件状态 // String eventStateKey = "agora:event_state:" + channelName; // String currentState = redisUtil.get(eventStateKey); // // // 定义事件顺序规则 // Map<Integer, Set<Integer>> allowedPreviousEvents = new ConcurrentHashMap<>(); // allowedPreviousEvents.put( // EventEnum.CHANNEL_CREATE.getEventType(), // Collections.singleton(null) // 101 是第一个事件,无前驱 // ); // allowedPreviousEvents.put( // EventEnum.BROADCASTER_JOIN_CHANNEL.getEventType(), // Collections.singleton(EventEnum.CHANNEL_CREATE.getEventType()) // 103 必须在 101 之后 // ); // allowedPreviousEvents.put( // EventEnum.BROADCASTER_LEAVE_CHANNEL.getEventType(), // Collections.singleton(EventEnum.BROADCASTER_JOIN_CHANNEL.getEventType()) // 104 必须在 103 之后 // ); // allowedPreviousEvents.put( // EventEnum.CHANNEL_DESTROY.getEventType(), // Collections.singleton(EventEnum.BROADCASTER_LEAVE_CHANNEL.getEventType()) // 102 必须在 104 之后 // ); // // // 验证事件顺序 // Integer previousEvent = currentState != null ? Integer.valueOf(currentState) : null; // Set<Integer> allowedPrevious = allowedPreviousEvents.get(eventType); // // if (allowedPrevious == null || !allowedPrevious.contains(previousEvent)) { // log.error("Invalid event order: channel={}, currentEvent={}, previousEvent={}", // channelName, eventType, previousEvent); // return R.error("Invalid event sequence"); // } // // // 更新事件状态 // redisUtil.set(eventStateKey, String.valueOf(eventType), 30 * 60); // 状态保留30分钟 // } // 4. 处理事件 if (eventEnum == null) { return R.error("Unknown eventType"); } switch (eventEnum) { case CHANNEL_CREATE: if (channelName == null) { return R.error("Missing channelName in CHANNEL_CREATE event"); } log.info("进入{}事件,eventType为:{},频道名称为:{}", eventEnum.getEventDescription(), eventType, channelName); // 主播创建频道时间 long currentTimeMillis = System.currentTimeMillis(); log.info("频道创建的时间为:{},时间戳:{}", DateUtil.toUTCLocalDateTime(currentTimeMillis), currentTimeMillis); // 记录开始时间 redisUtil.setNX(REDIS_CHANNEL_CREATE + channelName, String.valueOf(currentTimeMillis)); redisUtil.expire(REDIS_CHANNEL_CREATE + channelName, 5 * 60); redisUtil.setNX(REDIS_CHANNEL_NAME + channelName, channelName); redisUtil.expire(REDIS_CHANNEL_NAME + channelName, 5 * 60); break; case CHANNEL_DESTROY: log.info("进入{}事件,eventType为{}", eventEnum.getEventDescription(), eventType); // 删除频道相关redis key,避免内存泄露 if (StringUtils.isNotBlank(channelName)) { // 清理频道相关的所有缓存 redisUtil.delStr(REDIS_CHANNEL_CREATE + channelName); redisUtil.delStr(REDIS_CALL_START + channelName); // redisUtil.delStr(REDIS_CALL_JOIN + channelName); redisUtil.delStr("agora:event_state:" + channelName); //redisUtil.delStr(REDIS_CHANNEL_ANSWER+channelName); } break; case BROADCASTER_JOIN_CHANNEL: log.info("进入{}事件,eventType为{}", eventEnum.getEventDescription(), eventType); // 主播加入频道时间(开始通话时间) long callStartTime = System.currentTimeMillis(); if (jsonNode.has("payload") && jsonNode.get("payload").has("ts")){ callStartTime = jsonNode.get("payload").get("ts").asLong() * 1000; } log.info("通话开始时间为:{},时间戳:{}", DateUtil.toUTCLocalDateTime(callStartTime), callStartTime); // 修正:从 payload 中获取 channelName if (jsonNode.has("payload") && jsonNode.get("payload").has("channelName")) { channelName = jsonNode.get("payload").get("channelName").asText(); } else { channelName = redisUtil.get(REDIS_CHANNEL_NAME); } if (channelName != null) { // 设置发起人 String callerMember = jsonNode.get("payload").get("uid").asText(); redisUtil.set(REDIS_CALL_JOIN + channelName, callerMember, 24 * 60 * 60); ChannelDataResponse channelUsers = getChannelUsers(APP_ID, channelName); if (channelUsers.isSuccess()){ List<Integer> broadcasters = channelUsers.getData().getBroadcasters(); if (CollectionUtils.isEmpty(broadcasters)){ log.info("broadcasters为空"); } if (broadcasters.size() <2){ log.info("broadcasters数量小于2:{}",broadcasters.get(0)); } for (Integer broadcaster : broadcasters){ String json = redisUtil.get(REDIS_CALL_JOIN + channelName); if (StringUtils.isNotBlank(json) && !StringUtils.equals(broadcaster.toString(), json)){ log.info("发起人:{},频道名称:{},接听人:{}",callerMember,channelName,broadcaster); //记录接听人 redisUtil.set(REDIS_CHANNEL_ANSWER+channelName, broadcaster.toString(), 24 * 60 * 60); // 设置一天的过期时间 redisUtil.set(REDIS_CALL_START + channelName, String.valueOf(callStartTime), 24 * 60 * 60); } } } } break; case BROADCASTER_LEAVE_CHANNEL: Integer reason = null; //通话结束时间 服务器返回的事件发生unix时间戳 long ts = System.currentTimeMillis(); if (jsonNode.has("payload") && jsonNode.get("payload").has("ts")){ ts = jsonNode.get("payload").get("ts").asLong()*1000; } if (jsonNode.has("payload") && jsonNode.get("payload").has("reason")){ reason = jsonNode.get("payload").get("reason").asInt(); Long uid = jsonNode.get("payload").get("uid").asLong(); //转成毫秒 log.info("用户:{}离开频道:{},原因为:{}",uid,channelName,reason); if (reason == LeaveChannelReason.ABNORMAL_USER.getNumber()){ int minute = 10; log.info("用户:{}被踢出频道:{},封禁时间为{}分钟",uid,channelName,minute); handleAbnormalUser(channelName, uid,minute); } } // long endTime = System.currentTimeMillis(); // 修正:从 payload 中获取 channelName if (jsonNode.has("payload") && jsonNode.get("payload").has("channelName")) { channelName = jsonNode.get("payload").get("channelName").asText(); } else { channelName = redisUtil.get(REDIS_CHANNEL_NAME); } log.info("进入{}事件,eventType为{},频道名称为:{}", eventEnum.getEventDescription(), eventType, channelName); if (channelName == null) { return R.error("Missing channelName in BROADCASTER_LEAVE_CHANNEL event"); } String lockKey = "member:leave:lock:" + channelName; // 以频道ID作为锁键 String lockValue = UUID.randomUUID().toString(); // 唯一值防止误删 // 获取分布式锁(设置5秒超时避免死锁) boolean locked = redisUtil.setNX(lockKey, lockValue) == 1; redisUtil.expire(lockKey, 5); if (!locked) { log.warn("Failed to acquire lock for channel {}, skipping: {}", channelName, lockKey); return R.error("Concurrent request ignored"); } String caller = null; String answer = null; Long durationMinutes = null; CallRecord record = null; try { // 执行需要同步的业务逻辑(如计算时长、入库) record = new CallRecord(); record.setChannelName(channelName); // 频道创建时间 String channelCtime = redisUtil.get(REDIS_CHANNEL_CREATE + channelName); // 接听时间 String startTime = redisUtil.get(REDIS_CALL_START + channelName); // 发起人 caller = redisUtil.get(REDIS_CALL_JOIN + channelName); //接听人 answer = redisUtil.get(REDIS_CHANNEL_ANSWER + channelName); log.info("发起人end:{},接听人end:{}",caller,answer); if (StringUtils.isNotBlank(answer)){ record.setAnswerMember(answer); } if (StringUtils.isNotBlank(caller)){ record.setCallerMember(caller); } record.setChannelCtime(Long.parseLong(channelCtime)); record.setStartTimestamps(Long.parseLong(startTime)); record.setEndTimestamps(ts); if (jsonNode.has("payload") && jsonNode.get("payload").has("reason")){ record.setEndCallReason(String.valueOf(reason)); } // 计算通话时长 long durationMillis = ts - Long.parseLong(startTime); long durationSeconds = (long) Math.ceil(durationMillis / 1000.0); // 秒数向上取整 durationMinutes = calculateBillableDuration(durationSeconds); // 分钟数向上取整 record.setDurationSeconds(durationSeconds); log.info("通话时长为(毫秒)durationSeconds:{}", durationMillis); log.info("通话时长为(秒)ts:{}", ts); log.info("通话开始时间startTime:{}", Long.parseLong(startTime)); record.setDurationMinutes(durationMinutes); log.info("通话时长为(分钟):{}", durationMinutes); } catch (Exception e) { log.error("Error processing BROADCASTER_LEAVE_CHANNEL: {}", e.getMessage()); return R.error("Error processing leave event"); } finally { // 释放锁(验证当前线程持有锁) String storedValue = redisUtil.get(lockKey); if (lockValue.equals(storedValue)) { redisUtil.delStr(lockKey); log.info("Lock released: channelId={}", channelName); } } List<CallRecord> all = callRecordRepo.findAll(SpecTool.eq(CallRecord_.channelName, channelName)); //确保只执行一次 if (CollectionUtils.isEmpty(all)){ CallRecord save = callRecordRepo.save(record); Long callId = save.getCallId(); log.info("保存通话记录成功,callId:{}",callId); editRemainingTime(caller,durationMinutes,answer,callId); } //更新用户剩余时长 break; case AUDIENCE_JOIN_CHANNEL: case AUDIENCE_LEAVE_CHANNEL: case USER_JOIN_CHANNEL_WITH_COMMUNICATION_MODE: case USER_LEAVE_CHANNEL_WITH_COMMUNICATION_MODE: case CLIENT_ROLE_CHANGE_TO_BROADCASTER: case CLIENT_ROLE_CHANGE_TO_AUDIENCE: default: log.warn("Unhandled event type: {}", eventType); break; } return R.ok(); } public static long calculateBillableDuration(long durationInSeconds) { return durationInSeconds % 60 == 0 ? durationInSeconds / 60 : (durationInSeconds / 60) + 1; } /** * 更新通话剩余时长 * @param caller * @param durationMinutes */ public void editRemainingTime(String caller, Long durationMinutes, String answerGagaNo, Long callId) { // 查询用户开通的套餐包(确保列表可变) List<UserGoodsVideo> packages = new ArrayList<>(userGoodsVideoRepo.findAll( SpecTool.eq(UserGoodsVideo_.membGagano, caller) )); long remainingDuration = durationMinutes; // 1. 优先处理免费套餐(is_free = '1'),按剩余时长降序 remainingDuration = processFreePackages(packages, remainingDuration); // 2. 处理非免费套餐,按初始时长降序 if (remainingDuration > 0) { List<UserGoodsVideo> nonFreePackages = getNonFreePackages(packages); //根据时长大小降序排序 按初始时长降序扣减剩余时长 List<UserGoodsVideo> sortedNonFreePackages = sortByBuyDurationDescending(nonFreePackages); for (UserGoodsVideo packageVideo : sortedNonFreePackages) { if (remainingDuration <= 0) break; long remainingPackageMinutes = packageVideo.getRemainingTime(); Long originalUsedAmount = packageVideo.getUsedAmount(); if (remainingPackageMinutes > 0) { Long minutesToUse = Math.min(remainingDuration, remainingPackageMinutes); log.info("从套餐 {} 中扣除 {} 分钟", packageVideo.getPrice(), minutesToUse); // 更新剩余时长和使用量 packageVideo.setRemainingTime(remainingPackageMinutes - minutesToUse); packageVideo.setUsedAmount(originalUsedAmount + minutesToUse); // 更新使用量 UserGoodsVideo save = userGoodsVideoRepo.save(packageVideo); log.info("更新剩余时长和使用量:{}", save); remainingDuration -= minutesToUse; List<GoodsVideo> goodsVideoList = goodsVideoRepo.findAll(SpecTool.eq(GoodsVideo_.goodsVideoId, packageVideo.getGoodsId())); GoodsVideo goodsVideo = goodsVideoList.get(0); commissionService.commissionVideo(caller, answerGagaNo, packageVideo, packageVideo.getPrice(), minutesToUse, goodsVideo.getBuyDuration(),callId); } } } } // 处理免费套餐的辅助方法(返回剩余未扣减时长) private long processFreePackages(List<UserGoodsVideo> packages, long remainingDuration) { // 按剩余时长降序排序免费套餐 List<UserGoodsVideo> freePackages = packages.stream() .filter(this::isFreePackage) .filter(pkg->pkg.getRemainingTime() > 0) .sorted(Comparator.comparingLong(UserGoodsVideo::getRemainingTime).reversed()) .toList(); for (UserGoodsVideo packageVideo : freePackages) { if (remainingDuration <= 0) break; long remainingPackageMinutes = packageVideo.getRemainingTime(); Long originalUsedAmount = packageVideo.getUsedAmount(); if (remainingPackageMinutes > 0) { long minutesToUse = Math.min(remainingDuration, remainingPackageMinutes); // 更新剩余时长和使用量 packageVideo.setRemainingTime(remainingPackageMinutes - minutesToUse); packageVideo.setUsedAmount(originalUsedAmount + (int) minutesToUse); // 更新使用量 userGoodsVideoRepo.save(packageVideo); remainingDuration -= minutesToUse; } } return remainingDuration; } // 判断是否为免费套餐(保持不变) private boolean isFreePackage(UserGoodsVideo packageVideo) { String goodsId = packageVideo.getGoodsId(); UserGoodsVideo userGoodsVideo = getUserGoodsVideo(goodsId); return userGoodsVideo != null && "1".equals(userGoodsVideo.getIsFree()); } // 获取非免费套餐 private List<UserGoodsVideo> getNonFreePackages(List<UserGoodsVideo> packages) { return packages.stream() .filter(pkg -> !isFreePackage(pkg)) .filter(pkg -> pkg.getRemainingTime() > 0) .toList(); } // 按初始套餐时长降序排序 private List<UserGoodsVideo> sortByBuyDurationDescending(List<UserGoodsVideo> packages) { return packages.stream() .sorted((p1, p2) -> { GoodsVideo gv1 = getGoodsVideo(p1.getGoodsId()); GoodsVideo gv2 = getGoodsVideo(p2.getGoodsId()); int duration1 = gv1 != null ? gv1.getBuyDuration() : 0; int duration2 = gv2 != null ? gv2.getBuyDuration() : 0; return Integer.compare(duration2, duration1); }) .collect(Collectors.toList()); } public UserGoodsVideo getUserGoodsVideo(String goodsId) { List<UserGoodsVideo> goodsVideoList = userGoodsVideoRepo.findAll( SpecTool.eq(UserGoodsVideo_.goodsId, goodsId) ); return CollectionUtils.isNotEmpty(goodsVideoList) ? goodsVideoList.get(0) : null; } // 获取套餐详情的辅助方法 private GoodsVideo getGoodsVideo(String goodsId) { List<GoodsVideo> goodsVideoList = goodsVideoRepo.findAll( SpecTool.eq(GoodsVideo_.goodsVideoId, goodsId) ); return CollectionUtils.isNotEmpty(goodsVideoList) ? goodsVideoList.get(0) : null; } /** * 调用声网踢人 API(封禁用户加入频道权限) * @param channelName 频道名(cname) * @param uid 用户 ID(不能为 0) * @param duration 封禁时长(分钟,默认 1 分钟) * @return 规则 ID 或 null(失败时) */ public Long kickUser(String channelName, Long uid, int duration) { if (StringUtils.isBlank(channelName) || uid == null || uid == 0) { throw new IllegalArgumentException("channelName and uid are required, and uid cannot be 0"); } // 1. 构建请求体 JSONObject body = new JSONObject(); body.put("appid", APP_ID); body.put("cname", channelName); body.put("uid", uid); body.put("time", Math.max(duration, 1)); // 最小封禁 1 分钟 body.put("privileges", Collections.singletonList("join_channel")); // 封禁加入频道权限 // 2. 生成 Basic Auth 认证头 String authorizationHeader = getAuthorizationHeader(CUSTOMER_KEY, CUSTOMER_SECRET); // 3. 发起 POST 请求 HttpResponse response = HttpRequest.post(KICK_API_URL) .header("Authorization", authorizationHeader) .header("Content-Type", "application/json") .body(body.toString()) .timeout(20 * 1000) // 超时时间设为 20 秒 .execute(); // 4. 处理响应 if (response.getStatus() != 200) { log.error("Kick user failed, status code: {}, body: {}", response.getStatus(), response.body()); return null; } JSONObject result = JSONObject.parseObject(response.body()); if (!"success".equals(result.get("status"))) { log.error("Kick user failed, status: {}", result.get("status")); return null; } Long ruleId = result.getLong("id"); log.info("Kick user success, ruleId: {}", ruleId); return ruleId; } /** * 处理异常用户(reason: 999) * @param channelName 频道名 * @param uid 用户 ID */ public void handleAbnormalUser(String channelName, Long uid,int minute) { // 1. Redis 锁防止重复处理(60 秒内仅处理一次) String lockKey = "agora:kick:lock:" + channelName + ":" + uid; if (redisUtil.exists(lockKey)) { log.info("Abnormal user already handled: {}:{}", channelName, uid); return; } redisUtil.set(lockKey, "processed", 60); // 设置 60 秒锁 // 2. 调用踢人 API(封禁 1 分钟) kickUser(channelName, uid, minute); } /** * 获取频道内用户列表 * @param appId 声网应用ID * @param channelName 频道名称 * @return 用户列表(包含uid和角色) */ public ChannelDataResponse getChannelUsers(String appId, String channelName) { if (StringUtils.isBlank(appId) || StringUtils.isBlank(channelName)) { throw new IllegalArgumentException("appId and channelName are required"); } // 2. 生成Basic Auth认证头 String authorizationHeader = getAuthorizationHeader(CUSTOMER_KEY, CUSTOMER_SECRET); // 3. 发起GET请求 HttpResponse response = HttpRequest.get(API_URL + APP_ID+"/"+channelName) .header("Authorization", authorizationHeader) .timeout(30 * 1000) // 超时时间30秒 .execute(); // 4. 处理响应 if (response.getStatus() != 200) { log.error("Get channel users failed, status code: {}", response.getStatus()); ChannelDataResponse channelDataResponse = new ChannelDataResponse(); channelDataResponse.setSuccess(false); return channelDataResponse; } String body = response.body(); return JSONUtil.toBean(body, ChannelDataResponse.class); } public AnswerCallResponse answerCall(String channelName, Long uid){ String url = API_URL_V2 + APP_ID + "/" + uid + "/" + channelName; // 生成Basic Auth认证头 String authorizationHeader = getAuthorizationHeader(CUSTOMER_KEY, CUSTOMER_SECRET); HttpResponse response = HttpRequest.get(url) .header("Authorization", authorizationHeader) .timeout(30 * 1000) .execute(); if (response.getStatus() != 200) { log.error("Answer call failed, status code: {}", response.getStatus()); AnswerCallResponse answerCallResponse = new AnswerCallResponse(); answerCallResponse.setSuccess(false); return answerCallResponse; } String body = response.body(); return JSONUtil.toBean(body, AnswerCallResponse.class); } public ChannelInfoResponse inChannelUserList(){ String url = API_URL_V3 + APP_ID; // 生成Basic Auth认证头 String authorizationHeader = getAuthorizationHeader(CUSTOMER_KEY, CUSTOMER_SECRET); HttpResponse response = HttpRequest.get(url) .header("Authorization", authorizationHeader) .timeout(30 * 1000) .execute(); if (response.getStatus() != 200) { log.error("Answer call failed, status code: {}", response.getStatus()); ChannelInfoResponse channelInfoResponse = new ChannelInfoResponse(); channelInfoResponse.setSuccess(false); return channelInfoResponse; } String body = response.body(); return JSONUtil.toBean(body, ChannelInfoResponse.class); } public Boolean inChannelUserList(Long uid){ ChannelInfoResponse channelInfoResponse = inChannelUserList(); List<ChannelInfoResponse.Channel> channels = channelInfoResponse.getData().getChannels(); if (channelInfoResponse.isSuccess() && CollectionUtils.isEmpty(channels)){ return false; } for (ChannelInfoResponse.Channel channel : channels){ String channelName = channel.getChannel_name(); AnswerCallResponse answerCallResponse = answerCall(channelName, uid); if (answerCallResponse.isSuccess() && answerCallResponse.getData().isIn_channel()){ return true; } } return false; } }
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 '接听人', PRIMARY KEY (`call_id`) ) ENGINE=InnoDB AUTO_INCREMENT=341 DEFAULT CHARSET=utf8mb4 COMMENT='声网通话记录表';
实现声网的webhook回调接口 根据声网服务器不同的事件 处理相应的逻辑,需要注意的是声网的事件到达自己的服务器不保证顺序,还有可能有重复事件,这些都是需要手动处理的,否则会影响相关的主业务。
浙公网安备 33010602011771号