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>
This commit is contained in:
@@ -2,9 +2,13 @@ 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;
|
||||
@@ -49,6 +53,18 @@ public class TencentImClient {
|
||||
@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)
|
||||
*/
|
||||
@@ -68,6 +84,10 @@ public class TencentImClient {
|
||||
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();
|
||||
@@ -110,6 +130,39 @@ public class TencentImClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析租户 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)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user