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
+119
View File
@@ -0,0 +1,119 @@
server.port: 8080
server:
contextPath: /imutil
solon.app:
name: 'tencent-im-util'
group: 'imutil'
# 日志
solon.logging.logger:
"root":
level: INFO
solon.logging.appender:
console:
charset: GBK
pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39}%L)) : %msg%n"
file:
charset: UTF-8
pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} --- %-15([%15.15thread]) %-56(%-40.40logger{39}%L)) : %msg%n"
#=====================================数据库配置(PostgreSQL
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:postgresql://192.168.10.118:5432/tencen_im?currentSchema=public&reWriteBatchedInserts=true
driverClassName: org.postgresql.Driver
username: postgres
password: Aa135790!123
maximumPoolSize: 20
minimumIdle: 5
# MyBatis-Plus 配置
mybatis.db1:
typeAliases:
- "com.imutil.entity"
mappers:
- "com.imutil.mapper"
configuration:
logImpl: org.apache.ibatis.logging.nologging.NoLoggingImpl
globalConfig:
banner: false
# Sa-Token 配置
sa-token:
authFlag: true
token-name: imutil-token
timeout: -1
activity-timeout: -1
allow-concurrent-login: true
is-share: true
token-style: uuid
is-log: false
is-print: off
# Redis 配置
imutil.redis:
host: 10.10.10.228
port: 6379
password: "Aa135790!123"
database: 11
timeout: 2000 # 连接超时 ms
connectTimeout: 2000
# 腾讯 IM 配置(密钥仅本工具持有,禁止下发到业务系统)
imutil.tencent:
sdkAppId: 1600138798 # 主应用 SDKAppID(待填)
secretKey: "21c1616e59b4698355fa9707b24d4406c99d69be3cd5e4b95022c050ec86f935" # 主应用 SecretKey(待填)
adminUserId: "administrator" # IM 管理员账号
apiHost: "console.tim.qq.com" # IM REST API 域名
usersigExpireDays: 7 # UserSig 有效期(天)
callbackToken: "" # 回调鉴权 Token(控制台回调URL配置),为空则跳过签名校验(仅联调)
# 管理后台配置(首次启动若 admin_user 表为空,用此账号初始化)
imutil.admin:
defaultUsername: admin
defaultPassword: admin123 # 默认密码,登录后请尽快修改
# 分发工作线程配置
imutil.dispatch:
workerCount: 4 # 分发工作线程数
fetchBatch: 50 # 每轮抢占条数
pollIntervalMs: 1000 # 空闲轮询间隔
maxRetry: 5 # 最大重试次数
retryBaseMs: 2000 # 重试基础间隔(指数退避 base
lockTimeoutMin: 3 # processing 超时阈值(分钟),超时由巡检重置
# 补拉配置(历史消息兜底,由 pullCheckJob 驱动)
imutil.pull:
convsPerRound: 20 # 每轮补拉会话数
maxMsgPerConv: 20 # 每会话每轮拉取条数上限
lookbackMinutes: 30 # C2C 拉取时间窗(最近N分钟)
# 限流配置(按租户令牌桶)
imutil.ratelimit:
defaultImQps: 50 # 默认每租户 IM API QPS
globalImQps: 200 # 全局 IM API QPS 上限(腾讯限制的 80% 安全边际)
# 定时任务配置
solon.scheduling.job:
# 死信/卡死 processing 巡检:每 1 分钟
dispatchRecoverJob:
fixedDelay: 60000
zone: "+08"
enable: true
# 历史消息补拉巡检:每 5 分钟
pullCheckJob:
fixedDelay: 300000
zone: "+08"
enable: true
# 分区自动建表:每天凌晨 0:10
partitionCreateJob:
cron: "0 10 0 * * ?"
zone: "+08"
enable: true
# 用量统计聚合:每小时 03 分跑上一小时窗口(避开整点边界)
usageStatJob:
cron: "0 3 * * * ?"
zone: "+08"
enable: true
+213
View File
@@ -0,0 +1,213 @@
-- ======================================================================
-- tencent-im-util 数据库初始化脚本(PostgreSQL
-- 库: tencen_im schema: public
-- 说明: 多租户隔离,所有业务表均带 tenant_id;消息主表按月 RANGE 分区
-- ======================================================================
-- ===== 1. 租户表 =====
CREATE TABLE IF NOT EXISTS tenant (
tenant_id varchar(32) NOT NULL,
tenant_name varchar(64) NOT NULL,
app_key varchar(128) NOT NULL,
app_secret varchar(128) NOT NULL,
prefix_code varchar(16) NOT NULL,
callback_url text,
quota_im_qps int NOT NULL DEFAULT 50,
quota_trtc_concurrent int NOT NULL DEFAULT 100,
status smallint NOT NULL DEFAULT 1, -- 1=启用 0=停用
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_tenant PRIMARY KEY (tenant_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_app_key ON tenant(app_key);
-- ===== 2. 用户映射(业务用户 ↔ IM 用户) =====
CREATE TABLE IF NOT EXISTS user_mapping (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id varchar(32) NOT NULL,
biz_user_id varchar(128) NOT NULL,
im_user_id varchar(128) NOT NULL,
is_default boolean NOT NULL DEFAULT false,
is_global boolean NOT NULL DEFAULT false,
status smallint NOT NULL DEFAULT 1, -- 1=正常 0=封禁
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_user_mapping PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_tenant_biz ON user_mapping(tenant_id, biz_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_im ON user_mapping(im_user_id);
-- ===== 3. 群映射 =====
CREATE TABLE IF NOT EXISTS group_mapping (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id varchar(32) NOT NULL,
biz_group_id varchar(128) NOT NULL,
im_group_id varchar(128) NOT NULL,
group_type varchar(16) NOT NULL DEFAULT 'Public',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_group_mapping PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_group_tenant_biz ON group_mapping(tenant_id, biz_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS uk_group_im ON group_mapping(im_group_id);
-- ===== 4. 消息主表(按月 RANGE 分区) =====
CREATE TABLE IF NOT EXISTS im_message (
msg_key varchar(128) NOT NULL,
tenant_id varchar(32) NOT NULL,
msg_time timestamptz NOT NULL,
conv_type smallint NOT NULL, -- 1=C2C 2=GROUP
conv_id varchar(128) NOT NULL, -- C2C=对端账号 GROUP=群ID
from_account varchar(128),
to_account varchar(128),
group_id varchar(128),
msg_type varchar(32),
msg_body text,
source varchar(16) NOT NULL DEFAULT 'CALLBACK', -- CALLBACK/PULL_BACK/IMPORT
is_cross_tenant boolean NOT NULL DEFAULT false,
dist_status smallint NOT NULL DEFAULT 0, -- 0=待分发 1=已分发 2=失败
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_im_message PRIMARY KEY (msg_key, msg_time)
) PARTITION BY RANGE (msg_time);
-- DEFAULT 分区兜底(应用启动后由 partitionCreateJob 滚动建当月分区)
CREATE TABLE IF NOT EXISTS im_message_default PARTITION OF im_message DEFAULT;
-- 分区索引(建在父表自动传播到所有子分区)
CREATE INDEX IF NOT EXISTS idx_msg_time_brin ON im_message USING BRIN (msg_time);
CREATE INDEX IF NOT EXISTS idx_msg_tenant_conv ON im_message (tenant_id, conv_id, msg_time);
CREATE INDEX IF NOT EXISTS idx_msg_from ON im_message (from_account, msg_time);
CREATE INDEX IF NOT EXISTS idx_msg_dist ON im_message (dist_status) WHERE dist_status = 0;
-- ===== 5. 分发队列(替代 MQFOR UPDATE SKIP LOCKED 抢占消费) =====
CREATE TABLE IF NOT EXISTS dist_queue (
id bigserial,
msg_key varchar(128) NOT NULL,
tenant_id varchar(32) NOT NULL,
conv_id varchar(128),
target_url text,
payload text, -- 分发给业务系统的回调快照
status smallint NOT NULL DEFAULT 0, -- 0=pending 1=processing 2=done 3=dead
retry_count int NOT NULL DEFAULT 0,
next_retry_at timestamptz NOT NULL DEFAULT now(),
locked_by varchar(64),
locked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_dist_queue PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_dq_pending ON dist_queue (next_retry_at, id) WHERE status = 0;
CREATE INDEX IF NOT EXISTS idx_dq_processing ON dist_queue (locked_at) WHERE status = 1;
CREATE INDEX IF NOT EXISTS idx_dq_tenant ON dist_queue (tenant_id, status);
-- ===== 6. 跨租户授权 =====
CREATE TABLE IF NOT EXISTS cross_tenant_grant (
grant_id bigint GENERATED BY DEFAULT AS IDENTITY,
from_tenant_id varchar(32) NOT NULL,
from_im_user_id varchar(128), -- NULL=该租户任意账户
to_tenant_id varchar(32) NOT NULL,
to_im_user_id varchar(128), -- NULL=目标租户全员
permissions varchar(64) NOT NULL DEFAULT 'send_msg', -- send_msg,add_friend,join_group
direction smallint NOT NULL DEFAULT 0, -- 0=单向 1=双向
start_at timestamptz NOT NULL DEFAULT now(),
end_at timestamptz, -- NULL=长期有效
status smallint NOT NULL DEFAULT 1, -- 1=active 0=revoked 2=expired
approved_by_from varchar(64),
approved_by_to varchar(64),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_cross_grant PRIMARY KEY (grant_id)
);
CREATE INDEX IF NOT EXISTS idx_grant_from ON cross_tenant_grant (from_tenant_id, from_im_user_id, status);
CREATE INDEX IF NOT EXISTS idx_grant_to ON cross_tenant_grant (to_tenant_id, to_im_user_id, status);
-- ===== 7. 跨租户通讯审计 =====
CREATE TABLE IF NOT EXISTS cross_tenant_audit (
id bigserial,
grant_id bigint,
msg_key varchar(128),
from_im_user_id varchar(128),
to_im_user_id varchar(128),
action_time timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_cross_audit PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_cross_audit_time ON cross_tenant_audit (action_time);
-- ===== 8. TRTC 音视频房间 =====
CREATE TABLE IF NOT EXISTS trtc_room (
room_id bigint NOT NULL, -- 本工具统一分配(雪花)
tenant_id varchar(32) NOT NULL,
biz_room_id varchar(128),
im_group_id varchar(128), -- 关联 IM 群(可选)
status smallint NOT NULL DEFAULT 1, -- 1=进行中 0=已结束
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_trtc_room PRIMARY KEY (room_id)
);
CREATE INDEX IF NOT EXISTS idx_room_tenant_biz ON trtc_room (tenant_id, biz_room_id);
-- ===== 9. 录制文件 =====
CREATE TABLE IF NOT EXISTS recording (
file_id varchar(128) NOT NULL,
tenant_id varchar(32) NOT NULL,
room_id bigint,
cos_path text, -- 按 tenant_id 隔离目录
duration int, -- 秒
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_recording PRIMARY KEY (file_id)
);
CREATE INDEX IF NOT EXISTS idx_rec_tenant_room ON recording (tenant_id, room_id);
-- ===== 10. 用量统计(计费拆账依据) =====
CREATE TABLE IF NOT EXISTS usage_stat (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id varchar(32) NOT NULL,
stat_time timestamptz NOT NULL,
stat_level smallint NOT NULL DEFAULT 1, -- 1=小时 2=天
im_msg_count bigint NOT NULL DEFAULT 0,
im_dau bigint NOT NULL DEFAULT 0,
trtc_duration_sec bigint NOT NULL DEFAULT 0,
trtc_max_concurrent_room int NOT NULL DEFAULT 0,
api_call_count bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_usage_stat PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_usage ON usage_stat (tenant_id, stat_time, stat_level);
-- ===== 11. API 调用审计 =====
CREATE TABLE IF NOT EXISTS api_call_log (
id bigserial,
tenant_id varchar(32) NOT NULL,
api_name varchar(64) NOT NULL,
params text,
result text,
caller varchar(64),
called_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_api_call_log PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_api_tenant_time ON api_call_log (tenant_id, called_at);
CREATE INDEX IF NOT EXISTS idx_api_time ON api_call_log (called_at);
-- ===== 12. 补拉水位线(PG 持久,Redis 兜底) =====
CREATE TABLE IF NOT EXISTS pull_watermark (
tenant_id varchar(32) NOT NULL,
conv_id varchar(128) NOT NULL,
conv_type smallint NOT NULL,
last_seq bigint NOT NULL DEFAULT 0,
last_time timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_pull_watermark PRIMARY KEY (tenant_id, conv_id)
);
-- ===== 初始化默认租户(示例,可改) =====
INSERT INTO tenant (tenant_id, tenant_name, app_key, app_secret, prefix_code, callback_url)
VALUES ('sa', '系统A', 'sa_app_key_secret', 'sa_app_secret_secret', 'sa', NULL)
ON CONFLICT (tenant_id) DO NOTHING;
-- ===== 13. 管理后台用户(Sa-Token 登录) =====
CREATE TABLE IF NOT EXISTS admin_user (
id bigserial,
username varchar(64) NOT NULL,
password_hash varchar(200) NOT NULL, -- 格式 iterations:salt:hashPBKDF2,见 PasswordUtil
role varchar(16) NOT NULL DEFAULT 'admin',
status smallint NOT NULL DEFAULT 1, -- 1=启用 0=停用
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_admin_user PRIMARY KEY (id),
CONSTRAINT uk_admin_username UNIQUE (username)
);
-- 默认管理员账号由应用首次启动时按 app.yml imutil.admin 配置初始化(不在此写死密码hash)
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!-- 控制台输出(GBK,适配 Windows 终端) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<charset>GBK</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39})) : %msg%n</pattern>
</encoder>
</appender>
<!-- 文件输出(按天滚动) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/imutil.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/imutil.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} --- %-15([%15.15thread]) %-56(%-40.40logger{39}) : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<!-- 本项目包级别调试 -->
<logger name="com.imutil" level="INFO"/>
</configuration>
+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>