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>
This commit is contained in:
连龙刚
2026-07-10 08:40:46 +08:00
co-authored by Claude
parent b679d47adb
commit fdbb7724fa
19 changed files with 1120 additions and 52 deletions
+3
View File
@@ -38,3 +38,6 @@ logs/
# 本地配置覆盖(密钥等敏感信息) # 本地配置覆盖(密钥等敏感信息)
app-env.yml 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`
@@ -2,6 +2,7 @@ package com.imutil.controller;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.Ids;
import com.imutil.entity.AdminUser; import com.imutil.entity.AdminUser;
import com.imutil.entity.CrossTenantGrant; import com.imutil.entity.CrossTenantGrant;
import com.imutil.entity.DistQueue; import com.imutil.entity.DistQueue;
@@ -14,10 +15,12 @@ import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UsageStatMapper; import com.imutil.mapper.UsageStatMapper;
import com.imutil.service.AdminUserService; import com.imutil.service.AdminUserService;
import com.imutil.service.SourceAppService; import com.imutil.service.SourceAppService;
import com.imutil.service.TenantService;
import com.imutil.tencent.TencentImClient; import com.imutil.tencent.TencentImClient;
import com.imutil.entity.ImMessage; import com.imutil.entity.ImMessage;
import com.imutil.entity.MigrateTask; import com.imutil.entity.MigrateTask;
import com.imutil.service.MigrateService; import com.imutil.service.MigrateService;
import com.imutil.service.SyncService;
import com.imutil.mapper.ImMessageMapper; import com.imutil.mapper.ImMessageMapper;
import org.noear.solon.core.handle.UploadedFile; import org.noear.solon.core.handle.UploadedFile;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -61,6 +64,9 @@ public class AdminController {
@Inject @Inject
private TenantMapper tenantMapper; private TenantMapper tenantMapper;
@Inject
private TenantService tenantService;
@Inject @Inject
private CrossTenantGrantMapper grantMapper; private CrossTenantGrantMapper grantMapper;
@@ -82,6 +88,9 @@ public class AdminController {
@Inject @Inject
private MigrateService migrateService; private MigrateService migrateService;
@Inject
private SyncService syncService;
// ==================== 登录 / 登出 ==================== // ==================== 登录 / 登出 ====================
@Get @Get
@@ -190,21 +199,30 @@ public class AdminController {
@Post @Post
@Mapping("/tenant/save") @Mapping("/tenant/save")
public void tenantSave(Tenant t, Context ctx) throws Throwable { public void tenantSave(Tenant t, Context ctx) throws Throwable {
if (t.getTenantId() == null || t.getTenantId().isEmpty()) { // tenantId 页面不可见:新增时雪花生成,编辑时由 hidden 携带(主键不可改)
ctx.redirect(basePath + "/admin/tenant?msg=tenant_id_required"); boolean isNew = (t.getTenantId() == null || t.getTenantId().isEmpty());
return; if (isNew) {
} // 前缀码(IM 账号前缀)必填且全局唯一
if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) { if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) {
t.setPrefixCode(t.getTenantId()); ctx.redirect(basePath + "/admin/tenant?msg=prefix_code_required");
} return;
if (t.getStatus() == null) { }
t.setStatus(1); Tenant exist = tenantMapper.selectOne(Wrappers.<Tenant>lambdaQuery()
} .eq(Tenant::getPrefixCode, t.getPrefixCode()).last("LIMIT 1"));
if (tenantMapper.selectById(t.getTenantId()) == null) { if (exist != null) {
ctx.redirect(basePath + "/admin/tenant?msg=prefix_code_duplicate");
return;
}
t.setTenantId(Ids.nextIdStr());
if (t.getStatus() == null) {
t.setStatus(1);
}
tenantMapper.insert(t); tenantMapper.insert(t);
} else { } else {
tenantMapper.updateById(t); tenantMapper.updateById(t);
} }
// 失效缓存,使后续按 id/appKey/prefix 查询拿到最新值
tenantService.evictCache(t.getTenantId());
ctx.redirect(basePath + "/admin/tenant?msg=saved"); ctx.redirect(basePath + "/admin/tenant?msg=saved");
} }
@@ -215,6 +233,7 @@ public class AdminController {
t.setTenantId(tenantId); t.setTenantId(tenantId);
t.setStatus(status == null ? 0 : status); t.setStatus(status == null ? 0 : status);
tenantMapper.updateById(t); tenantMapper.updateById(t);
tenantService.evictCache(tenantId);
ctx.redirect(basePath + "/admin/tenant?msg=toggled"); ctx.redirect(basePath + "/admin/tenant?msg=toggled");
} }
@@ -262,11 +281,26 @@ public class AdminController {
@Get @Get
@Mapping("/queue") @Mapping("/queue")
public Object queuePage(@Param(defaultValue = "") String msg) { public Object queuePage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("queue.ftl", "队列监控", "queue"); // nextRetryAt 为 OffsetDateTimeFreeMarker ?string 对 java.time 不支持,Controller 端预格式化
mv.put("pendings", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery() DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
List<DistQueue> rawPendings = distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 0) .eq(DistQueue::getStatus, 0)
.orderByAsc(DistQueue::getId) .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() mv.put("deads", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3) .eq(DistQueue::getStatus, 3)
.orderByDesc(DistQueue::getId) .orderByDesc(DistQueue::getId)
@@ -289,10 +323,27 @@ public class AdminController {
@Get @Get
@Mapping("/usage") @Mapping("/usage")
public Object usagePage() { public Object usagePage() {
ModelAndView mv = view("usage.ftl", "用量报表", "usage"); // statTime 为 OffsetDateTimeFreeMarker ?string 对 java.time 不支持,Controller 端预格式化
mv.put("stats", usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery() List<UsageStat> raw = usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery()
.orderByDesc(UsageStat::getStatTime) .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; return mv;
} }
@@ -522,6 +573,67 @@ public class AdminController {
} }
} }
// ==================== 数据同步(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");
}
// ==================== 公共:构造页面模型 ==================== // ==================== 公共:构造页面模型 ====================
/** /**
@@ -542,7 +654,7 @@ public class AdminController {
@Mapping("/messages") @Mapping("/messages")
public Object messagesPage(@Param(defaultValue = "") String tenantId, public Object messagesPage(@Param(defaultValue = "") String tenantId,
@Param(required = false) Integer convType, @Param(required = false) Integer convType,
@Param(defaultValue = "1") Integer distStatus, @Param(required = false) String distStatus,
@Param(defaultValue = "") String dateFrom, @Param(defaultValue = "") String dateFrom,
@Param(defaultValue = "") String dateTo, @Param(defaultValue = "") String dateTo,
@Param(defaultValue = "") String keyword, @Param(defaultValue = "") String keyword,
@@ -556,10 +668,22 @@ public class AdminController {
int safeSize = size <= 0 ? 20 : Math.min(size, 200); int safeSize = size <= 0 ? 20 : Math.min(size, 200);
int offset = (safePage - 1) * safeSize; 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 tid = (tenantId == null || tenantId.isEmpty()) ? null : tenantId;
String kw = (keyword == null || keyword.isEmpty()) ? null : keyword; String kw = (keyword == null || keyword.isEmpty()) ? null : keyword;
List<ImMessage> rawRows = imMessageMapper.selectMessagePage(tid, convType, distStatus, from, to, kw, offset, safeSize); List<ImMessage> rawRows = imMessageMapper.selectMessagePage(tid, convType, distStatusInt, from, to, kw, offset, safeSize);
long total = imMessageMapper.countMessages(tid, convType, distStatus, from, to, kw); long total = imMessageMapper.countMessages(tid, convType, distStatusInt, from, to, kw);
int totalPages = (int) ((total + safeSize - 1) / Math.max(1, safeSize)); int totalPages = (int) ((total + safeSize - 1) / Math.max(1, safeSize));
// 转成 Map 并预格式化时间(FreeMarker 对 java.time OffsetDateTime 格式化支持不稳,Controller 端处理) // 转成 Map 并预格式化时间(FreeMarker 对 java.time OffsetDateTime 格式化支持不稳,Controller 端处理)
@@ -595,7 +719,7 @@ public class AdminController {
mv.put("totalPages", totalPages); mv.put("totalPages", totalPages);
mv.put("tenantId", tenantId == null ? "" : tenantId); mv.put("tenantId", tenantId == null ? "" : tenantId);
mv.put("convType", convType); mv.put("convType", convType);
mv.put("distStatus", distStatus); mv.put("distStatus", distStatusInt);
mv.put("dateFrom", dateFrom == null ? "" : dateFrom); mv.put("dateFrom", dateFrom == null ? "" : dateFrom);
mv.put("dateTo", dateTo == null ? "" : dateTo); mv.put("dateTo", dateTo == null ? "" : dateTo);
mv.put("keyword", keyword == null ? "" : keyword); mv.put("keyword", keyword == null ? "" : keyword);
@@ -42,6 +42,7 @@ public class CallbackController {
@Param(value = "RequestTime", required = false) String requestTime, @Param(value = "RequestTime", required = false) String requestTime,
@Param(value = "SdkAppid", required = false) String sdkAppid) throws Throwable { @Param(value = "SdkAppid", required = false) String sdkAppid) throws Throwable {
String body = ctx.body(); String body = ctx.body();
log.info("收到腾讯回调 command={} sdkAppid={} requestTime={} body={}", command, sdkAppid, requestTime, body);
// 1. 签名校验(未配置 Token 时跳过,便于联调,生产必须配置) // 1. 签名校验(未配置 Token 时跳过,便于联调,生产必须配置)
if (callbackToken != null && !callbackToken.isEmpty()) { if (callbackToken != null && !callbackToken.isEmpty()) {
@@ -62,6 +63,7 @@ public class CallbackController {
// 落库失败的消息由补拉服务(T10)兜底 // 落库失败的消息由补拉服务(T10)兜底
result = "{\"ActionStatus\":\"OK\",\"ErrorCode\":0,\"ErrorInfo\":\"\"}"; result = "{\"ActionStatus\":\"OK\",\"ErrorCode\":0,\"ErrorInfo\":\"\"}";
} }
log.info("回调处理完成 command={} result={}", command, result);
ctx.output(result); ctx.output(result);
} }
} }
@@ -30,5 +30,17 @@ public class GroupMapping {
/** 群类型:Public/Private/ChatRoom/AVChatRoom */ /** 群类型:Public/Private/ChatRoom/AVChatRoom */
private String groupType; 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; private OffsetDateTime createdAt;
} }
@@ -40,5 +40,11 @@ public class UserMapping {
/** 状态:1=正常 0=封禁 */ /** 状态:1=正常 0=封禁 */
private Integer status; private Integer status;
/** 用户昵称(同步拉取自腾讯群成员资料) */
private String nick;
/** 最近一次同步时间(数据同步功能刷新) */
private OffsetDateTime lastSyncedAt;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
} }
@@ -0,0 +1,41 @@
package com.imutil.service;
import com.imutil.entity.MigrateTask;
/**
* 数据同步服务(T17
* <p>
* 与 {@link MigrateService}(老应用→主应用的"迁移",带建群/建号写回副作用)不同,
* 本服务定位为<b>只读拉取</b>:把指定腾讯 IM 应用里的群组/群成员/群消息拉到本地表,
* 供管理后台按租户查看,不在腾讯侧产生任何写操作。
* <p>
* 腾讯 IM 现实约束:无"全量用户列表"接口(用户靠群成员反推);无"全量 C2C 会话列表"接口
* (单聊历史消息本轮不做全量,回调增量已由 {@link com.imutil.service.PullService} 覆盖)。
*
* @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);
}
@@ -22,6 +22,16 @@ public interface TenantService {
*/ */
Tenant getById(String tenantId); Tenant getById(String tenantId);
/**
* 根据 prefix_code(IM 账号前缀)查询租户(带缓存)
* <p>
* 回调识别租户时,账号前缀经此反查得 tenant_id。
*
* @param prefixCode IM 前缀码
* @return 租户实体,不存在返回 null
*/
Tenant getByPrefixCode(String prefixCode);
/** /**
* 失效租户缓存(租户变更时调用) * 失效租户缓存(租户变更时调用)
*/ */
@@ -83,7 +83,7 @@ public class CallbackServiceImpl implements CallbackService {
String tenantId = identifyTenant(callbackCommand, node); String tenantId = identifyTenant(callbackCommand, node);
if (tenantId == null) { if (tenantId == null) {
// 无法识别租户(如腾讯系统消息 administrator),不落库不分发 // 无法识别租户(如腾讯系统消息 administrator),不落库不分发
log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("FromAccount").getString()); log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("From_Account").getString());
return ok(); return ok();
} }
@@ -99,7 +99,8 @@ public class CallbackServiceImpl implements CallbackService {
// 跨租户标记:复查授权确认(before 已拦截未授权)。 // 跨租户标记:复查授权确认(before 已拦截未授权)。
// 若 before 未开启/失效,未授权消息会漏到 after,此处查不到授权即告警(兜底发现隔离异常)。 // 若 before 未开启/失效,未授权消息会漏到 after,此处查不到授权即告警(兜底发现隔离异常)。
if (msg.getConvType() != null && msg.getConvType() == 1) { if (msg.getConvType() != null && msg.getConvType() == 1) {
String toTenant = parsePrefix(msg.getConvId()); Tenant toT = tenantService.getByPrefixCode(parsePrefix(msg.getConvId()));
String toTenant = toT == null ? null : toT.getTenantId();
if (toTenant != null && !toTenant.equals(tenantId)) { if (toTenant != null && !toTenant.equals(tenantId)) {
if (crossTenantService.checkSendMsgGrant( if (crossTenantService.checkSendMsgGrant(
tenantId, msg.getFromAccount(), toTenant, msg.getConvId()) != null) { tenantId, msg.getFromAccount(), toTenant, msg.getConvId()) != null) {
@@ -138,13 +139,16 @@ public class CallbackServiceImpl implements CallbackService {
* @return 放行 ok() 或拦截 fail() * @return 放行 ok() 或拦截 fail()
*/ */
private String handleBeforeSendMsg(ONode node) { private String handleBeforeSendMsg(ONode node) {
String from = node.get("FromAccount").getString(); String from = node.get("From_Account").getString();
String to = node.get("ToAccount").getString(); String to = node.get("To_Account").getString();
if (to == null || to.isEmpty()) { if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString(); to = node.get("ToPlayerId").getString();
} }
String fromTenant = parsePrefix(from); // 前缀(prefixCode) → 反查 tenantId(主键雪花化后,授权记录按 tenant_id 存储)
String toTenant = parsePrefix(to); 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)) { if (fromTenant == null || toTenant == null || fromTenant.equals(toTenant)) {
return ok(); return ok();
@@ -160,7 +164,7 @@ public class CallbackServiceImpl implements CallbackService {
String msgKey = null; String msgKey = null;
try { try {
long msgSeq = node.get("MsgSeq").getLong(); long msgSeq = node.get("MsgSeq").getLong();
long msgRandom = node.get("MsgRandom").getLong(); long msgRandom = pickMsgRandom(node);
msgKey = MsgKeys.build(from, to, msgSeq, msgRandom); msgKey = MsgKeys.build(from, to, msgSeq, msgRandom);
} catch (Exception ignore) { } catch (Exception ignore) {
// 回调体缺字段无法算 msgKey,审计仍写(msgKey=null // 回调体缺字段无法算 msgKey,审计仍写(msgKey=null
@@ -215,14 +219,18 @@ public class CallbackServiceImpl implements CallbackService {
* @return 租户ID,识别失败返回 null * @return 租户ID,识别失败返回 null
*/ */
private String identifyTenant(String command, ONode node) { 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()) { if (from != null && !from.isEmpty()) {
String tenantId = parsePrefix(from); String prefix = parsePrefix(from);
if (tenantId != null && tenantService.getById(tenantId) != null) { if (prefix != null) {
return tenantId; Tenant t = tenantService.getByPrefixCode(prefix);
if (t != null) {
return t.getTenantId();
}
} }
} }
// 群回调:GroupId 查映射 // 群回调:GroupId 查映射group_mapping.tenant_id 即租户主键,雪花/短码皆正确)
String groupId = node.get("GroupId").getString(); String groupId = node.get("GroupId").getString();
if (groupId != null && !groupId.isEmpty()) { if (groupId != null && !groupId.isEmpty()) {
GroupMapping gm = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery() GroupMapping gm = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
@@ -230,11 +238,6 @@ public class CallbackServiceImpl implements CallbackService {
if (gm != null) { if (gm != null) {
return gm.getTenantId(); return gm.getTenantId();
} }
// 群ID也带前缀
String tid = parsePrefix(groupId);
if (tid != null && tenantService.getById(tid) != null) {
return tid;
}
} }
return null; return null;
} }
@@ -269,15 +272,15 @@ public class CallbackServiceImpl implements CallbackService {
* 解析消息体,构造 ImMessage(含幂等键 msg_key * 解析消息体,构造 ImMessage(含幂等键 msg_key
*/ */
private ImMessage parseMessage(String command, ONode node, String tenantId) { private ImMessage parseMessage(String command, ONode node, String tenantId) {
String from = node.get("FromAccount").getString(); String from = node.get("From_Account").getString();
String to = node.get("ToAccount").getString(); String to = node.get("To_Account").getString();
if (to == null || to.isEmpty()) { if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString(); to = node.get("ToPlayerId").getString();
} }
String groupId = node.get("GroupId").getString(); String groupId = node.get("GroupId").getString();
long msgSeq = node.get("MsgSeq").getLong(); long msgSeq = node.get("MsgSeq").getLong();
long msgRandom = node.get("MsgRandom").getLong(); long msgRandom = pickMsgRandom(node);
long msgTs = node.get("MsgTimeStamp").getLong(); long msgTs = pickMsgTime(node);
if (msgTs == 0) { if (msgTs == 0) {
msgTs = System.currentTimeMillis() / 1000L; msgTs = System.currentTimeMillis() / 1000L;
@@ -297,7 +300,7 @@ public class CallbackServiceImpl implements CallbackService {
msg.setFromAccount(from); msg.setFromAccount(from);
msg.setToAccount(groupId != null && !groupId.isEmpty() ? null : to); msg.setToAccount(groupId != null && !groupId.isEmpty() ? null : to);
msg.setGroupId(groupId); msg.setGroupId(groupId);
msg.setMsgType(node.get("MsgType").getString()); msg.setMsgType(pickMsgType(node));
msg.setMsgBody(node.get("MsgBody").toString()); msg.setMsgBody(node.get("MsgBody").toString());
msg.setSource("CALLBACK"); msg.setSource("CALLBACK");
msg.setIsCrossTenant(false); msg.setIsCrossTenant(false);
@@ -313,13 +316,43 @@ public class CallbackServiceImpl implements CallbackService {
if (groupId != null && !groupId.isEmpty()) { if (groupId != null && !groupId.isEmpty()) {
return groupId; return groupId;
} }
String to = node.get("ToAccount").getString(); String to = node.get("To_Account").getString();
if (to == null || to.isEmpty()) { if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString(); to = node.get("ToPlayerId").getString();
} }
return to; 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,468 @@
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.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.List;
/**
* 数据同步服务实现(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;
/** 数据源密钥解析结果 */
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++) {
String groupId = groupIds.get(i).getString();
if (groupId == null || groupId.isEmpty()) {
continue;
}
total++;
try {
if (upsertGroupFromRemote(ctx, groupId)) {
upserted++;
}
} catch (Exception ge) {
log.warn("同步单群失败 {} : {}", groupId, ge.getMessage());
}
}
task.setTotalCount((long) total);
task.setProcessedCount((long) upserted);
task.setPosCursor("page:" + guard);
migrateTaskMapper.updateById(task);
next = root.get("Next").getString();
if (next == null || next.isEmpty()) {
break;
}
}
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;
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 = 0;
for (int i = 0; i < n; i++) {
ONode m = rspList.get(i);
long seq = m.get("MsgSeq").getLong();
if (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++;
}
boolean finished = root.get("IsFinished").getLong() == 1;
if (finished || n == 0 || oldestSeq <= 0) {
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=1 不进分发)
*/
private ImMessage parseGroupMsgForSync(ONode m, String tenantId, String groupId) {
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(1);
return msg;
}
// ==================== 工具方法 ====================
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());
}
}
@@ -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_KEY = "imutil:tenant:bykey:";
private static final String CACHE_KEY_BY_ID = "imutil:tenant:byid:"; 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; private static final long CACHE_TTL_SEC = 300;
@Inject @Inject
@@ -84,6 +85,33 @@ public class TenantServiceImpl implements TenantService {
return t; 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 @Override
public void evictCache(String tenantId) { public void evictCache(String tenantId) {
Tenant t = tenantMapper.selectById(tenantId); Tenant t = tenantMapper.selectById(tenantId);
@@ -94,6 +122,9 @@ public class TenantServiceImpl implements TenantService {
String k2 = CACHE_KEY_BY_KEY + t.getAppKey(); String k2 = CACHE_KEY_BY_KEY + t.getAppKey();
redisService.del(k2); redisService.del(k2);
localCache.invalidate(k2); localCache.invalidate(k2);
String k3 = CACHE_KEY_BY_PREFIX + t.getPrefixCode();
redisService.del(k3);
localCache.invalidate(k3);
} }
} }
} }
@@ -341,6 +341,36 @@ public class TencentImClient {
return callApiAs("group_open_http_svc/get_appid_groups", Jsons.stringify(body), srcSdkAppId, srcSecretKey); 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> * <p>
+15
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS tenant (
CONSTRAINT pk_tenant PRIMARY KEY (tenant_id) 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_app_key ON tenant(app_key);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_prefix ON tenant(prefix_code);
-- ===== 2. 用户映射(业务用户 ↔ IM 用户) ===== -- ===== 2. 用户映射(业务用户 ↔ IM 用户) =====
CREATE TABLE IF NOT EXISTS user_mapping ( 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_default boolean NOT NULL DEFAULT false,
is_global boolean NOT NULL DEFAULT false, is_global boolean NOT NULL DEFAULT false,
status smallint NOT NULL DEFAULT 1, -- 1=正常 0=封禁 status smallint NOT NULL DEFAULT 1, -- 1=正常 0=封禁
nick varchar(128), -- 用户昵称(T17 数据同步拉取自群成员资料)
last_synced_at timestamptz, -- 最近一次同步时间(T17
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_user_mapping PRIMARY KEY (id) 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, biz_group_id varchar(128) NOT NULL,
im_group_id varchar(128) NOT NULL, im_group_id varchar(128) NOT NULL,
group_type varchar(16) NOT NULL DEFAULT 'Public', 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(), created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_group_mapping PRIMARY KEY (id) CONSTRAINT pk_group_mapping PRIMARY KEY (id)
); );
@@ -243,3 +250,11 @@ CREATE TABLE IF NOT EXISTS migrate_task (
CONSTRAINT pk_migrate_task PRIMARY KEY (id) 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); 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;
+1
View File
@@ -69,6 +69,7 @@
<a href="${basePath}/admin/messages" class="nav-item <#if active=='messages'>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/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/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> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<a href="${basePath}/admin/password" class="nav-item" style="border-left:none">修改密码</a> <a href="${basePath}/admin/password" class="nav-item" style="border-left:none">修改密码</a>
+1 -1
View File
@@ -15,7 +15,7 @@
<td>${q.tenantId!}</td> <td>${q.tenantId!}</td>
<td>${q.convId!}</td> <td>${q.convId!}</td>
<td>${q.retryCount!0}</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> </tr>
</#list> </#list>
</tbody> </tbody>
+59 -2
View File
@@ -41,7 +41,16 @@
<span class="badge badge-no">停用</span> <span class="badge badge-no">停用</span>
</#if> </#if>
</td> </td>
<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"> <form method="post" action="${basePath}/admin/sourceapp/toggle" style="display:inline">
<input type="hidden" name="id" value="${a.id!}"> <input type="hidden" name="id" value="${a.id!}">
<#if a.status?? && a.status == 1> <#if a.status?? && a.status == 1>
@@ -78,7 +87,7 @@
<select name="tenantId" required> <select name="tenantId" required>
<option value="">请选择租户</option> <option value="">请选择租户</option>
<#list tenants as t> <#list tenants as t>
<option value="${t.tenantId!}">${t.tenantId!}${t.tenantName!},前缀 ${t.prefixCode!}</option> <option value="${t.tenantId!}">${t.prefixCode!}${t.tenantName!}</option>
</#list> </#list>
</select> </select>
</div> </div>
@@ -91,4 +100,52 @@
</form> </form>
</div> </div>
</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> </@m.layout>
+99
View File
@@ -0,0 +1,99 @@
<#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=='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>C2C 单聊消息无全量会话列表接口</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>。</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>
<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"> <table class="data-table">
<thead> <thead>
<tr> <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> <th>回调URL</th><th>IM QPS</th><th>状态</th><th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<#list tenants as t> <#list tenants as t>
<tr> <tr>
<td>${t.tenantId!}</td>
<td>${t.tenantName!}</td> <td>${t.tenantName!}</td>
<td>${t.prefixCode!}</td> <td>${t.prefixCode!}</td>
<td>${t.appKey!}</td> <td>${t.appKey!}</td>
@@ -31,7 +30,18 @@
<span class="badge badge-no">停用</span> <span class="badge badge-no">停用</span>
</#if> </#if>
</td> </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"> <form method="post" action="${basePath}/admin/tenant/toggle" style="display:inline">
<input type="hidden" name="tenantId" value="${t.tenantId!}"> <input type="hidden" name="tenantId" value="${t.tenantId!}">
<#if t.status?? && t.status == 1> <#if t.status?? && t.status == 1>
@@ -56,7 +66,7 @@
<div class="modal-title">新增租户</div> <div class="modal-title">新增租户</div>
<form method="post" action="${basePath}/admin/tenant/save"> <form method="post" action="${basePath}/admin/tenant/save">
<div class="modal-body"> <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>租户名称</label><input name="tenantName"></div>
<div class="form-group"><label>AppKey(业务系统调用凭证)</label><input name="appKey" required></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> <div class="form-group"><label>AppSecret</label><input name="appSecret" required></div>
@@ -71,4 +81,48 @@
</form> </form>
</div> </div>
</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> </@m.layout>
+1 -1
View File
@@ -9,7 +9,7 @@
<#list stats as s> <#list stats as s>
<tr> <tr>
<td>${s.tenantId!}</td> <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><#if s.statLevel?? && s.statLevel == 1>小时<#else>天</#if></td>
<td>${s.imMsgCount!0}</td> <td>${s.imMsgCount!0}</td>
<td>${s.imDau!0}</td> <td>${s.imDau!0}</td>