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
@@ -2,6 +2,7 @@ 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;
@@ -14,10 +15,12 @@ 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;
@@ -61,6 +64,9 @@ public class AdminController {
@Inject
private TenantMapper tenantMapper;
@Inject
private TenantService tenantService;
@Inject
private CrossTenantGrantMapper grantMapper;
@@ -82,6 +88,9 @@ public class AdminController {
@Inject
private MigrateService migrateService;
@Inject
private SyncService syncService;
// ==================== 登录 / 登出 ====================
@Get
@@ -190,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");
return;
}
if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) {
t.setPrefixCode(t.getTenantId());
}
if (t.getStatus() == null) {
t.setStatus(1);
}
if (tenantMapper.selectById(t.getTenantId()) == null) {
// 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;
}
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);
}
tenantMapper.insert(t);
} else {
tenantMapper.updateById(t);
}
// 失效缓存,使后续按 id/appKey/prefix 查询拿到最新值
tenantService.evictCache(t.getTenantId());
ctx.redirect(basePath + "/admin/tenant?msg=saved");
}
@@ -215,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");
}
@@ -262,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)
@@ -289,10 +323,27 @@ 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;
}
@@ -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")
public Object messagesPage(@Param(defaultValue = "") String tenantId,
@Param(required = false) Integer convType,
@Param(defaultValue = "1") Integer distStatus,
@Param(required = false) String distStatus,
@Param(defaultValue = "") String dateFrom,
@Param(defaultValue = "") String dateTo,
@Param(defaultValue = "") String keyword,
@@ -556,10 +668,22 @@ public class AdminController {
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, distStatus, from, to, kw, offset, safeSize);
long total = imMessageMapper.countMessages(tid, convType, distStatus, from, to, kw);
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 端处理)
@@ -595,7 +719,7 @@ public class AdminController {
mv.put("totalPages", totalPages);
mv.put("tenantId", tenantId == null ? "" : tenantId);
mv.put("convType", convType);
mv.put("distStatus", distStatus);
mv.put("distStatus", distStatusInt);
mv.put("dateFrom", dateFrom == null ? "" : dateFrom);
mv.put("dateTo", dateTo == null ? "" : dateTo);
mv.put("keyword", keyword == null ? "" : keyword);