对接声网(环信)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回调接口 根据声网服务器不同的事件 处理相应的逻辑,需要注意的是声网的事件到达自己的服务器不保证顺序,还有可能有重复事件,这些都是需要手动处理的,否则会影响相关的主业务。

posted @ 2025-05-29 16:59  Fyy发大财  阅读(81)  评论(0)    收藏  举报