package com.imutil.tencent; import com.imutil.common.BizException; import com.imutil.common.Httpx; import com.imutil.common.Jsons; import com.imutil.common.LocalCache; import com.imutil.common.RateLimiter; import com.imutil.common.TenantContext; import com.imutil.entity.ApiCallLog; import com.imutil.entity.Tenant; import com.imutil.mapper.ApiCallLogMapper; import com.imutil.service.TenantService; 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.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 腾讯 IM REST API 客户端(管理 API 收口) *
* 鉴权方式:请求 URL 带 admin 的 UserSig + identifier + sdkappid query 参数(非 TC3 签名)。 * 所有调腾讯后台 API 的入口集中在此,密钥仅本类持有,业务系统不得直接调用。 *
* 每次调用同步写 {@code api_call_log} 审计(try-catch,写入失败不影响主流程), * 作为用量统计(api_call_count)与问题追溯的数据源。 *
* 返回体统一含 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; @Inject("${imutil.ratelimit.defaultImQps:50}") private int defaultImQps; @Inject private RateLimiter rateLimiter; // ===== 数据迁移:消息导入限速重试(T14 V3)===== /** 消息导入 429 限流重试次数 */ @Inject("${imutil.migrate.importMaxRetry:3}") private int migrateImportMaxRetry; /** 消息导入重试间隔(毫秒) */ @Inject("${imutil.migrate.importRetryMs:1000}") private long migrateImportRetryMs; @Inject private TenantService tenantService; @Inject private LocalCache localCache; /** * 调用主应用 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; // 出站腾讯 API 限流:按租户配额 + 全局共享配额(对齐设计 3.2),超限抛 429 // system(无租户上下文)用默认配额;补拉等后台任务超限由调用方 catch 跳过本轮 rateLimiter.checkApiLimit(tid, resolveTenantQps(tid)); return doCallApi(command, bodyJson, sdkAppId, secretKey, tid); } /** * 调用老应用 IM REST API(数据迁移拉取用,指定老应用密钥) *
* 老应用拉取消耗的是老应用自身套餐配额(非主应用),故不走主应用限流; * 审计 tid 记 "migrate" 便于与业务调用区分。管理员 identifier 默认 administrator * (腾讯各应用默认管理员账号),如某老应用管理员不同,后续扩展 source_app 字段。 * * @param srcSdkAppId 老应用 SDKAppId * @param srcSecretKey 老应用密钥 * @return 响应 JSON 字符串 */ public String callApiAs(String command, String bodyJson, long srcSdkAppId, String srcSecretKey) { return doCallApi(command, bodyJson, srcSdkAppId, srcSecretKey, "migrate"); } /** * 调用腾讯 API 的公共实现:生成管理员 UserSig + 拼 URL + POST + 审计落库 *
* 限流由调用方决定:主应用 callApi 走限流,老应用 callApiAs 不走(消耗老应用配额)。 */ private String doCallApi(String command, String bodyJson, long appid, String key, String tid) { String result; try { String adminSig = UserSigUtil.genSig(appid, key, adminUserId, 30L * 86400); String url = "https://" + apiHost + "/v4/" + command + "?sdkappid=" + appid + "&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 调用审计 *
* 同步写 + 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 API 配额:tenant.quota_im_qps,缺省 defaultImQps;本地缓存 60s *
* 配额变更(管理后台改 quota_im_qps)最长 60s 生效;system(无租户上下文)用默认配额。
*
* @param tid 租户ID
* @return 该租户 IM API QPS 配额
*/
private int resolveTenantQps(String tid) {
if (tid == null || tid.isEmpty() || "system".equals(tid)) {
return defaultImQps;
}
String cacheKey = "tenantQps:" + tid;
String cached = localCache.get(cacheKey);
if (cached != null) {
try {
return Integer.parseInt(cached);
} catch (NumberFormatException ignore) {
}
}
int qps = defaultImQps;
try {
Tenant t = tenantService.getById(tid);
if (t != null && t.getQuotaImQps() != null && t.getQuotaImQps() > 0) {
qps = t.getQuotaImQps();
}
// 仅查询成功才缓存(含"租户无配额配置→用默认");异常时不缓存,让下次重试
localCache.put(cacheKey, String.valueOf(qps));
} catch (Exception e) {
log.warn("查询租户配额失败 tenant={},用默认 {}(不缓存,下次重试): {}", tid, defaultImQps, e.getMessage());
}
return qps;
}
/**
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
*
* @return ActionStatus 是否 OK
*/
public boolean accountImport(String imUserId, String nick, String faceUrl) {
Map
* 幂等:已存在账号亦成功。数据迁移用户导入用:老应用 UserID 加租户前缀后批量导入主应用。
* 分批调用,返回成功导入数(整批 ActionStatus=OK 记为全部成功;个别非法 ID 场景后续可解析 RetItemList 精确化)。
*
* @param imUserIds 已加前缀的 IM 用户 ID 列表
* @return 成功导入数
*/
public int batchImport(List
* 用于验证 admin UserSig 有效性:返回 ActionStatus=OK 即签名校验通过(与账号是否存在无关)。
*
* @return true=API 调用成功(签名有效)
*/
public boolean accountCheck(String imUserId) {
Map
* 命令字 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
* 命令字 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
* 与 {@link #getRoamMsg} 同语义,但用指定老应用密钥调 callApiAs(迁移拉取消耗老应用配额)。
*/
public String getRoamMsgAs(String fromAccount, String toAccount, int maxCnt, long minTime, long maxInterval,
long srcSdkAppId, String srcSecretKey) {
Map
* 命令字 group_open_http_svc/get_appid_groups,返回 GroupIdList(GroupId/Type)。
* 与用户清单不同,群清单可从腾讯 API 直接拉取。
*
* @return 腾讯响应原始 JSON(含 GroupIdList),由调用方解析
*/
public String getAppidGroups(long srcSdkAppId, String srcSecretKey) {
Map
* 命令字 group_open_http_svc/get_appid_group_list,支持 Next 游标分页:
* 首次 Next 传空串,循环把上次返回的 Next 再传入,直到返回 Next 为空。
* 与 {@link #getAppidGroups} 的区别:标准接口、支持分页、GroupIdList 元素为纯字符串。
*
* @param limit 每页数量(建议 100)
* @param next 分页游标,首次传空串
* @return 腾讯响应原始 JSON(含 Next/Total/GroupIdList),由调用方解析
*/
public String getAppidGroupList(int limit, String next) {
Map
* 命令字 group_open_http_svc/get_group_info,返回 GroupInfo[].MemberList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String getGroupInfo(String groupId, long srcSdkAppId, String srcSecretKey) {
Map
* 命令字 group_open_http_svc/create_group。迁移时 newGroupId = 前缀 + 老 GroupId。
* 成员账号需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean createGroup(String groupId, String type, String name, String ownerAccount, List