类似于斗鱼直播间的聊天
接入第三方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(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(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(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来解决