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
+82
View File
@@ -0,0 +1,82 @@
<#--
管理后台公共布局宏
用法:<#import "_macros.ftl" as m><@m.layout active="home" title="仪表盘">页面内容</@m.layout>
对齐 yxtech:左侧边栏 + 右侧主区域,内嵌 CSS(无外部框架)。
-->
<#macro layout active title>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} - IM分发工具管理后台</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif; display:flex; height:100vh; overflow:hidden; color:#333; }
.sidebar { width:200px; flex-shrink:0; background:#2c3e50; display:flex; flex-direction:column; }
.sidebar-brand { padding:18px; font-size:13px; font-weight:600; color:#fff; line-height:1.5; border-bottom:1px solid rgba(255,255,255,.1); }
.sidebar-nav { flex:1; padding:10px 0; }
.nav-item { display:block; color:rgba(255,255,255,.72); text-decoration:none; padding:11px 20px; font-size:14px; border-left:3px solid transparent; }
.nav-item:hover { background:rgba(255,255,255,.08); color:#fff; }
.nav-item.active { background:rgba(74,144,226,.18); color:#fff; border-left-color:#4a90e2; }
.sidebar-footer { border-top:1px solid rgba(255,255,255,.1); }
.logout-link { display:block; color:rgba(248,165,165,.85); text-decoration:none; padding:10px 20px; font-size:14px; }
.main-wrapper { flex:1; display:flex; flex-direction:column; min-width:0; overflow:hidden; }
.main-header { height:50px; background:#fff; border-bottom:1px solid #e8e8e8; padding:0 24px; display:flex; align-items:center; }
.page-title { font-size:15px; font-weight:600; color:#2c3e50; }
.content { flex:1; overflow-y:auto; padding:20px 24px; background:#f8f9fa; }
.alert { padding:11px 16px; border-radius:6px; margin-bottom:16px; font-size:14px; background:#d4edda; border:1px solid #c3e6cb; color:#155724; }
.alert-warn { background:#fff3cd; border-color:#ffeaa7; color:#856404; }
.stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
.stat-card { background:#fff; border-radius:8px; padding:20px; box-shadow:0 1px 6px rgba(0,0,0,.07); }
.stat-card .label { font-size:13px; color:#888; }
.stat-card .value { font-size:28px; font-weight:600; color:#2c3e50; margin-top:6px; }
.data-table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 6px rgba(0,0,0,.07); font-size:14px; }
.data-table th,.data-table td { padding:11px 14px; text-align:left; border-bottom:1px solid #f0f0f0; }
.data-table th { background:#f5f6fa; font-weight:600; color:#495057; font-size:13px; }
.data-table tr:hover td { background:#fafbff; }
.btn { display:inline-block; padding:7px 15px; font-size:14px; border-radius:6px; cursor:pointer; border:none; text-decoration:none; }
.btn-primary { background:#4a90e2; color:#fff; }
.btn-secondary { background:#6c757d; color:#fff; }
.btn-danger { background:#dc3545; color:#fff; }
.btn-success { background:#28a745; color:#fff; }
.btn-sm { padding:4px 9px; font-size:12px; }
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; }
.badge-ok { background:#d1e7dd; color:#0f5132; }
.badge-no { background:#f8d7da; color:#842029; }
.badge-warn { background:#fff3cd; color:#856404; }
.modal { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,.5); align-items:center; justify-content:center; z-index:1000; }
.modal.show { display:flex; }
.modal-content { background:#fff; border-radius:10px; padding:24px 30px; width:480px; max-height:90vh; overflow-y:auto; }
.modal-title { font-size:16px; font-weight:600; margin-bottom:18px; }
.modal-body .form-group { margin-bottom:14px; }
.modal-body label { display:block; font-size:13px; color:#666; margin-bottom:6px; }
.modal-body input,.modal-body select { width:100%; padding:9px 12px; border:1px solid #ddd; border-radius:6px; font-size:14px; }
.modal-footer { display:flex; gap:10px; justify-content:flex-end; margin-top:18px; }
.muted { color:#999; font-size:12px; }
.empty { text-align:center; padding:30px; color:#aaa; font-size:14px; }
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">IM分发工具<br>管理后台</div>
<nav class="sidebar-nav">
<a href="${basePath}/admin/home" class="nav-item <#if active=='home'>active</#if>">仪表盘</a>
<a href="${basePath}/admin/tenant" class="nav-item <#if active=='tenant'>active</#if>">租户管理</a>
<a href="${basePath}/admin/grant" class="nav-item <#if active=='grant'>active</#if>">跨租户授权</a>
<a href="${basePath}/admin/queue" class="nav-item <#if active=='queue'>active</#if>">队列监控</a>
<a href="${basePath}/admin/usage" class="nav-item <#if active=='usage'>active</#if>">用量报表</a>
</nav>
<div class="sidebar-footer">
<a href="${basePath}/admin/logout" class="logout-link">退出登录</a>
</div>
</aside>
<div class="main-wrapper">
<header class="main-header"><span class="page-title">${title}</span></header>
<div class="content">
<#nested>
</div>
</div>
</body>
</html>
</#macro>
+74
View File
@@ -0,0 +1,74 @@
<#import "_macros.ftl" as m>
<@m.layout active="grant" title="跨租户授权">
<#if msg?has_content>
<div class="alert">${msg}</div>
</#if>
<div style="margin-bottom:14px">
<button class="btn btn-success" onclick="document.getElementById('addModal').classList.add('show')"> 新增授权</button>
<span class="muted" style="margin-left:10px">用于放行跨租户通讯(发消息/加好友/加群),空用户表示任意账户/全员</span>
</div>
<#if grants?has_content>
<table class="data-table">
<thead>
<tr><th>授权ID</th><th>源租户</th><th>源用户</th><th>目标租户</th><th>目标用户</th><th>权限</th><th>方向</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<#list grants as g>
<tr>
<td>${g.grantId?c}</td>
<td>${g.fromTenantId!}</td>
<td>${g.fromImUserId!'(任意)'}</td>
<td>${g.toTenantId!}</td>
<td>${g.toImUserId!'(全员)'}</td>
<td>${g.permissions!}</td>
<td><#if g.direction?? && g.direction == 1>双向<#else>单向</#if></td>
<td>
<#if g.status?? && g.status == 1><span class="badge badge-ok">生效</span>
<#else><span class="badge badge-no">撤销</span></#if>
</td>
<td>
<#if g.status?? && g.status == 1>
<form method="post" action="${basePath}/admin/grant/revoke" style="display:inline">
<input type="hidden" name="grantId" value="${g.grantId?c}">
<button class="btn btn-sm btn-danger">撤销</button>
</form>
</#if>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">暂无授权记录</div>
</#if>
<div id="addModal" class="modal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal-content">
<div class="modal-title">新增跨租户授权</div>
<form method="post" action="${basePath}/admin/grant/save">
<div class="modal-body">
<div class="form-group"><label>源租户</label>
<select name="fromTenantId">
<#list tenants![] as t><option value="${t.tenantId!}">${t.tenantId!} (${t.tenantName!})</option></#list>
</select>
</div>
<div class="form-group"><label>源用户(可空=任意账户)</label><input name="fromImUserId"></div>
<div class="form-group"><label>目标租户</label>
<select name="toTenantId">
<#list tenants![] as t><option value="${t.tenantId!}">${t.tenantId!} (${t.tenantName!})</option></#list>
</select>
</div>
<div class="form-group"><label>目标用户(可空=全员)</label><input name="toImUserId"></div>
<div class="form-group"><label>权限(逗号分隔:send_msg,add_friend,join_group</label><input name="permissions" value="send_msg"></div>
<div class="form-group"><label>方向</label>
<select name="direction"><option value="0">单向</option><option value="1">双向</option></select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('addModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</@m.layout>
+22
View File
@@ -0,0 +1,22 @@
<#import "_macros.ftl" as m>
<@m.layout active="home" title="仪表盘">
<div class="stat-grid">
<div class="stat-card">
<div class="label">租户数</div>
<div class="value">${tenantCount!'0'}</div>
</div>
<div class="stat-card">
<div class="label">待分发 (pending)</div>
<div class="value">${queuePending!'0'}</div>
</div>
<div class="stat-card">
<div class="label">死信 (dead)</div>
<div class="value" <#if queueDead?? && queueDead gt 0>style="color:#dc3545"</#if>>${queueDead!'0'}</div>
</div>
<div class="stat-card">
<div class="label">生效跨租户授权</div>
<div class="value">${grantActive!'0'}</div>
</div>
</div>
<p class="muted">默认账号 admin / admin123(首次启动初始化,请尽快修改密码)。死信 &gt; 0 时请到「队列监控」处理。</p>
</@m.layout>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>登录 - IM分发工具管理后台</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif; background:#f0f2f5; display:flex; align-items:center; justify-content:center; min-height:100vh; }
.login-wrapper { width:380px; }
.login-title { text-align:center; color:#2c3e50; font-size:22px; font-weight:600; margin-bottom:24px; }
.login-card { background:#fff; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,.1); padding:36px 40px; }
.form-group { margin-bottom:18px; }
.form-group label { display:block; font-size:13px; color:#666; margin-bottom:6px; }
.form-group input { width:100%; padding:10px 13px; border:1px solid #ddd; border-radius:6px; font-size:15px; outline:none; }
.form-group input:focus { border-color:#4a90e2; }
.btn-login { width:100%; padding:12px; background:#2c3e50; color:#fff; border:none; border-radius:6px; font-size:15px; cursor:pointer; margin-top:6px; letter-spacing:2px; }
.error-msg { background:#fff0f0; border:1px solid #ffcccc; color:#d32f2f; padding:10px 14px; border-radius:6px; font-size:14px; margin-bottom:18px; }
</style>
</head>
<body>
<div class="login-wrapper">
<div class="login-title">IM分发工具 管理后台</div>
<div class="login-card">
<#if errorMsg?has_content>
<div class="error-msg">${errorMsg}</div>
</#if>
<form method="post" action="${basePath}/admin/login">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" autofocus required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
</div>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<#import "_macros.ftl" as m>
<@m.layout active="queue" title="队列监控">
<#if msg?has_content>
<div class="alert">${msg}</div>
</#if>
<h3 style="margin-bottom:10px;font-size:15px;color:#2c3e50">待分发 (pending,最近 50 条)</h3>
<#if pendings?has_content>
<table class="data-table">
<thead><tr><th>ID</th><th>租户</th><th>会话</th><th>重试次数</th><th>下次重试时间</th></tr></thead>
<tbody>
<#list pendings as q>
<tr>
<td>${q.id?c}</td>
<td>${q.tenantId!}</td>
<td>${q.convId!}</td>
<td>${q.retryCount!0}</td>
<td><#if q.nextRetryAt??>${q.nextRetryAt?string('yyyy-MM-dd HH:mm:ss')}<#else>-</#if></td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">无待分发记录</div>
</#if>
<h3 style="margin:24px 0 10px;font-size:15px;color:#2c3e50">死信 (dead,共 ${deadCount!'0'} 条,最近 50 条)</h3>
<#if deads?has_content>
<table class="data-table">
<thead><tr><th>ID</th><th>租户</th><th>会话</th><th>重试次数</th><th>目标URL</th><th>操作</th></tr></thead>
<tbody>
<#list deads as q>
<tr>
<td>${q.id?c}</td>
<td>${q.tenantId!}</td>
<td>${q.convId!}</td>
<td>${q.retryCount!0}</td>
<td class="muted">${q.targetUrl!}</td>
<td>
<form method="post" action="${basePath}/admin/queue/redeliver" style="display:inline">
<input type="hidden" name="id" value="${q.id?c}">
<button class="btn btn-sm btn-primary">重发</button>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">无死信</div>
</#if>
</@m.layout>
+74
View File
@@ -0,0 +1,74 @@
<#import "_macros.ftl" as m>
<@m.layout active="tenant" title="租户管理">
<#if msg?has_content>
<div class="alert">${msg}</div>
</#if>
<div style="margin-bottom:14px">
<button class="btn btn-success" onclick="document.getElementById('addModal').classList.add('show')"> 新增租户</button>
</div>
<#if tenants?has_content>
<table class="data-table">
<thead>
<tr>
<th>租户ID</th><th>名称</th><th>前缀</th><th>AppKey</th><th>AppSecret</th>
<th>回调URL</th><th>IM QPS</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<#list tenants as t>
<tr>
<td>${t.tenantId!}</td>
<td>${t.tenantName!}</td>
<td>${t.prefixCode!}</td>
<td>${t.appKey!}</td>
<td class="muted">${t.appSecret!}</td>
<td class="muted">${t.callbackUrl!'-'}</td>
<td>${t.quotaImQps!'-'}</td>
<td>
<#if t.status?? && t.status == 1>
<span class="badge badge-ok">启用</span>
<#else>
<span class="badge badge-no">停用</span>
</#if>
</td>
<td>
<form method="post" action="${basePath}/admin/tenant/toggle" style="display:inline">
<input type="hidden" name="tenantId" value="${t.tenantId!}">
<#if t.status?? && t.status == 1>
<input type="hidden" name="status" value="0">
<button class="btn btn-sm btn-secondary">停用</button>
<#else>
<input type="hidden" name="status" value="1">
<button class="btn btn-sm btn-success">启用</button>
</#if>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">暂无租户,点击「新增租户」添加</div>
</#if>
<div id="addModal" class="modal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal-content">
<div class="modal-title">新增租户</div>
<form method="post" action="${basePath}/admin/tenant/save">
<div class="modal-body">
<div class="form-group"><label>租户ID(即前缀码,如 sa</label><input name="tenantId" required></div>
<div class="form-group"><label>租户名称</label><input name="tenantName"></div>
<div class="form-group"><label>AppKey(业务系统调用凭证)</label><input name="appKey" required></div>
<div class="form-group"><label>AppSecret</label><input name="appSecret" required></div>
<div class="form-group"><label>回调URL(业务系统接收分发)</label><input name="callbackUrl" placeholder="http://host:port/path"></div>
<div class="form-group"><label>IM QPS 配额</label><input name="quotaImQps" type="number" value="50"></div>
<div class="form-group"><label>TRTC 并发房间配额</label><input name="quotaTrtcConcurrent" type="number" value="0"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('addModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</@m.layout>
+25
View File
@@ -0,0 +1,25 @@
<#import "_macros.ftl" as m>
<@m.layout active="usage" title="用量报表">
<#if stats?has_content>
<table class="data-table">
<thead>
<tr><th>租户</th><th>统计时刻</th><th>粒度</th><th>IM消息数</th><th>IM DAU</th><th>API调用数</th><th>TRTC时长(秒)</th></tr>
</thead>
<tbody>
<#list stats as s>
<tr>
<td>${s.tenantId!}</td>
<td><#if s.statTime??>${s.statTime?string('yyyy-MM-dd HH:mm')}<#else>-</#if></td>
<td><#if s.statLevel?? && s.statLevel == 1>小时<#else>天</#if></td>
<td>${s.imMsgCount!0}</td>
<td>${s.imDau!0}</td>
<td>${s.apiCallCount!0}</td>
<td>${s.trtcDurationSec!0}</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">暂无用量数据(每小时 03 分自动聚合;触发 API 调用/回调后产生数据)</div>
</#if>
</@m.layout>