Java web后端直播接入腾讯IM聊天

类似于斗鱼直播间的聊天

直播.png

接入第三方IM,大部分功能实现依赖于前端。后端侧重于创建群组的时机,以及考虑群组解散的时机(如果有合理的退群机制和定期清理群人数的机制,当我没说,不用考虑解散群组机制。因为对腾讯IM来说一个人只能同时加入200个群组限制)。如果后端需要对聊天的内容和群组变化记录入库,就需要用到腾讯云IM的回调机制。对于直播间人数获取,可以获取IM群组人数,不过还是要设计好严格的退群加群机制,才能保证人数正确。

对于正确的退群机制,后端要注意IM文档中回调机制:在线状态相关回调

文档中该回调介绍为:
客户端 kill 后台进程,云服务器检测到客户端网络断开后触发下线回调。
客户端心跳超时,包括客户端 Crash、关闭网络 400 秒后,云服务器检测到客户端的心跳超时触发下线回调

通俗讲:也就是客户端用户无法正确执行quitGroup操作时,通过IM回调服务端接口来实现用户退群操作。

本文主要介绍建群和解散群组的实现方式

web直播间群组的创建是和主播进行绑定,一个主播对应一个群组,同时主播也是该群组的管理员,拥有授权,禁言,踢人等操作。当一个普通用户升级为主播的那一刻,后端就创建了一个IM群组,同时授权管理员是该主播。

用户加入IM群组的前提是必须先登录腾讯的IM系统。就好比你要加QQ群聊天,就必须先登录QQ一样,因此对于腾讯IM群组的private/public/chatroom的群组模式是没有不登录这个概念的。这也就解释了,为什么直播发言必须要求用户登录账号(同时也登录了IM系统,同步web的账号信息到IM系统中的账号)。而不登录web的用户要想看到群组的内容,也必须登录IM系统,只是此刻以游客的身份进行登录,也就是随机的账号登录IM系统,只是不能进行发言,这需要前端进行限制操作。

登录IM系统需要账号密码:

对于登录web的用户来说,IM的账号就是web体系的用户ID或者其他唯一标识,而密码则需要调用后端接口getUserSig获取(参考腾讯userSig机制)
在IM文档中可以获取到
Base64URL和GenUserSig,该加密用来生成IM密码

public class Base64URL {

    public static byte[] base64EncodeUrl(byte[] input){
        byte[] base64 = new BASE64Encoder().encode(input).getBytes();
        for (int i = 0; i < base64.length; ++i)
            switch (base64[i]) {
                case '+':
                    base64[i] = '*';
                    break;
                case '/':
                    base64[i] = '-';
                    break;
                case '=':
                    base64[i] = '_';
                    break;
                default:
                    break;
            }
        return base64;
    }

    public static byte[] base64DecodeUrl(byte[] input) throws IOException {
        byte[] base64 = input.clone();
        for (int i = 0; i < base64.length; ++i)
            switch (base64[i]) {
                case '*':
                    base64[i] = '+';
                    break;
                case '-':
                    base64[i] = '/';
                    break;
                case '_':
                    base64[i] = '=';
                    break;
                default:
                    break;
            }
        return new BASE64Decoder().decodeBuffer(base64.toString());
    }
}
public class TXGenUserSig {

    private long sdkappid;
    private String key;

    public TXGenUserSig(long sdkappid, String key) {
        this.sdkappid = sdkappid;
        this.key = key;
    }

    private String hmacsha256(String identifier, long currTime, long expire, String base64Userbuf) {
        String contentToBeSigned = "TLS.identifier:" + identifier + "\n"
                + "TLS.sdkappid:" + sdkappid + "\n"
                + "TLS.time:" + currTime + "\n"
                + "TLS.expire:" + expire + "\n";
        if (null != base64Userbuf) {
            contentToBeSigned += "TLS.userbuf:" + base64Userbuf + "\n";
        }
        try {
            byte[] byteKey = key.getBytes("UTF-8");
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(byteKey, "HmacSHA256");
            hmac.init(keySpec);
            byte[] byteSig = hmac.doFinal(contentToBeSigned.getBytes("UTF-8"));
            return (new BASE64Encoder().encode(byteSig)).replaceAll("\\s*", "");
        } catch (UnsupportedEncodingException e) {
            return "";
        } catch (NoSuchAlgorithmException e) {
            return "";
        } catch (InvalidKeyException e) {
            return "";
        }
    }

    private String genSig(String identifier, long expire, byte[] userbuf) {

        long currTime = System.currentTimeMillis()/1000;

        JSONObject sigDoc = new JSONObject();
        sigDoc.put("TLS.ver", "2.0");
        sigDoc.put("TLS.identifier", identifier);
        sigDoc.put("TLS.sdkappid", sdkappid);
        sigDoc.put("TLS.expire", expire);
        sigDoc.put("TLS.time", currTime);

        String base64UserBuf = null;
        if (null != userbuf) {
            base64UserBuf = new BASE64Encoder().encode(userbuf);
            sigDoc.put("TLS.userbuf", base64UserBuf);
        }
        String sig = hmacsha256(identifier, currTime, expire, base64UserBuf);
        if (sig.length() == 0) {
            return "";
        }
        sigDoc.put("TLS.sig", sig);
        Deflater compressor = new Deflater();
        compressor.setInput(sigDoc.toString().getBytes(Charset.forName("UTF-8")));
        compressor.finish();
        byte [] compressedBytes = new byte[2048];
        int compressedBytesLength = compressor.deflate(compressedBytes);
        compressor.end();
        return (new String(Base64URL.base64EncodeUrl(Arrays.copyOfRange(compressedBytes,
                0, compressedBytesLength)))).replaceAll("\\s*", "");
    }

    public String genSig(String identifier, long expire) {
        return genSig(identifier, expire, null);
    }

    public String genSigWithUserBuf(String identifier, long expire, byte[] userbuf) {
        return genSig(identifier, expire, userbuf);
    }
}

腾讯IM侧重前端,对java web后端没有好的sdk支持

自定义一个IM的配置类

@Configuration
public class TXIMConfiguration {


    private static long sdkappid;


    private static String key;

    private static String identifier;

    //userSig 有效期7天
    private static final long EXPIRE_TIME=7*24*60*60;

    private static TXGenUserSig txGenUserSig=null;

    @Value("${txim.sdkappid}")
    public  void setSdkappid(long sdkappid) {
        TXIMConfiguration.sdkappid = sdkappid;
    }

    @Value("${txim.key}")
    public  void setKey(String key) {
        TXIMConfiguration.key = key;
    }

    @Value("${txim.identifier}")
    public  void setIdentifier(String identifier) {
        TXIMConfiguration.identifier = identifier;
    }


    @Bean
    public Object services(){
        txGenUserSig=new TXGenUserSig(sdkappid,key);
        return Boolean.TRUE;
    }

    public static String getUserSig(String identifier){
        return txGenUserSig.genSig(identifier, EXPIRE_TIME);
    }

    /**
     * 创建IM群组API URL
     * @return
     */
    public static String getCreateGroupURL(){
        return "https://console.tim.qq.com/v4/group_open_http_svc/create_group?"+"sdkappid="+sdkappid+"&identifier="+identifier+"&usersig="+getUserSig(identifier)+
                "&random="+ CodeUtil.getRandomNumber(32)+"&contenttype=json";
    }

    /**
     * 解散IM群组API URL
     * @return
     */
    public static String getDestoryGroupURL(){
        return "https://console.tim.qq.com/v4/group_open_http_svc/destroy_group?"+"sdkappid="+sdkappid+"&identifier="+identifier+"&usersig="+getUserSig(identifier)+
                "&random="+CodeUtil.getRandomNumber(32)+"&contenttype=json";
    }

    /**
     * 检测账号 API URL
     * @return
     */
    public static String getCheckAccountURL(){
        return "https://console.tim.qq.com/v4/im_open_login_svc/account_check?"+"sdkappid="+sdkappid+"&identifier="+identifier+"&usersig="+getUserSig(identifier)+
                "&random="+CodeUtil.getRandomNumber(32)+"&contenttype=json";
    }

    /**
     * 单个账号导入 API URL
     * @return
     */
    public static String getAccountImportURL(){
        return "https://console.tim.qq.com/v4/im_open_login_svc/account_import?"+"sdkappid="+sdkappid+"&identifier="+identifier+"&usersig="+getUserSig(identifier)+
                "&random="+CodeUtil.getRandomNumber(32)+"&contenttype=json";
    }

}

获取userSig的接口

 /**
     * 获取登录IM聊天室的账号密码
     * @param
     * @return
     */
    @RequestMapping("/getUserSig")
    public ResultBean getUserSig(String account){
        try {
            String userSig = TXIMConfiguration.getUserSig(account);
            return ResultBean.setOk(0, "认证成功",userSig);
        }catch (Exception e){
            logger.error("腾讯SDK认证失败,检查秘钥 "+e);
            return ResultBean.setError(1,"认证失败");
        }
    }

创建IM群组和解散IM群组 这里以Springboot线程池异步方式执行

准备好实体参数实体类
CheckItem

public class CheckItem {
    private String UserID;
    public CheckItem(String userID) {
        UserID = userID;
    }

    @JSONField(name = "UserID")
    public String getUserID() {
        return UserID;
    }

    public void setUserID(String userID) {
        UserID = userID;
    }
}

GroupInfo

public class GroupInfo {

    //群主UserId
    private String Owner_Account;

    //群组类型 Private/Public/ChatRoom/
    private String Type;

    //自定义群组ID
    private String GroupId;

    //群名称
    private String Name;

    //最大群成员数量
    private int MaxMemberCount;

    //申请加群方式
    private String ApplyJoinOption;


    public GroupInfo(String Owner_Account, String Type, String GroupId, String Name, int MaxMemberCount,String ApplyJoinOption) {
        this.Owner_Account = Owner_Account;
        this.Type = Type;
        this.GroupId = GroupId;
        this.Name = Name;
        this.MaxMemberCount = MaxMemberCount;
        this.ApplyJoinOption = ApplyJoinOption;
    }

    public GroupInfo(String Type, String GroupId, String Name, int MaxMemberCount,String ApplyJoinOption) {
        this.Type = Type;
        this.GroupId = GroupId;
        this.Name = Name;
        this.MaxMemberCount = MaxMemberCount;
        this.ApplyJoinOption = ApplyJoinOption;
    }

    @JSONField(name="Owner_Account")
    public String getOwner_Account() {
        return Owner_Account;
    }

    public void setOwner_Account(String owner_Account) {
        Owner_Account = owner_Account;
    }
    @JSONField(name="Type")
    public String getType() {
        return Type;
    }

    public void setType(String type) {
        Type = type;
    }
    @JSONField(name="GroupId")
    public String getGroupId() {
        return GroupId;
    }

    public void setGroupId(String groupId) {
        GroupId = groupId;
    }
    @JSONField(name="Name")
    public String getName() {
        return Name;
    }

    public void setName(String name) {
        Name = name;
    }
    @JSONField(name="MaxMemberCount")
    public int getMaxMemberCount() {
        return MaxMemberCount;
    }

    public void setMaxMemberCount(int maxMemberCount) {
        MaxMemberCount = maxMemberCount;
    }
    @JSONField(name="ApplyJoinOption")
    public String getApplyJoinOption() {
        return ApplyJoinOption;
    }

    public void setApplyJoinOption(String applyJoinOption) {
        ApplyJoinOption = applyJoinOption;
    }
}

业务实现,根据自己需要来

@Service
public class TXIMAsynServiceImpl implements TXIMAsynService {

    private Logger logger = LoggerFactory.getLogger(TXIMAsynServiceImpl.class);

    /**
     * 异步创建群组
     * @param lessonId
     * @param periodIds
     */
    @Override
    @Async("asynTxImServiceExecutor")
    public void createIMGroup(String account) {
        logger.info("异步线程执行创建群组操作开始...userId=" + account);
        //该账号未注册到IM系统中,注册后才可以将该账号指定为IM群主
        if(account!=null) {
            String accountStr = String.valueOf(account);
            String checkRes = checkSingleAccount(accountStr);
            if (checkRes == null) {
                account = null;
            } else if ("Not Imported".equals(checkRes)) {
                //注册账号到IM体系
                if ("OK".equals(importSingleAccount(accountStr))) {
                    logger.info("注册账号到IM成功,账号:{}", account);
                } else {
                    logger.error("注册账号到IM失败,账号:{}", account);
                }
            }
        }

            //开始创建群组,如果账号为空创建无群主群组,否则有群主群组,这里创建群组类型为Public
            GroupInfo groupInfo = account == null ?
                    new GroupInfo("Public", account, "用户" + account, 200, "FreeAccess")
                    : new GroupInfo(account, "Public", account, "用户" + account, 200, "FreeAccess");
            String contentType = JSON.toJSONString(groupInfo);
            //获取第三方API地址
            String urlAddParam = TXIMConfiguration.getCreateGroupURL();
            String res = HttpUtil.doPostJson(urlAddParam, contentType);
            String actionStatus = JSONObject.parseObject(res).getString("ActionStatus");
            if ("OK".equals(actionStatus)) {
                logger.info("调用腾讯IM创建群组成功,参数:" + contentType + "结果:{}", res);
                //创建完群组后,执行自己业务逻辑
    
            } else {
                logger.error("调用腾讯IM创建群组失败,{}", res);
            }
 
    }

    /**
     * 解散群组。
     * @param periodIds
     */
    @Override
    @Async("asynTxImServiceExecutor")
    public void destoryIMGroup(String account) {
        for (Integer periodId : periodIds) {
            String destoryGroupURL = TXIMConfiguration.getDestoryGroupURL();
            String contentType = JSON.toJSONString(new HashMap<String, Object>(1) {
                {
                    put("GroupId", account);
                }
            });
            String res = HttpUtil.doPostJson(destoryGroupURL, contentType);
            String actionStatus = JSONObject.parseObject(res).getString("ActionStatus");
            if ("OK".equals(actionStatus)) {
                logger.info("调用腾讯IM删除群组成功,参数:" + contentType + "结果:{}", res);
            } else {
                logger.error("调用腾讯IM删除群组失败,{}", res);
            }

        }
    }


    /**
     * 检查单个账号
     *  指定群组的群主时需要先检查群主是否注册到IM系统中,否则指定不成功
     * @param account
     * @return
     */
    private String checkSingleAccount(String account) {

        String checkContent = JSON.toJSONString(new HashMap<String, Object>(1) {
            {
                put("CheckItem", Arrays.asList(new CheckItem(account)));
            }
        });
        String accountCheckRes = HttpUtil.doPostJson(TXIMConfiguration.getCheckAccountURL(), checkContent);
        String accountCheckStatus = JSONObject.parseObject(accountCheckRes).getString("ActionStatus");
        if ("OK".equals(accountCheckStatus)) {
            logger.info("调用腾讯IM账号检查接口成功,账号:" + account + "结果:{}", accountCheckRes);
            String resultItem = JSONObject.parseObject(accountCheckRes).getJSONArray("ResultItem").getString(0);
            //NotImported  Imported
            return (String) JSON.parseObject(resultItem, Map.class).get("AccountStatus");
        } else {
            logger.error("调用腾讯IM账号检查接口失败,账号:" + account + "参数:" + checkContent + "结果:{}", accountCheckRes);
            return null;
        }
    }

    /**
     * 注册单个账号
     *
     * @param account
     * @return
     */
    private String importSingleAccount(String account) {
        //json参数
        String checkContent = JSON.toJSONString(new HashMap<String, Object>(1) {
            {
                put("Identifier", account);
            }
        });
        String importAccountRes = HttpUtil.doPostJson(TXIMConfiguration.getAccountImportURL(), checkContent);
        logger.info("调用腾讯IM单个账号导入接口," + "参数:" + checkContent + "结果:{}", importAccountRes);
        return JSON.parseObject(importAccountRes).getString("ActionStatus");
    }

}

注意,腾讯IM参数首字母是大写,小写就无法识别,转换成JSON时候,会将参数首字母变成小写,需要注意这个问题,我这里用@JSONField来解决

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • 点击查看原文 Web SDK 开发手册 SDK 概述 网易云信 SDK 为 Web 应用提供一个完善的 IM 系统...
    layjoy阅读 13,588评论 0 15
  • 腾讯具有高并发、高可靠的即时通信能力;利用 腾讯云通信 提供的 SDK 可以将即时通信功能快速集成到自己的 APP...
    michael_jia阅读 3,091评论 2 4
  • 我一直很自卑 毫无自信 对生活失去了光彩 但是我不应该着急 我只是暂时的被尘埃蒙蔽了双眼 总会有被风吹开的一天
    绿子世界阅读 256评论 0 1
  • 一份工作,两份收入,你一直在占便宜。 老板付你薪水,一天上班8个小时,你用4个小时完成了工作。剩下的4个小时,上网...
    赵阳说阅读 744评论 0 1