Files
tencent-im-util/src/main/java/com/imutil/tencent/TencentImClient.java
T
连龙刚andClaude fdbb7724fa feat(t17): 数据同步——只读拉取群组/群成员/群消息到本地表,按租户查看
主体(T17 数据同步,/admin/sync 三个独立按钮):
- 同步群组:get_appid_group_list 全量 Next 分页 + 逐群 get_group_info → group_mapping
- 同步群成员(→用户):遍历该租户群取 MemberList → user_mapping(腾讯无全量用户API,靠群成员反推)
- 同步群消息:遍历该租户群 getGroupMsg + IsFinished 滚动全量 → im_message(source=SYNC)
- DB: group_mapping 加 name/owner_account/member_count/last_synced_at;user_mapping 加 nick/last_synced_at
  (init.sql 建表 + ADD COLUMN IF NOT EXISTS 老库升级补丁,幂等)
- TencentImClient: 新增 getAppidGroupList(limit,next[,sdkAppId,secretKey])
- 已知限制:C2C单聊无全量会话API;超大群成员需换 get_group_member_info 分页

附带收尾此前未提交的改动:
- 回调字段名修正(FromAccount→From_Account 等腾讯标准字段) + pickMsgRandom/Time/Type 兼容字段差异
- 租户识别重构:前缀经 TenantService.getByPrefixCode 反查 tenantId(主键雪花化与前缀解耦)
- FreeMarker java.time ?string 坑修复(usage/queue Controller 预格式化) + 消息记录"全部"状态修复
- 新增项目 CLAUDE.md + .claude/memory 基建(gitignore 含密钥记忆,不进 git)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 08:40:46 +08:00

569 lines
24 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.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 收口)
* <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;
// ===== 数据迁移:消息导入限速重试(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(数据迁移拉取用,指定老应用密钥)
* <p>
* 老应用拉取消耗的是老应用自身套餐配额(非主应用),故不走主应用限流;
* 审计 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 + 审计落库
* <p>
* 限流由调用方决定:主应用 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 调用审计
* <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();
}
// 仅查询成功才缓存(含"租户无配额配置→用默认");异常时不缓存,让下次重试
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<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);
}
/**
* 批量导入账号(multiaccount_import,每批 ≤ 100,主应用密钥)
* <p>
* 幂等:已存在账号亦成功。数据迁移用户导入用:老应用 UserID 加租户前缀后批量导入主应用。
* 分批调用,返回成功导入数(整批 ActionStatus=OK 记为全部成功;个别非法 ID 场景后续可解析 RetItemList 精确化)。
*
* @param imUserIds 已加前缀的 IM 用户 ID 列表
* @return 成功导入数
*/
public int batchImport(List<String> imUserIds) {
if (imUserIds == null || imUserIds.isEmpty()) {
return 0;
}
int success = 0;
int batchSize = 100; // 腾讯 multiaccount_import 单批上限 100
for (int i = 0; i < imUserIds.size(); i += batchSize) {
List<String> sub = imUserIds.subList(i, Math.min(i + batchSize, imUserIds.size()));
Map<String, Object> body = new HashMap<>();
body.put("Accounts", sub);
String resp = callApi("im_open_login_svc/multiaccount_import", Jsons.stringify(body));
if (isOk(resp)) {
success += sub.size();
} else {
log.warn("批量导入失败批次 {} size={} resp={}", i / batchSize, sub.size(), resp);
}
}
return success;
}
/**
* 检查账号状态(只读)
* <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));
}
/**
* 查单聊历史漫游消息(老应用拉取,用老密钥)— 数据迁移用
* <p>
* 与 {@link #getRoamMsg} 同语义,但用指定老应用密钥调 callApiAs(迁移拉取消耗老应用配额)。
*/
public String getRoamMsgAs(String fromAccount, String toAccount, int maxCnt, long minTime, long maxInterval,
long srcSdkAppId, String srcSecretKey) {
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 callApiAs("openim_admin/get_roam_msg", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 查群历史消息(老应用拉取,用老密钥,支持 ReqMsgSeq 分页)— 数据迁移用
*
* @param reqMsgSeq 分页游标(上次最旧消息 seq;≤0 表示从最新开始)
*/
public String getGroupMsgAs(String groupId, int reqMsgNumber, long reqMsgSeq,
long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("GroupId", groupId);
body.put("ReqMsgNumber", reqMsgNumber);
if (reqMsgSeq > 0) {
body.put("ReqMsgSeq", reqMsgSeq);
}
return callApiAs("group_open_http_svc/group_msg_get_simple", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
// ==================== 群迁移相关(T14 S3 ====================
/**
* 列出 SDKAppId 下所有群(老应用拉群清单,用老密钥)
* <p>
* 命令字 group_open_http_svc/get_appid_groups,返回 GroupIdListGroupId/Type)。
* 与用户清单不同,群清单可从腾讯 API 直接拉取。
*
* @return 腾讯响应原始 JSON(含 GroupIdList),由调用方解析
*/
public String getAppidGroups(long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("Limit", 10000);
return callApiAs("group_open_http_svc/get_appid_groups", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 全量分页列出 SDKAppId 下所有群(T17 数据同步,主应用密钥)
* <p>
* 命令字 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<String, Object> body = new HashMap<>();
body.put("Limit", limit);
body.put("Next", next == null ? "" : next);
return callApi("group_open_http_svc/get_appid_group_list", Jsons.stringify(body));
}
/**
* 全量分页列出 SDKAppId 下所有群(T17 数据同步,指定应用密钥,用于老应用)
*
* @see #getAppidGroupList(int, String)
*/
public String getAppidGroupList(int limit, String next, long sdkAppId, String secretKey) {
Map<String, Object> body = new HashMap<>();
body.put("Limit", limit);
body.put("Next", next == null ? "" : next);
return callApiAs("group_open_http_svc/get_appid_group_list", Jsons.stringify(body), sdkAppId, secretKey);
}
/**
* 查群资料 + 成员列表(老应用拉群详情,用老密钥)
* <p>
* 命令字 group_open_http_svc/get_group_info,返回 GroupInfo[].MemberList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String getGroupInfo(String groupId, long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("GroupId", List.of(groupId));
return callApiAs("group_open_http_svc/get_group_info", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 建群(主应用,可带初始成员 ≤ 100)
* <p>
* 命令字 group_open_http_svc/create_group。迁移时 newGroupId = 前缀 + 老 GroupId。
* 成员账号需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean createGroup(String groupId, String type, String name, String ownerAccount, List<String> memberAccounts) {
Map<String, Object> body = new HashMap<>();
body.put("Type", type == null || type.isEmpty() ? "Public" : type);
body.put("GroupId", groupId);
body.put("Name", name == null ? groupId : name);
if (ownerAccount != null && !ownerAccount.isEmpty()) {
body.put("Owner_Account", ownerAccount);
}
if (memberAccounts != null && !memberAccounts.isEmpty()) {
List<Map<String, String>> ml = new ArrayList<>();
for (String m : memberAccounts) {
Map<String, String> item = new HashMap<>();
item.put("Member_Account", m);
item.put("MemberRole", "Member");
ml.add(item);
}
body.put("MemberList", ml);
}
String resp = callApi("group_open_http_svc/create_group", Jsons.stringify(body));
return isOk(resp);
}
/**
* 增加群成员(主应用,成员 > 100 时分批补充;成员账号需已存在)
* <p>
* 命令字 group_open_http_svc/add_group_memberSilence=1 静默加入。
*
* @return ActionStatus 是否 OK
*/
public boolean addGroupMember(String groupId, List<String> memberAccounts) {
if (memberAccounts == null || memberAccounts.isEmpty()) {
return true;
}
Map<String, Object> body = new HashMap<>();
body.put("GroupId", groupId);
List<Map<String, String>> ml = new ArrayList<>();
for (String m : memberAccounts) {
Map<String, String> item = new HashMap<>();
item.put("Member_Account", m);
ml.add(item);
}
body.put("MemberList", ml);
body.put("Silence", 1);
String resp = callApi("group_open_http_svc/add_group_member", Jsons.stringify(body));
return isOk(resp);
}
// ==================== 消息迁移相关(T14 S4 ====================
/**
* 导入单聊消息(历史消息,不计未读,主应用)
* <p>
* 命令字 openim_http_svc/import_msgSyncFromOldSystem=1 标记为旧系统迁移消息(不计未读数)。
* From/To 需已存在于主应用(用户迁移已完成)。MsgBody 透传(来自 getRoamMsg 的原始消息体 JSON)。
*
* @param msgBodyJson MsgBody 原始 JSON 数组字符串(来自漫游消息,直接透传)
* @return ActionStatus 是否 OK
*/
public boolean importMsg(String fromAccount, String toAccount, long msgTimeStamp, String msgBodyJson) {
// 手动拼 bodyMsgBody 直接嵌入(已是合法 JSON 数组),from/to 为安全 IM ID 无需转义
String body = "{\"SyncFromOldSystem\":1,\"From_Account\":\"" + fromAccount
+ "\",\"To_Account\":\"" + toAccount
+ "\",\"MsgTimeStamp\":" + msgTimeStamp
+ ",\"MsgBody\":" + (msgBodyJson == null ? "[]" : msgBodyJson) + "}";
// V3: 消息导入密集调用易触主应用限流,捕获 429 限速重试;耗尽或非 429 返回 false 跳过该条
for (int attempt = 0; attempt <= migrateImportMaxRetry; attempt++) {
try {
String resp = callApi("openim_http_svc/import_msg", body);
return isOk(resp);
} catch (BizException e) {
if (e.getCode() != 429 || attempt == migrateImportMaxRetry) {
log.warn("导入单聊消息失败 from={} to={} ts={} : {}", fromAccount, toAccount, msgTimeStamp, e.getMessage());
return false;
}
sleepRetry();
}
}
return false;
}
/**
* 导入群消息(历史消息,主应用)
* <p>
* 命令字 group_open_http_svc/import_group_msg。GroupId/From 需已迁移(群迁移 + 用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean importGroupMsg(String groupId, String fromAccount, long msgTimeStamp, String msgBodyJson) {
String body = "{\"GroupId\":\"" + groupId
+ "\",\"From_Account\":\"" + (fromAccount == null ? "" : fromAccount)
+ "\",\"MsgTimeStamp\":" + msgTimeStamp
+ ",\"SyncFromOldSystem\":1,\"MsgBody\":" + (msgBodyJson == null ? "[]" : msgBodyJson) + "}";
// V3: 消息导入密集调用易触主应用限流,捕获 429 限速重试;耗尽或非 429 返回 false 跳过该条
for (int attempt = 0; attempt <= migrateImportMaxRetry; attempt++) {
try {
String resp = callApi("group_open_http_svc/import_group_msg", body);
return isOk(resp);
} catch (BizException e) {
if (e.getCode() != 429 || attempt == migrateImportMaxRetry) {
log.warn("导入群消息失败 group={} from={} ts={} : {}", groupId, fromAccount, msgTimeStamp, e.getMessage());
return false;
}
sleepRetry();
}
}
return false;
}
/**
* 消息导入限速重试间隔(T14 V3)
* <p>
* 429 配额超限时 sleep 后重试;线程被中断则提前返回(中断状态已恢复,由上层决定是否继续)。
*/
private void sleepRetry() {
try {
Thread.sleep(migrateImportRetryMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
// ==================== 关系链迁移相关(T14 S5 ====================
/**
* 导入好友关系(主应用,历史关系链迁移)
* <p>
* 命令字 sns/openim_http_svc/import_friend。From/To 需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean importFriend(String fromAccount, List<String> friendAccounts) {
if (friendAccounts == null || friendAccounts.isEmpty()) {
return true;
}
Map<String, Object> body = new HashMap<>();
body.put("From_Account", fromAccount);
List<Map<String, String>> fl = new ArrayList<>();
for (String f : friendAccounts) {
Map<String, String> item = new HashMap<>();
item.put("To_Account", f);
item.put("AddSource", "AddSource_Type_System");
fl.add(item);
}
body.put("FriendList", fl);
String resp = callApi("sns/openim_http_svc/import_friend", Jsons.stringify(body));
return isOk(resp);
}
/**
* 拉取用户好友列表(老应用拉关系链,用老密钥)
* <p>
* 命令字 sns/openim_http_svc/friend_get_list,返回 FriendList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String friendGetList(String account, long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("From_Account", account);
body.put("StartSequence", 0L);
return callApiAs("sns/openim_http_svc/friend_get_list", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 判断腾讯 API 返回是否成功
*/
private boolean isOk(String respJson) {
try {
ONode n = ONode.ofJson(respJson);
return "OK".equalsIgnoreCase(n.get("ActionStatus").getString());
} catch (Exception e) {
return false;
}
}
}