類似于斗魚直播間的聊天
接入第三方IM举反,大部分功能實(shí)現(xiàn)依賴于前端挂签。后端側(cè)重于創(chuàng)建群組的時(shí)機(jī)尖滚,以及考慮群組解散的時(shí)機(jī)(如果有合理的退群機(jī)制和定期清理群人數(shù)的機(jī)制拍皮,當(dāng)我沒說,不用考慮解散群組機(jī)制听诸。因?yàn)閷︱v訊IM來說一個(gè)人只能同時(shí)加入200個(gè)群組限制)瑟由。如果后端需要對聊天的內(nèi)容和群組變化記錄入庫奸笤,就需要用到騰訊云IM的回調(diào)機(jī)制伟恶。對于直播間人數(shù)獲取碴开,可以獲取IM群組人數(shù),不過還是要設(shè)計(jì)好嚴(yán)格的退群加群機(jī)制博秫,才能保證人數(shù)正確潦牛。
對于正確的退群機(jī)制,后端要注意IM文檔中回調(diào)機(jī)制:在線狀態(tài)相關(guān)回調(diào)
文檔中該回調(diào)介紹為:
客戶端 kill 后臺進(jìn)程挡育,云服務(wù)器檢測到客戶端網(wǎng)絡(luò)斷開后觸發(fā)下線回調(diào)巴碗。
客戶端心跳超時(shí),包括客戶端 Crash即寒、關(guān)閉網(wǎng)絡(luò) 400 秒后橡淆,云服務(wù)器檢測到客戶端的心跳超時(shí)觸發(fā)下線回調(diào)
通俗講:也就是客戶端用戶無法正確執(zhí)行quitGroup操作時(shí),通過IM回調(diào)服務(wù)端接口來實(shí)現(xiàn)用戶退群操作蒿叠。
本文主要介紹建群和解散群組的實(shí)現(xiàn)方式
web直播間群組的創(chuàng)建是和主播進(jìn)行綁定明垢,一個(gè)主播對應(yīng)一個(gè)群組蚣常,同時(shí)主播也是該群組的管理員市咽,擁有授權(quán),禁言抵蚊,踢人等操作施绎。當(dāng)一個(gè)普通用戶升級為主播的那一刻溯革,后端就創(chuàng)建了一個(gè)IM群組,同時(shí)授權(quán)管理員是該主播谷醉。
用戶加入IM群組的前提是必須先登錄騰訊的IM系統(tǒng)致稀。就好比你要加QQ群聊天,就必須先登錄QQ一樣俱尼,因此對于騰訊IM群組的private/public/chatroom的群組模式是沒有不登錄這個(gè)概念的抖单。這也就解釋了,為什么直播發(fā)言必須要求用戶登錄賬號(同時(shí)也登錄了IM系統(tǒng)遇八,同步web的賬號信息到IM系統(tǒng)中的賬號)矛绘。而不登錄web的用戶要想看到群組的內(nèi)容,也必須登錄IM系統(tǒng)刃永,只是此刻以游客的身份進(jìn)行登錄货矮,也就是隨機(jī)的賬號登錄IM系統(tǒng),只是不能進(jìn)行發(fā)言斯够,這需要前端進(jìn)行限制操作囚玫。
登錄IM系統(tǒng)需要賬號密碼:
對于登錄web的用戶來說,IM的賬號就是web體系的用戶ID或者其他唯一標(biāo)識读规,而密碼則需要調(diào)用后端接口getUserSig獲取(參考騰訊userSig機(jī)制)
在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側(cè)重前端,對java web后端沒有好的sdk支持
自定義一個(gè)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);
}
/**
* 創(chuàng)建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";
}
/**
* 單個(gè)賬號導(dǎo)入 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, "認(rèn)證成功",userSig);
}catch (Exception e){
logger.error("騰訊SDK認(rèn)證失敗掖桦,檢查秘鑰 "+e);
return ResultBean.setError(1,"認(rèn)證失敗");
}
}
創(chuàng)建IM群組和解散IM群組 這里以Springboot線程池異步方式執(zhí)行
準(zhǔn)備好實(shí)體參數(shù)實(shí)體類
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;
//最大群成員數(shù)量
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;
}
}
業(yè)務(wù)實(shí)現(xiàn)本昏,根據(jù)自己需要來
@Service
public class TXIMAsynServiceImpl implements TXIMAsynService {
private Logger logger = LoggerFactory.getLogger(TXIMAsynServiceImpl.class);
/**
* 異步創(chuàng)建群組
* @param lessonId
* @param periodIds
*/
@Override
@Async("asynTxImServiceExecutor")
public void createIMGroup(String account) {
logger.info("異步線程執(zhí)行創(chuàng)建群組操作開始...userId=" + account);
//該賬號未注冊到IM系統(tǒng)中,注冊后才可以將該賬號指定為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);
}
}
}
//開始創(chuàng)建群組,如果賬號為空創(chuàng)建無群主群組祝沸,否則有群主群組,這里創(chuàng)建群組類型為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("調(diào)用騰訊IM創(chuàng)建群組成功,參數(shù):" + contentType + "結(jié)果:{}", res);
//創(chuàng)建完群組后涩惑,執(zhí)行自己業(yè)務(wù)邏輯
} else {
logger.error("調(diào)用騰訊IM創(chuàng)建群組失敗,{}", 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("調(diào)用騰訊IM刪除群組成功,參數(shù):" + contentType + "結(jié)果:{}", res);
} else {
logger.error("調(diào)用騰訊IM刪除群組失敗,{}", res);
}
}
}
/**
* 檢查單個(gè)賬號
* 指定群組的群主時(shí)需要先檢查群主是否注冊到IM系統(tǒng)中赊级,否則指定不成功
* @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("調(diào)用騰訊IM賬號檢查接口成功,賬號:" + account + "結(jié)果:{}", accountCheckRes);
String resultItem = JSONObject.parseObject(accountCheckRes).getJSONArray("ResultItem").getString(0);
//NotImported Imported
return (String) JSON.parseObject(resultItem, Map.class).get("AccountStatus");
} else {
logger.error("調(diào)用騰訊IM賬號檢查接口失敗,賬號:" + account + "參數(shù):" + checkContent + "結(jié)果:{}", accountCheckRes);
return null;
}
}
/**
* 注冊單個(gè)賬號
*
* @param account
* @return
*/
private String importSingleAccount(String account) {
//json參數(shù)
String checkContent = JSON.toJSONString(new HashMap<String, Object>(1) {
{
put("Identifier", account);
}
});
String importAccountRes = HttpUtil.doPostJson(TXIMConfiguration.getAccountImportURL(), checkContent);
logger.info("調(diào)用騰訊IM單個(gè)賬號導(dǎo)入接口," + "參數(shù):" + checkContent + "結(jié)果:{}", importAccountRes);
return JSON.parseObject(importAccountRes).getString("ActionStatus");
}
}
注意预烙,騰訊IM參數(shù)首字母是大寫扁掸,小寫就無法識別翘县,轉(zhuǎn)換成JSON時(shí)候,會(huì)將參數(shù)首字母變成小寫忘伞,需要注意這個(gè)問題舀奶,我這里用@JSONField來解決