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; import org.noear.solon.annotation.Inject; import org.noear.solon.annotation.Mapping; import org.noear.solon.annotation.Param; import org.noear.solon.annotation.Post; import org.noear.solon.core.handle.Context; import org.noear.solon.core.handle.ModelAndView; /** * 管理后台控制器 *

* 路由前缀 /admin。鉴权由 {@link com.imutil.filter.AdminAuthFilter} 拦截(除 login/logout)。 * 页面用 FreeMarker 渲染(对齐 yxtech),数据 CRUD 直连对应 Mapper。 * * @author imutil */ @Mapping("/admin") @Controller @Slf4j public class AdminController { @Inject("${server.contextPath:}") private String basePath; @Inject private AdminUserService adminUserService; @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 @Mapping("") public void index(Context ctx) throws Throwable { ctx.redirect(basePath + "/admin/home"); } @Get @Mapping("/login") public Object loginPage(@Param(defaultValue = "") String error) { if (StpUtil.isLogin()) { // 已登录不再渲染登录页(重定向由调用方处理,此处仍渲染避免死循环) } ModelAndView mv = new ModelAndView("login.ftl"); mv.put("basePath", basePath); if ("1".equals(error)) { mv.put("errorMsg", "用户名或密码错误"); } return mv; } @Post @Mapping("/login") public void doLogin(@Param(defaultValue = "") String username, @Param(defaultValue = "") String password, Context ctx) throws Throwable { AdminUser u = adminUserService.login(username, password); if (u != null) { StpUtil.login(u.getId()); log.info("管理后台登录成功 id={} username={}", u.getId(), username); ctx.redirect(basePath + "/admin/home"); } else { log.warn("管理后台登录失败 username={}", username); ctx.redirect(basePath + "/admin/login?error=1"); } } @Get @Mapping("/logout") public void logout(Context ctx) throws Throwable { StpUtil.logout(); 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 { // 当前登录用户ID(Sa-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 @Mapping("/home") public Object home() { ModelAndView mv = view("home.ftl", "仪表盘", "home"); mv.put("tenantCount", tenantMapper.selectCount(null)); mv.put("queuePending", distQueueMapper.selectCount(Wrappers.lambdaQuery() .eq(DistQueue::getStatus, 0))); mv.put("queueDead", distQueueMapper.selectCount(Wrappers.lambdaQuery() .eq(DistQueue::getStatus, 3))); mv.put("grantActive", grantMapper.selectCount(Wrappers.lambdaQuery() .eq(CrossTenantGrant::getStatus, 1))); return mv; } // ==================== 租户管理 ==================== @Get @Mapping("/tenant") public Object tenantPage(@Param(defaultValue = "") String msg) { ModelAndView mv = view("tenant.ftl", "租户管理", "tenant"); mv.put("tenants", tenantMapper.selectList(null)); mv.put("msg", msg); return mv; } @Post @Mapping("/tenant/save") public void tenantSave(Tenant t, Context ctx) throws Throwable { // 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.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"); } @Post @Mapping("/tenant/toggle") public void tenantToggle(@Param String tenantId, @Param Integer status, Context ctx) throws Throwable { Tenant t = new Tenant(); t.setTenantId(tenantId); t.setStatus(status == null ? 0 : status); tenantMapper.updateById(t); tenantService.evictCache(tenantId); ctx.redirect(basePath + "/admin/tenant?msg=toggled"); } // ==================== 跨租户授权 ==================== @Get @Mapping("/grant") public Object grantPage(@Param(defaultValue = "") String msg) { ModelAndView mv = view("grant.ftl", "跨租户授权", "grant"); mv.put("grants", grantMapper.selectList(null)); mv.put("tenants", tenantMapper.selectList(null)); mv.put("msg", msg); return mv; } @Post @Mapping("/grant/save") public void grantSave(CrossTenantGrant g, Context ctx) throws Throwable { if (g.getStatus() == null) { g.setStatus(1); } if (g.getDirection() == null) { g.setDirection(0); } if (g.getGrantId() == null) { grantMapper.insert(g); } else { grantMapper.updateById(g); } ctx.redirect(basePath + "/admin/grant?msg=saved"); } @Post @Mapping("/grant/revoke") public void grantRevoke(@Param Long grantId, Context ctx) throws Throwable { CrossTenantGrant g = new CrossTenantGrant(); g.setGrantId(grantId); g.setStatus(0); grantMapper.updateById(g); ctx.redirect(basePath + "/admin/grant?msg=revoked"); } // ==================== 队列监控 / 死信重发 ==================== @Get @Mapping("/queue") public Object queuePage(@Param(defaultValue = "") String msg) { // nextRetryAt 为 OffsetDateTime,FreeMarker ?string 对 java.time 不支持,Controller 端预格式化 DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); List rawPendings = distQueueMapper.selectList(Wrappers.lambdaQuery() .eq(DistQueue::getStatus, 0) .orderByAsc(DistQueue::getId) .last("LIMIT 50")); List> pendings = new ArrayList<>(); if (rawPendings != null) { for (DistQueue q : rawPendings) { Map 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.lambdaQuery() .eq(DistQueue::getStatus, 3) .orderByDesc(DistQueue::getId) .last("LIMIT 50"))); mv.put("deadCount", distQueueMapper.selectCount(Wrappers.lambdaQuery() .eq(DistQueue::getStatus, 3))); mv.put("msg", msg); return mv; } @Post @Mapping("/queue/redeliver") public void redeliver(@Param Long id, Context ctx) throws Throwable { int n = distQueueMapper.redeliverDead(id); ctx.redirect(basePath + "/admin/queue?msg=" + (n > 0 ? "redelivered" : "not_dead")); } // ==================== 用量报表 ==================== @Get @Mapping("/usage") public Object usagePage() { // statTime 为 OffsetDateTime,FreeMarker ?string 对 java.time 不支持,Controller 端预格式化 List raw = usageStatMapper.selectList(Wrappers.lambdaQuery() .orderByDesc(UsageStat::getStatTime) .last("LIMIT 100")); DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); List> stats = new ArrayList<>(); if (raw != null) { for (UsageStat s : raw) { Map 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=-1,doCallApi 兜底)→失败;70003=UserSig 错误(密钥不匹配); // 其余任何腾讯业务级响应都说明请求已达腾讯且 sig 鉴权通过 → 连通成功 String msg; if (resp.contains("\"ErrorCode\":-1")) { msg = "test_fail"; } else if (resp.contains("70003")) { msg = "test_sigfail"; } else { msg = "test_ok"; } log.info("老应用连通测试 id={} sdk={} resp={}", id, app.getSdkAppId(), resp); ctx.redirect(basePath + "/admin/sourceapp?msg=" + msg); } // ==================== 数据迁移(T14) ==================== @Get @Mapping("/migrate") public Object migratePage(@Param(defaultValue = "") String msg, @Param(defaultValue = "") String imported, @Param(defaultValue = "") String migrated) { ModelAndView mv = view("migrate.ftl", "数据迁移", "migrate"); mv.put("tasks", migrateService.listTasks()); mv.put("apps", sourceAppService.list()); mv.put("msg", msg); mv.put("imported", imported); mv.put("migrated", migrated); return mv; } /** * 用户迁移:上传用户清单文件(每行一个老 UserID)或粘贴列表 → 加前缀 → 批量导入主应用 → 写映射 → 校验 */ @Post @Mapping("/migrate/users") public void migrateUsers(@Param Long sourceAppId, @Param(defaultValue = "") String userText, Context ctx) throws Throwable { List 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 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 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 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 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 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"); } // ==================== 公共:构造页面模型 ==================== /** * 构造带侧边栏布局的 ModelAndView * * @param tpl 模板名(如 home.ftl) * @param title 页面标题 * @param active 侧边栏高亮项(home/tenant/grant/queue/usage) */ // ==================== 消息记录 ==================== /** * 消息记录列表(已分发消息查询) *

* 默认最近 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; // 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 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> rows = new ArrayList<>(); if (rawRows != null) { for (ImMessage r : rawRows) { Map 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.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 rawDists = distQueueMapper.selectByMsgKey(msgKey); List> dists = new ArrayList<>(); if (rawDists != null) { for (DistQueue d : rawDists) { Map 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); mv.put("pageTitle", title); mv.put("active", active); return mv; } }