Files
tencent-im-util/src/main/java/com/imutil/controller/AdminController.java
T
连龙刚andClaude 4ce030aa74 feat(t16): 消息记录查看(列表+筛选+分页+详情+分发链路)
- ImMessageMapper: +selectMessagePage/+countMessages(动态条件+LIMIT/OFFSET,带时间窗分区裁剪)
- DistQueueMapper: +selectByMsgKey(详情关联分发链路,LIMIT 100)
- AdminController: GET /admin/messages(列表+筛选+分页,默认近7天已分发)+ /admin/messages/detail
- messages.ftl/messages_detail.ftl: 列表+筛选+分页 / 详情+msg_body+dist_queue链路
- _macros.ftl: 侧边栏加消息记录导航
- 时间由Controller预格式化(FreeMarker对java.time支持不稳),msgTimeIso用纳秒精度匹配DB微秒

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 17:22:31 +08:00

686 lines
27 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.imutil.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.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;
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;
/**
* 管理后台控制器
* <p>
* 路由前缀 /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 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;
// ==================== 登录 / 登出 ====================
@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 {
// 当前登录用户IDSa-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.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 0)));
mv.put("queueDead", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3)));
mv.put("grantActive", grantMapper.selectCount(Wrappers.<CrossTenantGrant>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 {
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) {
tenantMapper.insert(t);
} else {
tenantMapper.updateById(t);
}
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);
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) {
ModelAndView mv = view("queue.ftl", "队列监控", "queue");
mv.put("pendings", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 0)
.orderByAsc(DistQueue::getId)
.last("LIMIT 50")));
mv.put("deads", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3)
.orderByDesc(DistQueue::getId)
.last("LIMIT 50")));
mv.put("deadCount", distQueueMapper.selectCount(Wrappers.<DistQueue>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() {
ModelAndView mv = view("usage.ftl", "用量报表", "usage");
mv.put("stats", usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery()
.orderByDesc(UsageStat::getStatTime)
.last("LIMIT 100")));
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=-1doCallApi 兜底)→失败;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<String> 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<String> 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<String[]> 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<String> 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<String> 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<String[]> 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]});
}
}
}
// ==================== 公共:构造页面模型 ====================
/**
* 构造带侧边栏布局的 ModelAndView
*
* @param tpl 模板名(如 home.ftl
* @param title 页面标题
* @param active 侧边栏高亮项(home/tenant/grant/queue/usage
*/
// ==================== 消息记录 ====================
/**
* 消息记录列表(已分发消息查询)
* <p>
* 默认最近 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<ImMessage> 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<Map<String, Object>> rows = new ArrayList<>();
if (rawRows != null) {
for (ImMessage r : rawRows) {
Map<String, Object> 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.<ImMessage>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<DistQueue> rawDists = distQueueMapper.selectByMsgKey(msgKey);
List<Map<String, Object>> dists = new ArrayList<>();
if (rawDists != null) {
for (DistQueue d : rawDists) {
Map<String, Object> 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;
}
}