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:
@@ -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 为 OffsetDateTime,FreeMarker ?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 为 OffsetDateTime,FreeMarker ?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;
|
||||
|
||||
// distStatus:null=首次进入(默认已分发) / ""=用户选"全部" / "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);
|
||||
|
||||
Reference in New Issue
Block a user