Compare commits

..
14 Commits
Author SHA1 Message Date
连龙刚andClaude 7ca5bd4290 feat(t17): 单聊同步+补拉对账+同步消息进分发;修复 get_roam_msg 60008
新增单聊(C2C)同步、补拉对账定时任务,同步消息纳入分发队列推送业务系统。
1.单聊同步④:user遍历→recentcontact/get_list收集C2C会话→会话对去重→
  get_roam_msg时间窗向前滚动全量→落库(conv_type=1/SYNC/进分发)
2.补拉对账⑤:SyncCheckTask定时(1h)+手动按钮,重跑群+单聊幂等补缺失
3.同步消息进分发:群/单聊distStatus=0入DistQueue;限速分批入队
  (next_retry_at按序号错开delay=seq/dispatchQps)
4.重构:enqueue抽到DispatchService公共方法,Pull/Sync共用

修复(get_roam_msg一直60008静默失败,C2C补拉从未工作):
-命令字 openim_admin/get_roam_msg → openim/admin_getroammsg
-字段 From/To/MaxTimeInterval → Operator_Account/Peer_Account/MaxTime
-新增 getRoamMsgRangeAs(MinTime/MaxTime+LastMsgKey续拉)+getRecentContactListAs

联调(sa 16用户):单聊链路跑通(get_list32+admin_getroammsg OK/Complete=1),
幂等正常,补拉定时工作,无死循环。enqueue实证待配callbackUrl+新消息。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 11:21:49 +08:00
连龙刚andClaude 968a5f2e42 fix(t17): 数据同步联调修复——群消息死循环 + 字段名 + schema自动升级 + maxGroups精确
联调(新疆信创测试应用 1600134460,租户sa 67群)发现并修复4类问题:
1.群消息死循环:group_msg_get_simple 的 IsFinished=2 表"还有更旧可拉",占位消息群恒为2;
  原 oldestSeq=max(seq) 游标方向反,反复拉同一批致2217次/200s超时。
  改 min(seq) 向旧翻页 + 游标未推进即停兜底 + 过滤 IsPlaceMsg=1 占位消息(119次/15.7s)
2.get_group_info 请求体字段名 GroupId→GroupIdList(腾讯要求 GroupIdList:["id"] 字符串数组)
3.运行库缺列致 upserted=0:init.sql 仅新建库执行,新增 SchemaUpgradeBootstrap 启动幂等ALTER(6条DDL)
4.maxGroups 防护:break 由 for 外移入 for 内,精确限制避免单页海量群绕过上限

联调验证(租户sa 67群):群组67/67、群成员241/13、群消息119次/imported=3,死循环消除

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 08:40:46 +08:00
连龙刚andClaude b679d47adb fix(log): 控制台中文乱码——CONSOLE charset GBK→UTF-8(适配现代终端)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 17:22:31 +08:00
连龙刚andClaude 4ce030aa74 feat(t16): 消息记录查看(列表+筛选+分页+详情+分发链路)
- ImMessageMapper: +selectMessagePage/+countMessages(动态条件+LIMIT/OFFSET,带时间窗分区裁剪)
- DistQueueMapper: +selectByMsgKey(详情关联分发链路,LIMIT 100)
- AdminController: GET /admin/messages(列表+筛选+分页,默认近7天已分发)+ /admin/messages/detail
- messages.ftl/messages_detail.ftl: 列表+筛选+分页 / 详情+msg_body+dist_queue链路
- _macros.ftl: 侧边栏加消息记录导航
- 时间由Controller预格式化(FreeMarker对java.time支持不稳),msgTimeIso用纳秒精度匹配DB微秒

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 17:22:31 +08:00
连龙刚andClaude 707f0b4824 feat(admin): 修改密码功能(PBKDF2原密码校验 + 新密码≥8位合规 + 页面 + 导航入口)
- AdminUserService.changePassword: verify旧密码 + 新密码≥8位且≠原密码 + hash更新
- AdminController: GET/POST /admin/password(当前登录用户,Sa-Token loginId)
- password.ftl: 表单 + 前端JS预校验(长度/一致/新旧不同)
- _macros.ftl: 侧边栏footer加修改密码入口

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 14:00:43 +08:00
yixiongandClaude f401e8d065 feat(t14-v1234): 消息迁移增强——C2C全量分页+群消息迁移+429限速重试+回溯天数可配
V1: migrateMessages 7天窗滚动 + Complete分页拉全量历史
V2: migrateGroupMessages + getGroupMsgAs/importGroupMsg + /migrate/groupmsg
V3: importMsg/importGroupMsg 捕获429重试 + app.yml imutil.migrate(msgLookbackDays/importMaxRetry/importRetryMs)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 12:57:54 +08:00
yixiongandClaude 460a31b52f feat(t14-s345): 群迁移 + 消息迁移 + 关系链迁移
T14 S3/S4/S5 阶段:
- S3 群迁移:getAppidGroups/getGroupInfo(老密钥拉)+ createGroup/addGroupMember(主密钥导入)+ migrateGroups
- S4 消息迁移:importMsg/importGroupMsg + migrateMessages(会话清单+断点续传,v1 最近7天/100条)
- S5 关系链:importFriend/friendGetList(sns 系列)+ migrateRoster
- 后台 /admin/migrate/{groups,messages,roster} 触发 + migrate.ftl 三表单

S1/S2 完整联调通过;S3/S4/S5 代码完成+通路验证(腾讯返业务响应),
核心导入待真实老应用(10003 群组/60008 消息/60002 关系链)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 12:12:26 +08:00
yixiongandClaude 7b1385e160 feat(t14-s1s2): 数据迁移基础设施 + 用户迁移
T14 数据迁移工具 S1/S2 阶段:

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 11:12:25 +08:00
yixiongandClaude b4c6652f66 fix(review): 代码审查 6 项修复(M2/M3/m1/m2/m3/m5)
集中修复 T8/T9/T11/T15 已实现代码的审查问题:
- M2 令牌桶 Lua 改用 Redis TIME(消除多节点时钟偏斜)
- M3 after 跨租户标记复查授权 + 无授权告警兜底
- m1 before 审计 MsgSeq/MsgRandom null 防御
- m2 HealthService 加 forceDown 开关,实测 DOWN 真返 503
- m3 countByStatus 排除 done(WHERE status IN (0,1,3))
- m5 配额查询异常不缓存(下次重试)
- 附带 app.yml console charset GBK→UTF-8
M1 限流审计/事务收窄暂缓。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 09:52:13 +08:00
yixiongandClaude 8e2c66b363 feat(t15): 健康检查端点 /health(pg/redis/队列四态,DOWN 返503)
- DistQueueMapper: +countByStatus (GROUP BY status 一次拿全)
- HealthService(新): 聚合 pg 连通/redis ping/队列四态,status UP/DEGRADED/DOWN
- HealthController(新): GET /health,DOWN 返 503 便 LB 摘流;免租户鉴权(PathWhitelist)
- 联调:status=UP,pg/redis=UP,distQueue 四态 0,HTTP 200

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 08:50:15 +08:00
yixiongandClaude 43f7d30e46 feat(t9): 按租户令牌桶限流(出站腾讯API配额保护,对齐设计3.2)
- RateLimiter(新): Redis+Lua 原子令牌桶,租户桶(quota_im_qps)+全局桶(globalImQps),
  任一超限抛 BizException(429);Redis 异常 fail-open 放行
- RedisService: +eval(Lua) 通用脚本执行(令牌桶复合操作需原子)
- TencentImClient.callApi: 开头 checkApiLimit,resolveTenantQps 查 quota_im_qps(Caffeine 60s)
- 联调:sa quota=2,8 并发→2 放行+6 限流,被拒不调腾讯(api_call_log 仅 2 条)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 08:39:43 +08:00
yixiongandClaude e3a64bb861 fix(t8): 跨租户拦截对齐设计5.4——before阻断替代after额外分发
- CallbackServiceImpl: 新增 handleBeforeSendMsg 做 C2C.CallbackBeforeSendMsg
  跨租户拦截决策(未授权返回 FAIL,腾讯不投递);after 移除「额外推 to 租户」,
  is_cross_tenant 改在 after 标记;新增 fail() 响应(ErrorCode 80001)
- 解决旧实现(after 额外分发)无法阻断未授权跨租户消息的核心隔离缺陷
- 联调验证:before 同租户OK/无授权FAIL/有授权OK+审计;after 落库+按from分发
- 详见 实施记录第六章 + T8 跨租户拦截-方案决策记录.md

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 00:20:43 +08:00
yixiongandClaude 8c9037fc4f fix: 跨租户授权联调修复 + 新增分区自建模块
- CrossTenantServiceImpl: 有效期判断改用 apply("start_at <= now()"),规避
  OffsetDateTime 绑定 timestamptz 比较异常(见实施记录 6.5)
- CallbackServiceImpl: 集成 C2C 跨租户授权检查(额外分发+审计+is_cross_tenant),
  提取 enqueueIfConfigured
- 新增 PartitionService/Task: im_message 按月 RANGE 分区滚动建表
  (当月+下月,@Init 启动自检 + 每日 0:10)
- 新增 CrossTenantService: 正反向授权匹配 + 跨租户审计

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-08 23:56:32 +08:00
52 changed files with 4552 additions and 114 deletions
+3
View File
@@ -38,3 +38,6 @@ logs/
# 本地配置覆盖(密钥等敏感信息)
app-env.yml
# Claude 项目记忆(含密码/密钥等敏感信息,不进 git;基准在 .claude/memory/,由 /memory-sync 同步到会话目录)
.claude/memory/
+82
View File
@@ -0,0 +1,82 @@
<!-- Last updated: 2026-07-09 | Commit: b679d47 -->
# tencent-im-util
腾讯 IM 回调分发工具:接收腾讯 IM 的消息回调,落库后按租户/源应用分发。配套管理后台、用量统计、关系链/消息迁移等。
> 本文件为**项目级技术上下文**(行为准则见全局 `~/.claude/CLAUDE.md`)。密钥一律不写在此处,配置项以 `app.yml` 为准。
## 技术栈
- **框架**Solon`solon-parent`,非 Spring),Java 21`solon-web`
- **鉴权**Sa-Token`sa-token-solon-plugin`
- **持久层**MyBatis-Plus`mybatis-plus-extension-solon-plugin`+ PostgreSQL + HikariCP
- **缓存/队列**RedisJedis+ Caffeine 本地缓存
- **视图**FreeMarker`solon-view-freemarker`
- **日志**logback`solon-logging-logback-jakarta`
- **腾讯 IM**`tls-sig-api-v2`UserSig 签名)+ 自封装 `TencentImClient`REST API
- **定时**`solon-scheduling-simple`**工作线程**:自实现 `worker/DispatchWorker`
## 构建与运行
```bash
# 打包(离线模式,跳过测试)
mvn -o package -DskipTests
# 运行
java -jar target/tencent-im-util.jar
```
> ⚠️ **不要用 `mvn solon:solon`**(本项目下不可用,会失败)。详见 `.claude/memory/run-app-jar.md`。
> 端口 `8092`contextPath `/imutil`(即所有路由前缀 `/imutil`)。控制台中文需 UTF-8 终端(已在 `logback.xml` 修正 charset)。
## 目录结构(`src/main/java/com/imutil`
| 包 | 职责 |
|---|---|
| `controller` | `AdminController`(`/admin/*` 后台)、`CallbackController`(`/callback/im` 回调入口)、`HealthController``SigController`(UserSig 下发) |
| `service` + `service/impl` | 业务逻辑(接口 + 实现) |
| `entity` | MyBatis-Plus 实体:`Tenant`/`SourceApp`/`ImMessage`/`DistQueue`/`UsageStat`/`MigrateTask`/`UserMapping`/`GroupMapping`/`Recording`/`TrtcRoom`/`CrossTenantGrant`/`CrossTenantAudit`/`PullWatermark`/`ApiCallLog`/`AdminUser` |
| `mapper` | MyBatis 映射,含 `PartitionMapper`PG 分区管理) |
| `filter` | `AdminAuthFilter`(后台鉴权)、`TenantAuthFilter`(租户鉴权)、`GlobalExceptionFilter`(全局异常) |
| `task` | 定时任务:`DispatchRecoverTask`(死信重投)、`PartitionCreateTask`(分区创建)、`PullCheckTask``UsageStatTask` |
| `tencent` | `TencentImClient`REST API + 429 限速重试)、`TencentCallbackSign`(回调签名校验)、`UserSigUtil` |
| `worker` | `DispatchWorker`(消费分发队列) |
| `common` | 工具类:`PasswordUtil`(PBKDF2)、`RedisService``LocalCache``RateLimiter``Jsons``Httpx``Ids``MsgKeys``TenantContext``HealthService``BizException` |
| `model` | `Result`(统一响应体) |
## 核心调用链
1. **回调分发(主链路)**:腾讯服务器 → `CallbackController#callback`(`/imutil/callback/im`) → `CallbackServiceImpl`(验签 + 落 `ImMessage` + 入 `DistQueue`)→ `DispatchWorker` 消费 → 分发到目标租户/源应用;失败由 `DispatchRecoverTask` 重投。
2. **管理后台**`AdminController`Sa-Token 会话)+ FreeMarker 页面(`resources/templates/*.ftl`):login / home / tenant / sourceapp / grant / queue / usage / password / messages / migrate。
3. **数据迁移**`MigrateService`C2C 全量分页 + 群消息,`msgLookbackDays` 回溯天数,429 自动重试)。
4. **跨租户授权**`CrossTenantService``CrossTenantGrant` 授权 + `CrossTenantAudit` 审计)。
5. **分区与统计**`PartitionService`(按 `msg_time` 分区裁剪)+ `PartitionCreateTask``UsageStatService` + `UsageStatTask`
## 项目约定(务必遵守)
- **Long ID → String**:雪花 ID 等 `Long` 序列化给前端**必须** `String.valueOf()`,否则 JS 精度丢失(全局规则 10.1)。
- **FreeMarker + java.time 坑**ftl 里**禁止**对 `LocalDateTime`/`OffsetDateTime``?string(pattern)`(抛 `NonMethodException`)。改为 Controller 端 `DateTimeFormatter.format` 预格式化成 `xxxStr` 字段,ftl 用 `${obj.xxxStr!}`。详见 `.claude/memory/freemarker-java-time.md`
- **LocalDate / DateTimeFormatter**pattern 含 `H/m/s` 时只能用 `LocalDateTime`,禁用 `LocalDate.now()`(全局规则 10.2)。
- **中文注释**:所有注释用简体中文,标识符保持英文(全局规则 9)。
- **密码**`PasswordUtil` PBKDF2 哈希存 `admin_user.password_hash``iterations:salt:hash`);改密走 `/admin/password`
- **配置不提交**`app.yml` 含密钥(Redis 密码 / `tencent.secretKey` / admin 默认密码),长期处于未提交状态,**勿提交**;如需本地覆盖敏感值用 `app-env.yml`(已 gitignore)。
## 关键配置(`src/main/resources/app.yml`,值为准)
- `server.port` / `server.contextPath`
- `solon.dataSources.db1`PostgreSQL 连接)
- `imutil.redis`host/port/password/database
- `imutil.tencent``sdkAppId`/`secretKey`/`adminUserId`/`apiHost`/`usersigExpireDays`/`callbackToken`——`callbackToken` 留空则跳过回调签名校验,仅联调用)
- `imutil.admin``defaultUsername`/`defaultPassword`——仅首次初始化 `admin_user`
- `imutil.migrate``msgLookbackDays` 等)
- `sa-token.*`
## 文档与外部资源
- **实施记录与测试用例**Obsidian 库 `8 腾讯IM&音视频分发\实施记录与测试用例.md`(项目外,按 T1–T16 任务编号组织;**不进 git**)。
- **项目记忆**`.claude/memory/`(已 gitignore,含账号/密码/密钥提醒等;索引见其 `MEMORY.md`)。召回失效时跑 `/memory-sync` 重新同步到会话目录。
- **数据库操作**:本机无 `psql`,用 DBX MCP`192.168.10.118-pg`database `tencen_im`)查改数据。详见 `.claude/memory/pg-via-dbx.md`
- **回调测试**:端点 `/imutil/callback/im`,本地可用 curl 模拟(见 `.claude/memory/tencent-callback-test.md`)。
## 当前进度
核心功能 T1T15 已完成(T12/T13 TRTC 音视频按需暂缓),T14 消息迁移 V1–V4、T16 消息记录查看已完成。详见实施记录文档与 `.claude/memory/git-author-and-progress.md`
@@ -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:
* - UPPG + Redis 均连通
* - DEGRADEDPG 连通、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 每秒允许次数(容量与填充速率均取此值);&lt;=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 返回 numberJedis 转 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);
}
}
/**
* 执行 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);
}
}
}
@@ -2,16 +2,36 @@ package com.imutil.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.Ids;
import com.imutil.entity.AdminUser;
import com.imutil.entity.CrossTenantGrant;
import com.imutil.entity.DistQueue;
import com.imutil.entity.Tenant;
import com.imutil.entity.UsageStat;
import com.imutil.entity.SourceApp;
import com.imutil.mapper.CrossTenantGrantMapper;
import com.imutil.mapper.DistQueueMapper;
import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UsageStatMapper;
import com.imutil.service.AdminUserService;
import com.imutil.service.SourceAppService;
import com.imutil.service.TenantService;
import com.imutil.tencent.TencentImClient;
import com.imutil.entity.ImMessage;
import com.imutil.entity.MigrateTask;
import com.imutil.service.MigrateService;
import com.imutil.service.SyncService;
import com.imutil.mapper.ImMessageMapper;
import org.noear.solon.core.handle.UploadedFile;
import java.nio.charset.StandardCharsets;
import java.time.format.DateTimeFormatter;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Get;
@@ -44,15 +64,33 @@ public class AdminController {
@Inject
private TenantMapper tenantMapper;
@Inject
private TenantService tenantService;
@Inject
private CrossTenantGrantMapper grantMapper;
@Inject
private DistQueueMapper distQueueMapper;
@Inject
private ImMessageMapper imMessageMapper;
@Inject
private UsageStatMapper usageStatMapper;
@Inject
private SourceAppService sourceAppService;
@Inject
private TencentImClient tencentImClient;
@Inject
private MigrateService migrateService;
@Inject
private SyncService syncService;
// ==================== 登录 / 登出 ====================
@Get
@@ -98,6 +136,39 @@ public class AdminController {
ctx.redirect(basePath + "/admin/login");
}
// ==================== 修改密码 ====================
@Get
@Mapping("/password")
public Object passwordPage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("password.ftl", "修改密码", "password");
mv.put("msg", msg);
return mv;
}
@Post
@Mapping("/password")
public void changePassword(@Param(defaultValue = "") String oldPassword,
@Param(defaultValue = "") String newPassword,
@Param(defaultValue = "") String confirmPassword,
Context ctx) throws Throwable {
// 两次新密码一致性前端已校验,后端兜底
if (!newPassword.equals(confirmPassword)) {
ctx.redirect(basePath + "/admin/password?msg=pwdconfirm");
return;
}
try {
// 当前登录用户IDSa-Token 登录时存的是 AdminUser.id
adminUserService.changePassword(StpUtil.getLoginIdAsLong(), oldPassword, newPassword);
log.info("管理员密码已修改 loginId={}", StpUtil.getLoginIdAsLong());
ctx.redirect(basePath + "/admin/password?msg=pwddone");
} catch (Exception e) {
// 原密码错误 / 新密码不合规统一提示,细节见日志
log.warn("修改密码失败 : {}", e.getMessage());
ctx.redirect(basePath + "/admin/password?msg=pwdfail");
}
}
// ==================== 首页(仪表盘) ====================
@Get
@@ -128,21 +199,30 @@ public class AdminController {
@Post
@Mapping("/tenant/save")
public void tenantSave(Tenant t, Context ctx) throws Throwable {
if (t.getTenantId() == null || t.getTenantId().isEmpty()) {
ctx.redirect(basePath + "/admin/tenant?msg=tenant_id_required");
// tenantId 页面不可见:新增时雪花生成,编辑时由 hidden 携带(主键不可改)
boolean isNew = (t.getTenantId() == null || t.getTenantId().isEmpty());
if (isNew) {
// 前缀码(IM 账号前缀)必填且全局唯一
if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) {
ctx.redirect(basePath + "/admin/tenant?msg=prefix_code_required");
return;
}
if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) {
t.setPrefixCode(t.getTenantId());
Tenant exist = tenantMapper.selectOne(Wrappers.<Tenant>lambdaQuery()
.eq(Tenant::getPrefixCode, t.getPrefixCode()).last("LIMIT 1"));
if (exist != null) {
ctx.redirect(basePath + "/admin/tenant?msg=prefix_code_duplicate");
return;
}
t.setTenantId(Ids.nextIdStr());
if (t.getStatus() == null) {
t.setStatus(1);
}
if (tenantMapper.selectById(t.getTenantId()) == null) {
tenantMapper.insert(t);
} else {
tenantMapper.updateById(t);
}
// 失效缓存,使后续按 id/appKey/prefix 查询拿到最新值
tenantService.evictCache(t.getTenantId());
ctx.redirect(basePath + "/admin/tenant?msg=saved");
}
@@ -153,6 +233,7 @@ public class AdminController {
t.setTenantId(tenantId);
t.setStatus(status == null ? 0 : status);
tenantMapper.updateById(t);
tenantService.evictCache(tenantId);
ctx.redirect(basePath + "/admin/tenant?msg=toggled");
}
@@ -200,11 +281,26 @@ public class AdminController {
@Get
@Mapping("/queue")
public Object queuePage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("queue.ftl", "队列监控", "queue");
mv.put("pendings", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
// nextRetryAt 为 OffsetDateTimeFreeMarker ?string 对 java.time 不支持,Controller 端预格式化
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
List<DistQueue> rawPendings = distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 0)
.orderByAsc(DistQueue::getId)
.last("LIMIT 50")));
.last("LIMIT 50"));
List<Map<String, Object>> pendings = new ArrayList<>();
if (rawPendings != null) {
for (DistQueue q : rawPendings) {
Map<String, Object> v = new LinkedHashMap<>();
v.put("id", q.getId());
v.put("tenantId", q.getTenantId());
v.put("convId", q.getConvId());
v.put("retryCount", q.getRetryCount());
v.put("nextRetryAtStr", q.getNextRetryAt() == null ? "" : q.getNextRetryAt().format(fmt));
pendings.add(v);
}
}
ModelAndView mv = view("queue.ftl", "队列监控", "queue");
mv.put("pendings", pendings);
mv.put("deads", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3)
.orderByDesc(DistQueue::getId)
@@ -227,13 +323,343 @@ public class AdminController {
@Get
@Mapping("/usage")
public Object usagePage() {
ModelAndView mv = view("usage.ftl", "用量报表", "usage");
mv.put("stats", usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery()
// statTime 为 OffsetDateTimeFreeMarker ?string 对 java.time 不支持,Controller 端预格式化
List<UsageStat> raw = usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery()
.orderByDesc(UsageStat::getStatTime)
.last("LIMIT 100")));
.last("LIMIT 100"));
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
List<Map<String, Object>> stats = new ArrayList<>();
if (raw != null) {
for (UsageStat s : raw) {
Map<String, Object> v = new LinkedHashMap<>();
v.put("tenantId", s.getTenantId());
v.put("statTimeStr", s.getStatTime() == null ? "" : s.getStatTime().format(fmt));
v.put("statLevel", s.getStatLevel());
v.put("imMsgCount", s.getImMsgCount());
v.put("imDau", s.getImDau());
v.put("apiCallCount", s.getApiCallCount());
v.put("trtcDurationSec", s.getTrtcDurationSec());
stats.add(v);
}
}
ModelAndView mv = view("usage.ftl", "用量报表", "usage");
mv.put("stats", stats);
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=-1doCallApi 兜底)→失败;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]});
}
}
}
// ==================== 数据同步(T17 ====================
/**
* 数据同步页:只读拉取腾讯 IM 群组/群成员/群消息到本地表,供按租户查看。
* 与 migrate(带建群/建号写回)不同,本功能在腾讯侧无任何写操作。
*/
@Get
@Mapping("/sync")
public Object syncPage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("sync.ftl", "数据同步", "sync");
mv.put("tasks", migrateService.listTasks());
mv.put("apps", sourceAppService.list());
mv.put("tenants", tenantMapper.selectList(null));
mv.put("msg", msg);
return mv;
}
/**
* 同步群组:sourceAppId 空=主应用(需选 tenantId);非空=老应用(密钥+租户取其配置)
*/
@Post
@Mapping("/sync/groups")
public void syncGroups(@Param(required = false) Long sourceAppId,
@Param(defaultValue = "") String tenantId,
Context ctx) throws Throwable {
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
return;
}
syncService.syncGroups(sourceAppId, tenantId);
ctx.redirect(basePath + "/admin/sync?msg=sgdone");
}
/** 同步群成员(→用户):需先同步群组 */
@Post
@Mapping("/sync/users")
public void syncUsers(@Param(required = false) Long sourceAppId,
@Param(defaultValue = "") String tenantId,
Context ctx) throws Throwable {
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
return;
}
syncService.syncGroupMembers(sourceAppId, tenantId);
ctx.redirect(basePath + "/admin/sync?msg=sudone");
}
/** 同步群消息:需先同步群组 */
@Post
@Mapping("/sync/groupmsg")
public void syncGroupMsg(@Param(required = false) Long sourceAppId,
@Param(defaultValue = "") String tenantId,
Context ctx) throws Throwable {
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
return;
}
syncService.syncGroupMessages(sourceAppId, tenantId);
ctx.redirect(basePath + "/admin/sync?msg=smdone");
}
/** 同步单聊消息:需先同步用户(群成员) */
@Post
@Mapping("/sync/c2cmsg")
public void syncC2CMsg(@Param(required = false) Long sourceAppId,
@Param(defaultValue = "") String tenantId,
Context ctx) throws Throwable {
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
return;
}
syncService.syncC2CMessages(sourceAppId, tenantId);
ctx.redirect(basePath + "/admin/sync?msg=scdone");
}
/** 补拉对账:重跑群+单聊(幂等补缺失),仅主应用 + 指定租户 */
@Post
@Mapping("/sync/check")
public void syncCheck(@Param String tenantId, Context ctx) throws Throwable {
if (tenantId == null || tenantId.isEmpty()) {
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
return;
}
syncService.checkAndPull(tenantId);
ctx.redirect(basePath + "/admin/sync?msg=chkdone");
}
// ==================== 公共:构造页面模型 ====================
/**
@@ -243,6 +669,162 @@ public class AdminController {
* @param title 页面标题
* @param active 侧边栏高亮项(home/tenant/grant/queue/usage
*/
// ==================== 消息记录 ====================
/**
* 消息记录列表(已分发消息查询)
* <p>
* 默认最近 7 天 + 已分发(dist_status=1);带 msg_time 范围走分区裁剪。
*/
@Get
@Mapping("/messages")
public Object messagesPage(@Param(defaultValue = "") String tenantId,
@Param(required = false) Integer convType,
@Param(required = false) String distStatus,
@Param(defaultValue = "") String dateFrom,
@Param(defaultValue = "") String dateTo,
@Param(defaultValue = "") String keyword,
@Param(defaultValue = "1") int page,
@Param(defaultValue = "20") int size) {
// 默认时间窗:最近 7 天(带 msg_time 范围触发分区裁剪,避免全分区扫描)
OffsetDateTime now = OffsetDateTime.now();
OffsetDateTime from = parseDateStart(dateFrom, now.minusDays(7));
OffsetDateTime to = parseDateEnd(dateTo, now);
int safePage = Math.max(1, page);
int safeSize = size <= 0 ? 20 : Math.min(size, 200);
int offset = (safePage - 1) * safeSize;
// distStatusnull=首次进入(默认已分发) / ""=用户选"全部" / "0,1,2"=对应状态
// 前端"全部"提交空串,Solon 对带空值的 Integer 参数会判为缺失(defaultValue 不兜底)
// 故改用 String 接收后手动解析,Mapper 以 null 表示不限分发状态。
Integer distStatusInt;
if (distStatus == null) {
distStatusInt = 1;
} else if (distStatus.isEmpty()) {
distStatusInt = null;
} else {
distStatusInt = Integer.valueOf(distStatus);
}
String tid = (tenantId == null || tenantId.isEmpty()) ? null : tenantId;
String kw = (keyword == null || keyword.isEmpty()) ? null : keyword;
List<ImMessage> rawRows = imMessageMapper.selectMessagePage(tid, convType, distStatusInt, from, to, kw, offset, safeSize);
long total = imMessageMapper.countMessages(tid, convType, distStatusInt, from, to, kw);
int totalPages = (int) ((total + safeSize - 1) / Math.max(1, safeSize));
// 转成 Map 并预格式化时间(FreeMarker 对 java.time OffsetDateTime 格式化支持不稳,Controller 端处理)
DateTimeFormatter fmtDisp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
DateTimeFormatter fmtIso = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSxxx");
List<Map<String, Object>> rows = new ArrayList<>();
if (rawRows != null) {
for (ImMessage r : rawRows) {
Map<String, Object> v = new LinkedHashMap<>();
v.put("msgKey", r.getMsgKey());
v.put("msgKeyEnc", r.getMsgKey() == null ? "" : java.net.URLEncoder.encode(r.getMsgKey(), StandardCharsets.UTF_8));
v.put("msgTimeStr", r.getMsgTime() == null ? "" : r.getMsgTime().format(fmtDisp));
v.put("msgTimeIso", r.getMsgTime() == null ? "" : java.net.URLEncoder.encode(r.getMsgTime().format(fmtIso), StandardCharsets.UTF_8));
v.put("tenantId", r.getTenantId());
v.put("convType", r.getConvType());
v.put("convId", r.getConvId());
v.put("fromAccount", r.getFromAccount());
v.put("toAccount", r.getToAccount());
v.put("groupId", r.getGroupId());
v.put("msgType", r.getMsgType());
v.put("source", r.getSource());
v.put("isCrossTenant", r.getIsCrossTenant());
v.put("distStatus", r.getDistStatus());
rows.add(v);
}
}
ModelAndView mv = view("messages.ftl", "消息记录", "messages");
mv.put("rows", rows);
mv.put("total", total);
mv.put("page", safePage);
mv.put("size", safeSize);
mv.put("totalPages", totalPages);
mv.put("tenantId", tenantId == null ? "" : tenantId);
mv.put("convType", convType);
mv.put("distStatus", distStatusInt);
mv.put("dateFrom", dateFrom == null ? "" : dateFrom);
mv.put("dateTo", dateTo == null ? "" : dateTo);
mv.put("keyword", keyword == null ? "" : keyword);
mv.put("tenants", tenantMapper.selectList(null));
return mv;
}
/**
* 消息详情:完整字段 + msg_body + 关联 dist_queue 分发链路
*/
@Get
@Mapping("/messages/detail")
public Object messageDetail(@Param String msgKey, @Param String msgTime) {
ModelAndView mv = view("messages_detail.ftl", "消息详情", "messages");
if (msgKey == null || msgKey.isEmpty() || msgTime == null || msgTime.isEmpty()) {
mv.put("msg", null);
mv.put("dists", java.util.Collections.emptyList());
return mv;
}
OffsetDateTime t;
try {
t = OffsetDateTime.parse(msgTime);
} catch (Exception e) {
mv.put("msg", null);
mv.put("dists", java.util.Collections.emptyList());
return mv;
}
ImMessage m = imMessageMapper.selectOne(Wrappers.<ImMessage>lambdaQuery()
.eq(ImMessage::getMsgKey, msgKey).eq(ImMessage::getMsgTime, t));
mv.put("msg", m);
mv.put("msgTimeStr", (m != null && m.getMsgTime() != null)
? m.getMsgTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")) : "");
mv.put("msgCreatedStr", (m != null && m.getCreatedAt() != null)
? m.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : "");
// dist_queue 分发链路,时间预格式化(同列表原因)
DateTimeFormatter fmtDisp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
List<DistQueue> rawDists = distQueueMapper.selectByMsgKey(msgKey);
List<Map<String, Object>> dists = new ArrayList<>();
if (rawDists != null) {
for (DistQueue d : rawDists) {
Map<String, Object> v = new LinkedHashMap<>();
v.put("id", d.getId());
v.put("targetUrl", d.getTargetUrl());
v.put("tenantId", d.getTenantId());
v.put("status", d.getStatus());
v.put("retryCount", d.getRetryCount());
v.put("createdStr", d.getCreatedAt() == null ? "" : d.getCreatedAt().format(fmtDisp));
v.put("updatedStr", d.getUpdatedAt() == null ? "" : d.getUpdatedAt().format(fmtDisp));
dists.add(v);
}
}
mv.put("dists", dists);
return mv;
}
/** 解析 yyyy-MM-dd 为当天 0 点(+8),空或非法用默认值 */
private OffsetDateTime parseDateStart(String s, OffsetDateTime def) {
if (s == null || s.isEmpty()) {
return def;
}
try {
return LocalDate.parse(s).atStartOfDay(ZoneOffset.ofHours(8)).toOffsetDateTime();
} catch (Exception e) {
return def;
}
}
/** 解析 yyyy-MM-dd 为次日 0 点(+8,开区间覆盖整天),空或非法用默认值 */
private OffsetDateTime parseDateEnd(String s, OffsetDateTime def) {
if (s == null || s.isEmpty()) {
return def;
}
try {
return LocalDate.parse(s).plusDays(1).atStartOfDay(ZoneOffset.ofHours(8)).toOffsetDateTime();
} catch (Exception e) {
return def;
}
}
private ModelAndView view(String tpl, String title, String active) {
ModelAndView mv = new ModelAndView(tpl);
mv.put("basePath", basePath);
@@ -42,6 +42,7 @@ public class CallbackController {
@Param(value = "RequestTime", required = false) String requestTime,
@Param(value = "SdkAppid", required = false) String sdkAppid) throws Throwable {
String body = ctx.body();
log.info("收到腾讯回调 command={} sdkAppid={} requestTime={} body={}", command, sdkAppid, requestTime, body);
// 1. 签名校验(未配置 Token 时跳过,便于联调,生产必须配置)
if (callbackToken != null && !callbackToken.isEmpty()) {
@@ -62,6 +63,7 @@ public class CallbackController {
// 落库失败的消息由补拉服务(T10)兜底
result = "{\"ActionStatus\":\"OK\",\"ErrorCode\":0,\"ErrorInfo\":\"\"}";
}
log.info("回调处理完成 command={} result={}", command, result);
ctx.output(result);
}
}
@@ -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));
}
}
@@ -30,5 +30,17 @@ public class GroupMapping {
/** 群类型:Public/Private/ChatRoom/AVChatRoom */
private String groupType;
/** 群名称(同步拉取自腾讯 group_info.Name */
private String name;
/** 群主账号(同步拉取自腾讯 group_info.Owner_Account */
private String ownerAccount;
/** 群成员数(同步拉取自腾讯 group_info.MemberNum / 成员列表长度) */
private Integer memberCount;
/** 最近一次同步时间(数据同步功能刷新) */
private OffsetDateTime lastSyncedAt;
private OffsetDateTime createdAt;
}
@@ -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;
}
@@ -40,5 +40,11 @@ public class UserMapping {
/** 状态:1=正常 0=封禁 */
private Integer status;
/** 用户昵称(同步拉取自腾讯群成员资料) */
private String nick;
/** 最近一次同步时间(数据同步功能刷新) */
private OffsetDateTime lastSyncedAt;
private OffsetDateTime createdAt;
}
@@ -78,4 +78,25 @@ public interface DistQueueMapper extends BaseMapper<DistQueue> {
"locked_by = NULL, locked_at = NULL, updated_at = now() " +
"WHERE id = #{id} AND status = 3")
int redeliverDead(@Param("id") Long id);
/**
* 按状态聚合计数(健康检查/监控用,一次扫描拿全状态分布)
*
* @return 每项含 status0=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();
/**
* 按消息键查分发记录(消息详情关联分发链路)
* <p>
* msg_key 无独立索引,LIMIT 100 控制扫描范围;大数据量建议加 idx_dq_msgkey。
*
* @param msgKey 消息键
* @return 该消息的分发任务记录(按 id 倒序,最多 100 条)
*/
@org.apache.ibatis.annotations.Select("SELECT id, msg_key, tenant_id, conv_id, target_url, payload, status, retry_count, "
+ "next_retry_at, locked_by, locked_at, created_at, updated_at "
+ "FROM dist_queue WHERE msg_key = #{msgKey} ORDER BY id DESC LIMIT 100")
List<DistQueue> selectByMsgKey(@Param("msgKey") String msgKey);
}
@@ -3,9 +3,11 @@ package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.ImMessage;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 消息主表 Mapper
@@ -29,4 +31,62 @@ public interface ImMessageMapper extends BaseMapper<ImMessage> {
@Param("msgTime") OffsetDateTime msgTime,
@Param("fromStatus") int fromStatus,
@Param("toStatus") int toStatus);
/**
* 分页查询消息(管理后台消息记录列表)
* <p>
* 动态条件 + ORDER BY msg_time DESC + LIMIT/OFFSET。
* 必须带 msg_time 范围(dateFrom/dateTo)触发分区裁剪,避免全分区扫描。
*
* @param tenantId 租户(null/空=全部)
* @param convType 会话类型(null=全部,1=C2C 2=GROUP
* @param distStatus 分发状态(null=全部,0待发/1已发/2失败)
* @param dateFrom 起始时间(含)
* @param dateTo 结束时间(开区间,传次日 0 点以覆盖整天)
* @param keyword 模糊关键字(null/空=无,匹配 from/to/conv_id
* @param offset 偏移
* @param limit 条数
*/
@Select("<script>"
+ "SELECT msg_key, tenant_id, msg_time, conv_type, conv_id, from_account, to_account, group_id, "
+ "msg_type, source, is_cross_tenant, dist_status, created_at "
+ "FROM im_message "
+ "<where>"
+ "<if test='tenantId != null and tenantId != \"\"'>AND tenant_id = #{tenantId} </if>"
+ "<if test='convType != null'>AND conv_type = #{convType} </if>"
+ "<if test='distStatus != null'>AND dist_status = #{distStatus} </if>"
+ "<if test='dateFrom != null'>AND msg_time &gt;= #{dateFrom} </if>"
+ "<if test='dateTo != null'>AND msg_time &lt; #{dateTo} </if>"
+ "<if test='keyword != null and keyword != \"\"'>AND (from_account ILIKE CONCAT('%', #{keyword}, '%') OR to_account ILIKE CONCAT('%', #{keyword}, '%') OR conv_id ILIKE CONCAT('%', #{keyword}, '%')) </if>"
+ "</where>"
+ "ORDER BY msg_time DESC, msg_key DESC "
+ "LIMIT #{limit} OFFSET #{offset}</script>")
List<ImMessage> selectMessagePage(@Param("tenantId") String tenantId,
@Param("convType") Integer convType,
@Param("distStatus") Integer distStatus,
@Param("dateFrom") OffsetDateTime dateFrom,
@Param("dateTo") OffsetDateTime dateTo,
@Param("keyword") String keyword,
@Param("offset") int offset,
@Param("limit") int limit);
/**
* 计数(与 selectMessagePage 同条件,用于分页总数)
*/
@Select("<script>"
+ "SELECT COUNT(*) FROM im_message "
+ "<where>"
+ "<if test='tenantId != null and tenantId != \"\"'>AND tenant_id = #{tenantId} </if>"
+ "<if test='convType != null'>AND conv_type = #{convType} </if>"
+ "<if test='distStatus != null'>AND dist_status = #{distStatus} </if>"
+ "<if test='dateFrom != null'>AND msg_time &gt;= #{dateFrom} </if>"
+ "<if test='dateTo != null'>AND msg_time &lt; #{dateTo} </if>"
+ "<if test='keyword != null and keyword != \"\"'>AND (from_account ILIKE CONCAT('%', #{keyword}, '%') OR to_account ILIKE CONCAT('%', #{keyword}, '%') OR conv_id ILIKE CONCAT('%', #{keyword}, '%')) </if>"
+ "</where></script>")
long countMessages(@Param("tenantId") String tenantId,
@Param("convType") Integer convType,
@Param("distStatus") Integer distStatus,
@Param("dateFrom") OffsetDateTime dateFrom,
@Param("dateTo") OffsetDateTime dateTo,
@Param("keyword") String keyword);
}
@@ -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> {
}
@@ -22,4 +22,16 @@ public interface AdminUserService {
* 应用启动时确保存在至少一个管理员(表空则按 app.yml imutil.admin 初始化)
*/
void ensureDefaultAdmin();
/**
* 修改当前登录用户密码
* <p>
* 校验原密码(PBKDF2 verify)+ 新密码合规(≥ 8 位且与原密码不同)后写新 hash。
*
* @param userId 用户ID
* @param oldPassword 原密码(明文)
* @param newPassword 新密码(明文,需 ≥ 8 位且与原密码不同)
* @throws com.imutil.common.BizException 原密码错误 / 新密码不合规 / 用户不存在
*/
void changePassword(Long userId, String oldPassword, String newPassword);
}
@@ -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);
}
@@ -1,7 +1,9 @@
package com.imutil.service;
import com.imutil.entity.DistQueue;
import com.imutil.entity.ImMessage;
import java.time.OffsetDateTime;
import java.util.List;
/**
@@ -32,4 +34,24 @@ public interface DispatchService {
* @return 重置条数
*/
int recoverStuck();
/**
* 写入分发队列(默认 nextRetryAt=now,立即可被消费分发)
* <p>
* 取租户 callbackUrl 作为目标;租户未配置 callbackUrl 则跳过(不报错,本地留底仍有效)。
*
* @param tenantId 归属租户
* @param msg 已落库消息(payload 取其 msgKey/convType/convId/from/to/msgTime/msgType/msgBody/source
*/
void enqueue(String tenantId, ImMessage msg);
/**
* 写入分发队列并指定下次分发时间(限速平滑分发用)
* <p>
* 同步批量入队时按序号错开 nextRetryAtdelay = seq / dispatchQps 秒),
* DispatchWorker 按时间消费,避免历史消息瞬时打爆业务系统 callbackUrl。
*
* @param nextRetryAt 下次可分发时间
*/
void enqueue(String tenantId, ImMessage msg, OffsetDateTime nextRetryAt);
}
@@ -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 随建群带入,&gt; 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);
}
@@ -0,0 +1,60 @@
package com.imutil.service;
import com.imutil.entity.MigrateTask;
/**
* 数据同步服务(T17
* <p>
* 与 {@link MigrateService}(老应用→主应用的"迁移",带建群/建号写回副作用)不同,
* 本服务定位为<b>只读拉取</b>:把指定腾讯 IM 应用里的群组/群成员/群消息拉到本地表,
* 供管理后台按租户查看,不在腾讯侧产生任何写操作。
* <p>
* 腾讯 IM 现实约束:无"全量用户列表"接口(用户靠群成员反推);C2C 单聊会话通过
* recentcontact/get_list 按"已同步用户"反查({@link #syncC2CMessages}),漫游消息用 get_roam_msg 全量翻页。
* 同步进来的消息(群/单聊)均入分发队列(dist_status=0)推送业务系统,本地同时留底。
*
* @author imutil
*/
public interface SyncService {
/**
* 同步群组:get_appid_group_list 全量 Next 分页 + 逐群 get_group_info 拉资料 → upsert group_mapping
*
* @param sourceAppId 数据源:null=主应用(全局密钥);非空=该老应用密钥
* @param tenantId 数据归属租户(主应用时必填;源应用时以其 tenant_id 为准)
* @return 同步任务记录(含 total/processed/校验结果)
*/
MigrateTask syncGroups(Long sourceAppId, String tenantId);
/**
* 同步群成员(→用户):遍历该租户已同步的群,逐群 get_group_info 取成员 → upsert user_mapping
*
* @see #syncGroups(Long, String) 先同步群组,群成员才有来源
*/
MigrateTask syncGroupMembers(Long sourceAppId, String tenantId);
/**
* 同步群消息:遍历该租户已同步的群,逐群 getGroupMsg + IsFinished 滚动全量 → 写 im_message(source=SYNC,进分发)
*
* @see #syncGroups(Long, String) 先同步群组,群消息才有目标群清单
*/
MigrateTask syncGroupMessages(Long sourceAppId, String tenantId);
/**
* 同步单聊(C2C)消息:遍历该租户已同步用户 → recentcontact/get_list 收集 C2C 会话 →
* 会话对去重 → get_roam_msg 时间窗向前滚动全量 → 写 im_message(conv_type=1, source=SYNC, 进分发)。
* <p>
* 腾讯无全量 C2C 会话列表接口,靠"已同步用户"反查其最近会话;未导入用户返回 50001 跳过。
*
* @see #syncGroupMembers(Long, String) 先同步用户(群成员),单聊才有用户清单可遍历
*/
MigrateTask syncC2CMessages(Long sourceAppId, String tenantId);
/**
* 补拉对账:重跑群消息 + 单聊同步(幂等,本地已有的跳过、缺失的补入),确保本系统数据最全。
* 定时任务 {@link com.imutil.task.SyncCheckTask} 驱动,也可手动触发。
*
* @param tenantId 归属租户(补拉对账仅对主应用归属租户有意义)
*/
MigrateTask checkAndPull(String tenantId);
}
@@ -22,6 +22,16 @@ public interface TenantService {
*/
Tenant getById(String tenantId);
/**
* 根据 prefix_code(IM 账号前缀)查询租户(带缓存)
* <p>
* 回调识别租户时,账号前缀经此反查得 tenant_id。
*
* @param prefixCode IM 前缀码
* @return 租户实体,不存在返回 null
*/
Tenant getByPrefixCode(String prefixCode);
/**
* 失效租户缓存(租户变更时调用)
*/
@@ -1,6 +1,7 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.BizException;
import com.imutil.common.PasswordUtil;
import com.imutil.entity.AdminUser;
import com.imutil.mapper.AdminUserMapper;
@@ -74,4 +75,26 @@ public class AdminUserServiceImpl implements AdminUserService {
adminUserMapper.insert(u);
log.info("已初始化默认管理员账号: {}(请尽快修改默认密码)", defaultUsername);
}
@Override
public void changePassword(Long userId, String oldPassword, String newPassword) {
AdminUser u = adminUserMapper.selectById(userId);
if (u == null) {
throw new BizException("用户不存在");
}
// 原密码校验(PBKDF2 常量时间比对)
if (!PasswordUtil.verify(oldPassword, u.getPasswordHash())) {
throw new BizException("原密码错误");
}
// 新密码合规:≥ 8 位且与原密码不同(防改成又一个弱密码)
if (newPassword == null || newPassword.length() < 8) {
throw new BizException("新密码至少 8 位");
}
if (newPassword.equals(oldPassword)) {
throw new BizException("新密码不能与原密码相同");
}
u.setPasswordHash(PasswordUtil.hash(newPassword));
adminUserMapper.updateById(u);
log.info("管理员密码已修改 userId={} username={}", userId, u.getUsername());
}
}
@@ -11,6 +11,7 @@ import com.imutil.mapper.GroupMappingMapper;
import com.imutil.mapper.ImMessageMapper;
import com.imutil.mapper.PullWatermarkMapper;
import com.imutil.service.CallbackService;
import com.imutil.service.CrossTenantService;
import com.imutil.service.TenantService;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack4.ONode;
@@ -25,7 +26,10 @@ import java.time.ZoneId;
/**
* 腾讯回调处理服务实现
* <p>
* 流程:识别租户 → 消息类回调落 im_message(幂等)→ 所有回调写 dist_queue 分发业务系统。
* 流程:
* 1. 发单聊消息之前回调(C2C.CallbackBeforeSendMsg):跨租户拦截决策(对齐设计 5.4)——
* 同租户放行;跨租户查 cross_tenant_grant,命中授权放行并写审计,未命中返回 FAIL(腾讯不投递);
* 2. 其他回调(含发消息之后回调):识别租户 → 消息类回调落 im_message(幂等)→ 按 from 租户写 dist_queue 分发。
* {@code @Tran} 保证消息落库与分发入队同事务:要么同时成功,要么都不入库(避免半写)。
*
* @author imutil
@@ -52,6 +56,9 @@ public class CallbackServiceImpl implements CallbackService {
@Inject
private TenantService tenantService;
@Inject
private CrossTenantService crossTenantService;
@Override
@Tran
public String handleCallback(String callbackCommand, String body) {
@@ -66,16 +73,22 @@ public class CallbackServiceImpl implements CallbackService {
return ok();
}
// 1. 识别租户
// 1. 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4)
// 未授权的跨租户消息在此返回 FAIL,腾讯不投递、也不触发 after 回调 → 从源头阻断串扰
if (isBeforeSendMsg(callbackCommand)) {
return handleBeforeSendMsg(node);
}
// 2. 识别租户
String tenantId = identifyTenant(callbackCommand, node);
if (tenantId == null) {
// 无法识别租户(如腾讯系统消息 administrator),不落库不分发
log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("FromAccount").getString());
log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("From_Account").getString());
return ok();
}
// 2. 消息类回调落库 im_message(幂等:msg_key 存在则跳过)
// msg_key = command:from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键;
// 3. 消息类回调(发消息后)落库 im_message(幂等:msg_key 存在则跳过)
// msg_key = from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键;
// 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。
// DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。
String msgKey = null;
@@ -83,6 +96,21 @@ public class CallbackServiceImpl implements CallbackService {
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
if (msg != null) {
msgKey = msg.getMsgKey();
// 跨租户标记:复查授权确认(before 已拦截未授权)。
// 若 before 未开启/失效,未授权消息会漏到 after,此处查不到授权即告警(兜底发现隔离异常)。
if (msg.getConvType() != null && msg.getConvType() == 1) {
Tenant toT = tenantService.getByPrefixCode(parsePrefix(msg.getConvId()));
String toTenant = toT == null ? null : toT.getTenantId();
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()
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
if (exists == 0) {
@@ -94,22 +122,96 @@ public class CallbackServiceImpl implements CallbackService {
}
}
// 3. 写分发队列(payload=原始回调体,供业务系统消费)
// 4. 按发送方租户分发(payload=原始回调体,供业务系统消费)
// 设计 5.4/5.4.1 未要求额外推给 to 租户(其客户端已由腾讯直接投递);
// 跨租户审计已在 before 阶段(handleBeforeSendMsg)写入。
String convId = extractConvId(callbackCommand, node);
enqueueIfConfigured(tenantId, msgKey, convId, body);
return ok();
}
/**
* 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4)
* <p>
* 同租户放行;跨租户查 cross_tenant_grant,命中授权则放行并写审计,未命中返回 FAIL(腾讯不投递消息)。
*
* @param node 回调体
* @return 放行 ok() 或拦截 fail()
*/
private String handleBeforeSendMsg(ONode node) {
String from = node.get("From_Account").getString();
String to = node.get("To_Account").getString();
if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString();
}
// 前缀(prefixCode) → 反查 tenantId(主键雪花化后,授权记录按 tenant_id 存储)
Tenant fromT = tenantService.getByPrefixCode(parsePrefix(from));
Tenant toT = tenantService.getByPrefixCode(parsePrefix(to));
String fromTenant = fromT == null ? null : fromT.getTenantId();
String toTenant = toT == null ? null : toT.getTenantId();
// 同租户或任一租户无法识别(如系统消息):不拦截,放行正常通讯
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 = pickMsgRandom(node);
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()) {
if (tenant == null || tenant.getCallbackUrl() == null || tenant.getCallbackUrl().isEmpty()) {
return;
}
DistQueue q = new DistQueue();
q.setMsgKey(msgKey);
q.setTenantId(tenantId);
q.setConvId(extractConvId(callbackCommand, node));
q.setConvId(convId);
q.setTargetUrl(tenant.getCallbackUrl());
q.setPayload(body);
q.setPayload(payload);
q.setStatus(0);
q.setRetryCount(0);
q.setNextRetryAt(OffsetDateTime.now());
distQueueMapper.insert(q);
}
return ok();
}
/**
* 识别租户:优先 FromAccount 前缀,群回调用 GroupId 查 group_mapping
@@ -117,14 +219,18 @@ public class CallbackServiceImpl implements CallbackService {
* @return 租户ID,识别失败返回 null
*/
private String identifyTenant(String command, ONode node) {
String from = node.get("FromAccount").getString();
// 账号前缀即 prefixCode(短码),按 prefix_code 反查得 tenantId(主键已雪花化,与前缀解耦)
String from = node.get("From_Account").getString();
if (from != null && !from.isEmpty()) {
String tenantId = parsePrefix(from);
if (tenantId != null && tenantService.getById(tenantId) != null) {
return tenantId;
String prefix = parsePrefix(from);
if (prefix != null) {
Tenant t = tenantService.getByPrefixCode(prefix);
if (t != null) {
return t.getTenantId();
}
}
// 群回调:GroupId 查映射
}
// 群回调:GroupId 查映射(group_mapping.tenant_id 即租户主键,雪花/短码皆正确)
String groupId = node.get("GroupId").getString();
if (groupId != null && !groupId.isEmpty()) {
GroupMapping gm = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
@@ -132,11 +238,6 @@ public class CallbackServiceImpl implements CallbackService {
if (gm != null) {
return gm.getTenantId();
}
// 群ID也带前缀
String tid = parsePrefix(groupId);
if (tid != null && tenantService.getById(tid) != null) {
return tid;
}
}
return null;
}
@@ -171,15 +272,15 @@ public class CallbackServiceImpl implements CallbackService {
* 解析消息体,构造 ImMessage(含幂等键 msg_key
*/
private ImMessage parseMessage(String command, ONode node, String tenantId) {
String from = node.get("FromAccount").getString();
String to = node.get("ToAccount").getString();
String from = node.get("From_Account").getString();
String to = node.get("To_Account").getString();
if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString();
}
String groupId = node.get("GroupId").getString();
long msgSeq = node.get("MsgSeq").getLong();
long msgRandom = node.get("MsgRandom").getLong();
long msgTs = node.get("MsgTimeStamp").getLong();
long msgRandom = pickMsgRandom(node);
long msgTs = pickMsgTime(node);
if (msgTs == 0) {
msgTs = System.currentTimeMillis() / 1000L;
@@ -199,7 +300,7 @@ public class CallbackServiceImpl implements CallbackService {
msg.setFromAccount(from);
msg.setToAccount(groupId != null && !groupId.isEmpty() ? null : to);
msg.setGroupId(groupId);
msg.setMsgType(node.get("MsgType").getString());
msg.setMsgType(pickMsgType(node));
msg.setMsgBody(node.get("MsgBody").toString());
msg.setSource("CALLBACK");
msg.setIsCrossTenant(false);
@@ -215,13 +316,43 @@ public class CallbackServiceImpl implements CallbackService {
if (groupId != null && !groupId.isEmpty()) {
return groupId;
}
String to = node.get("ToAccount").getString();
String to = node.get("To_Account").getString();
if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString();
}
return to;
}
/**
* 消息随机数:C2C=MsgRandom,群=Random,取到非 0 者为准(避免字段差异导致 msg_key 退化为 :0
*/
private long pickMsgRandom(ONode node) {
long v = node.get("MsgRandom").getLong();
return v != 0 ? v : node.get("Random").getLong();
}
/**
* 消息时间(秒):优先 MsgTime(腾讯标准),回退 MsgTimeStamp(兼容历史/异常回调体)
*/
private long pickMsgTime(ONode node) {
long v = node.get("MsgTime").getLong();
return v != 0 ? v : node.get("MsgTimeStamp").getLong();
}
/**
* 消息类型:优先 MsgBody[0].MsgType(群/C2C 均在数组元素内),回退顶层 MsgType
*/
private String pickMsgType(ONode node) {
ONode body = node.get("MsgBody");
if (body.isArray() && body.size() > 0) {
String t = body.get(0).get("MsgType").getString();
if (t != null && !t.isEmpty()) {
return t;
}
}
return node.get("MsgType").getString();
}
/**
* 返回腾讯回调成功响应
*/
@@ -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_userNULL 通配由 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();
}
}
@@ -2,9 +2,13 @@ package com.imutil.service.impl;
import com.imutil.common.Httpx;
import com.imutil.entity.DistQueue;
import com.imutil.entity.ImMessage;
import com.imutil.entity.Tenant;
import com.imutil.mapper.DistQueueMapper;
import com.imutil.service.DispatchService;
import com.imutil.service.TenantService;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack4.ONode;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.data.annotation.Tran;
@@ -29,6 +33,9 @@ public class DispatchServiceImpl implements DispatchService {
@Inject
private DistQueueMapper distQueueMapper;
@Inject
private TenantService tenantService;
@Inject("${imutil.dispatch.fetchBatch:50}")
private int fetchBatch;
@@ -91,4 +98,51 @@ public class DispatchServiceImpl implements DispatchService {
OffsetDateTime threshold = now.minusMinutes(lockTimeoutMin);
return distQueueMapper.recoverStuck(threshold, now);
}
@Override
public void enqueue(String tenantId, ImMessage msg) {
enqueue(tenantId, msg, OffsetDateTime.now());
}
/**
* 写入分发队列(复用回调分发链路,payload 为结构化 JSON
* <p>
* 取租户 callbackUrl 作为目标;未配置则跳过(不报错,本地留底仍有效)。
* nextRetryAt 由调用方控制:同步批量入队时按序号错开以限速平滑分发。
*/
@Override
public void enqueue(String tenantId, ImMessage msg, OffsetDateTime nextRetryAt) {
Tenant t = tenantService.getById(tenantId);
if (t == null || t.getCallbackUrl() == null || t.getCallbackUrl().isEmpty()) {
return;
}
ONode payload = ONode.ofJson("{}");
payload.set("source", msg.getSource());
payload.set("msgKey", msg.getMsgKey());
payload.set("tenantId", tenantId);
payload.set("convType", msg.getConvType());
payload.set("convId", msg.getConvId());
payload.set("fromAccount", msg.getFromAccount());
payload.set("toAccount", msg.getToAccount());
payload.set("groupId", msg.getGroupId());
payload.set("msgTime", msg.getMsgTime());
payload.set("msgType", msg.getMsgType());
if (msg.getMsgBody() != null) {
try {
payload.set("msgBody", ONode.ofJson(msg.getMsgBody()));
} catch (Exception e) {
payload.set("msgBody", msg.getMsgBody());
}
}
DistQueue q = new DistQueue();
q.setMsgKey(msg.getMsgKey());
q.setTenantId(tenantId);
q.setConvId(msg.getConvId());
q.setTargetUrl(t.getCallbackUrl());
q.setPayload(payload.toJson());
q.setStatus(0);
q.setRetryCount(0);
q.setNextRetryAt(nextRetryAt);
distQueueMapper.insert(q);
}
}
@@ -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);
}
}
@@ -10,6 +10,7 @@ import com.imutil.entity.Tenant;
import com.imutil.mapper.DistQueueMapper;
import com.imutil.mapper.ImMessageMapper;
import com.imutil.mapper.PullWatermarkMapper;
import com.imutil.service.DispatchService;
import com.imutil.service.PullService;
import com.imutil.service.TenantService;
import com.imutil.tencent.TencentImClient;
@@ -56,6 +57,9 @@ public class PullServiceImpl implements PullService {
@Inject
private TenantService tenantService;
@Inject
private DispatchService dispatchService;
@Inject("${imutil.pull.convsPerRound:20}")
private int convsPerRound;
@@ -168,50 +172,12 @@ public class PullServiceImpl implements PullService {
imMessageMapper.insert(msg);
newCount++;
// 仅新插入的消息入队分发
enqueue(wm.getTenantId(), msg);
dispatchService.enqueue(wm.getTenantId(), msg);
}
advanceWatermark(wm, maxSeq, maxTime);
return newCount;
}
/**
* 写入分发队列(复用回调分发链路,payload 用结构化 JSON
*/
private void enqueue(String tenantId, ImMessage msg) {
Tenant t = tenantService.getById(tenantId);
if (t == null || t.getCallbackUrl() == null || t.getCallbackUrl().isEmpty()) {
return;
}
ONode payload = ONode.ofJson("{}");
payload.set("source", msg.getSource());
payload.set("msgKey", msg.getMsgKey());
payload.set("tenantId", tenantId);
payload.set("convType", msg.getConvType());
payload.set("convId", msg.getConvId());
payload.set("fromAccount", msg.getFromAccount());
payload.set("toAccount", msg.getToAccount());
payload.set("groupId", msg.getGroupId());
payload.set("msgTime", msg.getMsgTime());
payload.set("msgType", msg.getMsgType());
if (msg.getMsgBody() != null) {
try {
payload.set("msgBody", ONode.ofJson(msg.getMsgBody()));
} catch (Exception e) {
payload.set("msgBody", msg.getMsgBody());
}
}
DistQueue q = new DistQueue();
q.setMsgKey(msg.getMsgKey());
q.setTenantId(tenantId);
q.setConvId(msg.getConvId());
q.setTargetUrl(t.getCallbackUrl());
q.setPayload(payload.toJson());
q.setStatus(0);
q.setRetryCount(0);
q.setNextRetryAt(OffsetDateTime.now());
distQueueMapper.insert(q);
}
/**
* 解析群消息节点为 ImMessage
*/
@@ -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,747 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.BizException;
import com.imutil.common.MsgKeys;
import com.imutil.entity.GroupMapping;
import com.imutil.entity.ImMessage;
import com.imutil.entity.MigrateTask;
import com.imutil.entity.SourceApp;
import com.imutil.entity.Tenant;
import com.imutil.entity.UserMapping;
import com.imutil.mapper.GroupMappingMapper;
import com.imutil.mapper.ImMessageMapper;
import com.imutil.mapper.MigrateTaskMapper;
import com.imutil.mapper.SourceAppMapper;
import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UserMappingMapper;
import com.imutil.service.DispatchService;
import com.imutil.service.SyncService;
import com.imutil.tencent.TencentImClient;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack4.ONode;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 数据同步服务实现(T17
* <p>
* 只读拉取腾讯 IM 数据到本地表,供后台按租户查看。与 MigrateService(带写回副作用)互补。
* <ul>
* <li>群组:get_appid_group_list 全量 Next 分页 + 逐群 get_group_info → upsert group_mapping</li>
* <li>群成员(→用户):遍历该租户已同步群,取 MemberList → upsert user_mapping(腾讯无全量用户接口,靠群成员反推)</li>
* <li>群消息:遍历该租户已同步群,getGroupMsg + IsFinished 滚动全量 → 写 im_message(source=SYNC)</li>
* </ul>
* 数据源密钥:sourceAppId=null 走主应用全局密钥(需指定归属租户);非空走 source_app 密钥(租户取其关联值)。
* 幂等可重跑:群/用户按 imGroupId/imUserId 唯一索引 upsert;消息按 msgKey 查重跳过。
*
* @author imutil
*/
@Slf4j
@Component
public class SyncServiceImpl implements SyncService {
@Inject
private SourceAppMapper sourceAppMapper;
@Inject
private TenantMapper tenantMapper;
@Inject
private GroupMappingMapper groupMappingMapper;
@Inject
private UserMappingMapper userMappingMapper;
@Inject
private ImMessageMapper imMessageMapper;
@Inject
private MigrateTaskMapper migrateTaskMapper;
@Inject
private TencentImClient tencentImClient;
/** 主应用全局密钥(sourceAppId=null 时用) */
@Inject("${imutil.tencent.sdkAppId:0}")
private long configSdkAppId;
@Inject("${imutil.tencent.secretKey:}")
private String configSecretKey;
/** 拉群列表每页数量(get_appid_group_list Limit */
@Inject("${imutil.sync.groupListPageSize:100}")
private int groupListPageSize;
/** 拉群消息每页条数(getGroupMsg reqMsgNumber */
@Inject("${imutil.sync.groupMsgPerPage:100}")
private int groupMsgPerPage;
/** 单群消息分页上限(防异常死循环) */
@Inject("${imutil.sync.groupMsgMaxPages:500}")
private int groupMsgMaxPages;
/** 单次同步群组数量上限(同步阻塞 HTTP,避免海量群卡死;如需全量调大) */
@Inject("${imutil.sync.maxGroups:500}")
private int maxGroups;
/** 同步入队分发限速(每秒入队条数,保护业务系统 callbackUrl;错开 next_retry_at 平滑分发) */
@Inject("${imutil.sync.dispatchQps:10}")
private int syncDispatchQps;
@Inject
private DispatchService dispatchService;
/** 单聊同步用户上限(防海量用户 N×M 爆配额;如需全量调大) */
@Inject("${imutil.sync.maxSyncUsers:200}")
private int maxSyncUsers;
/** 单聊漫游回溯天数(腾讯 C2C 漫游存储期,默认 7 天,套餐更长可调大) */
@Inject("${imutil.sync.c2cLookbackDays:7}")
private int c2cLookbackDays;
/** 数据源密钥解析结果 */
private static class SyncCtx {
long sdkAppId;
String secretKey;
String tenantId;
/** migrate_task.source_app_id;主应用记 0(该字段 NOT NULL,0 表示主应用) */
Long taskSourceAppId;
}
/**
* 解析数据源密钥 + 归属租户
*/
private SyncCtx resolveCtx(Long sourceAppId, String tenantId) {
SyncCtx ctx = new SyncCtx();
if (sourceAppId == null) {
if (tenantId == null || tenantId.isEmpty()) {
throw new BizException("主应用同步需指定归属租户");
}
Tenant t = tenantMapper.selectById(tenantId);
if (t == null) {
throw new BizException("租户不存在: " + tenantId);
}
ctx.sdkAppId = configSdkAppId;
ctx.secretKey = configSecretKey;
ctx.tenantId = tenantId;
ctx.taskSourceAppId = 0L;
} else {
SourceApp app = sourceAppMapper.selectById(sourceAppId);
if (app == null) {
throw new BizException("老应用不存在");
}
ctx.sdkAppId = Long.parseLong(app.getSdkAppId());
ctx.secretKey = app.getSecretKey();
ctx.tenantId = app.getTenantId();
ctx.taskSourceAppId = sourceAppId;
}
if (ctx.sdkAppId <= 0 || ctx.secretKey == null || ctx.secretKey.isEmpty()) {
throw new BizException("数据源密钥未配置");
}
return ctx;
}
private MigrateTask startTask(SyncCtx ctx, String objectType) {
MigrateTask task = new MigrateTask();
task.setSourceAppId(ctx.taskSourceAppId);
task.setObjectType(objectType);
task.setStatus(1);
task.setStartedAt(OffsetDateTime.now());
migrateTaskMapper.insert(task);
return task;
}
// ==================== 同步群组 ====================
@Override
public MigrateTask syncGroups(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "GROUP_SYNC");
try {
int total = 0;
int upserted = 0;
String next = "";
int guard = 0;
// Next 游标分页拉全量群,直到返回 Next 为空
while (guard++ < 100000) {
String resp = tencentImClient.getAppidGroupList(groupListPageSize, next, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
throw new BizException("拉群组列表失败: " + safeErr(root));
}
ONode groupIds = root.get("GroupIdList");
int n = groupIds.size();
for (int i = 0; i < n; i++) {
// 实测 GroupIdList 元素为对象 {GroupId:"..."},兼容字符串形态
ONode gidNode = groupIds.get(i);
String groupId = gidNode.isObject() ? gidNode.get("GroupId").getString() : gidNode.getString();
if (groupId == null || groupId.isEmpty()) {
continue;
}
total++;
try {
if (upsertGroupFromRemote(ctx, groupId)) {
upserted++;
}
} catch (Exception ge) {
log.warn("同步单群失败 {} : {}", groupId, ge.toString());
}
// 达上限停止(for 内精确限制,避免单页海量群卡死同步 HTTP)
if (total >= maxGroups) {
log.warn("群组同步达上限 {},停止(调大 imutil.sync.maxGroups 可继续)", maxGroups);
break;
}
}
task.setTotalCount((long) total);
task.setProcessedCount((long) upserted);
task.setPosCursor("page:" + guard);
migrateTaskMapper.updateById(task);
// 达上限停止(由 for 内触发并告警,此处仅跳出 while)
if (total >= maxGroups) {
break;
}
// Next 游标推进;为空或未变化则结束(防死循环)
String respNext = root.get("Next").getString();
if (respNext == null || respNext.isEmpty() || respNext.equals(next)) {
break;
}
next = respNext;
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"total\":%d,\"upserted\":%d}", total, upserted));
migrateTaskMapper.updateById(task);
log.info("群组同步完成 tenant={} total={} upserted={}", ctx.tenantId, total, upserted);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("群组同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* 拉单群资料 → upsert group_mapping
*
* @return 是否成功处理(已记录为 true)
*/
private boolean upsertGroupFromRemote(SyncCtx ctx, String groupId) {
String resp = tencentImClient.getGroupInfo(groupId, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
return false;
}
ONode groupInfo = root.get("GroupInfo");
if (groupInfo.size() == 0) {
return false;
}
ONode g = groupInfo.get(0);
String name = g.get("Name").getString();
String type = g.get("Type").getString();
String owner = g.get("Owner_Account").getString();
long memberNum = g.get("MemberNum").getLong();
OffsetDateTime now = OffsetDateTime.now();
GroupMapping exist = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getImGroupId, groupId).last("LIMIT 1"));
if (exist != null) {
exist.setTenantId(ctx.tenantId);
exist.setBizGroupId(groupId);
exist.setGroupType(type != null && !type.isEmpty() ? type : "Public");
exist.setName(name);
exist.setOwnerAccount(owner);
exist.setMemberCount((int) memberNum);
exist.setLastSyncedAt(now);
groupMappingMapper.updateById(exist);
} else {
GroupMapping gm = new GroupMapping();
gm.setTenantId(ctx.tenantId);
gm.setBizGroupId(groupId);
gm.setImGroupId(groupId);
gm.setGroupType(type != null && !type.isEmpty() ? type : "Public");
gm.setName(name);
gm.setOwnerAccount(owner);
gm.setMemberCount((int) memberNum);
gm.setLastSyncedAt(now);
groupMappingMapper.insert(gm);
}
return true;
}
// ==================== 同步群成员 → 用户 ====================
@Override
public MigrateTask syncGroupMembers(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "USER_SYNC");
try {
List<GroupMapping> groups = groupMappingMapper.selectList(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getTenantId, ctx.tenantId));
task.setTotalCount((long) groups.size());
migrateTaskMapper.updateById(task);
int memberSeen = 0;
int upserted = 0;
for (int gi = 0; gi < groups.size(); gi++) {
String groupId = groups.get(gi).getImGroupId();
try {
String resp = tencentImClient.getGroupInfo(groupId, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
continue;
}
ONode groupInfo = root.get("GroupInfo");
if (groupInfo.size() == 0) {
continue;
}
ONode memberList = groupInfo.get(0).get("MemberList");
int mc = memberList.size();
for (int j = 0; j < mc; j++) {
ONode mem = memberList.get(j);
String accountId = mem.get("Member_Account").getString();
if (accountId == null || accountId.isEmpty()) {
continue;
}
memberSeen++;
String nick = mem.get("Name").getString();
if (upsertUser(ctx, accountId, nick)) {
upserted++;
}
}
} catch (Exception ge) {
log.warn("同步群成员失败 {} : {}", groupId, ge.getMessage());
}
task.setProcessedCount((long) (gi + 1));
task.setPosCursor("group:" + (gi + 1));
migrateTaskMapper.updateById(task);
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"groups\":%d,\"memberSeen\":%d,\"upserted\":%d}",
groups.size(), memberSeen, upserted));
migrateTaskMapper.updateById(task);
log.info("群成员同步完成 tenant={} groups={} memberSeen={} upserted={}",
ctx.tenantId, groups.size(), memberSeen, upserted);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("群成员同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* upsert user_mapping(按 im_user_id 唯一索引)
*
* @return 是否为本次新增
*/
private boolean upsertUser(SyncCtx ctx, String imUserId, String nick) {
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
.eq(UserMapping::getImUserId, imUserId).last("LIMIT 1"));
OffsetDateTime now = OffsetDateTime.now();
if (exist != null) {
exist.setNick(nick);
exist.setLastSyncedAt(now);
if (exist.getStatus() == null) {
exist.setStatus(1);
}
userMappingMapper.updateById(exist);
return false;
}
UserMapping um = new UserMapping();
um.setTenantId(ctx.tenantId);
// 同步场景无业务侧 ID,用 IM ID 兜底(biz_user_id 仅做本地标识)
um.setBizUserId(imUserId);
um.setImUserId(imUserId);
um.setIsDefault(false);
um.setIsGlobal(false);
um.setStatus(1);
um.setNick(nick);
um.setLastSyncedAt(now);
userMappingMapper.insert(um);
return true;
}
// ==================== 同步群消息 ====================
@Override
public MigrateTask syncGroupMessages(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "GMSG_SYNC");
try {
List<GroupMapping> groups = groupMappingMapper.selectList(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getTenantId, ctx.tenantId));
task.setTotalCount((long) groups.size());
migrateTaskMapper.updateById(task);
int imported = 0;
OffsetDateTime syncBase = OffsetDateTime.now();
for (int gi = 0; gi < groups.size(); gi++) {
String groupId = groups.get(gi).getImGroupId();
try {
long reqSeq = 0;
int pages = 0;
// IsFinished 滚动全量:reqSeq 取本页最旧 seq 继续向前
while (pages++ < groupMsgMaxPages) {
String resp = tencentImClient.getGroupMsgAs(groupId, groupMsgPerPage, reqSeq, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
break;
}
ONode rspList = root.get("RspMsgList");
int n = rspList.size();
long oldestSeq = Long.MAX_VALUE; // 本页最小 seq(最旧)
for (int i = 0; i < n; i++) {
ONode m = rspList.get(i);
long seq = m.get("MsgSeq").getLong();
if (seq > 0 && seq < oldestSeq) {
oldestSeq = seq;
}
ImMessage msg = parseGroupMsgForSync(m, ctx.tenantId, groupId);
if (msg == null) {
continue;
}
// 幂等:本地已存在跳过
long cnt = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
if (cnt > 0) {
continue;
}
imMessageMapper.insert(msg);
imported++;
// 限速入队分发:按序号错开 next_retry_atDispatchWorker 按速率消费
dispatchService.enqueue(ctx.tenantId, msg, nextRetryAt(imported, syncBase));
}
boolean finished = root.get("IsFinished").getLong() == 1;
// 终止:已拉完 / 空页 / 本页无有效 seq
if (finished || n == 0 || oldestSeq == Long.MAX_VALUE) {
break;
}
// 游标未向更旧方向推进则停(占位消息群 IsFinished 恒为 2,靠此兜底防死循环)
if (reqSeq > 0 && oldestSeq >= reqSeq) {
break;
}
reqSeq = oldestSeq;
}
} catch (Exception ge) {
log.warn("同步群消息失败 {} : {}", groupId, 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.size(), imported));
migrateTaskMapper.updateById(task);
log.info("群消息同步完成 tenant={} groups={} imported={}", ctx.tenantId, groups.size(), imported);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("群消息同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* 解析腾讯群消息节点为 ImMessagesource=SYNCdist_status=0 进分发)
*/
private ImMessage parseGroupMsgForSync(ONode m, String tenantId, String groupId) {
// 过滤占位/系统消息(IsPlaceMsg=1From_Account 与 MsgBody 均空,非真实消息,不入库)
if (m.get("IsPlaceMsg").getLong() == 1) {
return null;
}
long msgSeq = m.get("MsgSeq").getLong();
long msgRandom = m.get("MsgRandom").getLong();
long msgTs = m.get("MsgTimeStamp").getLong();
String from = m.get("From_Account").getString();
ONode body = m.get("MsgBody");
ImMessage msg = new ImMessage();
msg.setMsgKey(MsgKeys.build(from, groupId, msgSeq, msgRandom));
msg.setTenantId(tenantId);
msg.setMsgTime(toTime(msgTs));
msg.setConvType(2);
msg.setConvId(groupId);
msg.setFromAccount(from);
msg.setGroupId(groupId);
msg.setMsgType(firstBodyType(body));
msg.setMsgBody(body.toString());
msg.setSource("SYNC");
msg.setIsCrossTenant(false);
msg.setDistStatus(0); // 进分发
return msg;
}
// ==================== 同步单聊(C2C) ====================
/**
* 同步单聊消息:遍历已同步用户 → recentcontact/get_list 收集 C2C 会话 → 会话对去重 →
* get_roam_msg 时间窗向前滚动全量 → 落库(conv_type=1/source=SYNC/进分发)。
*/
@Override
public MigrateTask syncC2CMessages(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "C2C_SYNC");
try {
List<UserMapping> users = userMappingMapper.selectList(Wrappers.<UserMapping>lambdaQuery()
.eq(UserMapping::getTenantId, ctx.tenantId));
task.setTotalCount((long) users.size());
migrateTaskMapper.updateById(task);
Set<String> seenPairs = new HashSet<>();
int imported = 0;
OffsetDateTime syncBase = OffsetDateTime.now();
int userCnt = 0;
for (UserMapping u : users) {
if (++userCnt > maxSyncUsers) {
log.warn("单聊同步用户达上限 {},停止(调大 imutil.sync.maxSyncUsers 可继续)", maxSyncUsers);
break;
}
String self = u.getImUserId();
if (self == null || self.isEmpty()) {
continue;
}
try {
List<String> peers = collectC2CPeers(self, ctx);
for (String peer : peers) {
if (peer == null || peer.isEmpty() || peer.equals(self)) {
continue;
}
// 会话对去重:A↔B 只拉一次
if (!seenPairs.add(pairKey(self, peer))) {
continue;
}
imported += pullRoamMsgs(self, peer, ctx, syncBase, imported);
}
} catch (Exception ue) {
// 50001(用户未导入)等按用户级跳过
log.warn("单聊同步用户失败 {} : {}", self, ue.toString());
}
task.setProcessedCount((long) imported);
task.setPosCursor("user:" + userCnt);
migrateTaskMapper.updateById(task);
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"users\":%d,\"pairs\":%d,\"imported\":%d}",
Math.min(userCnt, maxSyncUsers), seenPairs.size(), imported));
migrateTaskMapper.updateById(task);
log.info("单聊同步完成 tenant={} users={} pairs={} imported={}",
ctx.tenantId, Math.min(userCnt, maxSyncUsers), seenPairs.size(), imported);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("单聊同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* 分页拉取用户最近会话,收集 C2C(Type=1) 对端账号
*/
private List<String> collectC2CPeers(String self, SyncCtx ctx) {
List<String> peers = new ArrayList<>();
long timeStamp = 0;
long startIndex = 0;
int guard = 0;
while (guard++ < 1000) {
String resp = tencentImClient.getRecentContactListAs(self, timeStamp, startIndex,
ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
break;
}
ONode items = root.get("SessionItem");
int n = items.size();
for (int i = 0; i < n; i++) {
ONode item = items.get(i);
if (item.get("Type").getLong() == 1) { // 1=C2C
String to = item.get("To_Account").getString();
if (to != null && !to.isEmpty()) {
peers.add(to);
}
}
}
long complete = root.get("CompleteFlag").getLong();
if (complete == 1 || n == 0) {
break;
}
long ts = root.get("TimeStamp").getLong();
long si = root.get("StartIndex").getLong();
if (ts == timeStamp && si == startIndex) {
break; // 游标未推进防死循环
}
timeStamp = ts;
startIndex = si;
}
return peers;
}
/**
* 全量拉取两人漫游消息(向旧翻页),落库 + 限速入队分发,返回新增条数
*/
private int pullRoamMsgs(String self, String peer, SyncCtx ctx, OffsetDateTime syncBase, int seqBase) {
long minTime = Instant.now().getEpochSecond() - c2cLookbackDays * 86400L;
long maxTime = Instant.now().getEpochSecond();
String lastMsgKey = null;
int imported = 0;
int pages = 0;
while (pages++ < groupMsgMaxPages) {
String resp = tencentImClient.getRoamMsgRangeAs(self, peer, groupMsgPerPage, minTime, maxTime,
lastMsgKey, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
break;
}
ONode list = root.get("MsgList");
int n = list.size();
for (int i = 0; i < n; i++) {
ONode m = list.get(i);
ImMessage msg = parseC2CMsgForSync(m, ctx.tenantId, self, peer);
if (msg == null) {
continue;
}
long cnt = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
if (cnt > 0) {
continue;
}
imMessageMapper.insert(msg);
imported++;
dispatchService.enqueue(ctx.tenantId, msg, nextRetryAt(seqBase + imported, syncBase));
}
long complete = root.get("Complete").getLong();
long lastMsgTime = root.get("LastMsgTime").getLong();
String nextLastMsgKey = root.get("LastMsgKey").getString();
// 终止:已拉完 / 空页 / 无 LastMsgTime
if (complete == 1 || n == 0 || lastMsgTime <= 0) {
break;
}
// 游标未推进防死循环(LastMsgTime 未向旧推进且 LastMsgKey 未变)
if (lastMsgTime >= maxTime && (nextLastMsgKey == null || nextLastMsgKey.equals(lastMsgKey))) {
break;
}
maxTime = lastMsgTime;
lastMsgKey = nextLastMsgKey;
}
return imported;
}
/**
* 解析腾讯单聊消息节点为 ImMessagesource=SYNC,进分发)
*/
private ImMessage parseC2CMsgForSync(ONode m, String tenantId, String from, String to) {
long msgSeq = m.get("MsgSeq").getLong();
long msgRandom = m.get("MsgRandom").getLong();
long msgTs = m.get("MsgTimeStamp").getLong();
String fromAcc = m.get("From_Account").getString();
if (fromAcc == null || fromAcc.isEmpty()) {
fromAcc = from;
}
ONode body = m.get("MsgBody");
ImMessage msg = new ImMessage();
msg.setMsgKey(MsgKeys.build(fromAcc, to, msgSeq, msgRandom));
msg.setTenantId(tenantId);
msg.setMsgTime(toTime(msgTs));
msg.setConvType(1);
msg.setConvId(to);
msg.setFromAccount(fromAcc);
msg.setToAccount(to);
msg.setMsgType(firstBodyType(body));
msg.setMsgBody(body.toString());
msg.setSource("SYNC");
msg.setIsCrossTenant(false);
msg.setDistStatus(0); // 进分发
return msg;
}
/** 会话对去重键:min#max,使 A↔B 只拉一次 */
private String pairKey(String a, String b) {
return (a.compareTo(b) <= 0) ? a + "#" + b : b + "#" + a;
}
// ==================== 补拉对账 ====================
/**
* 补拉对账:顺序重跑群消息 + 单聊同步(幂等,本地已有的跳过、缺失的补入)。
* 供定时任务 SyncCheckTask 与后台手动按钮调用,确保本系统数据最全。
*/
@Override
public MigrateTask checkAndPull(String tenantId) {
SyncCtx ctx = resolveCtx(null, tenantId);
MigrateTask task = startTask(ctx, "CHECK_PULL");
try {
task.setPosCursor("start");
migrateTaskMapper.updateById(task);
log.info("补拉对账开始 tenant={}", tenantId);
MigrateTask g = syncGroupMessages(null, tenantId);
MigrateTask c = syncC2CMessages(null, tenantId);
long gImp = g.getProcessedCount() == null ? 0 : g.getProcessedCount();
long cImp = c.getProcessedCount() == null ? 0 : c.getProcessedCount();
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"groupImported\":%d,\"c2cImported\":%d}", gImp, cImp));
migrateTaskMapper.updateById(task);
log.info("补拉对账完成 tenant={} groupImported={} c2cImported={}", tenantId, gImp, cImp);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("补拉对账失败 tenant={}", tenantId, e);
throw e;
}
}
// ==================== 工具方法 ====================
private void failTask(MigrateTask task, Exception e) {
task.setStatus(4);
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
task.setFinishedAt(OffsetDateTime.now());
migrateTaskMapper.updateById(task);
}
private String safeErr(ONode root) {
try {
String info = root.get("ErrorInfo").getString();
return info == null ? "" : info;
} catch (Exception e) {
return "";
}
}
private String firstBodyType(ONode body) {
if (body == null || !body.isArray() || body.size() == 0) {
return null;
}
return body.get(0).get("MsgType").getString();
}
private OffsetDateTime toTime(long epochSecond) {
if (epochSecond <= 0) {
return OffsetDateTime.now();
}
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZoneId.systemDefault());
}
/**
* 计算限速入队的 next_retry_at:按序号错开(delay = seq / dispatchQps 秒),
* 使同步进来的历史消息按速率平滑分发,避免瞬时打爆业务系统 callbackUrl。
* dispatchQps&lt;=0 时不限速,立即 next_retry_at=now。
*/
private OffsetDateTime nextRetryAt(long seq, OffsetDateTime base) {
if (syncDispatchQps <= 0) {
return OffsetDateTime.now();
}
return base.plusSeconds(seq / syncDispatchQps);
}
}
@@ -24,6 +24,7 @@ public class TenantServiceImpl implements TenantService {
private static final String CACHE_KEY_BY_KEY = "imutil:tenant:bykey:";
private static final String CACHE_KEY_BY_ID = "imutil:tenant:byid:";
private static final String CACHE_KEY_BY_PREFIX = "imutil:tenant:byprefix:";
private static final long CACHE_TTL_SEC = 300;
@Inject
@@ -84,6 +85,33 @@ public class TenantServiceImpl implements TenantService {
return t;
}
@Override
public Tenant getByPrefixCode(String prefixCode) {
if (prefixCode == null || prefixCode.isEmpty()) {
return null;
}
String key = CACHE_KEY_BY_PREFIX + prefixCode;
// 1. 本地缓存
Tenant t = localCache.get(key, Tenant.class);
if (t != null) {
return t;
}
// 2. Redis
t = redisService.getJson(key, Tenant.class);
if (t == null) {
// 3. DB
t = tenantMapper.selectOne(Wrappers.<Tenant>lambdaQuery()
.eq(Tenant::getPrefixCode, prefixCode).last("LIMIT 1"));
if (t != null) {
redisService.setJson(key, t, CACHE_TTL_SEC, TimeUnit.SECONDS);
}
}
if (t != null) {
localCache.put(key, t);
}
return t;
}
@Override
public void evictCache(String tenantId) {
Tenant t = tenantMapper.selectById(tenantId);
@@ -94,6 +122,9 @@ public class TenantServiceImpl implements TenantService {
String k2 = CACHE_KEY_BY_KEY + t.getAppKey();
redisService.del(k2);
localCache.invalidate(k2);
String k3 = CACHE_KEY_BY_PREFIX + t.getPrefixCode();
redisService.del(k3);
localCache.invalidate(k3);
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,47 @@
package com.imutil.task;
import com.imutil.mapper.PartitionMapper;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Init;
import org.noear.solon.annotation.Inject;
/**
* 数据库 Schema 启动自动升级(幂等)
* <p>
* 解决"实体加字段但运行库未 ALTER"问题:init.sql 仅新建库时执行,老库不会自动加列,
* 导致 MyBatis insert/update 到新列时报 column 不存在。启动时对增量列执行
* ADD COLUMN IF NOT EXISTS,老库自动补列,新库重复执行也无副作用。
*
* @author imutil
*/
@Slf4j
@Component
public class SchemaUpgradeBootstrap {
@Inject
private PartitionMapper partitionMapper;
@Init
public void init() {
// T17: group_mapping/user_mapping 同步资料列
String[] ddls = {
"ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS name varchar(256)",
"ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS owner_account varchar(128)",
"ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS member_count int",
"ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS last_synced_at timestamptz",
"ALTER TABLE user_mapping ADD COLUMN IF NOT EXISTS nick varchar(128)",
"ALTER TABLE user_mapping ADD COLUMN IF NOT EXISTS last_synced_at timestamptz",
};
int ok = 0;
for (String sql : ddls) {
try {
partitionMapper.execute(sql);
ok++;
} catch (Exception e) {
log.warn("schema升级跳过 [{}] : {}", sql, e.getMessage());
}
}
log.info("启动 schema 升级完成({}/{} 条 DDL", ok, ddls.length);
}
}
@@ -0,0 +1,59 @@
package com.imutil.task;
import com.imutil.entity.Tenant;
import com.imutil.mapper.TenantMapper;
import com.imutil.service.SyncService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
import java.util.List;
/**
* 数据同步补拉对账定时任务
* <p>
* 定期重跑群消息 + 单聊同步(幂等,本地已有的跳过、缺失的补入),
* 兜底腾讯侧有而本系统暂无的消息,确保本系统数据最全、业务系统不漏收。
* 对应 app.yml 的 solon.scheduling.job.syncCheckJob。
* <p>
* 遍历所有租户,对每个用主应用密钥执行 checkAndPull(主应用数据为主)。
*
* @author imutil
*/
@Slf4j
@Component
public class SyncCheckTask {
@Inject
private SyncService syncService;
@Inject
private TenantMapper tenantMapper;
/**
* 由 app.yml syncCheckJob 驱动(默认 fixedDelay=1 小时)
*/
@Scheduled(name = "syncCheckJob")
public void run() {
List<Tenant> tenants = tenantMapper.selectList(null);
if (tenants == null || tenants.isEmpty()) {
return;
}
int ok = 0;
for (Tenant t : tenants) {
String tid = t.getTenantId();
if (tid == null || tid.isEmpty()) {
continue;
}
try {
syncService.checkAndPull(tid);
ok++;
} catch (Throwable e) {
// 单租户异常不影响其他租户
log.error("补拉对账定时任务异常 tenant={}", tid, e);
}
}
log.info("补拉对账定时任务完成 租户数={} 成功={}", tenants.size(), ok);
}
}
@@ -1,10 +1,15 @@
package com.imutil.tencent;
import com.imutil.common.BizException;
import com.imutil.common.Httpx;
import com.imutil.common.Jsons;
import com.imutil.common.LocalCache;
import com.imutil.common.RateLimiter;
import com.imutil.common.TenantContext;
import com.imutil.entity.ApiCallLog;
import com.imutil.entity.Tenant;
import com.imutil.mapper.ApiCallLogMapper;
import com.imutil.service.TenantService;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack4.ONode;
import org.noear.solon.annotation.Component;
@@ -13,6 +18,7 @@ import org.noear.solon.annotation.Inject;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -49,15 +55,29 @@ public class TencentImClient {
@Inject
private ApiCallLogMapper apiCallLogMapper;
/**
* 生成管理员 UserSig(长效,用于调后台 API
*/
private String genAdminSig() {
return UserSigUtil.genSig(sdkAppId, secretKey, adminUserId, 30L * 86400);
}
@Inject("${imutil.ratelimit.defaultImQps:50}")
private int defaultImQps;
@Inject
private RateLimiter rateLimiter;
// ===== 数据迁移:消息导入限速重试(T14 V3)=====
/** 消息导入 429 限流重试次数 */
@Inject("${imutil.migrate.importMaxRetry:3}")
private int migrateImportMaxRetry;
/** 消息导入重试间隔(毫秒) */
@Inject("${imutil.migrate.importRetryMs:1000}")
private long migrateImportRetryMs;
@Inject
private TenantService tenantService;
@Inject
private LocalCache localCache;
/**
* 调用 IM REST API
* 调用主应用 IM REST API(用主应用密钥,走出站限流)
*
* @param command 命令路径,如 im_open_login_svc/account_import
* @param bodyJson 请求体 JSON
@@ -67,12 +87,38 @@ public class TencentImClient {
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
String tenantId = TenantContext.get();
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
// 出站腾讯 API 限流:按租户配额 + 全局共享配额(对齐设计 3.2),超限抛 429
// system(无租户上下文)用默认配额;补拉等后台任务超限由调用方 catch 跳过本轮
rateLimiter.checkApiLimit(tid, resolveTenantQps(tid));
return doCallApi(command, bodyJson, sdkAppId, secretKey, tid);
}
/**
* 调用老应用 IM REST API(数据迁移拉取用,指定老应用密钥)
* <p>
* 老应用拉取消耗的是老应用自身套餐配额(非主应用),故不走主应用限流;
* 审计 tid 记 "migrate" 便于与业务调用区分。管理员 identifier 默认 administrator
* (腾讯各应用默认管理员账号),如某老应用管理员不同,后续扩展 source_app 字段。
*
* @param srcSdkAppId 老应用 SDKAppId
* @param srcSecretKey 老应用密钥
* @return 响应 JSON 字符串
*/
public String callApiAs(String command, String bodyJson, long srcSdkAppId, String srcSecretKey) {
return doCallApi(command, bodyJson, srcSdkAppId, srcSecretKey, "migrate");
}
/**
* 调用腾讯 API 的公共实现:生成管理员 UserSig + 拼 URL + POST + 审计落库
* <p>
* 限流由调用方决定:主应用 callApi 走限流,老应用 callApiAs 不走(消耗老应用配额)。
*/
private String doCallApi(String command, String bodyJson, long appid, String key, String tid) {
String result;
try {
String adminSig = genAdminSig();
String adminSig = UserSigUtil.genSig(appid, key, adminUserId, 30L * 86400);
String url = "https://" + apiHost + "/v4/" + command
+ "?sdkappid=" + sdkAppId
+ "?sdkappid=" + appid
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
+ "&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)
*
@@ -128,6 +208,35 @@ public class TencentImClient {
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>
@@ -147,7 +256,7 @@ public class TencentImClient {
/**
* 查询单聊历史漫游消息(C2C)
* <p>
* 命令字 openim_admin/get_roam_msg,按时间窗返回指定两人之间最近的 MaxCnt 条消息。
* 命令字 openim/admin_getroammsg,按时间窗返回指定两人之间最近的 MaxCnt 条消息。
* 补拉服务据此增量补全回调丢失的单聊消息。
*
* @param fromAccount 发送方 IM 账号
@@ -159,12 +268,13 @@ public class TencentImClient {
*/
public String getRoamMsg(String fromAccount, String toAccount, int maxCnt, long minTime, long maxInterval) {
Map<String, Object> body = new HashMap<>();
body.put("From_Account", fromAccount);
body.put("To_Account", toAccount);
// 腾讯 get_roam_msg 请求字段为 Operator_Account/Peer_Account + MinTime/MaxTime(无 MaxTimeInterval
body.put("Operator_Account", fromAccount);
body.put("Peer_Account", toAccount);
body.put("MaxCnt", maxCnt);
body.put("MinTime", minTime);
body.put("MaxTimeInterval", maxInterval);
return callApi("openim_admin/get_roam_msg", Jsons.stringify(body));
body.put("MaxTime", minTime + maxInterval);
return callApi("openim/admin_getroammsg", Jsons.stringify(body));
}
/**
@@ -184,6 +294,320 @@ public class TencentImClient {
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("Operator_Account", fromAccount);
body.put("Peer_Account", toAccount);
body.put("MaxCnt", maxCnt);
body.put("MinTime", minTime);
body.put("MaxTime", minTime + maxInterval);
return callApiAs("openim/admin_getroammsg", 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);
}
/**
* 拉取用户最近会话列表(含 C2C 单聊会话)— 单聊同步用
* <p>
* 命令字 recentcontact/get_list,分页返回 From_Account 的会话 SessionItem
* Type=1 为 C2C(带 To_Account),Type=2 为群(带 GroupId)。
* 单聊同步据此发现用户的 C2C 会话对,再配 getRoamMsgRangeAs 全量拉取漫游消息。
*
* @param fromAccount 本端账号
* @param timeStamp 分页时间戳(首轮 0;后续取上轮返回的 TimeStamp
* @param startIndex 分页起始索引(首轮 0;后续取上轮返回的 StartIndex
* @return 腾讯响应原始 JSON(含 SessionItem[]/CompleteFlag/TimeStamp/StartIndex
*/
public String getRecentContactListAs(String fromAccount, long timeStamp, long startIndex,
long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("From_Account", fromAccount);
body.put("TimeStamp", timeStamp);
body.put("StartIndex", startIndex);
body.put("TopTimeStamp", 0);
body.put("AssistFlags", 15);
return callApiAs("recentcontact/get_list", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 查询单聊漫游消息(按时间区间,全量续拉用)— 单聊同步用
* <p>
* 腾讯 openim/admin_getroammsg 请求字段为 Operator_Account/Peer_Account + MinTime/MaxTime
* 续拉:MaxTime 取上轮返回 LastMsgTime,并带 LastMsgKey,直到 Complete=1。
*
* @param operatorAccount 会话一方(本端)
* @param peerAccount 会话另一方
* @param maxCnt 单次条数上限
* @param minTime 区间下界(秒级 epoch)
* @param maxTime 区间上界(秒级 epoch;首轮 now,续拉取上轮 LastMsgTime
* @param lastMsgKey 上轮返回 LastMsgKey(首轮 null
* @return 腾讯响应原始 JSON(含 MsgList/Complete/LastMsgTime/LastMsgKey
*/
public String getRoamMsgRangeAs(String operatorAccount, String peerAccount, int maxCnt,
long minTime, long maxTime, String lastMsgKey,
long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("Operator_Account", operatorAccount);
body.put("Peer_Account", peerAccount);
body.put("MaxCnt", maxCnt);
body.put("MinTime", minTime);
body.put("MaxTime", maxTime);
if (lastMsgKey != null && !lastMsgKey.isEmpty()) {
body.put("LastMsgKey", lastMsgKey);
}
return callApiAs("openim/admin_getroammsg", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
// ==================== 群迁移相关(T14 S3 ====================
/**
* 列出 SDKAppId 下所有群(老应用拉群清单,用老密钥)
* <p>
* 命令字 group_open_http_svc/get_appid_groups,返回 GroupIdListGroupId/Type)。
* 与用户清单不同,群清单可从腾讯 API 直接拉取。
*
* @return 腾讯响应原始 JSON(含 GroupIdList),由调用方解析
*/
public String getAppidGroups(long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("Limit", 10000);
return callApiAs("group_open_http_svc/get_appid_groups", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 全量分页列出 SDKAppId 下所有群(T17 数据同步,主应用密钥)
* <p>
* 命令字 group_open_http_svc/get_appid_group_list,支持 Next 游标分页:
* 首次 Next 传空串,循环把上次返回的 Next 再传入,直到返回 Next 为空。
* 与 {@link #getAppidGroups} 的区别:标准接口、支持分页、GroupIdList 元素为纯字符串。
*
* @param limit 每页数量(建议 100
* @param next 分页游标,首次传空串
* @return 腾讯响应原始 JSON(含 Next/Total/GroupIdList),由调用方解析
*/
public String getAppidGroupList(int limit, String next) {
Map<String, Object> body = new HashMap<>();
body.put("Limit", limit);
body.put("Next", next == null ? "" : next);
return callApi("group_open_http_svc/get_appid_group_list", Jsons.stringify(body));
}
/**
* 全量分页列出 SDKAppId 下所有群(T17 数据同步,指定应用密钥,用于老应用)
*
* @see #getAppidGroupList(int, String)
*/
public String getAppidGroupList(int limit, String next, long sdkAppId, String secretKey) {
Map<String, Object> body = new HashMap<>();
body.put("Limit", limit);
body.put("Next", next == null ? "" : next);
return callApiAs("group_open_http_svc/get_appid_group_list", Jsons.stringify(body), sdkAppId, secretKey);
}
/**
* 查群资料 + 成员列表(老应用拉群详情,用老密钥)
* <p>
* 命令字 group_open_http_svc/get_group_info,返回 GroupInfo[].MemberList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String getGroupInfo(String groupId, long srcSdkAppId, String srcSecretKey) {
// 腾讯 get_group_info 要求 GroupIdList: ["groupId"](字符串数组,元素须为 string)
Map<String, Object> body = new HashMap<>();
body.put("GroupIdList", List.of(groupId));
return callApiAs("group_open_http_svc/get_group_info", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 建群(主应用,可带初始成员 ≤ 100)
* <p>
* 命令字 group_open_http_svc/create_group。迁移时 newGroupId = 前缀 + 老 GroupId。
* 成员账号需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean createGroup(String groupId, String type, String name, String ownerAccount, List<String> memberAccounts) {
Map<String, Object> body = new HashMap<>();
body.put("Type", type == null || type.isEmpty() ? "Public" : type);
body.put("GroupId", groupId);
body.put("Name", name == null ? groupId : name);
if (ownerAccount != null && !ownerAccount.isEmpty()) {
body.put("Owner_Account", ownerAccount);
}
if (memberAccounts != null && !memberAccounts.isEmpty()) {
List<Map<String, String>> ml = new ArrayList<>();
for (String m : memberAccounts) {
Map<String, String> item = new HashMap<>();
item.put("Member_Account", m);
item.put("MemberRole", "Member");
ml.add(item);
}
body.put("MemberList", ml);
}
String resp = callApi("group_open_http_svc/create_group", Jsons.stringify(body));
return isOk(resp);
}
/**
* 增加群成员(主应用,成员 > 100 时分批补充;成员账号需已存在)
* <p>
* 命令字 group_open_http_svc/add_group_memberSilence=1 静默加入。
*
* @return ActionStatus 是否 OK
*/
public boolean addGroupMember(String groupId, List<String> memberAccounts) {
if (memberAccounts == null || memberAccounts.isEmpty()) {
return true;
}
Map<String, Object> body = new HashMap<>();
body.put("GroupId", groupId);
List<Map<String, String>> ml = new ArrayList<>();
for (String m : memberAccounts) {
Map<String, String> item = new HashMap<>();
item.put("Member_Account", m);
ml.add(item);
}
body.put("MemberList", ml);
body.put("Silence", 1);
String resp = callApi("group_open_http_svc/add_group_member", Jsons.stringify(body));
return isOk(resp);
}
// ==================== 消息迁移相关(T14 S4 ====================
/**
* 导入单聊消息(历史消息,不计未读,主应用)
* <p>
* 命令字 openim_http_svc/import_msgSyncFromOldSystem=1 标记为旧系统迁移消息(不计未读数)。
* From/To 需已存在于主应用(用户迁移已完成)。MsgBody 透传(来自 getRoamMsg 的原始消息体 JSON)。
*
* @param msgBodyJson MsgBody 原始 JSON 数组字符串(来自漫游消息,直接透传)
* @return ActionStatus 是否 OK
*/
public boolean importMsg(String fromAccount, String toAccount, long msgTimeStamp, String msgBodyJson) {
// 手动拼 bodyMsgBody 直接嵌入(已是合法 JSON 数组),from/to 为安全 IM ID 无需转义
String body = "{\"SyncFromOldSystem\":1,\"From_Account\":\"" + fromAccount
+ "\",\"To_Account\":\"" + toAccount
+ "\",\"MsgTimeStamp\":" + msgTimeStamp
+ ",\"MsgBody\":" + (msgBodyJson == null ? "[]" : msgBodyJson) + "}";
// V3: 消息导入密集调用易触主应用限流,捕获 429 限速重试;耗尽或非 429 返回 false 跳过该条
for (int attempt = 0; attempt <= migrateImportMaxRetry; attempt++) {
try {
String resp = callApi("openim_http_svc/import_msg", body);
return isOk(resp);
} catch (BizException e) {
if (e.getCode() != 429 || attempt == migrateImportMaxRetry) {
log.warn("导入单聊消息失败 from={} to={} ts={} : {}", fromAccount, toAccount, msgTimeStamp, e.getMessage());
return false;
}
sleepRetry();
}
}
return false;
}
/**
* 导入群消息(历史消息,主应用)
* <p>
* 命令字 group_open_http_svc/import_group_msg。GroupId/From 需已迁移(群迁移 + 用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean importGroupMsg(String groupId, String fromAccount, long msgTimeStamp, String msgBodyJson) {
String body = "{\"GroupId\":\"" + groupId
+ "\",\"From_Account\":\"" + (fromAccount == null ? "" : fromAccount)
+ "\",\"MsgTimeStamp\":" + msgTimeStamp
+ ",\"SyncFromOldSystem\":1,\"MsgBody\":" + (msgBodyJson == null ? "[]" : msgBodyJson) + "}";
// V3: 消息导入密集调用易触主应用限流,捕获 429 限速重试;耗尽或非 429 返回 false 跳过该条
for (int attempt = 0; attempt <= migrateImportMaxRetry; attempt++) {
try {
String resp = callApi("group_open_http_svc/import_group_msg", body);
return isOk(resp);
} catch (BizException e) {
if (e.getCode() != 429 || attempt == migrateImportMaxRetry) {
log.warn("导入群消息失败 group={} from={} ts={} : {}", groupId, fromAccount, msgTimeStamp, e.getMessage());
return false;
}
sleepRetry();
}
}
return false;
}
/**
* 消息导入限速重试间隔(T14 V3)
* <p>
* 429 配额超限时 sleep 后重试;线程被中断则提前返回(中断状态已恢复,由上层决定是否继续)。
*/
private void sleepRetry() {
try {
Thread.sleep(migrateImportRetryMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
// ==================== 关系链迁移相关(T14 S5 ====================
/**
* 导入好友关系(主应用,历史关系链迁移)
* <p>
* 命令字 sns/openim_http_svc/import_friend。From/To 需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean importFriend(String fromAccount, List<String> friendAccounts) {
if (friendAccounts == null || friendAccounts.isEmpty()) {
return true;
}
Map<String, Object> body = new HashMap<>();
body.put("From_Account", fromAccount);
List<Map<String, String>> fl = new ArrayList<>();
for (String f : friendAccounts) {
Map<String, String> item = new HashMap<>();
item.put("To_Account", f);
item.put("AddSource", "AddSource_Type_System");
fl.add(item);
}
body.put("FriendList", fl);
String resp = callApi("sns/openim_http_svc/import_friend", Jsons.stringify(body));
return isOk(resp);
}
/**
* 拉取用户好友列表(老应用拉关系链,用老密钥)
* <p>
* 命令字 sns/openim_http_svc/friend_get_list,返回 FriendList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String friendGetList(String account, long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("From_Account", account);
body.put("StartSequence", 0L);
return callApiAs("sns/openim_http_svc/friend_get_list", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 判断腾讯 API 返回是否成功
*/
+7 -1
View File
@@ -12,7 +12,7 @@ solon.logging.logger:
level: INFO
solon.logging.appender:
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"
file:
charset: UTF-8
@@ -90,6 +90,12 @@ imutil.pull:
maxMsgPerConv: 20 # 每会话每轮拉取条数上限
lookbackMinutes: 30 # C2C 拉取时间窗(最近N分钟)
# 数据迁移配置(T14 消息迁移)
imutil.migrate:
msgLookbackDays: 7 # 消息迁移回溯天数,0=全量(受套餐云端保存时长限制)
importMaxRetry: 3 # 消息导入 429 限流重试次数
importRetryMs: 1000 # 消息导入重试间隔(毫秒)
# 限流配置(按租户令牌桶)
imutil.ratelimit:
defaultImQps: 50 # 默认每租户 IM API QPS
+47
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS tenant (
CONSTRAINT pk_tenant PRIMARY KEY (tenant_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_app_key ON tenant(app_key);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_prefix ON tenant(prefix_code);
-- ===== 2. 用户映射(业务用户 ↔ IM 用户) =====
CREATE TABLE IF NOT EXISTS user_mapping (
@@ -29,6 +30,8 @@ CREATE TABLE IF NOT EXISTS user_mapping (
is_default boolean NOT NULL DEFAULT false,
is_global boolean NOT NULL DEFAULT false,
status smallint NOT NULL DEFAULT 1, -- 1=正常 0=封禁
nick varchar(128), -- 用户昵称(T17 数据同步拉取自群成员资料)
last_synced_at timestamptz, -- 最近一次同步时间(T17
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_user_mapping PRIMARY KEY (id)
);
@@ -42,6 +45,10 @@ CREATE TABLE IF NOT EXISTS group_mapping (
biz_group_id varchar(128) NOT NULL,
im_group_id varchar(128) NOT NULL,
group_type varchar(16) NOT NULL DEFAULT 'Public',
name varchar(256), -- 群名称(T17 数据同步拉取)
owner_account varchar(128), -- 群主账号(T17 数据同步拉取)
member_count int, -- 群成员数(T17 数据同步拉取)
last_synced_at timestamptz, -- 最近一次同步时间(T17
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_group_mapping PRIMARY KEY (id)
);
@@ -211,3 +218,43 @@ CREATE TABLE IF NOT EXISTS admin_user (
CONSTRAINT uk_admin_username UNIQUE (username)
);
-- 默认管理员账号由应用首次启动时按 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);
-- ===== T17 数据同步:老库升级补丁(ADD COLUMN IF NOT EXISTS,幂等可重跑) =====
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS name varchar(256);
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS owner_account varchar(128);
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS member_count int;
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS last_synced_at timestamptz;
ALTER TABLE user_mapping ADD COLUMN IF NOT EXISTS nick varchar(128);
ALTER TABLE user_mapping ADD COLUMN IF NOT EXISTS last_synced_at timestamptz;
+2 -2
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!-- 控制台输出(GBK,适配 Windows 终端 -->
<!-- 控制台输出(UTF-8,适配现代终端;老 cmd.exe 需先 chcp 65001 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<charset>GBK</charset>
<charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39})) : %msg%n</pattern>
</encoder>
</appender>
+5
View File
@@ -66,8 +66,13 @@
<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/usage" class="nav-item <#if active=='usage'>active</#if>">用量报表</a>
<a href="${basePath}/admin/messages" class="nav-item <#if active=='messages'>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>
<a href="${basePath}/admin/sync" class="nav-item <#if active=='sync'>active</#if>">数据同步</a>
</nav>
<div class="sidebar-footer">
<a href="${basePath}/admin/password" class="nav-item" style="border-left:none">修改密码</a>
<a href="${basePath}/admin/logout" class="logout-link">退出登录</a>
</div>
</aside>
+107
View File
@@ -0,0 +1,107 @@
<#--
消息记录列表页
数据源 im_message(按月分区),默认最近 7 天 + 已分发(dist_status=1)。
带时间窗走分区裁剪;时间已由 Controller 预格式化(msgTimeStr/msgTimeIso)。
-->
<#import "_macros.ftl" as m>
<@m.layout active="messages" title="消息记录">
<form method="get" action="${basePath}/admin/messages" style="background:#fff;padding:16px;border-radius:8px;margin-bottom:16px;display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end">
<div style="margin:0">
<label style="display:block;font-size:12px;color:#666;margin-bottom:6px">租户</label>
<select name="tenantId" style="padding:7px;border:1px solid #ddd;border-radius:6px">
<option value="">全部租户</option>
<#list tenants as t>
<option value="${t.tenantId!}" <#if tenantId?? && tenantId==t.tenantId?string>selected</#if>>${t.tenantId!}${t.tenantName!}</option>
</#list>
</select>
</div>
<div style="margin:0">
<label style="display:block;font-size:12px;color:#666;margin-bottom:6px">会话类型</label>
<select name="convType" style="padding:7px;border:1px solid #ddd;border-radius:6px">
<option value="">全部</option>
<option value="1" <#if convType?? && convType==1>selected</#if>>C2C</option>
<option value="2" <#if convType?? && convType==2>selected</#if>>群</option>
</select>
</div>
<div style="margin:0">
<label style="display:block;font-size:12px;color:#666;margin-bottom:6px">分发状态</label>
<select name="distStatus" style="padding:7px;border:1px solid #ddd;border-radius:6px">
<option value="">全部</option>
<option value="1" <#if distStatus?? && distStatus==1>selected</#if>>已分发</option>
<option value="0" <#if distStatus?? && distStatus==0>selected</#if>>待分发</option>
<option value="2" <#if distStatus?? && distStatus==2>selected</#if>>失败</option>
</select>
</div>
<div style="margin:0">
<label style="display:block;font-size:12px;color:#666;margin-bottom:6px">起始日期</label>
<input type="date" name="dateFrom" value="${dateFrom!}" style="padding:7px;border:1px solid #ddd;border-radius:6px">
</div>
<div style="margin:0">
<label style="display:block;font-size:12px;color:#666;margin-bottom:6px">结束日期</label>
<input type="date" name="dateTo" value="${dateTo!}" style="padding:7px;border:1px solid #ddd;border-radius:6px">
</div>
<div style="margin:0">
<label style="display:block;font-size:12px;color:#666;margin-bottom:6px">关键字(账号/会话)</label>
<input type="text" name="keyword" value="${keyword!}" placeholder="from/to/conv_id" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:180px">
</div>
<button type="submit" class="btn btn-primary">查询</button>
<a href="${basePath}/admin/messages" class="btn btn-secondary">重置</a>
</form>
<p class="muted" style="margin-bottom:10px">共 ${total!0} 条,第 ${page!1}/${totalPages!0} 页(默认最近 7 天 + 已分发)</p>
<#if rows?has_content>
<table class="data-table">
<thead>
<tr>
<th>时间</th><th>租户</th><th>类型</th><th>会话</th><th>发送方</th><th>接收方/群</th><th>消息类型</th><th>分发状态</th><th>来源</th><th>跨租户</th><th>操作</th>
</tr>
</thead>
<tbody>
<#list rows as r>
<tr>
<td>${r.msgTimeStr!}</td>
<td>${r.tenantId!}</td>
<td><#if r.convType?? && r.convType==1>C2C<#elseif r.convType?? && r.convType==2>群<#else>-</#if></td>
<td class="muted">${r.convId!}</td>
<td>${r.fromAccount!}</td>
<td><#if r.convType?? && r.convType==2>${r.groupId!}<#else>${r.toAccount!}</#if></td>
<td>${r.msgType!}</td>
<td>
<#if r.distStatus?? && r.distStatus==1><span class="badge badge-ok">已分发</span>
<#elseif r.distStatus?? && r.distStatus==0><span class="badge badge-warn">待分发</span>
<#elseif r.distStatus?? && r.distStatus==2><span class="badge badge-no">失败</span>
<#else>-</#if>
</td>
<td>${r.source!}</td>
<td><#if r.isCrossTenant?? && r.isCrossTenant>是<#else>-</#if></td>
<td>
<a class="btn btn-sm btn-primary" href="${basePath}/admin/messages/detail?msgKey=${r.msgKeyEnc!}&msgTime=${r.msgTimeIso!}">详情</a>
</td>
</tr>
</#list>
</tbody>
</table>
<form id="pageForm" method="get" action="${basePath}/admin/messages" style="display:inline">
<input type="hidden" name="tenantId" value="${tenantId!}">
<input type="hidden" name="convType" value="${convType!}">
<input type="hidden" name="distStatus" value="${distStatus!}">
<input type="hidden" name="dateFrom" value="${dateFrom!}">
<input type="hidden" name="dateTo" value="${dateTo!}">
<input type="hidden" name="keyword" value="${keyword!}">
<input type="hidden" name="page" id="pageInput" value="${page!1}">
</form>
<div style="margin-top:16px;display:flex;gap:8px;align-items:center">
<#if (page!1) gt 1>
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('pageInput').value='${page-1}';document.getElementById('pageForm').submit()">上一页</button>
</#if>
<span class="muted">第 ${page!1} 页</span>
<#if (page!1) lt (totalPages!0)>
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('pageInput').value='${page+1}';document.getElementById('pageForm').submit()">下一页</button>
</#if>
</div>
<#else>
<div class="empty">无符合条件的消息</div>
</#if>
</@m.layout>
@@ -0,0 +1,77 @@
<#--
消息详情页
基本信息(im_message 完整字段,时间由 Controller 预格式化)+ msg_body 原始 JSON + 分发链路(dist_queue
-->
<#import "_macros.ftl" as m>
<@m.layout active="messages" title="消息详情">
<div style="margin-bottom:16px">
<a href="${basePath}/admin/messages" class="btn btn-secondary btn-sm">← 返回列表</a>
</div>
<#if msg??>
<div style="background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 6px rgba(0,0,0,.07);margin-bottom:16px">
<h3 style="font-size:15px;margin-bottom:14px">基本信息</h3>
<table class="data-table" style="margin-bottom:0">
<tbody>
<tr><th style="width:140px">MsgKey</th><td>${msg.msgKey!}</td></tr>
<tr><th>消息时间</th><td>${msgTimeStr!}</td></tr>
<tr><th>租户</th><td>${msg.tenantId!}</td></tr>
<tr><th>会话类型</th><td><#if msg.convType?? && msg.convType==1>C2C<#elseif msg.convType?? && msg.convType==2>群<#else>-</#if></td></tr>
<tr><th>会话ID</th><td>${msg.convId!}</td></tr>
<tr><th>发送方</th><td>${msg.fromAccount!}</td></tr>
<tr><th>接收方</th><td>${msg.toAccount!}</td></tr>
<tr><th>群ID</th><td>${msg.groupId!}</td></tr>
<tr><th>消息类型</th><td>${msg.msgType!}</td></tr>
<tr><th>来源</th><td>${msg.source!}</td></tr>
<tr><th>跨租户</th><td><#if msg.isCrossTenant?? && msg.isCrossTenant>是<#else>否</#if></td></tr>
<tr><th>分发状态</th><td>
<#if msg.distStatus?? && msg.distStatus==1><span class="badge badge-ok">已分发</span>
<#elseif msg.distStatus?? && msg.distStatus==0><span class="badge badge-warn">待分发</span>
<#elseif msg.distStatus?? && msg.distStatus==2><span class="badge badge-no">失败</span>
<#else>-</#if>
</td></tr>
<tr><th>入库时间</th><td>${msgCreatedStr!}</td></tr>
</tbody>
</table>
</div>
<div style="background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 6px rgba(0,0,0,.07);margin-bottom:16px">
<h3 style="font-size:15px;margin-bottom:14px">消息体(msg_body</h3>
<pre style="background:#f6f8fa;padding:14px;border-radius:6px;overflow:auto;font-size:13px;max-height:400px;white-space:pre-wrap;word-break:break-all">${msg.msgBody!}</pre>
</div>
<div style="background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 6px rgba(0,0,0,.07)">
<h3 style="font-size:15px;margin-bottom:14px">分发链路(dist_queue,最多 100 条)</h3>
<#if dists?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 dists as d>
<tr>
<td>${d.id!}</td>
<td class="muted" style="max-width:320px;word-break:break-all">${d.targetUrl!}</td>
<td>${d.tenantId!}</td>
<td>
<#if d.status?? && d.status==0><span class="badge badge-warn">待分发</span>
<#elseif d.status?? && d.status==1><span class="badge badge-warn">处理中</span>
<#elseif d.status?? && d.status==2><span class="badge badge-ok">已分发</span>
<#elseif d.status?? && d.status==3><span class="badge badge-no">死信</span>
<#else>-</#if>
</td>
<td>${d.retryCount!}</td>
<td>${d.createdStr!}</td>
<td>${d.updatedStr!}</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">无分发记录</div>
</#if>
</div>
<#else>
<div class="empty">消息不存在或参数缺失</div>
</#if>
</@m.layout>
+149
View File
@@ -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&#10;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>
+41
View File
@@ -0,0 +1,41 @@
<#--
修改密码页
校验:原密码正确 + 新密码 ≥ 8 位 + 新旧不同 + 两次新密码一致(前端 JS + 后端 AdminUserServiceImpl 兜底)
-->
<#import "_macros.ftl" as m>
<@m.layout active="password" title="修改密码">
<#if msg?has_content>
<div class="alert <#if msg!='pwddone'>alert-warn</#if>">
<#if msg=='pwddone'>✅ 密码修改成功,下次登录请使用新密码
<#elseif msg=='pwdfail'>❌ 修改失败:原密码错误,或新密码不合规(至少 8 位且与原密码不同)
<#elseif msg=='pwdconfirm'>❌ 两次输入的新密码不一致
</#if>
</div>
</#if>
<div style="max-width:420px;background:#fff;border-radius:8px;padding:24px 28px;box-shadow:0 1px 6px rgba(0,0,0,.07)">
<p class="muted" style="margin-bottom:16px">当前默认密码为弱密码(admin123),建议尽快修改为含字母+数字的强密码。</p>
<form method="post" action="${basePath}/admin/password" id="pwdForm">
<div class="modal-body">
<div class="form-group"><label>原密码</label><input type="password" name="oldPassword" required autocomplete="current-password"></div>
<div class="form-group"><label>新密码(至少 8 位,建议字母+数字)</label><input type="password" name="newPassword" id="newPwd" required autocomplete="new-password"></div>
<div class="form-group"><label>确认新密码</label><input type="password" name="confirmPassword" id="confirmPwd" required autocomplete="new-password"></div>
</div>
<div style="margin-top:8px;text-align:right">
<button type="submit" class="btn btn-primary">确认修改</button>
</div>
</form>
</div>
<script>
// 前端预校验:长度 ≥ 8 + 两次一致 + 新旧不同(后端服务层强校验兜底)
document.getElementById('pwdForm').addEventListener('submit', function (e) {
var np = document.getElementById('newPwd').value;
var cp = document.getElementById('confirmPwd').value;
var op = document.getElementsByName('oldPassword')[0].value;
if (np.length < 8) { e.preventDefault(); alert('新密码至少 8 位'); return; }
if (np !== cp) { e.preventDefault(); alert('两次输入的新密码不一致'); return; }
if (np === op) { e.preventDefault(); alert('新密码不能与原密码相同'); return; }
});
</script>
</@m.layout>
+1 -1
View File
@@ -15,7 +15,7 @@
<td>${q.tenantId!}</td>
<td>${q.convId!}</td>
<td>${q.retryCount!0}</td>
<td><#if q.nextRetryAt??>${q.nextRetryAt?string('yyyy-MM-dd HH:mm:ss')}<#else>-</#if></td>
<td>${(q.nextRetryAtStr)!}</td>
</tr>
</#list>
</tbody>
+151
View File
@@ -0,0 +1,151 @@
<#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 错误(70003SDKAppId/密钥不匹配)
<#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 style="white-space:nowrap">
<button class="btn btn-sm btn-primary"
data-id="${a.id!}"
data-app-name="${(a.appName!'')?html}"
data-sdk-app-id="${(a.sdkAppId!'')?html}"
data-secret-key="${(a.secretKey!'')?html}"
data-tenant-id="${(a.tenantId!'')?html}"
data-remark="${(a.remark!'')?html}"
data-status="${(a.status!'')?html}"
onclick="openEditApp(this)">编辑</button>
<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.prefixCode!}${t.tenantName!}</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>
<#-- 编辑弹窗:主键 id 自增 hidden 携带;secretKey 回填明文(与租户 appSecret 一致) -->
<div id="editAppModal" 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">
<input type="hidden" name="id" id="eaId">
<div class="form-group"><label>应用名称</label><input name="appName" id="eaAppName" required></div>
<div class="form-group"><label>SDKAppID(老应用)</label><input name="sdkAppId" id="eaSdkAppId" required></div>
<div class="form-group"><label>SecretKey(老应用密钥)</label><input name="secretKey" id="eaSecretKey" required></div>
<div class="form-group">
<label>对应租户(决定 UserID 前缀)</label>
<select name="tenantId" id="eaTenantId" required>
<option value="">请选择租户</option>
<#list tenants as t>
<option value="${t.tenantId!}">${t.prefixCode!}${t.tenantName!}</option>
</#list>
</select>
</div>
<div class="form-group"><label>备注</label><input name="remark" id="eaRemark"></div>
<div class="form-group"><label>状态</label>
<select name="status" id="eaStatus">
<option value="1">启用</option>
<option value="0">停用</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('editAppModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script>
function openEditApp(b){
var d=b.dataset;
document.getElementById('eaId').value=d.id||'';
document.getElementById('eaAppName').value=d.appName||'';
document.getElementById('eaSdkAppId').value=d.sdkAppId||'';
document.getElementById('eaSecretKey').value=d.secretKey||'';
document.getElementById('eaTenantId').value=d.tenantId||'';
document.getElementById('eaRemark').value=d.remark||'';
document.getElementById('eaStatus').value=d.status||1;
document.getElementById('editAppModal').classList.add('show');
}
</script>
</@m.layout>
+127
View File
@@ -0,0 +1,127 @@
<#import "_macros.ftl" as m>
<@m.layout active="sync" title="数据同步">
<#if msg?has_content>
<div class="alert">
<#if msg=='sgdone'>✅ 群组同步完成(详见任务记录)
<#elseif msg=='sudone'>✅ 群成员/用户同步完成(详见任务记录)
<#elseif msg=='smdone'>✅ 群消息同步完成(详见任务记录)
<#elseif msg=='scdone'>✅ 单聊消息同步完成(详见任务记录)
<#elseif msg=='chkdone'>✅ 补拉对账完成(详见任务记录)
<#elseif msg=='syncopt'>❌ 请选择数据源(主应用时须同时选归属租户)
<#else>${msg}
</#if>
</div>
</#if>
<div class="stat-card" style="margin-bottom:14px;padding:12px 18px">
<b>说明</b>
<span class="muted">:本页为<b>只读拉取</b>,把腾讯 IM 数据同步到本地表用于查看,不在腾讯侧建群/建号。
<b>群组</b>=get_appid_group_list 全量分页;<b>群成员(用户)</b>=遍历群取成员(腾讯无全量用户接口,靠群成员反推);<b>群消息</b>=逐群全量拉取;<b>单聊</b>=recentcontact/get_list 按已同步用户反查会话 + get_roam_msg 全量翻页。
<b>所有同步消息均入分发队列推送业务系统</b>(限速分批入队),本地同时留底。</span>
</div>
<#-- 公共:数据源 + 归属租户选择(三个表单各自携带) -->
<#macro srcSelect>
<div class="form-group">
<label>数据源</label>
<select name="sourceAppId">
<option value="">主应用(app.yml 当前配置)</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>归属租户 <span class="muted">(选「主应用」时必选;选老应用时自动取其配置租户,此项忽略)</span></label>
<select name="tenantId">
<option value="">请选择租户</option>
<#list tenants as t>
<option value="${t.tenantId!}">${t.tenantName!}${t.tenantId!}</option>
</#list>
</select>
</div>
</#macro>
<div class="stat-card" style="margin-bottom:20px">
<div class="modal-title">① 同步群组</div>
<p class="muted">拉取数据源应用的所有群(get_appid_group_list 全量分页)+ 逐群 get_group_info(名/类型/群主/成员数)→ 写 group_mapping。幂等可重跑。</p>
<form method="post" action="${basePath}/admin/sync/groups" style="margin-top:14px">
<div class="modal-body"><@srcSelect/></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">遍历该租户已同步的群,逐群取成员列表 → 写 user_mapping。<b>请先执行「同步群组」</b>,否则无群可遍历。</p>
<form method="post" action="${basePath}/admin/sync/users" style="margin-top:14px">
<div class="modal-body"><@srcSelect/></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">遍历该租户已同步的群,逐群 getGroupMsg + IsFinished 滚动全量 → 写 im_messagesource=SYNC<b>进分发队列</b>,限速分批)。<b>请先执行「同步群组」</b>。</p>
<form method="post" action="${basePath}/admin/sync/groupmsg" style="margin-top:14px">
<div class="modal-body"><@srcSelect/></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">遍历该租户已同步用户 → recentcontact/get_list 收集 C2C 会话 → 会话对去重 → get_roam_msg 全量翻页 → 写 im_messageconv_type=1source=SYNC,进分发)。<b>请先执行「同步群成员」</b>。</p>
<form method="post" action="${basePath}/admin/sync/c2cmsg" style="margin-top:14px">
<div class="modal-body"><@srcSelect/></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">重跑群消息 + 单聊同步(幂等:本地已有的跳过、缺失的补入),确保本系统数据最全。仅主应用 + 指定租户,可定时自动或手动触发。</p>
<form method="post" action="${basePath}/admin/sync/check" style="margin-top:14px">
<div class="form-group">
<label>归属租户 <span class="muted">(主应用,对账该租户)</span></label>
<select name="tenantId">
<option value="">请选择租户</option>
<#list tenants as t>
<option value="${t.tenantId!}">${t.tenantName!}${t.tenantId!}</option>
</#list>
</select>
</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><#if t.sourceAppId?? && t.sourceAppId==0>主应用<#else>${t.sourceAppId!}</#if></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>
+58 -4
View File
@@ -10,14 +10,13 @@
<table class="data-table">
<thead>
<tr>
<th>租户ID</th><th>名称</th><th>前缀</th><th>AppKey</th><th>AppSecret</th>
<th>名称</th><th>前缀</th><th>AppKey</th><th>AppSecret</th>
<th>回调URL</th><th>IM QPS</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<#list tenants as t>
<tr>
<td>${t.tenantId!}</td>
<td>${t.tenantName!}</td>
<td>${t.prefixCode!}</td>
<td>${t.appKey!}</td>
@@ -31,7 +30,18 @@
<span class="badge badge-no">停用</span>
</#if>
</td>
<td>
<td style="white-space:nowrap">
<button class="btn btn-sm btn-primary"
data-tenant-id="${(t.tenantId!'')?html}"
data-tenant-name="${(t.tenantName!'')?html}"
data-prefix-code="${(t.prefixCode!'')?html}"
data-app-key="${(t.appKey!'')?html}"
data-app-secret="${(t.appSecret!'')?html}"
data-callback-url="${(t.callbackUrl!'')?html}"
data-quota-im-qps="${(t.quotaImQps!'')?html}"
data-quota-trtc-concurrent="${(t.quotaTrtcConcurrent!'')?html}"
data-status="${(t.status!'')?html}"
onclick="openEdit(this)">编辑</button>
<form method="post" action="${basePath}/admin/tenant/toggle" style="display:inline">
<input type="hidden" name="tenantId" value="${t.tenantId!}">
<#if t.status?? && t.status == 1>
@@ -56,7 +66,7 @@
<div class="modal-title">新增租户</div>
<form method="post" action="${basePath}/admin/tenant/save">
<div class="modal-body">
<div class="form-group"><label>租户ID(即前缀,如 sa</label><input name="tenantId" required></div>
<div class="form-group"><label>前缀码(IM 账号前缀,如 sa,全局唯一</label><input name="prefixCode" required></div>
<div class="form-group"><label>租户名称</label><input name="tenantName"></div>
<div class="form-group"><label>AppKey(业务系统调用凭证)</label><input name="appKey" required></div>
<div class="form-group"><label>AppSecret</label><input name="appSecret" required></div>
@@ -71,4 +81,48 @@
</form>
</div>
</div>
<#-- 编辑弹窗:主键 tenantId 只读(readonly 仍随表单提交,供 updateById 定位);其余字段全部放开 -->
<div id="editModal" 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/tenant/save">
<div class="modal-body">
<input type="hidden" name="tenantId" id="eTenantId">
<div class="form-group"><label>租户名称</label><input name="tenantName" id="eTenantName"></div>
<div class="form-group"><label>前缀码</label><input name="prefixCode" id="ePrefixCode"></div>
<div class="form-group"><label>AppKey</label><input name="appKey" id="eAppKey" required></div>
<div class="form-group"><label>AppSecret</label><input name="appSecret" id="eAppSecret" required></div>
<div class="form-group"><label>回调URL</label><input name="callbackUrl" id="eCallbackUrl"></div>
<div class="form-group"><label>IM QPS 配额</label><input name="quotaImQps" type="number" id="eQuotaImQps"></div>
<div class="form-group"><label>TRTC 并发房间配额</label><input name="quotaTrtcConcurrent" type="number" id="eQuotaTrtc"></div>
<div class="form-group"><label>状态</label>
<select name="status" id="eStatus">
<option value="1">启用</option>
<option value="0">停用</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('editModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script>
function openEdit(b){
var d=b.dataset;
document.getElementById('eTenantId').value=d.tenantId||'';
document.getElementById('eTenantName').value=d.tenantName||'';
document.getElementById('ePrefixCode').value=d.prefixCode||'';
document.getElementById('eAppKey').value=d.appKey||'';
document.getElementById('eAppSecret').value=d.appSecret||'';
document.getElementById('eCallbackUrl').value=d.callbackUrl||'';
document.getElementById('eQuotaImQps').value=d.quotaImQps||50;
document.getElementById('eQuotaTrtc').value=d.quotaTrtcConcurrent||0;
document.getElementById('eStatus').value=d.status||1;
document.getElementById('editModal').classList.add('show');
}
</script>
</@m.layout>
+1 -1
View File
@@ -9,7 +9,7 @@
<#list stats as s>
<tr>
<td>${s.tenantId!}</td>
<td><#if s.statTime??>${s.statTime?string('yyyy-MM-dd HH:mm')}<#else>-</#if></td>
<td>${(s.statTimeStr)!}</td>
<td><#if s.statLevel?? && s.statLevel == 1>小时<#else>天</#if></td>
<td>${s.imMsgCount!0}</td>
<td>${s.imDau!0}</td>