feat: 初始化腾讯IM分发工具骨架,含回调分发链路与历史消息补拉
- 应用骨架: Solon + MyBatis-Plus + PostgreSQL + Redis + Sa-Token - 核心链路: 回调网关 → 消息落库(幂等) → 分发队列(FOR UPDATE SKIP LOCKED) → worker消费/重试/死信 - 多租户隔离: 前缀法账号映射, 所有业务表带 tenant_id - 管理后台: FreeMarker 渲染, 租户/授权/队列/用量管理页面 - 历史消息补拉: 水位线驱动, getRoamMsg/getGroupMsg 增量拉取, 兜底回调丢失 - msg_key 统一算法(回调/补拉共享, 跨路径去重) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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.mapper.CrossTenantGrantMapper;
|
||||
import com.imutil.mapper.DistQueueMapper;
|
||||
import com.imutil.mapper.TenantMapper;
|
||||
import com.imutil.mapper.UsageStatMapper;
|
||||
import com.imutil.service.AdminUserService;
|
||||
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 UsageStatMapper usageStatMapper;
|
||||
|
||||
// ==================== 登录 / 登出 ====================
|
||||
|
||||
@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("/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;
|
||||
}
|
||||
|
||||
// ==================== 公共:构造页面模型 ====================
|
||||
|
||||
/**
|
||||
* 构造带侧边栏布局的 ModelAndView
|
||||
*
|
||||
* @param tpl 模板名(如 home.ftl)
|
||||
* @param title 页面标题
|
||||
* @param active 侧边栏高亮项(home/tenant/grant/queue/usage)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user