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; /** * 管理后台控制器 *

* 路由前缀 /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 { // 当前登录用户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 { 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.lambdaQuery() .eq(DistQueue::getStatus, 0) .orderByAsc(DistQueue::getId) .last("LIMIT 50"))); 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() { ModelAndView mv = view("usage.ftl", "用量报表", "usage"); mv.put("stats", usageStatMapper.selectList(Wrappers.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=-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]}); } } } // ==================== 公共:构造页面模型 ==================== /** * 构造带侧边栏布局的 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(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); mv.put("pageTitle", title); mv.put("active", active); return mv; } }