feat: 初始化腾讯IM分发工具骨架,含回调分发链路与历史消息补拉
- 应用骨架: Solon + MyBatis-Plus + PostgreSQL + Redis + Sa-Token - 核心链路: 回调网关 → 消息落库(幂等) → 分发队列(FOR UPDATE SKIP LOCKED) → worker消费/重试/死信 - 多租户隔离: 前缀法账号映射, 所有业务表带 tenant_id - 管理后台: FreeMarker 渲染, 租户/授权/队列/用量管理页面 - 历史消息补拉: 水位线驱动, getRoamMsg/getGroupMsg 增量拉取, 兜底回调丢失 - msg_key 统一算法(回调/补拉共享, 跨路径去重) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package com.imutil.tencent;
|
||||
|
||||
import com.imutil.common.Httpx;
|
||||
import com.imutil.common.Jsons;
|
||||
import com.imutil.common.TenantContext;
|
||||
import com.imutil.entity.ApiCallLog;
|
||||
import com.imutil.mapper.ApiCallLogMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.snack4.ONode;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 腾讯 IM REST API 客户端(管理 API 收口)
|
||||
* <p>
|
||||
* 鉴权方式:请求 URL 带 admin 的 UserSig + identifier + sdkappid query 参数(非 TC3 签名)。
|
||||
* 所有调腾讯后台 API 的入口集中在此,密钥仅本类持有,业务系统不得直接调用。
|
||||
* <p>
|
||||
* 每次调用同步写 {@code api_call_log} 审计(try-catch,写入失败不影响主流程),
|
||||
* 作为用量统计(api_call_count)与问题追溯的数据源。
|
||||
* <p>
|
||||
* 返回体统一含 ActionStatus(OK/FAIL)、ErrorCode、ErrorInfo,本类透传状态。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TencentImClient {
|
||||
|
||||
@Inject("${imutil.tencent.sdkAppId:0}")
|
||||
private long sdkAppId;
|
||||
|
||||
@Inject("${imutil.tencent.secretKey:}")
|
||||
private String secretKey;
|
||||
|
||||
@Inject("${imutil.tencent.adminUserId:administrator}")
|
||||
private String adminUserId;
|
||||
|
||||
@Inject("${imutil.tencent.apiHost:console.tim.qq.com}")
|
||||
private String apiHost;
|
||||
|
||||
@Inject
|
||||
private ApiCallLogMapper apiCallLogMapper;
|
||||
|
||||
/**
|
||||
* 生成管理员 UserSig(长效,用于调后台 API)
|
||||
*/
|
||||
private String genAdminSig() {
|
||||
return UserSigUtil.genSig(sdkAppId, secretKey, adminUserId, 30L * 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 IM REST API
|
||||
*
|
||||
* @param command 命令路径,如 im_open_login_svc/account_import
|
||||
* @param bodyJson 请求体 JSON
|
||||
* @return 响应 JSON 字符串
|
||||
*/
|
||||
public String callApi(String command, String bodyJson) {
|
||||
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
|
||||
String tenantId = TenantContext.get();
|
||||
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
|
||||
|
||||
String result;
|
||||
try {
|
||||
String adminSig = genAdminSig();
|
||||
String url = "https://" + apiHost + "/v4/" + command
|
||||
+ "?sdkappid=" + sdkAppId
|
||||
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
|
||||
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
|
||||
+ "&contenttype=json&platform=10&apn=1";
|
||||
Httpx.Response resp = Httpx.postJsonDetail(url, bodyJson);
|
||||
log.info("调腾讯API {} code={} body={}", command, resp.statusCode(), resp.body());
|
||||
result = resp.body();
|
||||
} catch (Exception e) {
|
||||
log.error("调腾讯API异常 {} : {}", command, e.toString());
|
||||
result = "{\"ActionStatus\":\"FAIL\",\"ErrorCode\":-1,\"ErrorInfo\":\""
|
||||
+ e.getClass().getSimpleName() + "\"}";
|
||||
}
|
||||
// 审计落库(失败不影响主流程)
|
||||
recordCallLog(tid, command, bodyJson, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 API 调用审计
|
||||
* <p>
|
||||
* 同步写 + try-catch:审计写入异常不阻塞业务调用。
|
||||
* params/result 完整存(审计完整性优先,text 字段无长度限制)。
|
||||
*/
|
||||
private void recordCallLog(String tenantId, String apiName, String params, String result) {
|
||||
try {
|
||||
ApiCallLog entry = new ApiCallLog();
|
||||
entry.setTenantId(tenantId);
|
||||
entry.setApiName(apiName);
|
||||
entry.setParams(params);
|
||||
entry.setResult(result);
|
||||
entry.setCaller("system");
|
||||
entry.setCalledAt(OffsetDateTime.now());
|
||||
apiCallLogMapper.insert(entry);
|
||||
} catch (Exception e) {
|
||||
log.warn("写入 api_call_log 失败 api={} : {}", apiName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
|
||||
*
|
||||
* @return ActionStatus 是否 OK
|
||||
*/
|
||||
public boolean accountImport(String imUserId, String nick, String faceUrl) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("Identifier", imUserId);
|
||||
if (nick != null && !nick.isEmpty()) {
|
||||
body.put("Nick", nick);
|
||||
}
|
||||
if (faceUrl != null && !faceUrl.isEmpty()) {
|
||||
body.put("FaceUrl", faceUrl);
|
||||
}
|
||||
String resp = callApi("im_open_login_svc/account_import", Jsons.stringify(body));
|
||||
return isOk(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号状态(只读)
|
||||
* <p>
|
||||
* 用于验证 admin UserSig 有效性:返回 ActionStatus=OK 即签名校验通过(与账号是否存在无关)。
|
||||
*
|
||||
* @return true=API 调用成功(签名有效)
|
||||
*/
|
||||
public boolean accountCheck(String imUserId) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("UserID", imUserId);
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("CheckItem", List.of(item));
|
||||
String resp = callApi("im_open_login_svc/account_check", Jsons.stringify(body));
|
||||
return isOk(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单聊历史漫游消息(C2C)
|
||||
* <p>
|
||||
* 命令字 openim_admin/get_roam_msg,按时间窗返回指定两人之间最近的 MaxCnt 条消息。
|
||||
* 补拉服务据此增量补全回调丢失的单聊消息。
|
||||
*
|
||||
* @param fromAccount 发送方 IM 账号
|
||||
* @param toAccount 接收方 IM 账号
|
||||
* @param maxCnt 单次拉取条数上限
|
||||
* @param minTime 拉取时间窗起点(秒级 epoch)
|
||||
* @param maxInterval 拉取时间窗跨度(秒),从 minTime 起算
|
||||
* @return 腾讯响应原始 JSON(含 MsgList/Complete/LastMsgTime/LastMsgSeq),由调用方解析
|
||||
*/
|
||||
public String getRoamMsg(String fromAccount, String toAccount, int maxCnt, long minTime, long maxInterval) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("From_Account", fromAccount);
|
||||
body.put("To_Account", toAccount);
|
||||
body.put("MaxCnt", maxCnt);
|
||||
body.put("MinTime", minTime);
|
||||
body.put("MaxTimeInterval", maxInterval);
|
||||
return callApi("openim_admin/get_roam_msg", Jsons.stringify(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询群历史消息(GROUP)
|
||||
* <p>
|
||||
* 命令字 group_open_http_svc/group_msg_get_simple,不传 ReqMsgSeq 时返回最新 ReqMsgNumber 条。
|
||||
* 补拉服务据此增量补全回调丢失的群消息。
|
||||
*
|
||||
* @param groupId 群 ID
|
||||
* @param reqMsgNumber 单次拉取条数上限
|
||||
* @return 腾讯响应原始 JSON(含 RspMsgList/IsFinished),由调用方解析
|
||||
*/
|
||||
public String getGroupMsg(String groupId, int reqMsgNumber) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("GroupId", groupId);
|
||||
body.put("ReqMsgNumber", reqMsgNumber);
|
||||
return callApi("group_open_http_svc/group_msg_get_simple", Jsons.stringify(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断腾讯 API 返回是否成功
|
||||
*/
|
||||
private boolean isOk(String respJson) {
|
||||
try {
|
||||
ONode n = ONode.ofJson(respJson);
|
||||
return "OK".equalsIgnoreCase(n.get("ActionStatus").getString());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user