Files
tencent-im-util/src/main/java/com/imutil/tencent/TencentImClient.java
T
yixiongandClaude 7b1385e160 feat(t14-s1s2): 数据迁移基础设施 + 用户迁移
T14 数据迁移工具 S1/S2 阶段:

S1 基础设施:
- source_app/migrate_task 建表 + 实体 + Mapper
- TencentImClient 双密钥 callApiAs(老应用拉取,不走主应用限流,审计记 migrate)
- 后台「老应用配置」页(CRUD + 测试连通)

S2 用户迁移:
- batchImport(multiaccount_import,每批≤100)
- MigrateService.migrateUsers(前缀转换+导入+映射+校验,幂等)
- 后台「数据迁移」页(上传清单文件/粘贴列表 + 任务记录)

联调通过:3用户清单→加sa前缀→腾讯导入OK→user_mapping→校验3/3/3。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 11:12:25 +08:00

297 lines
12 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;
/**
* 调用主应用 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));
}
/**
* 判断腾讯 API 返回是否成功
*/
private boolean isOk(String respJson) {
try {
ONode n = ONode.ofJson(respJson);
return "OK".equalsIgnoreCase(n.get("ActionStatus").getString());
} catch (Exception e) {
return false;
}
}
}