Compare commits
8
Commits
d22fdcfaad
...
f401e8d065
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f401e8d065 | ||
|
|
460a31b52f | ||
|
|
7b1385e160 | ||
|
|
b4c6652f66 | ||
|
|
8e2c66b363 | ||
|
|
43f7d30e46 | ||
|
|
e3a64bb861 | ||
|
|
8c9037fc4f |
@@ -0,0 +1,110 @@
|
|||||||
|
package com.imutil.common;
|
||||||
|
|
||||||
|
import com.imutil.mapper.DistQueueMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
import redis.clients.jedis.Jedis;
|
||||||
|
import redis.clients.jedis.JedisPool;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查服务(对齐设计:监控告警 - 中间件连通、dist_queue 堆积)
|
||||||
|
* <p>
|
||||||
|
* 聚合 PG / Redis 连通性与分发队列状态分布,输出整体 status:
|
||||||
|
* - UP:PG + Redis 均连通
|
||||||
|
* - DEGRADED:PG 连通、Redis 异常(限流 fail-open、缓存降级,核心链路仍可用)
|
||||||
|
* - DOWN:PG 异常(消息落库不可用,核心受损)
|
||||||
|
* <p>
|
||||||
|
* 队列 pending/processing/dead 计数供运维判断是否需干预:
|
||||||
|
* - pending 持续上涨 → worker 处理不过去,考虑扩容
|
||||||
|
* - processing 长期不归零且无 done 增长 → 工作线程可能卡死
|
||||||
|
* - dead 积累 → 需排查死信或重发
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class HealthService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private DistQueueMapper distQueueMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private JedisPool jedisPool;
|
||||||
|
|
||||||
|
/** 运维测试用:强制返回 DOWN,验证 LB/k8s 探针摘流(默认关) */
|
||||||
|
@Inject("${imutil.health.forceDown:false}")
|
||||||
|
private boolean forceDown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集健康指标
|
||||||
|
*
|
||||||
|
* @return 含 status / pg / redis / distQueue / timestamp
|
||||||
|
*/
|
||||||
|
public Map<String, Object> health() {
|
||||||
|
// 运维测试开关:强制 DOWN,验证探针据 503 摘流
|
||||||
|
if (forceDown) {
|
||||||
|
Map<String, Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("status", "DOWN");
|
||||||
|
r.put("pg", "DOWN");
|
||||||
|
r.put("redis", "UP");
|
||||||
|
r.put("distQueue", new LinkedHashMap<>());
|
||||||
|
r.put("forced", true);
|
||||||
|
r.put("timestamp", OffsetDateTime.now().toString());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
Map<String, Object> distQueue = new LinkedHashMap<>();
|
||||||
|
boolean pgUp;
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> rows = distQueueMapper.countByStatus();
|
||||||
|
// 四态计数初始化为 0(无记录的状态不出现在 GROUP BY 结果中)
|
||||||
|
long[] cnt = new long[4];
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
int st = ((Number) row.get("status")).intValue();
|
||||||
|
long c = ((Number) row.get("cnt")).longValue();
|
||||||
|
if (st >= 0 && st < 4) {
|
||||||
|
cnt[st] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
distQueue.put("pending", cnt[0]);
|
||||||
|
distQueue.put("processing", cnt[1]);
|
||||||
|
distQueue.put("dead", cnt[3]);
|
||||||
|
// done(status=2) 不统计:无索引且无限增长,健康检查只关心堆积/死信
|
||||||
|
pgUp = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("健康检查 PG 查询失败 : {}", e.getMessage());
|
||||||
|
distQueue.put("error", e.getMessage());
|
||||||
|
pgUp = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean redisUp;
|
||||||
|
try (Jedis j = jedisPool.getResource()) {
|
||||||
|
redisUp = "PONG".equals(j.ping());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("健康检查 Redis ping 失败 : {}", e.getMessage());
|
||||||
|
redisUp = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String status;
|
||||||
|
if (pgUp && redisUp) {
|
||||||
|
status = "UP";
|
||||||
|
} else if (pgUp) {
|
||||||
|
status = "DEGRADED";
|
||||||
|
} else {
|
||||||
|
status = "DOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("status", status);
|
||||||
|
r.put("pg", pgUp ? "UP" : "DOWN");
|
||||||
|
r.put("redis", redisUp ? "UP" : "DOWN");
|
||||||
|
r.put("distQueue", distQueue);
|
||||||
|
r.put("timestamp", OffsetDateTime.now().toString());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.imutil.common;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按租户令牌桶限流(出站腾讯 API 配额保护,对齐设计 3.2)
|
||||||
|
* <p>
|
||||||
|
* 合用 SDKAppID 后所有租户瓜分腾讯侧共享 QPS,本工具作为唯一出站入口,
|
||||||
|
* 在调腾讯 API 前做两层令牌桶限流:
|
||||||
|
* 1. 租户桶(tenant.quota_im_qps,缺省 defaultImQps)—— 防单租户突发;
|
||||||
|
* 2. 全局桶(globalImQps,腾讯限制的 80%)—— 兜底共享配额。
|
||||||
|
* 任一桶令牌耗尽即拒绝(抛 {@link BizException} 429)。
|
||||||
|
* <p>
|
||||||
|
* 令牌桶用 Redis + Lua 原子实现,多节点一致;Redis 异常时 fail-open(放行,避免拖垮业务)。
|
||||||
|
* 容量=填充速率=QPS,即严格每秒 QPS 次(不为突发额外放大容量,以稳守腾讯配额)。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RateLimiter {
|
||||||
|
|
||||||
|
// 令牌桶 Lua:用 Redis 服务端 TIME(避免多节点客户端时钟偏斜导致令牌凭空填充),
|
||||||
|
// 按时间填充令牌、扣减,返回 1=放行 0=超限
|
||||||
|
private static final String TOKEN_BUCKET_LUA =
|
||||||
|
"local key = KEYS[1] " +
|
||||||
|
"local capacity = tonumber(ARGV[1]) " +
|
||||||
|
"local rate = tonumber(ARGV[2]) " +
|
||||||
|
"local requested = tonumber(ARGV[3]) " +
|
||||||
|
"local tt = redis.call('TIME') " +
|
||||||
|
"local now = tonumber(tt[1]) * 1000 + math.floor(tonumber(tt[2]) / 1000) " +
|
||||||
|
"local tokens = tonumber(redis.call('HGET', key, 'tokens')) " +
|
||||||
|
"local last = tonumber(redis.call('HGET', key, 'last')) " +
|
||||||
|
"if tokens == nil then tokens = capacity end " +
|
||||||
|
"if last == nil then last = now end " +
|
||||||
|
"local delta = math.max(0, now - last) / 1000.0 * rate " +
|
||||||
|
"tokens = math.min(capacity, tokens + delta) " +
|
||||||
|
"local allowed = 0 " +
|
||||||
|
"if tokens >= requested then tokens = tokens - requested; allowed = 1 end " +
|
||||||
|
"redis.call('HMSET', key, 'tokens', tokens, 'last', now) " +
|
||||||
|
"redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) " +
|
||||||
|
"return allowed";
|
||||||
|
|
||||||
|
@Inject("${imutil.ratelimit.globalImQps:200}")
|
||||||
|
private int globalImQps;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private RedisService redisService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出站腾讯 API 限流检查:租户桶 + 全局桶,任一超限抛 BizException(429)
|
||||||
|
*
|
||||||
|
* @param tenantId 当前租户(无上下文为 system)
|
||||||
|
* @param tenantQps 该租户配额(QPS),由调用方解析(tenant.quota_im_qps 或默认)
|
||||||
|
*/
|
||||||
|
public void checkApiLimit(String tenantId, int tenantQps) {
|
||||||
|
// 1. 租户桶:防单租户突发打爆共享配额
|
||||||
|
if (!tryAcquire("rl:tenant:" + tenantId, tenantQps)) {
|
||||||
|
log.warn("租户IM API配额超限 tenant={} qps={}", tenantId, tenantQps);
|
||||||
|
throw new BizException(429, "租户[" + tenantId + "] IM API 配额超限,请稍后重试");
|
||||||
|
}
|
||||||
|
// 2. 全局桶:兜底共享 SDKAppID 总配额(腾讯限制的 80%)
|
||||||
|
if (!tryAcquire("rl:global", globalImQps)) {
|
||||||
|
log.warn("全局IM API配额超限 globalQps={}", globalImQps);
|
||||||
|
throw new BizException(429, "全局 IM API 配额超限,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌桶尝试获取 1 个令牌(capacity=rate=qps,严格每秒 qps 次)
|
||||||
|
* <p>
|
||||||
|
* Redis 异常时 fail-open 放行:限流是保护措施,不可因 Redis 故障拖垮业务调用。
|
||||||
|
*
|
||||||
|
* @param key 桶键
|
||||||
|
* @param qps 每秒允许次数(容量与填充速率均取此值);<=0 表示不限流
|
||||||
|
* @return true=放行 false=超限
|
||||||
|
*/
|
||||||
|
private boolean tryAcquire(String key, int qps) {
|
||||||
|
if (qps <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object r = redisService.eval(TOKEN_BUCKET_LUA,
|
||||||
|
List.of(key),
|
||||||
|
List.of(String.valueOf(qps), String.valueOf(qps), "1"));
|
||||||
|
// Lua 返回 number,Jedis 转 Long
|
||||||
|
long allowed = (r instanceof Number) ? ((Number) r).longValue() : Long.parseLong(String.valueOf(r));
|
||||||
|
return allowed == 1L;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("限流Redis异常,fail-open放行 key={} : {}", key, e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,4 +139,18 @@ public class RedisService {
|
|||||||
return j.ttl(key);
|
return j.ttl(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 Lua 脚本(原子操作,用于令牌桶限流等复合场景)
|
||||||
|
*
|
||||||
|
* @param script Lua 脚本
|
||||||
|
* @param keys KEYS 列表
|
||||||
|
* @param args ARGV 列表
|
||||||
|
* @return 脚本返回值(Jedis 按类型转 Long/String/List)
|
||||||
|
*/
|
||||||
|
public Object eval(String script, java.util.List<String> keys, java.util.List<String> args) {
|
||||||
|
try (Jedis j = jedisPool.getResource()) {
|
||||||
|
return j.eval(script, keys, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ import com.imutil.entity.CrossTenantGrant;
|
|||||||
import com.imutil.entity.DistQueue;
|
import com.imutil.entity.DistQueue;
|
||||||
import com.imutil.entity.Tenant;
|
import com.imutil.entity.Tenant;
|
||||||
import com.imutil.entity.UsageStat;
|
import com.imutil.entity.UsageStat;
|
||||||
|
import com.imutil.entity.SourceApp;
|
||||||
import com.imutil.mapper.CrossTenantGrantMapper;
|
import com.imutil.mapper.CrossTenantGrantMapper;
|
||||||
import com.imutil.mapper.DistQueueMapper;
|
import com.imutil.mapper.DistQueueMapper;
|
||||||
import com.imutil.mapper.TenantMapper;
|
import com.imutil.mapper.TenantMapper;
|
||||||
import com.imutil.mapper.UsageStatMapper;
|
import com.imutil.mapper.UsageStatMapper;
|
||||||
import com.imutil.service.AdminUserService;
|
import com.imutil.service.AdminUserService;
|
||||||
|
import com.imutil.service.SourceAppService;
|
||||||
|
import com.imutil.tencent.TencentImClient;
|
||||||
|
import com.imutil.entity.MigrateTask;
|
||||||
|
import com.imutil.service.MigrateService;
|
||||||
|
import org.noear.solon.core.handle.UploadedFile;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.noear.solon.annotation.Controller;
|
import org.noear.solon.annotation.Controller;
|
||||||
import org.noear.solon.annotation.Get;
|
import org.noear.solon.annotation.Get;
|
||||||
@@ -53,6 +62,15 @@ public class AdminController {
|
|||||||
@Inject
|
@Inject
|
||||||
private UsageStatMapper usageStatMapper;
|
private UsageStatMapper usageStatMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private SourceAppService sourceAppService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private TencentImClient tencentImClient;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private MigrateService migrateService;
|
||||||
|
|
||||||
// ==================== 登录 / 登出 ====================
|
// ==================== 登录 / 登出 ====================
|
||||||
|
|
||||||
@Get
|
@Get
|
||||||
@@ -234,6 +252,232 @@ public class AdminController {
|
|||||||
return mv;
|
return mv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 老应用配置(数据迁移源) ====================
|
||||||
|
|
||||||
|
@Get
|
||||||
|
@Mapping("/sourceapp")
|
||||||
|
public Object sourceAppPage(@Param(defaultValue = "") String msg) {
|
||||||
|
ModelAndView mv = view("sourceapp.ftl", "老应用配置", "sourceapp");
|
||||||
|
mv.put("apps", sourceAppService.list());
|
||||||
|
mv.put("tenants", tenantMapper.selectList(null));
|
||||||
|
mv.put("msg", msg);
|
||||||
|
return mv;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post
|
||||||
|
@Mapping("/sourceapp/save")
|
||||||
|
public void sourceAppSave(SourceApp app, Context ctx) throws Throwable {
|
||||||
|
if (app.getSdkAppId() == null || app.getSdkAppId().isEmpty()) {
|
||||||
|
ctx.redirect(basePath + "/admin/sourceapp?msg=sdkappid_required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app.getTenantId() == null || app.getTenantId().isEmpty()) {
|
||||||
|
ctx.redirect(basePath + "/admin/sourceapp?msg=tenant_required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceAppService.save(app);
|
||||||
|
ctx.redirect(basePath + "/admin/sourceapp?msg=saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post
|
||||||
|
@Mapping("/sourceapp/toggle")
|
||||||
|
public void sourceAppToggle(@Param Long id, @Param Integer status, Context ctx) throws Throwable {
|
||||||
|
sourceAppService.toggle(id, status);
|
||||||
|
ctx.redirect(basePath + "/admin/sourceapp?msg=toggled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试老应用连通性:用老应用密钥调 account_import(幂等,导入 administrator,无实质副作用),
|
||||||
|
* 验证 SDKAppId/密钥/sig 正确。腾讯返回业务级响应(非 70003)即说明 sig 鉴权通过、连通成功。
|
||||||
|
*/
|
||||||
|
@Post
|
||||||
|
@Mapping("/sourceapp/test")
|
||||||
|
public void sourceAppTest(@Param Long id, Context ctx) throws Throwable {
|
||||||
|
SourceApp app = sourceAppService.getById(id);
|
||||||
|
if (app == null) {
|
||||||
|
ctx.redirect(basePath + "/admin/sourceapp?msg=notfound");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// account_import:幂等导入 administrator(主应用/老应用本就需要该管理员账号),必返 ActionStatus=OK
|
||||||
|
String body = "{\"Identifier\":\"administrator\"}";
|
||||||
|
String resp = tencentImClient.callApiAs("im_open_login_svc/account_import", body,
|
||||||
|
Long.parseLong(app.getSdkAppId()), app.getSecretKey());
|
||||||
|
// 连通判定:网络异常(ErrorCode=-1,doCallApi 兜底)→失败;70003=UserSig 错误(密钥不匹配);
|
||||||
|
// 其余任何腾讯业务级响应都说明请求已达腾讯且 sig 鉴权通过 → 连通成功
|
||||||
|
String msg;
|
||||||
|
if (resp.contains("\"ErrorCode\":-1")) {
|
||||||
|
msg = "test_fail";
|
||||||
|
} else if (resp.contains("70003")) {
|
||||||
|
msg = "test_sigfail";
|
||||||
|
} else {
|
||||||
|
msg = "test_ok";
|
||||||
|
}
|
||||||
|
log.info("老应用连通测试 id={} sdk={} resp={}", id, app.getSdkAppId(), resp);
|
||||||
|
ctx.redirect(basePath + "/admin/sourceapp?msg=" + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据迁移(T14) ====================
|
||||||
|
|
||||||
|
@Get
|
||||||
|
@Mapping("/migrate")
|
||||||
|
public Object migratePage(@Param(defaultValue = "") String msg,
|
||||||
|
@Param(defaultValue = "") String imported,
|
||||||
|
@Param(defaultValue = "") String migrated) {
|
||||||
|
ModelAndView mv = view("migrate.ftl", "数据迁移", "migrate");
|
||||||
|
mv.put("tasks", migrateService.listTasks());
|
||||||
|
mv.put("apps", sourceAppService.list());
|
||||||
|
mv.put("msg", msg);
|
||||||
|
mv.put("imported", imported);
|
||||||
|
mv.put("migrated", migrated);
|
||||||
|
return mv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户迁移:上传用户清单文件(每行一个老 UserID)或粘贴列表 → 加前缀 → 批量导入主应用 → 写映射 → 校验
|
||||||
|
*/
|
||||||
|
@Post
|
||||||
|
@Mapping("/migrate/users")
|
||||||
|
public void migrateUsers(@Param Long sourceAppId,
|
||||||
|
@Param(defaultValue = "") String userText,
|
||||||
|
Context ctx) throws Throwable {
|
||||||
|
List<String> userIds = new ArrayList<>();
|
||||||
|
UploadedFile file = ctx.file("file");
|
||||||
|
if (file != null) {
|
||||||
|
parseUserIds(new String(file.getContentAsBytes(), StandardCharsets.UTF_8), userIds);
|
||||||
|
} else if (!userText.isEmpty()) {
|
||||||
|
parseUserIds(userText, userIds);
|
||||||
|
}
|
||||||
|
if (sourceAppId == null) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MigrateTask task = migrateService.migrateUsers(sourceAppId, userIds);
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=done&imported=" + task.getProcessedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群迁移:选老应用 → getAppidGroups 拉群 → 逐群拉成员 → 加前缀建群 → 导入成员 → 写 group_mapping
|
||||||
|
*/
|
||||||
|
@Post
|
||||||
|
@Mapping("/migrate/groups")
|
||||||
|
public void migrateGroups(@Param Long sourceAppId, Context ctx) throws Throwable {
|
||||||
|
if (sourceAppId == null) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MigrateTask task = migrateService.migrateGroups(sourceAppId);
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=gdone&migrated=" + task.getProcessedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群消息迁移:上传/粘贴群 ID 清单 → 逐群拉历史消息 → 加前缀 → importGroupMsg
|
||||||
|
*/
|
||||||
|
@Post
|
||||||
|
@Mapping("/migrate/groupmsg")
|
||||||
|
public void migrateGroupMsg(@Param Long sourceAppId,
|
||||||
|
@Param(defaultValue = "") String userText,
|
||||||
|
Context ctx) throws Throwable {
|
||||||
|
UploadedFile file = ctx.file("file");
|
||||||
|
List<String> groupIds = new ArrayList<>();
|
||||||
|
if (file != null) {
|
||||||
|
parseUserIds(new String(file.getContentAsBytes(), StandardCharsets.UTF_8), groupIds);
|
||||||
|
} else if (!userText.isEmpty()) {
|
||||||
|
parseUserIds(userText, groupIds);
|
||||||
|
}
|
||||||
|
if (sourceAppId == null) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (groupIds.isEmpty()) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MigrateTask task = migrateService.migrateGroupMessages(sourceAppId, groupIds);
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=gmdone&imported=" + task.getProcessedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单聊消息迁移:上传会话清单(每行 from,to)或粘贴 → getRoamMsg 拉 → 加前缀 → importMsg
|
||||||
|
*/
|
||||||
|
@Post
|
||||||
|
@Mapping("/migrate/messages")
|
||||||
|
public void migrateMessages(@Param Long sourceAppId,
|
||||||
|
@Param(defaultValue = "") String convText,
|
||||||
|
Context ctx) throws Throwable {
|
||||||
|
UploadedFile file = ctx.file("file");
|
||||||
|
String content = file != null ? new String(file.getContentAsBytes(), StandardCharsets.UTF_8) : convText;
|
||||||
|
List<String[]> pairs = new ArrayList<>();
|
||||||
|
parseConvPairs(content, pairs);
|
||||||
|
if (sourceAppId == null) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pairs.isEmpty()) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MigrateTask task = migrateService.migrateMessages(sourceAppId, pairs);
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=mdone&imported=" + task.getProcessedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关系链迁移:上传用户清单(每行一个 UserID)或粘贴 → 逐用户拉好友 → 加前缀 → importFriend
|
||||||
|
*/
|
||||||
|
@Post
|
||||||
|
@Mapping("/migrate/roster")
|
||||||
|
public void migrateRoster(@Param Long sourceAppId,
|
||||||
|
@Param(defaultValue = "") String userText,
|
||||||
|
Context ctx) throws Throwable {
|
||||||
|
UploadedFile file = ctx.file("file");
|
||||||
|
List<String> userIds = new ArrayList<>();
|
||||||
|
if (file != null) {
|
||||||
|
parseUserIds(new String(file.getContentAsBytes(), StandardCharsets.UTF_8), userIds);
|
||||||
|
} else if (!userText.isEmpty()) {
|
||||||
|
parseUserIds(userText, userIds);
|
||||||
|
}
|
||||||
|
if (sourceAppId == null) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MigrateTask task = migrateService.migrateRoster(sourceAppId, userIds);
|
||||||
|
ctx.redirect(basePath + "/admin/migrate?msg=rdone&imported=" + task.getProcessedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析用户清单:每行一个 UserID,空行与 # 注释跳过 */
|
||||||
|
private void parseUserIds(String content, List<String> out) {
|
||||||
|
for (String line : content.split("\\r?\\n")) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line.isEmpty() && !line.startsWith("#")) {
|
||||||
|
out.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析会话清单:每行 from,to(逗号或空格分隔),空行与 # 注释跳过 */
|
||||||
|
private void parseConvPairs(String content, List<String[]> out) {
|
||||||
|
if (content == null || content.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String line : content.split("\\r?\\n")) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String[] parts = line.split("[,,\\s]+");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
out.add(new String[]{parts[0], parts[1]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 公共:构造页面模型 ====================
|
// ==================== 公共:构造页面模型 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.imutil.controller;
|
||||||
|
|
||||||
|
import com.imutil.common.HealthService;
|
||||||
|
import com.imutil.model.Result;
|
||||||
|
import org.noear.solon.annotation.Controller;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
import org.noear.solon.annotation.Mapping;
|
||||||
|
import org.noear.solon.core.handle.Context;
|
||||||
|
import org.noear.solon.core.handle.MethodType;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查端点(运维 / LB 探针)
|
||||||
|
* <p>
|
||||||
|
* GET /health:返回 PG/Redis 连通 + 分发队列四态计数。
|
||||||
|
* 免租户鉴权(见 {@link com.imutil.common.PathWhitelist})。
|
||||||
|
* HTTP 状态码:200 = UP/DEGRADED(核心可用);503 = DOWN(PG 异常,核心受损),
|
||||||
|
* 便于 LB/k8s 探针据状态码摘流。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private HealthService healthService;
|
||||||
|
|
||||||
|
@Mapping(value = "/health", method = MethodType.GET)
|
||||||
|
public void health(Context ctx) throws Throwable {
|
||||||
|
Map<String, Object> h = healthService.health();
|
||||||
|
// DOWN 返回 503,便探针摘流;UP/DEGRADED 返回 200
|
||||||
|
ctx.status("DOWN".equals(h.get("status")) ? 503 : 200);
|
||||||
|
ctx.render(Result.ok(h));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.imutil.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移任务记录实体(支持断点续传与校验)
|
||||||
|
* <p>
|
||||||
|
* 一次迁移对应一条记录:按对象类型(USER/GROUP/MSG/ROSTER)从老应用迁到主应用。
|
||||||
|
* pos_cursor 记录断点游标,任务中断后可从游标续传;verify_result 存老 vs 新条数对比。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("migrate_task")
|
||||||
|
public class MigrateTask {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 关联 source_app.id */
|
||||||
|
private Long sourceAppId;
|
||||||
|
|
||||||
|
/** 对象类型:USER/GROUP/MSG/ROSTER */
|
||||||
|
private String objectType;
|
||||||
|
|
||||||
|
/** 0=pending 1=running 2=paused 3=done 4=failed */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/** 应处理总数 */
|
||||||
|
private Long totalCount;
|
||||||
|
|
||||||
|
/** 已处理数 */
|
||||||
|
private Long processedCount;
|
||||||
|
|
||||||
|
/** 断点游标(时间戳/会话+seq,按对象类型语义不同) */
|
||||||
|
private String posCursor;
|
||||||
|
|
||||||
|
/** 校验结果 JSON(老 vs 新 条数对比) */
|
||||||
|
private String verifyResult;
|
||||||
|
|
||||||
|
/** 失败原因 */
|
||||||
|
private String errorMsg;
|
||||||
|
|
||||||
|
private OffsetDateTime startedAt;
|
||||||
|
|
||||||
|
private OffsetDateTime finishedAt;
|
||||||
|
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.imutil.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老应用配置实体(数据迁移源)
|
||||||
|
* <p>
|
||||||
|
* 每个老应用对应主应用一个租户(决定 UserID 前缀)。迁移时用本应用的 sdkAppId/secretKey
|
||||||
|
* 调腾讯 API 拉取存量数据,加前缀后导入主应用。
|
||||||
|
* <p>
|
||||||
|
* 密钥仅本工具持有,禁止下发到业务系统。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("source_app")
|
||||||
|
public class SourceApp {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 老应用名称 */
|
||||||
|
private String appName;
|
||||||
|
|
||||||
|
/** 老应用 SDKAppID */
|
||||||
|
private String sdkAppId;
|
||||||
|
|
||||||
|
/** 老应用密钥(迁移拉取用) */
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
/** 对应主应用租户(决定 UserID 前缀),关联 tenant.tenant_id */
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
|
/** 1=启用 0=停用 */
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -78,4 +78,12 @@ public interface DistQueueMapper extends BaseMapper<DistQueue> {
|
|||||||
"locked_by = NULL, locked_at = NULL, updated_at = now() " +
|
"locked_by = NULL, locked_at = NULL, updated_at = now() " +
|
||||||
"WHERE id = #{id} AND status = 3")
|
"WHERE id = #{id} AND status = 3")
|
||||||
int redeliverDead(@Param("id") Long id);
|
int redeliverDead(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按状态聚合计数(健康检查/监控用,一次扫描拿全状态分布)
|
||||||
|
*
|
||||||
|
* @return 每项含 status(0=pending/1=processing/2=done/3=dead)与 cnt
|
||||||
|
*/
|
||||||
|
@org.apache.ibatis.annotations.Select("SELECT status, count(*) AS cnt FROM dist_queue WHERE status IN (0, 1, 3) GROUP BY status")
|
||||||
|
java.util.List<java.util.Map<String, Object>> countByStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.imutil.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imutil.entity.MigrateTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移任务记录 Mapper
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface MigrateTaskMapper extends BaseMapper<MigrateTask> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.imutil.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分区表 DDL 执行 Mapper
|
||||||
|
* <p>
|
||||||
|
* im_message 按月 RANGE 分区,分区名含动态年月(im_message_yyyyMM),无法用静态 SQL,
|
||||||
|
* 故提供 ${sql} 占位的通用执行入口。
|
||||||
|
* <p>
|
||||||
|
* SQL 由 {@link com.imutil.service.PartitionService} 按年月在服务端拼装,无外部输入,不存在注入风险。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface PartitionMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行任意 DDL(建分区表)
|
||||||
|
*
|
||||||
|
* @param sql 形如 CREATE TABLE IF NOT EXISTS im_message_yyyyMM PARTITION OF im_message FOR VALUES FROM (...) TO (...)
|
||||||
|
*/
|
||||||
|
@Update("${sql}")
|
||||||
|
void execute(@Param("sql") String sql);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.imutil.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imutil.entity.SourceApp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老应用配置 Mapper
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface SourceAppMapper extends BaseMapper<SourceApp> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.imutil.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨租户通讯授权服务
|
||||||
|
* <p>
|
||||||
|
* 基于 cross_tenant_grant 判断跨租户通讯是否被授权放行,
|
||||||
|
* 并对放行的跨租户消息记录审计(cross_tenant_audit)。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface CrossTenantService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查发消息(send_msg)的跨租户授权
|
||||||
|
* <p>
|
||||||
|
* 匹配规则:正向授权(from→to) 或 反向双向授权(to→from 且 direction=1);
|
||||||
|
* 授权需 status=1、在有效期内、permissions 含 send_msg;
|
||||||
|
* from_im_user_id / to_im_user_id 为 NULL 表示通配(任意账户/全员)。
|
||||||
|
*
|
||||||
|
* @param fromTenant 发送方租户
|
||||||
|
* @param fromUser 发送方 IM 账号
|
||||||
|
* @param toTenant 接收方租户
|
||||||
|
* @param toUser 接收方 IM 账号
|
||||||
|
* @return 命中的授权ID,无授权或同租户返回 null
|
||||||
|
*/
|
||||||
|
Long checkSendMsgGrant(String fromTenant, String fromUser, String toTenant, String toUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一条跨租户通讯审计
|
||||||
|
*
|
||||||
|
* @param grantId 命中的授权ID
|
||||||
|
* @param msgKey 消息键
|
||||||
|
* @param fromUser 发送方 IM 账号
|
||||||
|
* @param toUser 接收方 IM 账号
|
||||||
|
*/
|
||||||
|
void audit(Long grantId, String msgKey, String fromUser, String toUser);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.imutil.service;
|
||||||
|
|
||||||
|
import com.imutil.entity.MigrateTask;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据迁移服务(T14)
|
||||||
|
* <p>
|
||||||
|
* 把老应用存量数据迁到主应用:拉取/读取 → 加租户前缀 → 导入主应用 → 写映射 → 校验。
|
||||||
|
* 各对象类型(USER/GROUP/MSG/ROSTER)分方法实现,均记 migrate_task 支持进度与校验。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface MigrateService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户迁移:老应用用户 ID 清单 → 加前缀 → 批量导入主应用 → 写 user_mapping → 校验。
|
||||||
|
* <p>
|
||||||
|
* 老应用用户清单由调用方提供(后台上传文件解析),因腾讯 IM 无「列出所有用户」API。
|
||||||
|
* 幂等:已导入账号与已存在映射跳过,可重复执行。
|
||||||
|
*
|
||||||
|
* @param sourceAppId 老应用配置 id
|
||||||
|
* @param oldUserIds 老应用用户 ID 清单
|
||||||
|
* @return 迁移任务记录(含进度与校验结果)
|
||||||
|
*/
|
||||||
|
MigrateTask migrateUsers(Long sourceAppId, List<String> oldUserIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群迁移:get_appid_groups 拉老应用所有群 → 逐群拉成员 → 加前缀建群(主应用)→ 导入成员 → 写 group_mapping → 校验。
|
||||||
|
* <p>
|
||||||
|
* 群清单可从腾讯 API 拉取(与用户清单不同);成员账号需已迁移(依赖用户迁移已完成)。
|
||||||
|
* 单群成员 ≤ 100 随建群带入,> 100 用 add_group_member 补充。
|
||||||
|
*
|
||||||
|
* @param sourceAppId 老应用配置 id
|
||||||
|
* @return 迁移任务记录
|
||||||
|
*/
|
||||||
|
MigrateTask migrateGroups(Long sourceAppId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单聊消息迁移:按会话清单(from-to 对)→ getRoamMsg(老密钥)拉 → 加前缀 → importMsg(主密钥)→ 校验。
|
||||||
|
* <p>
|
||||||
|
* 会话清单由调用方提供(后台上传,腾讯无「列出所有会话」API)。v1 每会话拉最近 100 条,
|
||||||
|
* 断点续传按会话索引(pos_cursor=pair:N);限速由 callApi 的 RateLimiter 兜底。
|
||||||
|
* 群消息迁移依赖 S3 群结果(group_mapping),作为遗留。
|
||||||
|
*
|
||||||
|
* @param c2cPairs 单聊会话清单,每项 [from, to](老应用 UserID)
|
||||||
|
* @return 迁移任务记录
|
||||||
|
*/
|
||||||
|
MigrateTask migrateMessages(Long sourceAppId, List<String[]> c2cPairs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群消息迁移:逐群 getGroupMsgAs(老密钥)分页拉历史 → 加前缀 from → importGroupMsg(主密钥)→ 校验。
|
||||||
|
*
|
||||||
|
* @param groupIds 老应用群 ID 清单
|
||||||
|
*/
|
||||||
|
MigrateTask migrateGroupMessages(Long sourceAppId, List<String> groupIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关系链迁移:逐用户 friendGetList(老密钥)拉好友 → 加前缀 → importFriend(主密钥)→ 校验。
|
||||||
|
* <p>
|
||||||
|
* 输入用户清单(后台上传,与 S2 同源);好友账号需已迁移。
|
||||||
|
*
|
||||||
|
* @param oldUserIds 老应用用户 ID 清单(对其逐个拉好友并导入)
|
||||||
|
* @return 迁移任务记录
|
||||||
|
*/
|
||||||
|
MigrateTask migrateRoster(Long sourceAppId, List<String> oldUserIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移任务列表(最近 100 条,按 id 倒序)
|
||||||
|
*/
|
||||||
|
List<MigrateTask> listTasks();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.imutil.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* im_message 分区管理服务
|
||||||
|
* <p>
|
||||||
|
* 按月滚动建分区,避免跨月数据落入 default 分区导致查询性能退化。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface PartitionService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建当月与下月分区(CREATE IF NOT EXISTS 幂等),跨月前确保下月分区就绪
|
||||||
|
*/
|
||||||
|
void createCurrentAndNextMonth();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.imutil.service;
|
||||||
|
|
||||||
|
import com.imutil.entity.SourceApp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老应用配置服务(数据迁移源)
|
||||||
|
* <p>
|
||||||
|
* 供管理后台 CRUD 与迁移逻辑查询老应用配置(sdkAppId/密钥/对应租户前缀)。
|
||||||
|
* 老应用数量少,不走缓存。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
public interface SourceAppService {
|
||||||
|
|
||||||
|
/** 全部老应用 */
|
||||||
|
List<SourceApp> list();
|
||||||
|
|
||||||
|
/** 按 id 查 */
|
||||||
|
SourceApp getById(Long id);
|
||||||
|
|
||||||
|
/** 新增或更新(id 为空则新增) */
|
||||||
|
void save(SourceApp app);
|
||||||
|
|
||||||
|
/** 启用/停用 */
|
||||||
|
void toggle(Long id, Integer status);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.imutil.mapper.GroupMappingMapper;
|
|||||||
import com.imutil.mapper.ImMessageMapper;
|
import com.imutil.mapper.ImMessageMapper;
|
||||||
import com.imutil.mapper.PullWatermarkMapper;
|
import com.imutil.mapper.PullWatermarkMapper;
|
||||||
import com.imutil.service.CallbackService;
|
import com.imutil.service.CallbackService;
|
||||||
|
import com.imutil.service.CrossTenantService;
|
||||||
import com.imutil.service.TenantService;
|
import com.imutil.service.TenantService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.noear.snack4.ONode;
|
import org.noear.snack4.ONode;
|
||||||
@@ -25,7 +26,10 @@ import java.time.ZoneId;
|
|||||||
/**
|
/**
|
||||||
* 腾讯回调处理服务实现
|
* 腾讯回调处理服务实现
|
||||||
* <p>
|
* <p>
|
||||||
* 流程:识别租户 → 消息类回调落 im_message(幂等)→ 所有回调写 dist_queue 分发业务系统。
|
* 流程:
|
||||||
|
* 1. 发单聊消息之前回调(C2C.CallbackBeforeSendMsg):跨租户拦截决策(对齐设计 5.4)——
|
||||||
|
* 同租户放行;跨租户查 cross_tenant_grant,命中授权放行并写审计,未命中返回 FAIL(腾讯不投递);
|
||||||
|
* 2. 其他回调(含发消息之后回调):识别租户 → 消息类回调落 im_message(幂等)→ 按 from 租户写 dist_queue 分发。
|
||||||
* {@code @Tran} 保证消息落库与分发入队同事务:要么同时成功,要么都不入库(避免半写)。
|
* {@code @Tran} 保证消息落库与分发入队同事务:要么同时成功,要么都不入库(避免半写)。
|
||||||
*
|
*
|
||||||
* @author imutil
|
* @author imutil
|
||||||
@@ -52,6 +56,9 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
@Inject
|
@Inject
|
||||||
private TenantService tenantService;
|
private TenantService tenantService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private CrossTenantService crossTenantService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Tran
|
@Tran
|
||||||
public String handleCallback(String callbackCommand, String body) {
|
public String handleCallback(String callbackCommand, String body) {
|
||||||
@@ -66,7 +73,13 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
return ok();
|
return ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 识别租户
|
// 1. 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4)
|
||||||
|
// 未授权的跨租户消息在此返回 FAIL,腾讯不投递、也不触发 after 回调 → 从源头阻断串扰
|
||||||
|
if (isBeforeSendMsg(callbackCommand)) {
|
||||||
|
return handleBeforeSendMsg(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 识别租户
|
||||||
String tenantId = identifyTenant(callbackCommand, node);
|
String tenantId = identifyTenant(callbackCommand, node);
|
||||||
if (tenantId == null) {
|
if (tenantId == null) {
|
||||||
// 无法识别租户(如腾讯系统消息 administrator),不落库不分发
|
// 无法识别租户(如腾讯系统消息 administrator),不落库不分发
|
||||||
@@ -74,8 +87,8 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
return ok();
|
return ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 消息类回调落库 im_message(幂等:msg_key 存在则跳过)
|
// 3. 消息类回调(发消息后)落库 im_message(幂等:msg_key 存在则跳过)
|
||||||
// msg_key = command:from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键;
|
// msg_key = from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键;
|
||||||
// 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。
|
// 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。
|
||||||
// DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。
|
// DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。
|
||||||
String msgKey = null;
|
String msgKey = null;
|
||||||
@@ -83,6 +96,20 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
|
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
|
||||||
if (msg != null) {
|
if (msg != null) {
|
||||||
msgKey = msg.getMsgKey();
|
msgKey = msg.getMsgKey();
|
||||||
|
// 跨租户标记:复查授权确认(before 已拦截未授权)。
|
||||||
|
// 若 before 未开启/失效,未授权消息会漏到 after,此处查不到授权即告警(兜底发现隔离异常)。
|
||||||
|
if (msg.getConvType() != null && msg.getConvType() == 1) {
|
||||||
|
String toTenant = parsePrefix(msg.getConvId());
|
||||||
|
if (toTenant != null && !toTenant.equals(tenantId)) {
|
||||||
|
if (crossTenantService.checkSendMsgGrant(
|
||||||
|
tenantId, msg.getFromAccount(), toTenant, msg.getConvId()) != null) {
|
||||||
|
msg.setIsCrossTenant(true);
|
||||||
|
} else {
|
||||||
|
log.warn("疑似未授权跨租户消息到达 after(before 可能未开启/失效)from={} to={}",
|
||||||
|
msg.getFromAccount(), msg.getConvId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
long exists = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
|
long exists = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
|
||||||
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
|
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
|
||||||
if (exists == 0) {
|
if (exists == 0) {
|
||||||
@@ -94,23 +121,94 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 写分发队列(payload=原始回调体,供业务系统消费)
|
// 4. 按发送方租户分发(payload=原始回调体,供业务系统消费)
|
||||||
Tenant tenant = tenantService.getById(tenantId);
|
// 设计 5.4/5.4.1 未要求额外推给 to 租户(其客户端已由腾讯直接投递);
|
||||||
if (tenant != null && tenant.getCallbackUrl() != null && !tenant.getCallbackUrl().isEmpty()) {
|
// 跨租户审计已在 before 阶段(handleBeforeSendMsg)写入。
|
||||||
DistQueue q = new DistQueue();
|
String convId = extractConvId(callbackCommand, node);
|
||||||
q.setMsgKey(msgKey);
|
enqueueIfConfigured(tenantId, msgKey, convId, body);
|
||||||
q.setTenantId(tenantId);
|
|
||||||
q.setConvId(extractConvId(callbackCommand, node));
|
|
||||||
q.setTargetUrl(tenant.getCallbackUrl());
|
|
||||||
q.setPayload(body);
|
|
||||||
q.setStatus(0);
|
|
||||||
q.setRetryCount(0);
|
|
||||||
q.setNextRetryAt(OffsetDateTime.now());
|
|
||||||
distQueueMapper.insert(q);
|
|
||||||
}
|
|
||||||
return ok();
|
return ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4)
|
||||||
|
* <p>
|
||||||
|
* 同租户放行;跨租户查 cross_tenant_grant,命中授权则放行并写审计,未命中返回 FAIL(腾讯不投递消息)。
|
||||||
|
*
|
||||||
|
* @param node 回调体
|
||||||
|
* @return 放行 ok() 或拦截 fail()
|
||||||
|
*/
|
||||||
|
private String handleBeforeSendMsg(ONode node) {
|
||||||
|
String from = node.get("FromAccount").getString();
|
||||||
|
String to = node.get("ToAccount").getString();
|
||||||
|
if (to == null || to.isEmpty()) {
|
||||||
|
to = node.get("ToPlayerId").getString();
|
||||||
|
}
|
||||||
|
String fromTenant = parsePrefix(from);
|
||||||
|
String toTenant = parsePrefix(to);
|
||||||
|
// 同租户或任一租户无法识别(如系统消息):不拦截,放行正常通讯
|
||||||
|
if (fromTenant == null || toTenant == null || fromTenant.equals(toTenant)) {
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
// 跨租户:查 send_msg 授权
|
||||||
|
Long grantId = crossTenantService.checkSendMsgGrant(fromTenant, from, toTenant, to);
|
||||||
|
if (grantId == null) {
|
||||||
|
log.info("跨租户通讯未授权,拦截 from={} to={}", from, to);
|
||||||
|
return fail();
|
||||||
|
}
|
||||||
|
// 命中授权:放行 + 写审计(before 阶段记录放行决策,msgKey 便于追溯)
|
||||||
|
// MsgSeq/MsgRandom 缺失时审计 msgKey 留空,不影响拦截决策(授权检查已完成)
|
||||||
|
String msgKey = null;
|
||||||
|
try {
|
||||||
|
long msgSeq = node.get("MsgSeq").getLong();
|
||||||
|
long msgRandom = node.get("MsgRandom").getLong();
|
||||||
|
msgKey = MsgKeys.build(from, to, msgSeq, msgRandom);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// 回调体缺字段无法算 msgKey,审计仍写(msgKey=null)
|
||||||
|
}
|
||||||
|
crossTenantService.audit(grantId, msgKey, from, to);
|
||||||
|
log.info("跨租户授权放行 from={} to={} grant={}", from, to, grantId);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为发单聊消息之前回调(跨租户拦截决策入口)
|
||||||
|
*/
|
||||||
|
private boolean isBeforeSendMsg(String command) {
|
||||||
|
return command != null && command.contains("CallbackBeforeSendMsg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回腾讯回调拦截响应(消息不投递,客户端收到 ErrorCode + ErrorInfo)
|
||||||
|
*/
|
||||||
|
private String fail() {
|
||||||
|
return "{\"ActionStatus\":\"FAIL\",\"ErrorCode\":80001,\"ErrorInfo\":\"cross-tenant not authorized\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户配置了回调地址则入队分发
|
||||||
|
*
|
||||||
|
* @param tenantId 目标租户
|
||||||
|
* @param msgKey 消息键(非消息类回调可为 null)
|
||||||
|
* @param convId 会话ID(保序分桶用)
|
||||||
|
* @param payload 分发载荷(原始回调体)
|
||||||
|
*/
|
||||||
|
private void enqueueIfConfigured(String tenantId, String msgKey, String convId, String payload) {
|
||||||
|
Tenant tenant = tenantService.getById(tenantId);
|
||||||
|
if (tenant == null || tenant.getCallbackUrl() == null || tenant.getCallbackUrl().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DistQueue q = new DistQueue();
|
||||||
|
q.setMsgKey(msgKey);
|
||||||
|
q.setTenantId(tenantId);
|
||||||
|
q.setConvId(convId);
|
||||||
|
q.setTargetUrl(tenant.getCallbackUrl());
|
||||||
|
q.setPayload(payload);
|
||||||
|
q.setStatus(0);
|
||||||
|
q.setRetryCount(0);
|
||||||
|
q.setNextRetryAt(OffsetDateTime.now());
|
||||||
|
distQueueMapper.insert(q);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 识别租户:优先 FromAccount 前缀,群回调用 GroupId 查 group_mapping
|
* 识别租户:优先 FromAccount 前缀,群回调用 GroupId 查 group_mapping
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.imutil.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.imutil.entity.CrossTenantAudit;
|
||||||
|
import com.imutil.entity.CrossTenantGrant;
|
||||||
|
import com.imutil.mapper.CrossTenantAuditMapper;
|
||||||
|
import com.imutil.mapper.CrossTenantGrantMapper;
|
||||||
|
import com.imutil.service.CrossTenantService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨租户通讯授权服务实现
|
||||||
|
* <p>
|
||||||
|
* 授权匹配:正向(from→to) 优先,未命中再查反向双向(to→from 且 direction=1)。
|
||||||
|
* user 字段 NULL 表示通配;permissions 用 LIKE 匹配是否含 send_msg。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class CrossTenantServiceImpl implements CrossTenantService {
|
||||||
|
|
||||||
|
private static final String PERM_SEND_MSG = "send_msg";
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private CrossTenantGrantMapper grantMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private CrossTenantAuditMapper auditMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long checkSendMsgGrant(String fromTenant, String fromUser, String toTenant, String toUser) {
|
||||||
|
// 同租户或任一租户缺失,不算跨租户通讯
|
||||||
|
if (fromTenant == null || toTenant == null || fromTenant.equals(toTenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 1. 正向授权 from→to
|
||||||
|
Long gid = matchDirected(fromTenant, fromUser, toTenant, toUser, false);
|
||||||
|
if (gid != null) {
|
||||||
|
return gid;
|
||||||
|
}
|
||||||
|
// 2. 反向双向授权:记录方向为 to→from 且 direction=1
|
||||||
|
return matchDirected(toTenant, toUser, fromTenant, fromUser, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void audit(Long grantId, String msgKey, String fromUser, String toUser) {
|
||||||
|
try {
|
||||||
|
CrossTenantAudit a = new CrossTenantAudit();
|
||||||
|
a.setGrantId(grantId);
|
||||||
|
a.setMsgKey(msgKey);
|
||||||
|
a.setFromImUserId(fromUser);
|
||||||
|
a.setToImUserId(toUser);
|
||||||
|
a.setActionTime(OffsetDateTime.now());
|
||||||
|
auditMapper.insert(a);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 审计写入失败不阻塞主流程
|
||||||
|
log.warn("跨租户审计写入失败 grant={} msgKey={} : {}", grantId, msgKey, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按指定方向匹配一条 send_msg 授权
|
||||||
|
*
|
||||||
|
* @param matchFromTenant 查询条件 from_tenant
|
||||||
|
* @param matchFromUser 查询条件 from_im_user(NULL 通配由 SQL 处理)
|
||||||
|
* @param matchToTenant 查询条件 to_tenant
|
||||||
|
* @param matchToUser 查询条件 to_im_user
|
||||||
|
* @param now 当前时间(校验有效期)
|
||||||
|
* @param requireBidir 是否要求 direction=1(反向匹配时)
|
||||||
|
* @return 命中的 grantId,未命中返回 null
|
||||||
|
*/
|
||||||
|
private Long matchDirected(String matchFromTenant, String matchFromUser,
|
||||||
|
String matchToTenant, String matchToUser,
|
||||||
|
boolean requireBidir) {
|
||||||
|
CrossTenantGrant g = grantMapper.selectOne(Wrappers.<CrossTenantGrant>lambdaQuery()
|
||||||
|
.eq(CrossTenantGrant::getFromTenantId, matchFromTenant)
|
||||||
|
.eq(CrossTenantGrant::getToTenantId, matchToTenant)
|
||||||
|
.eq(CrossTenantGrant::getStatus, 1)
|
||||||
|
.eq(requireBidir, CrossTenantGrant::getDirection, 1)
|
||||||
|
.like(CrossTenantGrant::getPermissions, PERM_SEND_MSG)
|
||||||
|
.and(w -> w.isNull(CrossTenantGrant::getStartAt).or().apply("start_at <= now()"))
|
||||||
|
.and(w -> w.isNull(CrossTenantGrant::getEndAt).or().apply("end_at >= now()"))
|
||||||
|
.and(w -> w.isNull(CrossTenantGrant::getFromImUserId).or().eq(CrossTenantGrant::getFromImUserId, matchFromUser))
|
||||||
|
.and(w -> w.isNull(CrossTenantGrant::getToImUserId).or().eq(CrossTenantGrant::getToImUserId, matchToUser))
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
return g == null ? null : g.getGrantId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
package com.imutil.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.imutil.common.BizException;
|
||||||
|
import com.imutil.entity.MigrateTask;
|
||||||
|
import com.imutil.entity.SourceApp;
|
||||||
|
import com.imutil.entity.Tenant;
|
||||||
|
import com.imutil.entity.UserMapping;
|
||||||
|
import com.imutil.entity.GroupMapping;
|
||||||
|
import com.imutil.mapper.MigrateTaskMapper;
|
||||||
|
import com.imutil.mapper.SourceAppMapper;
|
||||||
|
import com.imutil.mapper.TenantMapper;
|
||||||
|
import com.imutil.mapper.UserMappingMapper;
|
||||||
|
import com.imutil.mapper.GroupMappingMapper;
|
||||||
|
import com.imutil.service.MigrateService;
|
||||||
|
import com.imutil.tencent.TencentImClient;
|
||||||
|
import org.noear.snack4.ONode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据迁移服务实现(T14)
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class MigrateServiceImpl implements MigrateService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private SourceAppMapper sourceAppMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private TenantMapper tenantMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private UserMappingMapper userMappingMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private GroupMappingMapper groupMappingMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private MigrateTaskMapper migrateTaskMapper;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private TencentImClient tencentImClient;
|
||||||
|
|
||||||
|
/** 消息迁移回溯天数,0=全量(T14 V3) */
|
||||||
|
@Inject("${imutil.migrate.msgLookbackDays:7}")
|
||||||
|
private int msgLookbackDays;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MigrateTask migrateUsers(Long sourceAppId, List<String> oldUserIds) {
|
||||||
|
SourceApp app = sourceAppMapper.selectById(sourceAppId);
|
||||||
|
if (app == null) {
|
||||||
|
throw new BizException("老应用不存在");
|
||||||
|
}
|
||||||
|
Tenant tenant = tenantMapper.selectById(app.getTenantId());
|
||||||
|
if (tenant == null) {
|
||||||
|
throw new BizException("关联租户不存在: " + app.getTenantId());
|
||||||
|
}
|
||||||
|
String prefix = tenant.getPrefixCode();
|
||||||
|
|
||||||
|
// 建迁移任务记录(running)
|
||||||
|
MigrateTask task = new MigrateTask();
|
||||||
|
task.setSourceAppId(sourceAppId);
|
||||||
|
task.setObjectType("USER");
|
||||||
|
task.setStatus(1);
|
||||||
|
task.setTotalCount(oldUserIds == null ? 0L : (long) oldUserIds.size());
|
||||||
|
task.setStartedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.insert(task);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 老用户 ID 加租户前缀(对齐 user_mapping: im_user_id = prefix + '_' + biz_user_id)
|
||||||
|
List<String> newImUserIds = new ArrayList<>();
|
||||||
|
Map<String, String> oldToNew = new HashMap<>();
|
||||||
|
if (oldUserIds != null) {
|
||||||
|
for (String oldId : oldUserIds) {
|
||||||
|
if (oldId == null || oldId.trim().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String oldTrim = oldId.trim();
|
||||||
|
String newId = prefix + "_" + oldTrim;
|
||||||
|
newImUserIds.add(newId);
|
||||||
|
oldToNew.put(oldTrim, newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量导入主应用(走主应用密钥+限流;幂等,已存在账号亦成功)
|
||||||
|
int imported = tencentImClient.batchImport(newImUserIds);
|
||||||
|
|
||||||
|
// 写 user_mapping(幂等:同 tenant+bizUserId 已存在则跳过)
|
||||||
|
int mapped = 0;
|
||||||
|
for (Map.Entry<String, String> e : oldToNew.entrySet()) {
|
||||||
|
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
|
||||||
|
.eq(UserMapping::getTenantId, app.getTenantId())
|
||||||
|
.eq(UserMapping::getBizUserId, e.getKey())
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (exist != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UserMapping um = new UserMapping();
|
||||||
|
um.setTenantId(app.getTenantId());
|
||||||
|
um.setBizUserId(e.getKey());
|
||||||
|
um.setImUserId(e.getValue());
|
||||||
|
um.setIsDefault(false);
|
||||||
|
um.setIsGlobal(false);
|
||||||
|
um.setStatus(1);
|
||||||
|
userMappingMapper.insert(um);
|
||||||
|
mapped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验:老清单数 vs 导入成功数 vs 新建映射数
|
||||||
|
task.setProcessedCount((long) imported);
|
||||||
|
task.setStatus(3); // done
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
task.setVerifyResult(String.format(
|
||||||
|
"{\"oldCount\":%d,\"imported\":%d,\"mapped\":%d}",
|
||||||
|
oldToNew.size(), imported, mapped));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.info("用户迁移完成 sourceApp={} old={} imported={} mapped={}",
|
||||||
|
sourceAppId, oldToNew.size(), imported, mapped);
|
||||||
|
return task;
|
||||||
|
} catch (Exception e) {
|
||||||
|
task.setStatus(4); // failed
|
||||||
|
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.error("用户迁移失败 sourceApp={}", sourceAppId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MigrateTask migrateGroups(Long sourceAppId) {
|
||||||
|
SourceApp app = sourceAppMapper.selectById(sourceAppId);
|
||||||
|
if (app == null) {
|
||||||
|
throw new BizException("老应用不存在");
|
||||||
|
}
|
||||||
|
Tenant tenant = tenantMapper.selectById(app.getTenantId());
|
||||||
|
if (tenant == null) {
|
||||||
|
throw new BizException("关联租户不存在: " + app.getTenantId());
|
||||||
|
}
|
||||||
|
String prefix = tenant.getPrefixCode();
|
||||||
|
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
|
||||||
|
String oldSecret = app.getSecretKey();
|
||||||
|
|
||||||
|
MigrateTask task = new MigrateTask();
|
||||||
|
task.setSourceAppId(sourceAppId);
|
||||||
|
task.setObjectType("GROUP");
|
||||||
|
task.setStatus(1);
|
||||||
|
task.setStartedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.insert(task);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 拉老应用所有群(老密钥)
|
||||||
|
String groupsResp = tencentImClient.getAppidGroups(oldSdkAppId, oldSecret);
|
||||||
|
ONode groupsNode = ONode.ofJson(groupsResp);
|
||||||
|
ONode groupIdList = groupsNode.get("GroupIdList");
|
||||||
|
int total = groupIdList.size();
|
||||||
|
task.setTotalCount((long) total);
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
|
||||||
|
int migrated = 0;
|
||||||
|
int mapped = 0;
|
||||||
|
int memberTotal = 0;
|
||||||
|
// 逐群迁移(snack4 数组遍历用 size + get(int))
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
ONode g = groupIdList.get(i);
|
||||||
|
String oldGroupId = g.get("GroupId").getString();
|
||||||
|
String type = g.get("Type").getString();
|
||||||
|
try {
|
||||||
|
// 2. 拉老群详情(资料 + 成员,老密钥)
|
||||||
|
String infoResp = tencentImClient.getGroupInfo(oldGroupId, oldSdkAppId, oldSecret);
|
||||||
|
ONode infoNode = ONode.ofJson(infoResp);
|
||||||
|
ONode groupInfo = infoNode.get("GroupInfo").get(0);
|
||||||
|
String name = groupInfo.get("Name").getString();
|
||||||
|
String owner = groupInfo.get("Owner_Account").getString();
|
||||||
|
|
||||||
|
// 成员列表加前缀
|
||||||
|
ONode memberListNode = groupInfo.get("MemberList");
|
||||||
|
List<String> newMembers = new ArrayList<>();
|
||||||
|
int mc = memberListNode.size();
|
||||||
|
for (int j = 0; j < mc; j++) {
|
||||||
|
String memId = memberListNode.get(j).get("Member_Account").getString();
|
||||||
|
if (memId != null && !memId.isEmpty()) {
|
||||||
|
newMembers.add(prefix + "_" + memId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memberTotal += newMembers.size();
|
||||||
|
|
||||||
|
// 3. 建新群(加前缀,带初始成员 ≤ 100)
|
||||||
|
String newGroupId = prefix + "_" + oldGroupId;
|
||||||
|
String newOwner = (owner != null && !owner.isEmpty()) ? prefix + "_" + owner : null;
|
||||||
|
List<String> initMembers = newMembers.size() <= 100
|
||||||
|
? newMembers : new ArrayList<>(newMembers.subList(0, 100));
|
||||||
|
boolean ok = tencentImClient.createGroup(newGroupId, type, name, newOwner, initMembers);
|
||||||
|
if (!ok) {
|
||||||
|
log.warn("群迁移建群失败 old={} new={}", oldGroupId, newGroupId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
migrated++;
|
||||||
|
// 成员 > 100 补充
|
||||||
|
if (newMembers.size() > 100) {
|
||||||
|
tencentImClient.addGroupMember(newGroupId, new ArrayList<>(newMembers.subList(100, newMembers.size())));
|
||||||
|
}
|
||||||
|
// 写 group_mapping(幂等)
|
||||||
|
GroupMapping exist = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
|
||||||
|
.eq(GroupMapping::getTenantId, app.getTenantId())
|
||||||
|
.eq(GroupMapping::getBizGroupId, oldGroupId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (exist == null) {
|
||||||
|
GroupMapping gm = new GroupMapping();
|
||||||
|
gm.setTenantId(app.getTenantId());
|
||||||
|
gm.setBizGroupId(oldGroupId);
|
||||||
|
gm.setImGroupId(newGroupId);
|
||||||
|
gm.setGroupType(type != null && !type.isEmpty() ? type : "Public");
|
||||||
|
groupMappingMapper.insert(gm);
|
||||||
|
mapped++;
|
||||||
|
}
|
||||||
|
} catch (Exception ge) {
|
||||||
|
log.warn("群迁移单群失败 old={} : {}", oldGroupId, ge.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setProcessedCount((long) migrated);
|
||||||
|
task.setStatus(3);
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
task.setVerifyResult(String.format(
|
||||||
|
"{\"oldGroups\":%d,\"migrated\":%d,\"mapped\":%d,\"memberTotal\":%d}",
|
||||||
|
total, migrated, mapped, memberTotal));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.info("群迁移完成 sourceApp={} old={} migrated={} mapped={}", sourceAppId, total, migrated, mapped);
|
||||||
|
return task;
|
||||||
|
} catch (Exception e) {
|
||||||
|
task.setStatus(4);
|
||||||
|
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.error("群迁移失败 sourceApp={}", sourceAppId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MigrateTask migrateMessages(Long sourceAppId, List<String[]> c2cPairs) {
|
||||||
|
SourceApp app = sourceAppMapper.selectById(sourceAppId);
|
||||||
|
if (app == null) {
|
||||||
|
throw new BizException("老应用不存在");
|
||||||
|
}
|
||||||
|
Tenant tenant = tenantMapper.selectById(app.getTenantId());
|
||||||
|
if (tenant == null) {
|
||||||
|
throw new BizException("关联租户不存在: " + app.getTenantId());
|
||||||
|
}
|
||||||
|
String prefix = tenant.getPrefixCode();
|
||||||
|
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
|
||||||
|
String oldSecret = app.getSecretKey();
|
||||||
|
|
||||||
|
MigrateTask task = new MigrateTask();
|
||||||
|
task.setSourceAppId(sourceAppId);
|
||||||
|
task.setObjectType("MSG");
|
||||||
|
task.setStatus(1);
|
||||||
|
int pairs = c2cPairs == null ? 0 : c2cPairs.size();
|
||||||
|
task.setTotalCount((long) pairs);
|
||||||
|
task.setStartedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.insert(task);
|
||||||
|
|
||||||
|
try {
|
||||||
|
int imported = 0;
|
||||||
|
long nowSec = OffsetDateTime.now().toEpochSecond();
|
||||||
|
for (int pi = 0; pi < pairs; pi++) {
|
||||||
|
String[] pair = c2cPairs.get(pi);
|
||||||
|
if (pair == null || pair.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String oldFrom = pair[0];
|
||||||
|
String oldTo = pair[1];
|
||||||
|
String newFrom = prefix + "_" + oldFrom;
|
||||||
|
String newTo = prefix + "_" + oldTo;
|
||||||
|
try {
|
||||||
|
// 分页全量拉取:7 天窗滚动 + 窗内 Complete 分页(腾讯 MaxTimeInterval ≤ 604800)
|
||||||
|
// V3: 回溯天数可配置(0=全量,受套餐云端保存时长限制)
|
||||||
|
long minTimeBound = (msgLookbackDays <= 0) ? 0 : nowSec - msgLookbackDays * 86400L;
|
||||||
|
long segEnd = nowSec;
|
||||||
|
while (segEnd > minTimeBound) {
|
||||||
|
long segStart = Math.max(minTimeBound, segEnd - 604800L);
|
||||||
|
long fetchMaxTime = segEnd;
|
||||||
|
// 窗内 Complete 分页(LastMsgTime 滚动至 Complete=1 或无消息)
|
||||||
|
int guard = 0;
|
||||||
|
while (guard++ < 1000) {
|
||||||
|
long interval = Math.min(604800L, fetchMaxTime - segStart);
|
||||||
|
if (interval <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String resp = tencentImClient.getRoamMsgAs(oldFrom, oldTo, 100, segStart, interval, oldSdkAppId, oldSecret);
|
||||||
|
ONode node = ONode.ofJson(resp);
|
||||||
|
ONode msgList = node.get("MsgList");
|
||||||
|
int n = msgList.size();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
ONode msg = msgList.get(i);
|
||||||
|
long ts = msg.get("MsgTimeStamp").getLong();
|
||||||
|
if (tencentImClient.importMsg(newFrom, newTo, ts, msg.get("MsgBody").toString())) {
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean complete = node.get("Complete").getLong() == 1;
|
||||||
|
if (complete || n == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
long nextLast = node.get("LastMsgTime").getLong();
|
||||||
|
if (nextLast <= 0 || nextLast >= fetchMaxTime) {
|
||||||
|
break; // 防死循环:LastMsgTime 必须向前(减小)
|
||||||
|
}
|
||||||
|
fetchMaxTime = nextLast;
|
||||||
|
}
|
||||||
|
segEnd = segStart; // 下一个 7 天窗
|
||||||
|
}
|
||||||
|
} catch (Exception ce) {
|
||||||
|
log.warn("单聊消息迁移会话失败 {}->{} : {}", oldFrom, oldTo, ce.getMessage());
|
||||||
|
}
|
||||||
|
// 断点续传游标(会话索引)
|
||||||
|
task.setProcessedCount((long) imported);
|
||||||
|
task.setPosCursor("pair:" + (pi + 1));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
}
|
||||||
|
task.setStatus(3);
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
task.setVerifyResult(String.format("{\"pairs\":%d,\"imported\":%d}", pairs, imported));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.info("单聊消息迁移完成 sourceApp={} pairs={} imported={}", sourceAppId, pairs, imported);
|
||||||
|
return task;
|
||||||
|
} catch (Exception e) {
|
||||||
|
task.setStatus(4);
|
||||||
|
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.error("消息迁移失败 sourceApp={}", sourceAppId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MigrateTask migrateGroupMessages(Long sourceAppId, List<String> groupIds) {
|
||||||
|
SourceApp app = sourceAppMapper.selectById(sourceAppId);
|
||||||
|
if (app == null) {
|
||||||
|
throw new BizException("老应用不存在");
|
||||||
|
}
|
||||||
|
Tenant tenant = tenantMapper.selectById(app.getTenantId());
|
||||||
|
if (tenant == null) {
|
||||||
|
throw new BizException("关联租户不存在: " + app.getTenantId());
|
||||||
|
}
|
||||||
|
String prefix = tenant.getPrefixCode();
|
||||||
|
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
|
||||||
|
String oldSecret = app.getSecretKey();
|
||||||
|
|
||||||
|
MigrateTask task = new MigrateTask();
|
||||||
|
task.setSourceAppId(sourceAppId);
|
||||||
|
task.setObjectType("GROUP_MSG");
|
||||||
|
task.setStatus(1);
|
||||||
|
int groups = groupIds == null ? 0 : groupIds.size();
|
||||||
|
task.setTotalCount((long) groups);
|
||||||
|
task.setStartedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.insert(task);
|
||||||
|
|
||||||
|
try {
|
||||||
|
int imported = 0;
|
||||||
|
for (int gi = 0; gi < groups; gi++) {
|
||||||
|
String oldGroupId = groupIds.get(gi).trim();
|
||||||
|
if (oldGroupId.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String newGroupId = prefix + "_" + oldGroupId;
|
||||||
|
try {
|
||||||
|
// 分页拉取:ReqMsgSeq 滚动至 IsFinished=1 或无消息
|
||||||
|
long reqSeq = 0;
|
||||||
|
int guard = 0;
|
||||||
|
while (guard++ < 10000) {
|
||||||
|
String resp = tencentImClient.getGroupMsgAs(oldGroupId, 100, reqSeq, oldSdkAppId, oldSecret);
|
||||||
|
ONode node = ONode.ofJson(resp);
|
||||||
|
ONode rspList = node.get("RspMsgList");
|
||||||
|
int n = rspList.size();
|
||||||
|
long oldestSeq = 0;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
ONode msg = rspList.get(i);
|
||||||
|
long seq = msg.get("MsgSeq").getLong();
|
||||||
|
String from = msg.get("From_Account").getString();
|
||||||
|
long ts = msg.get("MsgTimeStamp").getLong();
|
||||||
|
String newFrom = (from != null && !from.isEmpty()) ? prefix + "_" + from : from;
|
||||||
|
if (tencentImClient.importGroupMsg(newGroupId, newFrom, ts, msg.get("MsgBody").toString())) {
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
oldestSeq = seq;
|
||||||
|
}
|
||||||
|
boolean finished = node.get("IsFinished").getLong() == 1;
|
||||||
|
if (finished || n == 0 || oldestSeq <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
reqSeq = oldestSeq; // 滚动到更旧
|
||||||
|
}
|
||||||
|
} catch (Exception ge) {
|
||||||
|
log.warn("群消息迁移单群失败 {} : {}", oldGroupId, ge.getMessage());
|
||||||
|
}
|
||||||
|
task.setProcessedCount((long) imported);
|
||||||
|
task.setPosCursor("group:" + (gi + 1));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
}
|
||||||
|
task.setStatus(3);
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
task.setVerifyResult(String.format("{\"groups\":%d,\"imported\":%d}", groups, imported));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.info("群消息迁移完成 sourceApp={} groups={} imported={}", sourceAppId, groups, imported);
|
||||||
|
return task;
|
||||||
|
} catch (Exception e) {
|
||||||
|
task.setStatus(4);
|
||||||
|
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.error("群消息迁移失败 sourceApp={}", sourceAppId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MigrateTask migrateRoster(Long sourceAppId, List<String> oldUserIds) {
|
||||||
|
SourceApp app = sourceAppMapper.selectById(sourceAppId);
|
||||||
|
if (app == null) {
|
||||||
|
throw new BizException("老应用不存在");
|
||||||
|
}
|
||||||
|
Tenant tenant = tenantMapper.selectById(app.getTenantId());
|
||||||
|
if (tenant == null) {
|
||||||
|
throw new BizException("关联租户不存在: " + app.getTenantId());
|
||||||
|
}
|
||||||
|
String prefix = tenant.getPrefixCode();
|
||||||
|
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
|
||||||
|
String oldSecret = app.getSecretKey();
|
||||||
|
|
||||||
|
MigrateTask task = new MigrateTask();
|
||||||
|
task.setSourceAppId(sourceAppId);
|
||||||
|
task.setObjectType("ROSTER");
|
||||||
|
task.setStatus(1);
|
||||||
|
int users = oldUserIds == null ? 0 : oldUserIds.size();
|
||||||
|
task.setTotalCount((long) users);
|
||||||
|
task.setStartedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.insert(task);
|
||||||
|
|
||||||
|
try {
|
||||||
|
int imported = 0;
|
||||||
|
for (int ui = 0; ui < users; ui++) {
|
||||||
|
String oldUser = oldUserIds.get(ui);
|
||||||
|
String newUser = prefix + "_" + oldUser;
|
||||||
|
try {
|
||||||
|
String resp = tencentImClient.friendGetList(oldUser, oldSdkAppId, oldSecret);
|
||||||
|
ONode node = ONode.ofJson(resp);
|
||||||
|
ONode friendListNode = node.get("FriendList");
|
||||||
|
List<String> newFriends = new ArrayList<>();
|
||||||
|
int n = friendListNode.size();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
String fid = friendListNode.get(i).get("To_Account").getString();
|
||||||
|
if (fid != null && !fid.isEmpty()) {
|
||||||
|
newFriends.add(prefix + "_" + fid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!newFriends.isEmpty() && tencentImClient.importFriend(newUser, newFriends)) {
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
} catch (Exception fe) {
|
||||||
|
log.warn("关系链迁移用户失败 {} : {}", oldUser, fe.getMessage());
|
||||||
|
}
|
||||||
|
task.setProcessedCount((long) imported);
|
||||||
|
task.setPosCursor("user:" + (ui + 1));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
}
|
||||||
|
task.setStatus(3);
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
task.setVerifyResult(String.format("{\"users\":%d,\"imported\":%d}", users, imported));
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.info("关系链迁移完成 sourceApp={} users={} imported={}", sourceAppId, users, imported);
|
||||||
|
return task;
|
||||||
|
} catch (Exception e) {
|
||||||
|
task.setStatus(4);
|
||||||
|
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
task.setFinishedAt(OffsetDateTime.now());
|
||||||
|
migrateTaskMapper.updateById(task);
|
||||||
|
log.error("关系链迁移失败 sourceApp={}", sourceAppId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MigrateTask> listTasks() {
|
||||||
|
return migrateTaskMapper.selectList(Wrappers.<MigrateTask>lambdaQuery()
|
||||||
|
.orderByDesc(MigrateTask::getId)
|
||||||
|
.last("LIMIT 100"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.imutil.service.impl;
|
||||||
|
|
||||||
|
import com.imutil.mapper.PartitionMapper;
|
||||||
|
import com.imutil.service.PartitionService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Init;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* im_message 分区管理服务实现
|
||||||
|
* <p>
|
||||||
|
* 每次建当月+下月分区(CREATE TABLE IF NOT EXISTS 幂等),保证:
|
||||||
|
* - 启动时当月分区就绪(init.sql 仅建了 default 兜底分区)
|
||||||
|
* - 跨月前下月分区已存在,消息不落 default 分区(避免查询性能退化)
|
||||||
|
* 分区按自然月 RANGE(msg_time),+08 时区边界。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class PartitionServiceImpl implements PartitionService {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("+08");
|
||||||
|
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
|
||||||
|
/** PG timestamptz 字面量格式:2026-07-01 00:00:00+08:00 */
|
||||||
|
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssxxx");
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private PartitionMapper partitionMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用启动后自检一次:首次启动当月分区缺失则补建
|
||||||
|
*/
|
||||||
|
@Init
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
createCurrentAndNextMonth();
|
||||||
|
log.info("启动分区自检完成(当月+下月)");
|
||||||
|
} catch (Throwable e) {
|
||||||
|
log.error("启动分区自检异常", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createCurrentAndNextMonth() {
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(ZONE);
|
||||||
|
ensureMonth(now.getYear(), now.getMonthValue());
|
||||||
|
// 下月:用 plusMonths 计算避免年底跨月
|
||||||
|
ZonedDateTime next = now.plusMonths(1);
|
||||||
|
ensureMonth(next.getYear(), next.getMonthValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保指定年月的分区存在(不存在则创建,幂等)
|
||||||
|
*/
|
||||||
|
private void ensureMonth(int year, int month) {
|
||||||
|
// 用 YearMonth 拼表名与边界,规避 1月/12月 跨年的日历计算坑
|
||||||
|
java.time.YearMonth ym = java.time.YearMonth.of(year, month);
|
||||||
|
String name = "im_message_" + ym.format(MONTH_FMT);
|
||||||
|
ZonedDateTime start = ym.atDay(1).atStartOfDay(ZONE);
|
||||||
|
ZonedDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay(ZONE);
|
||||||
|
String sql = "CREATE TABLE IF NOT EXISTS " + name +
|
||||||
|
" PARTITION OF im_message FOR VALUES FROM ('" +
|
||||||
|
start.format(TS_FMT) + "') TO ('" + end.format(TS_FMT) + "')";
|
||||||
|
partitionMapper.execute(sql);
|
||||||
|
log.debug("分区就绪 {}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.imutil.service.impl;
|
||||||
|
|
||||||
|
import com.imutil.entity.SourceApp;
|
||||||
|
import com.imutil.mapper.SourceAppMapper;
|
||||||
|
import com.imutil.service.SourceAppService;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老应用配置服务实现
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SourceAppServiceImpl implements SourceAppService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private SourceAppMapper sourceAppMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SourceApp> list() {
|
||||||
|
return sourceAppMapper.selectList(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SourceApp getById(Long id) {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sourceAppMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(SourceApp app) {
|
||||||
|
if (app.getId() == null) {
|
||||||
|
if (app.getStatus() == null) {
|
||||||
|
app.setStatus(1);
|
||||||
|
}
|
||||||
|
sourceAppMapper.insert(app);
|
||||||
|
} else {
|
||||||
|
sourceAppMapper.updateById(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toggle(Long id, Integer status) {
|
||||||
|
SourceApp app = new SourceApp();
|
||||||
|
app.setId(id);
|
||||||
|
app.setStatus(status == null ? 0 : status);
|
||||||
|
sourceAppMapper.updateById(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.imutil.task;
|
||||||
|
|
||||||
|
import com.imutil.service.PartitionService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.noear.solon.annotation.Component;
|
||||||
|
import org.noear.solon.annotation.Inject;
|
||||||
|
import org.noear.solon.scheduling.annotation.Scheduled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* im_message 分区自建任务
|
||||||
|
* <p>
|
||||||
|
* 每天凌晨检查并创建当月+下月分区,确保跨月前下月分区已就绪、消息不落 default 分区。
|
||||||
|
* 对应 app.yml 的 solon.scheduling.job.partitionCreateJob。
|
||||||
|
* <p>
|
||||||
|
* 启动时的首次自检由 {@link com.imutil.service.impl.PartitionServiceImpl} 的 @Init 负责,
|
||||||
|
* 此处仅负责运行期的每日巡检。
|
||||||
|
*
|
||||||
|
* @author imutil
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class PartitionCreateTask {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private PartitionService partitionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 app.yml partitionCreateJob 驱动(默认每天 0:10)
|
||||||
|
*/
|
||||||
|
@Scheduled(name = "partitionCreateJob")
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
partitionService.createCurrentAndNextMonth();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
log.error("分区自建任务异常", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.imutil.tencent;
|
package com.imutil.tencent;
|
||||||
|
|
||||||
|
import com.imutil.common.BizException;
|
||||||
import com.imutil.common.Httpx;
|
import com.imutil.common.Httpx;
|
||||||
import com.imutil.common.Jsons;
|
import com.imutil.common.Jsons;
|
||||||
|
import com.imutil.common.LocalCache;
|
||||||
|
import com.imutil.common.RateLimiter;
|
||||||
import com.imutil.common.TenantContext;
|
import com.imutil.common.TenantContext;
|
||||||
import com.imutil.entity.ApiCallLog;
|
import com.imutil.entity.ApiCallLog;
|
||||||
|
import com.imutil.entity.Tenant;
|
||||||
import com.imutil.mapper.ApiCallLogMapper;
|
import com.imutil.mapper.ApiCallLogMapper;
|
||||||
|
import com.imutil.service.TenantService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.noear.snack4.ONode;
|
import org.noear.snack4.ONode;
|
||||||
import org.noear.solon.annotation.Component;
|
import org.noear.solon.annotation.Component;
|
||||||
@@ -13,6 +18,7 @@ import org.noear.solon.annotation.Inject;
|
|||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -49,15 +55,29 @@ public class TencentImClient {
|
|||||||
@Inject
|
@Inject
|
||||||
private ApiCallLogMapper apiCallLogMapper;
|
private ApiCallLogMapper apiCallLogMapper;
|
||||||
|
|
||||||
/**
|
@Inject("${imutil.ratelimit.defaultImQps:50}")
|
||||||
* 生成管理员 UserSig(长效,用于调后台 API)
|
private int defaultImQps;
|
||||||
*/
|
|
||||||
private String genAdminSig() {
|
@Inject
|
||||||
return UserSigUtil.genSig(sdkAppId, secretKey, adminUserId, 30L * 86400);
|
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
|
* 调用主应用 IM REST API(用主应用密钥,走出站限流)
|
||||||
*
|
*
|
||||||
* @param command 命令路径,如 im_open_login_svc/account_import
|
* @param command 命令路径,如 im_open_login_svc/account_import
|
||||||
* @param bodyJson 请求体 JSON
|
* @param bodyJson 请求体 JSON
|
||||||
@@ -67,12 +87,38 @@ public class TencentImClient {
|
|||||||
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
|
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
|
||||||
String tenantId = TenantContext.get();
|
String tenantId = TenantContext.get();
|
||||||
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
|
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;
|
String result;
|
||||||
try {
|
try {
|
||||||
String adminSig = genAdminSig();
|
String adminSig = UserSigUtil.genSig(appid, key, adminUserId, 30L * 86400);
|
||||||
String url = "https://" + apiHost + "/v4/" + command
|
String url = "https://" + apiHost + "/v4/" + command
|
||||||
+ "?sdkappid=" + sdkAppId
|
+ "?sdkappid=" + appid
|
||||||
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
|
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
|
||||||
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
|
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
|
||||||
+ "&contenttype=json&platform=10&apn=1";
|
+ "&contenttype=json&platform=10&apn=1";
|
||||||
@@ -110,6 +156,40 @@ 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();
|
||||||
|
}
|
||||||
|
// 仅查询成功才缓存(含"租户无配额配置→用默认");异常时不缓存,让下次重试
|
||||||
|
localCache.put(cacheKey, String.valueOf(qps));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("查询租户配额失败 tenant={},用默认 {}(不缓存,下次重试): {}", tid, defaultImQps, e.getMessage());
|
||||||
|
}
|
||||||
|
return qps;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
|
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
|
||||||
*
|
*
|
||||||
@@ -128,6 +208,35 @@ public class TencentImClient {
|
|||||||
return isOk(resp);
|
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>
|
* <p>
|
||||||
@@ -184,6 +293,237 @@ public class TencentImClient {
|
|||||||
return callApi("group_open_http_svc/group_msg_get_simple", Jsons.stringify(body));
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查群资料 + 成员列表(老应用拉群详情,用老密钥)
|
||||||
|
* <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_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 返回是否成功
|
* 判断腾讯 API 返回是否成功
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ solon.logging.logger:
|
|||||||
level: INFO
|
level: INFO
|
||||||
solon.logging.appender:
|
solon.logging.appender:
|
||||||
console:
|
console:
|
||||||
charset: GBK
|
charset: UTF-8
|
||||||
pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39}%L)) : %msg%n"
|
pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39}%L)) : %msg%n"
|
||||||
file:
|
file:
|
||||||
charset: UTF-8
|
charset: UTF-8
|
||||||
@@ -90,6 +90,12 @@ imutil.pull:
|
|||||||
maxMsgPerConv: 20 # 每会话每轮拉取条数上限
|
maxMsgPerConv: 20 # 每会话每轮拉取条数上限
|
||||||
lookbackMinutes: 30 # C2C 拉取时间窗(最近N分钟)
|
lookbackMinutes: 30 # C2C 拉取时间窗(最近N分钟)
|
||||||
|
|
||||||
|
# 数据迁移配置(T14 消息迁移)
|
||||||
|
imutil.migrate:
|
||||||
|
msgLookbackDays: 7 # 消息迁移回溯天数,0=全量(受套餐云端保存时长限制)
|
||||||
|
importMaxRetry: 3 # 消息导入 429 限流重试次数
|
||||||
|
importRetryMs: 1000 # 消息导入重试间隔(毫秒)
|
||||||
|
|
||||||
# 限流配置(按租户令牌桶)
|
# 限流配置(按租户令牌桶)
|
||||||
imutil.ratelimit:
|
imutil.ratelimit:
|
||||||
defaultImQps: 50 # 默认每租户 IM API QPS
|
defaultImQps: 50 # 默认每租户 IM API QPS
|
||||||
|
|||||||
@@ -211,3 +211,35 @@ CREATE TABLE IF NOT EXISTS admin_user (
|
|||||||
CONSTRAINT uk_admin_username UNIQUE (username)
|
CONSTRAINT uk_admin_username UNIQUE (username)
|
||||||
);
|
);
|
||||||
-- 默认管理员账号由应用首次启动时按 app.yml imutil.admin 配置初始化(不在此写死密码hash)
|
-- 默认管理员账号由应用首次启动时按 app.yml imutil.admin 配置初始化(不在此写死密码hash)
|
||||||
|
|
||||||
|
-- ===== 14. 老应用配置(数据迁移源:每个老应用对应主应用一个租户前缀) =====
|
||||||
|
CREATE TABLE IF NOT EXISTS source_app (
|
||||||
|
id bigint GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
app_name varchar(64) NOT NULL, -- 老应用名称
|
||||||
|
sdk_app_id varchar(32) NOT NULL, -- 老应用 SDKAppID
|
||||||
|
secret_key varchar(128) NOT NULL, -- 老应用密钥(仅本工具持有,迁移拉取用)
|
||||||
|
tenant_id varchar(32) NOT NULL, -- 对应主应用租户(决定 UserID 前缀),关联 tenant.tenant_id
|
||||||
|
status smallint NOT NULL DEFAULT 1,-- 1=启用 0=停用
|
||||||
|
remark text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT pk_source_app PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_source_app_sdk ON source_app(sdk_app_id);
|
||||||
|
|
||||||
|
-- ===== 15. 迁移任务记录(支持断点续传与校验) =====
|
||||||
|
CREATE TABLE IF NOT EXISTS migrate_task (
|
||||||
|
id bigint GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
source_app_id bigint NOT NULL, -- 关联 source_app.id
|
||||||
|
object_type varchar(16) NOT NULL, -- USER/GROUP/MSG/ROSTER
|
||||||
|
status smallint NOT NULL DEFAULT 0, -- 0=pending 1=running 2=paused 3=done 4=failed
|
||||||
|
total_count bigint NOT NULL DEFAULT 0, -- 应处理总数
|
||||||
|
processed_count bigint NOT NULL DEFAULT 0, -- 已处理数
|
||||||
|
pos_cursor varchar(256), -- 断点游标(时间戳/会话+seq,按对象类型语义不同)
|
||||||
|
verify_result text, -- 校验结果 JSON(老 vs 新 条数对比)
|
||||||
|
error_msg text, -- 失败原因
|
||||||
|
started_at timestamptz,
|
||||||
|
finished_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT pk_migrate_task PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_migrate_task_app ON migrate_task(source_app_id, object_type, status);
|
||||||
|
|||||||
@@ -66,6 +66,8 @@
|
|||||||
<a href="${basePath}/admin/grant" class="nav-item <#if active=='grant'>active</#if>">跨租户授权</a>
|
<a href="${basePath}/admin/grant" class="nav-item <#if active=='grant'>active</#if>">跨租户授权</a>
|
||||||
<a href="${basePath}/admin/queue" class="nav-item <#if active=='queue'>active</#if>">队列监控</a>
|
<a href="${basePath}/admin/queue" class="nav-item <#if active=='queue'>active</#if>">队列监控</a>
|
||||||
<a href="${basePath}/admin/usage" class="nav-item <#if active=='usage'>active</#if>">用量报表</a>
|
<a href="${basePath}/admin/usage" class="nav-item <#if active=='usage'>active</#if>">用量报表</a>
|
||||||
|
<a href="${basePath}/admin/sourceapp" class="nav-item <#if active=='sourceapp'>active</#if>">老应用配置</a>
|
||||||
|
<a href="${basePath}/admin/migrate" class="nav-item <#if active=='migrate'>active</#if>">数据迁移</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<a href="${basePath}/admin/logout" class="logout-link">退出登录</a>
|
<a href="${basePath}/admin/logout" class="logout-link">退出登录</a>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<#import "_macros.ftl" as m>
|
||||||
|
<@m.layout active="migrate" title="数据迁移">
|
||||||
|
<#if msg?has_content>
|
||||||
|
<div class="alert">
|
||||||
|
<#if msg=='done'>✅ 迁移完成(成功导入 ${imported!'0'} 用户,详见任务记录)
|
||||||
|
<#elseif msg=='gdone'>✅ 群迁移完成(迁移 ${migrated!'0'} 群,详见任务记录)
|
||||||
|
<#elseif msg=='gmdone'>✅ 群消息迁移完成(导入 ${imported!'0'} 条,详见任务记录)
|
||||||
|
<#elseif msg=='mdone'>✅ 消息迁移完成(导入 ${imported!'0'} 条,详见任务记录)
|
||||||
|
<#elseif msg=='rdone'>✅ 关系链迁移完成(导入 ${imported!'0'} 用户好友,详见任务记录)
|
||||||
|
<#elseif msg=='empty'>❌ 用户清单为空(每行一个 UserID,# 开头为注释)
|
||||||
|
<#elseif msg=='noapp'>❌ 请选择老应用
|
||||||
|
<#else>${msg}
|
||||||
|
</#if>
|
||||||
|
</div>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<div class="stat-card" style="margin-bottom:20px">
|
||||||
|
<div class="modal-title">用户迁移</div>
|
||||||
|
<p class="muted">上传用户清单文件(txt/csv,每行一个老应用 UserID,# 开头注释)或粘贴列表,选择老应用后触发。老 UserID 将加租户前缀后批量导入主应用,并写 user_mapping。幂等可重跑。</p>
|
||||||
|
<form method="post" action="${basePath}/admin/migrate/users" enctype="multipart/form-data" style="margin-top:14px">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>老应用</label>
|
||||||
|
<select name="sourceAppId" required>
|
||||||
|
<option value="">请选择老应用</option>
|
||||||
|
<#list apps as a>
|
||||||
|
<option value="${a.id!}">${a.appName!}(SDK ${a.sdkAppId!} → 租户 ${a.tenantId!})</option>
|
||||||
|
</#list>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>上传用户清单文件(每行一个 UserID)</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
|
||||||
|
<div class="form-group"><label>或粘贴用户 ID 列表(每行一个)</label><textarea name="userText" rows="6" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">开始迁移</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" style="margin-bottom:20px">
|
||||||
|
<div class="modal-title">群迁移</div>
|
||||||
|
<p class="muted">从老应用拉取所有群(get_appid_groups)→ 逐群拉成员 → 加前缀建群(主应用)→ 导入成员 → 写 group_mapping。群清单由腾讯 API 拉取(无需文件);成员账号需已迁移(请先做用户迁移)。</p>
|
||||||
|
<form method="post" action="${basePath}/admin/migrate/groups" style="margin-top:14px">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>老应用</label>
|
||||||
|
<select name="sourceAppId" required>
|
||||||
|
<option value="">请选择老应用</option>
|
||||||
|
<#list apps as a>
|
||||||
|
<option value="${a.id!}">${a.appName!}(SDK ${a.sdkAppId!} → 租户 ${a.tenantId!})</option>
|
||||||
|
</#list>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">开始群迁移</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" style="margin-bottom:20px">
|
||||||
|
<div class="modal-title">群消息迁移</div>
|
||||||
|
<p class="muted">上传/粘贴老应用群 ID 清单(每行一个),选老应用触发。逐群 getGroupMsg(老密钥)分页拉历史 → 加前缀 from → importGroupMsg(主密钥)。群 ID 需已迁移(先做群迁移)。</p>
|
||||||
|
<form method="post" action="${basePath}/admin/migrate/groupmsg" enctype="multipart/form-data" style="margin-top:14px">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>老应用</label>
|
||||||
|
<select name="sourceAppId" required>
|
||||||
|
<option value="">请选择老应用</option>
|
||||||
|
<#list apps as a>
|
||||||
|
<option value="${a.id!}">${a.appName!}(SDK ${a.sdkAppId!} → 租户 ${a.tenantId!})</option>
|
||||||
|
</#list>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>上传群 ID 清单(每行一个)</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
|
||||||
|
<div class="form-group"><label>或粘贴群 ID 列表</label><textarea name="userText" rows="4" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">开始群消息迁移</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" style="margin-bottom:20px">
|
||||||
|
<div class="modal-title">单聊消息迁移</div>
|
||||||
|
<p class="muted">上传会话清单(每行 from,to 逗号或空格分隔,# 注释)或粘贴,选老应用后触发。getRoamMsg(老密钥)拉 → 加前缀 → importMsg(主密钥,不计未读)。v1 每会话拉最近 100 条,断点续传按会话索引。用户需已迁移。</p>
|
||||||
|
<form method="post" action="${basePath}/admin/migrate/messages" enctype="multipart/form-data" style="margin-top:14px">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>老应用</label>
|
||||||
|
<select name="sourceAppId" required>
|
||||||
|
<option value="">请选择老应用</option>
|
||||||
|
<#list apps as a>
|
||||||
|
<option value="${a.id!}">${a.appName!}(SDK ${a.sdkAppId!} → 租户 ${a.tenantId!})</option>
|
||||||
|
</#list>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>上传会话清单(每行 from,to)</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
|
||||||
|
<div class="form-group"><label>或粘贴会话列表(每行 from,to)</label><textarea name="convText" rows="5" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px" placeholder="user_a,user_b user_c,user_d"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">开始消息迁移</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" style="margin-bottom:20px">
|
||||||
|
<div class="modal-title">关系链迁移</div>
|
||||||
|
<p class="muted">上传用户清单(每行一个 UserID)或粘贴,选老应用后触发。逐用户 friendGetList(老密钥)拉好友 → 加前缀 → importFriend(主密钥)。用户与好友账号需已迁移。</p>
|
||||||
|
<form method="post" action="${basePath}/admin/migrate/roster" enctype="multipart/form-data" style="margin-top:14px">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>老应用</label>
|
||||||
|
<select name="sourceAppId" required>
|
||||||
|
<option value="">请选择老应用</option>
|
||||||
|
<#list apps as a>
|
||||||
|
<option value="${a.id!}">${a.appName!}(SDK ${a.sdkAppId!} → 租户 ${a.tenantId!})</option>
|
||||||
|
</#list>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>上传用户清单(每行一个 UserID)</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
|
||||||
|
<div class="form-group"><label>或粘贴用户 ID 列表</label><textarea name="userText" rows="4" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">开始关系链迁移</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:20px 0 10px;font-size:15px;color:#2c3e50">迁移任务记录</h3>
|
||||||
|
<#if tasks?has_content>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>老应用</th><th>对象</th><th>进度(已处理/总数)</th><th>状态</th><th>校验结果</th><th>开始时间</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<#list tasks as t>
|
||||||
|
<tr>
|
||||||
|
<td>${t.id!}</td>
|
||||||
|
<td>${t.sourceAppId!}</td>
|
||||||
|
<td>${t.objectType!}</td>
|
||||||
|
<td>${t.processedCount!'0'}/${t.totalCount!'0'}</td>
|
||||||
|
<td>
|
||||||
|
<#if t.status?? && t.status==3><span class="badge badge-ok">完成</span>
|
||||||
|
<#elseif t.status?? && t.status==1><span class="badge badge-warn">运行</span>
|
||||||
|
<#elseif t.status?? && t.status==4><span class="badge badge-no">失败</span>
|
||||||
|
<#else><span class="badge">待</span>
|
||||||
|
</#if>
|
||||||
|
</td>
|
||||||
|
<td class="muted">${t.verifyResult!'-'}</td>
|
||||||
|
<td class="muted">${t.startedAt!}</td>
|
||||||
|
</tr>
|
||||||
|
</#list>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<#else>
|
||||||
|
<div class="empty">暂无迁移任务</div>
|
||||||
|
</#if>
|
||||||
|
</@m.layout>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<#import "_macros.ftl" as m>
|
||||||
|
<@m.layout active="sourceapp" title="老应用配置">
|
||||||
|
<#if msg?has_content>
|
||||||
|
<div class="alert">
|
||||||
|
<#if msg=='saved'>保存成功
|
||||||
|
<#elseif msg=='toggled'>状态已切换
|
||||||
|
<#elseif msg=='test_ok'>✅ 连通成功(SDKAppId/密钥/sig 正确)
|
||||||
|
<#elseif msg=='test_sigfail'>❌ 连通失败:UserSig 错误(70003,SDKAppId/密钥不匹配)
|
||||||
|
<#elseif msg=='test_fail'>❌ 连通失败(详见日志)
|
||||||
|
<#elseif msg=='sdkappid_required'>SDKAppID 必填
|
||||||
|
<#elseif msg=='tenant_required'>对应租户必填
|
||||||
|
<#elseif msg=='notfound'>老应用不存在
|
||||||
|
<#else>${msg}
|
||||||
|
</#if>
|
||||||
|
</div>
|
||||||
|
</#if>
|
||||||
|
<p class="muted">数据迁移源:每个老应用对应主应用一个租户(决定 UserID 前缀)。迁移时用其 SDKAppID/密钥调腾讯 API 拉取存量数据,加前缀后导入主应用。密钥仅本工具持有。</p>
|
||||||
|
<div style="margin-bottom:14px">
|
||||||
|
<button class="btn btn-success" onclick="document.getElementById('addModal').classList.add('show')">➕ 新增老应用</button>
|
||||||
|
</div>
|
||||||
|
<#if apps?has_content>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>应用名称</th><th>SDKAppID</th><th>密钥</th><th>对应租户</th>
|
||||||
|
<th>状态</th><th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<#list apps as a>
|
||||||
|
<tr>
|
||||||
|
<td>${a.id!}</td>
|
||||||
|
<td>${a.appName!}</td>
|
||||||
|
<td>${a.sdkAppId!}</td>
|
||||||
|
<td class="muted">••••••••</td>
|
||||||
|
<td>${a.tenantId!}</td>
|
||||||
|
<td>
|
||||||
|
<#if a.status?? && a.status == 1>
|
||||||
|
<span class="badge badge-ok">启用</span>
|
||||||
|
<#else>
|
||||||
|
<span class="badge badge-no">停用</span>
|
||||||
|
</#if>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="${basePath}/admin/sourceapp/toggle" style="display:inline">
|
||||||
|
<input type="hidden" name="id" value="${a.id!}">
|
||||||
|
<#if a.status?? && a.status == 1>
|
||||||
|
<input type="hidden" name="status" value="0">
|
||||||
|
<button class="btn btn-sm btn-secondary">停用</button>
|
||||||
|
<#else>
|
||||||
|
<input type="hidden" name="status" value="1">
|
||||||
|
<button class="btn btn-sm btn-success">启用</button>
|
||||||
|
</#if>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="${basePath}/admin/sourceapp/test" style="display:inline">
|
||||||
|
<input type="hidden" name="id" value="${a.id!}">
|
||||||
|
<button class="btn btn-sm btn-primary">测试连通</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</#list>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<#else>
|
||||||
|
<div class="empty">暂无老应用配置,点击「新增老应用」添加</div>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<div id="addModal" class="modal" onclick="if(event.target===this)this.classList.remove('show')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-title">新增老应用</div>
|
||||||
|
<form method="post" action="${basePath}/admin/sourceapp/save">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group"><label>应用名称</label><input name="appName" required></div>
|
||||||
|
<div class="form-group"><label>SDKAppID(老应用)</label><input name="sdkAppId" required></div>
|
||||||
|
<div class="form-group"><label>SecretKey(老应用密钥)</label><input name="secretKey" required></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>对应租户(决定 UserID 前缀)</label>
|
||||||
|
<select name="tenantId" required>
|
||||||
|
<option value="">请选择租户</option>
|
||||||
|
<#list tenants as t>
|
||||||
|
<option value="${t.tenantId!}">${t.tenantId!}(${t.tenantName!},前缀 ${t.prefixCode!})</option>
|
||||||
|
</#list>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>备注</label><input name="remark" placeholder="如:原XX系统IM应用"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('addModal').classList.remove('show')">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</@m.layout>
|
||||||
Reference in New Issue
Block a user