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