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