联调(新疆信创测试应用 1600134460,租户sa 67群)发现并修复4类问题: 1.群消息死循环:group_msg_get_simple 的 IsFinished=2 表"还有更旧可拉",占位消息群恒为2; 原 oldestSeq=max(seq) 游标方向反,反复拉同一批致2217次/200s超时。 改 min(seq) 向旧翻页 + 游标未推进即停兜底 + 过滤 IsPlaceMsg=1 占位消息(119次/15.7s) 2.get_group_info 请求体字段名 GroupId→GroupIdList(腾讯要求 GroupIdList:["id"] 字符串数组) 3.运行库缺列致 upserted=0:init.sql 仅新建库执行,新增 SchemaUpgradeBootstrap 启动幂等ALTER(6条DDL) 4.maxGroups 防护:break 由 for 外移入 for 内,精确限制避免单页海量群绕过上限 联调验证(租户sa 67群):群组67/67、群成员241/13、群消息119次/imported=3,死循环消除 Co-Authored-By: Claude <noreply@anthropic.com>
570 lines
24 KiB
Java
570 lines
24 KiB
Java
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>
|
||
* 返回体统一含 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(数据迁移拉取用,指定老应用密钥)
|
||
* <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,返回 GroupIdList(GroupId/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) {
|
||
// 腾讯 get_group_info 要求 GroupIdList: ["groupId"](字符串数组,元素须为 string)
|
||
Map<String, Object> body = new HashMap<>();
|
||
body.put("GroupIdList", 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_member,Silence=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_msg,SyncFromOldSystem=1 标记为旧系统迁移消息(不计未读数)。
|
||
* From/To 需已存在于主应用(用户迁移已完成)。MsgBody 透传(来自 getRoamMsg 的原始消息体 JSON)。
|
||
*
|
||
* @param msgBodyJson MsgBody 原始 JSON 数组字符串(来自漫游消息,直接透传)
|
||
* @return ActionStatus 是否 OK
|
||
*/
|
||
public boolean importMsg(String fromAccount, String toAccount, long msgTimeStamp, String msgBodyJson) {
|
||
// 手动拼 body:MsgBody 直接嵌入(已是合法 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;
|
||
}
|
||
}
|
||
}
|