diff --git a/src/main/java/com/imutil/controller/AdminController.java b/src/main/java/com/imutil/controller/AdminController.java index e826196..62e5c9b 100644 --- a/src/main/java/com/imutil/controller/AdminController.java +++ b/src/main/java/com/imutil/controller/AdminController.java @@ -15,12 +15,20 @@ import com.imutil.mapper.UsageStatMapper; import com.imutil.service.AdminUserService; import com.imutil.service.SourceAppService; import com.imutil.tencent.TencentImClient; +import com.imutil.entity.ImMessage; import com.imutil.entity.MigrateTask; import com.imutil.service.MigrateService; +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; @@ -59,6 +67,9 @@ public class AdminController { @Inject private DistQueueMapper distQueueMapper; + @Inject + private ImMessageMapper imMessageMapper; + @Inject private UsageStatMapper usageStatMapper; @@ -520,6 +531,150 @@ public class AdminController { * @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(defaultValue = "1") Integer 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; + + String tid = (tenantId == null || tenantId.isEmpty()) ? null : tenantId; + String kw = (keyword == null || keyword.isEmpty()) ? null : keyword; + List rawRows = imMessageMapper.selectMessagePage(tid, convType, distStatus, from, to, kw, offset, safeSize); + long total = imMessageMapper.countMessages(tid, convType, distStatus, 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", distStatus); + 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); diff --git a/src/main/java/com/imutil/mapper/DistQueueMapper.java b/src/main/java/com/imutil/mapper/DistQueueMapper.java index 90bcac7..7898396 100644 --- a/src/main/java/com/imutil/mapper/DistQueueMapper.java +++ b/src/main/java/com/imutil/mapper/DistQueueMapper.java @@ -86,4 +86,17 @@ public interface DistQueueMapper extends BaseMapper { */ @org.apache.ibatis.annotations.Select("SELECT status, count(*) AS cnt FROM dist_queue WHERE status IN (0, 1, 3) GROUP BY status") java.util.List> countByStatus(); + + /** + * 按消息键查分发记录(消息详情关联分发链路) + *

+ * msg_key 无独立索引,LIMIT 100 控制扫描范围;大数据量建议加 idx_dq_msgkey。 + * + * @param msgKey 消息键 + * @return 该消息的分发任务记录(按 id 倒序,最多 100 条) + */ + @org.apache.ibatis.annotations.Select("SELECT id, msg_key, tenant_id, conv_id, target_url, payload, status, retry_count, " + + "next_retry_at, locked_by, locked_at, created_at, updated_at " + + "FROM dist_queue WHERE msg_key = #{msgKey} ORDER BY id DESC LIMIT 100") + List selectByMsgKey(@Param("msgKey") String msgKey); } diff --git a/src/main/java/com/imutil/mapper/ImMessageMapper.java b/src/main/java/com/imutil/mapper/ImMessageMapper.java index 0df2d64..1462ed0 100644 --- a/src/main/java/com/imutil/mapper/ImMessageMapper.java +++ b/src/main/java/com/imutil/mapper/ImMessageMapper.java @@ -3,9 +3,11 @@ package com.imutil.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.imutil.entity.ImMessage; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import java.time.OffsetDateTime; +import java.util.List; /** * 消息主表 Mapper @@ -29,4 +31,62 @@ public interface ImMessageMapper extends BaseMapper { @Param("msgTime") OffsetDateTime msgTime, @Param("fromStatus") int fromStatus, @Param("toStatus") int toStatus); + + /** + * 分页查询消息(管理后台消息记录列表) + *

+ * 动态条件 + ORDER BY msg_time DESC + LIMIT/OFFSET。 + * 必须带 msg_time 范围(dateFrom/dateTo)触发分区裁剪,避免全分区扫描。 + * + * @param tenantId 租户(null/空=全部) + * @param convType 会话类型(null=全部,1=C2C 2=GROUP) + * @param distStatus 分发状态(null=全部,0待发/1已发/2失败) + * @param dateFrom 起始时间(含) + * @param dateTo 结束时间(开区间,传次日 0 点以覆盖整天) + * @param keyword 模糊关键字(null/空=无,匹配 from/to/conv_id) + * @param offset 偏移 + * @param limit 条数 + */ + @Select("") + List selectMessagePage(@Param("tenantId") String tenantId, + @Param("convType") Integer convType, + @Param("distStatus") Integer distStatus, + @Param("dateFrom") OffsetDateTime dateFrom, + @Param("dateTo") OffsetDateTime dateTo, + @Param("keyword") String keyword, + @Param("offset") int offset, + @Param("limit") int limit); + + /** + * 计数(与 selectMessagePage 同条件,用于分页总数) + */ + @Select("") + long countMessages(@Param("tenantId") String tenantId, + @Param("convType") Integer convType, + @Param("distStatus") Integer distStatus, + @Param("dateFrom") OffsetDateTime dateFrom, + @Param("dateTo") OffsetDateTime dateTo, + @Param("keyword") String keyword); } diff --git a/src/main/resources/templates/_macros.ftl b/src/main/resources/templates/_macros.ftl index 2373a87..c432047 100644 --- a/src/main/resources/templates/_macros.ftl +++ b/src/main/resources/templates/_macros.ftl @@ -66,6 +66,7 @@ 跨租户授权 队列监控 用量报表 + 消息记录 老应用配置 数据迁移 diff --git a/src/main/resources/templates/messages.ftl b/src/main/resources/templates/messages.ftl new file mode 100644 index 0000000..67e71f8 --- /dev/null +++ b/src/main/resources/templates/messages.ftl @@ -0,0 +1,107 @@ +<#-- + 消息记录列表页 + 数据源 im_message(按月分区),默认最近 7 天 + 已分发(dist_status=1)。 + 带时间窗走分区裁剪;时间已由 Controller 预格式化(msgTimeStr/msgTimeIso)。 +--> +<#import "_macros.ftl" as m> +<@m.layout active="messages" title="消息记录"> +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + 重置 +
+ +

共 ${total!0} 条,第 ${page!1}/${totalPages!0} 页(默认最近 7 天 + 已分发)

+ + <#if rows?has_content> + + + + + + + + <#list rows as r> + + + + + + + + + + + + + + + +
时间租户类型会话发送方接收方/群消息类型分发状态来源跨租户操作
${r.msgTimeStr!}${r.tenantId!}<#if r.convType?? && r.convType==1>C2C<#elseif r.convType?? && r.convType==2>群<#else>-${r.convId!}${r.fromAccount!}<#if r.convType?? && r.convType==2>${r.groupId!}<#else>${r.toAccount!}${r.msgType!} + <#if r.distStatus?? && r.distStatus==1>已分发 + <#elseif r.distStatus?? && r.distStatus==0>待分发 + <#elseif r.distStatus?? && r.distStatus==2>失败 + <#else>- + ${r.source!}<#if r.isCrossTenant?? && r.isCrossTenant>是<#else>- + 详情 +
+ +
+ + + + + + + +
+
+ <#if (page!1) gt 1> + + + 第 ${page!1} 页 + <#if (page!1) lt (totalPages!0)> + + +
+ <#else> +
无符合条件的消息
+ + diff --git a/src/main/resources/templates/messages_detail.ftl b/src/main/resources/templates/messages_detail.ftl new file mode 100644 index 0000000..c526743 --- /dev/null +++ b/src/main/resources/templates/messages_detail.ftl @@ -0,0 +1,77 @@ +<#-- + 消息详情页 + 基本信息(im_message 完整字段,时间由 Controller 预格式化)+ msg_body 原始 JSON + 分发链路(dist_queue) +--> +<#import "_macros.ftl" as m> +<@m.layout active="messages" title="消息详情"> +
+ ← 返回列表 +
+ + <#if msg??> +
+

基本信息

+ + + + + + + + + + + + + + + + +
MsgKey${msg.msgKey!}
消息时间${msgTimeStr!}
租户${msg.tenantId!}
会话类型<#if msg.convType?? && msg.convType==1>C2C<#elseif msg.convType?? && msg.convType==2>群<#else>-
会话ID${msg.convId!}
发送方${msg.fromAccount!}
接收方${msg.toAccount!}
群ID${msg.groupId!}
消息类型${msg.msgType!}
来源${msg.source!}
跨租户<#if msg.isCrossTenant?? && msg.isCrossTenant>是<#else>否
分发状态 + <#if msg.distStatus?? && msg.distStatus==1>已分发 + <#elseif msg.distStatus?? && msg.distStatus==0>待分发 + <#elseif msg.distStatus?? && msg.distStatus==2>失败 + <#else>- +
入库时间${msgCreatedStr!}
+
+ +
+

消息体(msg_body)

+
${msg.msgBody!}
+
+ +
+

分发链路(dist_queue,最多 100 条)

+ <#if dists?has_content> + + + + + + <#list dists as d> + + + + + + + + + + + +
ID目标地址租户状态重试创建时间更新时间
${d.id!}${d.targetUrl!}${d.tenantId!} + <#if d.status?? && d.status==0>待分发 + <#elseif d.status?? && d.status==1>处理中 + <#elseif d.status?? && d.status==2>已分发 + <#elseif d.status?? && d.status==3>死信 + <#else>- + ${d.retryCount!}${d.createdStr!}${d.updatedStr!}
+ <#else> +
无分发记录
+ +
+ <#else> +
消息不存在或参数缺失
+ +