Files
tencent-im-util/src/main/java/com/imutil/tencent/TencentImClient.java
T
yixiongandClaude 43f7d30e46 feat(t9): 按租户令牌桶限流(出站腾讯API配额保护,对齐设计3.2)
- RateLimiter(新): Redis+Lua 原子令牌桶,租户桶(quota_im_qps)+全局桶(globalImQps),
  任一超限抛 BizException(429);Redis 异常 fail-open 放行
- RedisService: +eval(Lua) 通用脚本执行(令牌桶复合操作需原子)
- TencentImClient.callApi: 开头 checkApiLimit,resolveTenantQps 查 quota_im_qps(Caffeine 60s)
- 联调:sa quota=2,8 并发→2 放行+6 限流,被拒不调腾讯(api_call_log 仅 2 条)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 08:39:43 +08:00

252 lines
9.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.imutil.tencent;
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.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>
* 返回体统一含 ActionStatusOK/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;
@Inject
private TenantService tenantService;
@Inject
private LocalCache localCache;
/**
* 生成管理员 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;
// 出站腾讯 API 限流:按租户配额 + 全局共享配额(对齐设计 3.2),超限抛 429
// system(无租户上下文)用默认配额;补拉等后台任务超限由调用方 catch 跳过本轮
rateLimiter.checkApiLimit(tid, resolveTenantQps(tid));
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 API 配额:tenant.quota_im_qps,缺省 defaultImQps;本地缓存 60s
* <p>
* 配额变更(管理后台改 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();
}
} catch (Exception e) {
log.warn("查询租户配额失败 tenant={},用默认 {} : {}", tid, defaultImQps, e.getMessage());
}
localCache.put(cacheKey, String.valueOf(qps));
return qps;
}
/**
* 导入账号(创建 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;
}
}
}