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:
yixiong
2026-07-08 20:28:32 +08:00
co-authored by Claude
parent 20bd78baa8
commit d22fdcfaad
80 changed files with 4955 additions and 0 deletions
@@ -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;
}
}