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,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
|
||||
@@ -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. 分发队列(替代 MQ,FOR 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:hash(PBKDF2,见 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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(首次启动初始化,请尽快修改密码)。死信 > 0 时请到「队列监控」处理。</p>
|
||||
</@m.layout>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user