feat(t17): 数据同步——只读拉取群组/群成员/群消息到本地表,按租户查看
主体(T17 数据同步,/admin/sync 三个独立按钮): - 同步群组:get_appid_group_list 全量 Next 分页 + 逐群 get_group_info → group_mapping - 同步群成员(→用户):遍历该租户群取 MemberList → user_mapping(腾讯无全量用户API,靠群成员反推) - 同步群消息:遍历该租户群 getGroupMsg + IsFinished 滚动全量 → im_message(source=SYNC) - DB: group_mapping 加 name/owner_account/member_count/last_synced_at;user_mapping 加 nick/last_synced_at (init.sql 建表 + ADD COLUMN IF NOT EXISTS 老库升级补丁,幂等) - TencentImClient: 新增 getAppidGroupList(limit,next[,sdkAppId,secretKey]) - 已知限制:C2C单聊无全量会话API;超大群成员需换 get_group_member_info 分页 附带收尾此前未提交的改动: - 回调字段名修正(FromAccount→From_Account 等腾讯标准字段) + pickMsgRandom/Time/Type 兼容字段差异 - 租户识别重构:前缀经 TenantService.getByPrefixCode 反查 tenantId(主键雪花化与前缀解耦) - FreeMarker java.time ?string 坑修复(usage/queue Controller 预格式化) + 消息记录"全部"状态修复 - 新增项目 CLAUDE.md + .claude/memory 基建(gitignore 含密钥记忆,不进 git) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS tenant (
|
||||
CONSTRAINT pk_tenant PRIMARY KEY (tenant_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_app_key ON tenant(app_key);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_prefix ON tenant(prefix_code);
|
||||
|
||||
-- ===== 2. 用户映射(业务用户 ↔ IM 用户) =====
|
||||
CREATE TABLE IF NOT EXISTS user_mapping (
|
||||
@@ -29,6 +30,8 @@ CREATE TABLE IF NOT EXISTS user_mapping (
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
is_global boolean NOT NULL DEFAULT false,
|
||||
status smallint NOT NULL DEFAULT 1, -- 1=正常 0=封禁
|
||||
nick varchar(128), -- 用户昵称(T17 数据同步拉取自群成员资料)
|
||||
last_synced_at timestamptz, -- 最近一次同步时间(T17)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_user_mapping PRIMARY KEY (id)
|
||||
);
|
||||
@@ -42,6 +45,10 @@ CREATE TABLE IF NOT EXISTS group_mapping (
|
||||
biz_group_id varchar(128) NOT NULL,
|
||||
im_group_id varchar(128) NOT NULL,
|
||||
group_type varchar(16) NOT NULL DEFAULT 'Public',
|
||||
name varchar(256), -- 群名称(T17 数据同步拉取)
|
||||
owner_account varchar(128), -- 群主账号(T17 数据同步拉取)
|
||||
member_count int, -- 群成员数(T17 数据同步拉取)
|
||||
last_synced_at timestamptz, -- 最近一次同步时间(T17)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_group_mapping PRIMARY KEY (id)
|
||||
);
|
||||
@@ -243,3 +250,11 @@ CREATE TABLE IF NOT EXISTS migrate_task (
|
||||
CONSTRAINT pk_migrate_task PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_migrate_task_app ON migrate_task(source_app_id, object_type, status);
|
||||
|
||||
-- ===== T17 数据同步:老库升级补丁(ADD COLUMN IF NOT EXISTS,幂等可重跑) =====
|
||||
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS name varchar(256);
|
||||
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS owner_account varchar(128);
|
||||
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS member_count int;
|
||||
ALTER TABLE group_mapping ADD COLUMN IF NOT EXISTS last_synced_at timestamptz;
|
||||
ALTER TABLE user_mapping ADD COLUMN IF NOT EXISTS nick varchar(128);
|
||||
ALTER TABLE user_mapping ADD COLUMN IF NOT EXISTS last_synced_at timestamptz;
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
<a href="${basePath}/admin/messages" class="nav-item <#if active=='messages'>active</#if>">消息记录</a>
|
||||
<a href="${basePath}/admin/sourceapp" class="nav-item <#if active=='sourceapp'>active</#if>">老应用配置</a>
|
||||
<a href="${basePath}/admin/migrate" class="nav-item <#if active=='migrate'>active</#if>">数据迁移</a>
|
||||
<a href="${basePath}/admin/sync" class="nav-item <#if active=='sync'>active</#if>">数据同步</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="${basePath}/admin/password" class="nav-item" style="border-left:none">修改密码</a>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<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>
|
||||
<td>${(q.nextRetryAtStr)!}</td>
|
||||
</tr>
|
||||
</#list>
|
||||
</tbody>
|
||||
|
||||
@@ -41,7 +41,16 @@
|
||||
<span class="badge badge-no">停用</span>
|
||||
</#if>
|
||||
</td>
|
||||
<td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-sm btn-primary"
|
||||
data-id="${a.id!}"
|
||||
data-app-name="${(a.appName!'')?html}"
|
||||
data-sdk-app-id="${(a.sdkAppId!'')?html}"
|
||||
data-secret-key="${(a.secretKey!'')?html}"
|
||||
data-tenant-id="${(a.tenantId!'')?html}"
|
||||
data-remark="${(a.remark!'')?html}"
|
||||
data-status="${(a.status!'')?html}"
|
||||
onclick="openEditApp(this)">编辑</button>
|
||||
<form method="post" action="${basePath}/admin/sourceapp/toggle" style="display:inline">
|
||||
<input type="hidden" name="id" value="${a.id!}">
|
||||
<#if a.status?? && a.status == 1>
|
||||
@@ -78,7 +87,7 @@
|
||||
<select name="tenantId" required>
|
||||
<option value="">请选择租户</option>
|
||||
<#list tenants as t>
|
||||
<option value="${t.tenantId!}">${t.tenantId!}(${t.tenantName!},前缀 ${t.prefixCode!})</option>
|
||||
<option value="${t.tenantId!}">${t.prefixCode!}(${t.tenantName!})</option>
|
||||
</#list>
|
||||
</select>
|
||||
</div>
|
||||
@@ -91,4 +100,52 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<#-- 编辑弹窗:主键 id 自增 hidden 携带;secretKey 回填明文(与租户 appSecret 一致) -->
|
||||
<div id="editAppModal" 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/sourceapp/save">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="eaId">
|
||||
<div class="form-group"><label>应用名称</label><input name="appName" id="eaAppName" required></div>
|
||||
<div class="form-group"><label>SDKAppID(老应用)</label><input name="sdkAppId" id="eaSdkAppId" required></div>
|
||||
<div class="form-group"><label>SecretKey(老应用密钥)</label><input name="secretKey" id="eaSecretKey" required></div>
|
||||
<div class="form-group">
|
||||
<label>对应租户(决定 UserID 前缀)</label>
|
||||
<select name="tenantId" id="eaTenantId" required>
|
||||
<option value="">请选择租户</option>
|
||||
<#list tenants as t>
|
||||
<option value="${t.tenantId!}">${t.prefixCode!}(${t.tenantName!})</option>
|
||||
</#list>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>备注</label><input name="remark" id="eaRemark"></div>
|
||||
<div class="form-group"><label>状态</label>
|
||||
<select name="status" id="eaStatus">
|
||||
<option value="1">启用</option>
|
||||
<option value="0">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('editAppModal').classList.remove('show')">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function openEditApp(b){
|
||||
var d=b.dataset;
|
||||
document.getElementById('eaId').value=d.id||'';
|
||||
document.getElementById('eaAppName').value=d.appName||'';
|
||||
document.getElementById('eaSdkAppId').value=d.sdkAppId||'';
|
||||
document.getElementById('eaSecretKey').value=d.secretKey||'';
|
||||
document.getElementById('eaTenantId').value=d.tenantId||'';
|
||||
document.getElementById('eaRemark').value=d.remark||'';
|
||||
document.getElementById('eaStatus').value=d.status||1;
|
||||
document.getElementById('editAppModal').classList.add('show');
|
||||
}
|
||||
</script>
|
||||
</@m.layout>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<#import "_macros.ftl" as m>
|
||||
<@m.layout active="sync" title="数据同步">
|
||||
<#if msg?has_content>
|
||||
<div class="alert">
|
||||
<#if msg=='sgdone'>✅ 群组同步完成(详见任务记录)
|
||||
<#elseif msg=='sudone'>✅ 群成员/用户同步完成(详见任务记录)
|
||||
<#elseif msg=='smdone'>✅ 群消息同步完成(详见任务记录)
|
||||
<#elseif msg=='syncopt'>❌ 请选择数据源(主应用时须同时选归属租户)
|
||||
<#else>${msg}
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<div class="stat-card" style="margin-bottom:14px;padding:12px 18px">
|
||||
<b>说明</b>
|
||||
<span class="muted">:本页为<b>只读拉取</b>,把腾讯 IM 数据同步到本地表用于查看,不在腾讯侧建群/建号。
|
||||
<b>群组</b>=get_appid_group_list 全量分页;<b>群成员(用户)</b>=遍历群取成员(腾讯无全量用户接口,靠群成员反推);<b>群消息</b>=逐群全量拉取。
|
||||
<b>C2C 单聊消息无全量会话列表接口</b>,历史单聊请用「数据迁移」页或依赖回调增量。</span>
|
||||
</div>
|
||||
|
||||
<#-- 公共:数据源 + 归属租户选择(三个表单各自携带) -->
|
||||
<#macro srcSelect>
|
||||
<div class="form-group">
|
||||
<label>数据源</label>
|
||||
<select name="sourceAppId">
|
||||
<option value="">主应用(app.yml 当前配置)</option>
|
||||
<#list apps as a>
|
||||
<option value="${a.id!}">${a.appName!}(SDK ${a.sdkAppId!} → 租户 ${a.tenantId!})</option>
|
||||
</#list>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>归属租户 <span class="muted">(选「主应用」时必选;选老应用时自动取其配置租户,此项忽略)</span></label>
|
||||
<select name="tenantId">
|
||||
<option value="">请选择租户</option>
|
||||
<#list tenants as t>
|
||||
<option value="${t.tenantId!}">${t.tenantName!}(${t.tenantId!})</option>
|
||||
</#list>
|
||||
</select>
|
||||
</div>
|
||||
</#macro>
|
||||
|
||||
<div class="stat-card" style="margin-bottom:20px">
|
||||
<div class="modal-title">① 同步群组</div>
|
||||
<p class="muted">拉取数据源应用的所有群(get_appid_group_list 全量分页)+ 逐群 get_group_info(名/类型/群主/成员数)→ 写 group_mapping。幂等可重跑。</p>
|
||||
<form method="post" action="${basePath}/admin/sync/groups" style="margin-top:14px">
|
||||
<div class="modal-body"><@srcSelect/></div>
|
||||
<button type="submit" class="btn btn-primary">同步群组</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stat-card" style="margin-bottom:20px">
|
||||
<div class="modal-title">② 同步群成员(→ 用户)</div>
|
||||
<p class="muted">遍历该租户已同步的群,逐群取成员列表 → 写 user_mapping。<b>请先执行「同步群组」</b>,否则无群可遍历。</p>
|
||||
<form method="post" action="${basePath}/admin/sync/users" style="margin-top:14px">
|
||||
<div class="modal-body"><@srcSelect/></div>
|
||||
<button type="submit" class="btn btn-primary">同步群成员</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stat-card" style="margin-bottom:20px">
|
||||
<div class="modal-title">③ 同步群消息</div>
|
||||
<p class="muted">遍历该租户已同步的群,逐群 getGroupMsg + IsFinished 滚动全量 → 写 im_message(source=SYNC,不进分发队列)。<b>请先执行「同步群组」</b>。</p>
|
||||
<form method="post" action="${basePath}/admin/sync/groupmsg" style="margin-top:14px">
|
||||
<div class="modal-body"><@srcSelect/></div>
|
||||
<button type="submit" class="btn btn-primary">同步群消息</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:20px 0 10px;font-size:15px;color:#2c3e50">同步/迁移任务记录</h3>
|
||||
<#if tasks?has_content>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>源应用</th><th>对象</th><th>进度(已处理/总数)</th><th>状态</th><th>校验结果</th><th>开始时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<#list tasks as t>
|
||||
<tr>
|
||||
<td>${t.id!}</td>
|
||||
<td><#if t.sourceAppId?? && t.sourceAppId==0>主应用<#else>${t.sourceAppId!}</#if></td>
|
||||
<td>${t.objectType!}</td>
|
||||
<td>${t.processedCount!'0'}/${t.totalCount!'0'}</td>
|
||||
<td>
|
||||
<#if t.status?? && t.status==3><span class="badge badge-ok">完成</span>
|
||||
<#elseif t.status?? && t.status==1><span class="badge badge-warn">运行</span>
|
||||
<#elseif t.status?? && t.status==4><span class="badge badge-no">失败</span>
|
||||
<#else><span class="badge">待</span>
|
||||
</#if>
|
||||
</td>
|
||||
<td class="muted">${t.verifyResult!'-'}</td>
|
||||
<td class="muted">${t.startedAt!}</td>
|
||||
</tr>
|
||||
</#list>
|
||||
</tbody>
|
||||
</table>
|
||||
<#else>
|
||||
<div class="empty">暂无任务</div>
|
||||
</#if>
|
||||
</@m.layout>
|
||||
@@ -10,14 +10,13 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>租户ID</th><th>名称</th><th>前缀</th><th>AppKey</th><th>AppSecret</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>
|
||||
@@ -31,7 +30,18 @@
|
||||
<span class="badge badge-no">停用</span>
|
||||
</#if>
|
||||
</td>
|
||||
<td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-sm btn-primary"
|
||||
data-tenant-id="${(t.tenantId!'')?html}"
|
||||
data-tenant-name="${(t.tenantName!'')?html}"
|
||||
data-prefix-code="${(t.prefixCode!'')?html}"
|
||||
data-app-key="${(t.appKey!'')?html}"
|
||||
data-app-secret="${(t.appSecret!'')?html}"
|
||||
data-callback-url="${(t.callbackUrl!'')?html}"
|
||||
data-quota-im-qps="${(t.quotaImQps!'')?html}"
|
||||
data-quota-trtc-concurrent="${(t.quotaTrtcConcurrent!'')?html}"
|
||||
data-status="${(t.status!'')?html}"
|
||||
onclick="openEdit(this)">编辑</button>
|
||||
<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>
|
||||
@@ -56,7 +66,7 @@
|
||||
<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>前缀码(IM 账号前缀,如 sa,全局唯一)</label><input name="prefixCode" 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>
|
||||
@@ -71,4 +81,48 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<#-- 编辑弹窗:主键 tenantId 只读(readonly 仍随表单提交,供 updateById 定位);其余字段全部放开 -->
|
||||
<div id="editModal" 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">
|
||||
<input type="hidden" name="tenantId" id="eTenantId">
|
||||
<div class="form-group"><label>租户名称</label><input name="tenantName" id="eTenantName"></div>
|
||||
<div class="form-group"><label>前缀码</label><input name="prefixCode" id="ePrefixCode"></div>
|
||||
<div class="form-group"><label>AppKey</label><input name="appKey" id="eAppKey" required></div>
|
||||
<div class="form-group"><label>AppSecret</label><input name="appSecret" id="eAppSecret" required></div>
|
||||
<div class="form-group"><label>回调URL</label><input name="callbackUrl" id="eCallbackUrl"></div>
|
||||
<div class="form-group"><label>IM QPS 配额</label><input name="quotaImQps" type="number" id="eQuotaImQps"></div>
|
||||
<div class="form-group"><label>TRTC 并发房间配额</label><input name="quotaTrtcConcurrent" type="number" id="eQuotaTrtc"></div>
|
||||
<div class="form-group"><label>状态</label>
|
||||
<select name="status" id="eStatus">
|
||||
<option value="1">启用</option>
|
||||
<option value="0">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('editModal').classList.remove('show')">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function openEdit(b){
|
||||
var d=b.dataset;
|
||||
document.getElementById('eTenantId').value=d.tenantId||'';
|
||||
document.getElementById('eTenantName').value=d.tenantName||'';
|
||||
document.getElementById('ePrefixCode').value=d.prefixCode||'';
|
||||
document.getElementById('eAppKey').value=d.appKey||'';
|
||||
document.getElementById('eAppSecret').value=d.appSecret||'';
|
||||
document.getElementById('eCallbackUrl').value=d.callbackUrl||'';
|
||||
document.getElementById('eQuotaImQps').value=d.quotaImQps||50;
|
||||
document.getElementById('eQuotaTrtc').value=d.quotaTrtcConcurrent||0;
|
||||
document.getElementById('eStatus').value=d.status||1;
|
||||
document.getElementById('editModal').classList.add('show');
|
||||
}
|
||||
</script>
|
||||
</@m.layout>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<#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>${(s.statTimeStr)!}</td>
|
||||
<td><#if s.statLevel?? && s.statLevel == 1>小时<#else>天</#if></td>
|
||||
<td>${s.imMsgCount!0}</td>
|
||||
<td>${s.imDau!0}</td>
|
||||
|
||||
Reference in New Issue
Block a user