feat(t14-s1s2): 数据迁移基础设施 + 用户迁移
T14 数据迁移工具 S1/S2 阶段: S1 基础设施: - source_app/migrate_task 建表 + 实体 + Mapper - TencentImClient 双密钥 callApiAs(老应用拉取,不走主应用限流,审计记 migrate) - 后台「老应用配置」页(CRUD + 测试连通) S2 用户迁移: - batchImport(multiaccount_import,每批≤100) - MigrateService.migrateUsers(前缀转换+导入+映射+校验,幂等) - 后台「数据迁移」页(上传清单文件/粘贴列表 + 任务记录) 联调通过:3用户清单→加sa前缀→腾讯导入OK→user_mapping→校验3/3/3。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,20 @@ import com.imutil.entity.CrossTenantGrant;
|
||||
import com.imutil.entity.DistQueue;
|
||||
import com.imutil.entity.Tenant;
|
||||
import com.imutil.entity.UsageStat;
|
||||
import com.imutil.entity.SourceApp;
|
||||
import com.imutil.mapper.CrossTenantGrantMapper;
|
||||
import com.imutil.mapper.DistQueueMapper;
|
||||
import com.imutil.mapper.TenantMapper;
|
||||
import com.imutil.mapper.UsageStatMapper;
|
||||
import com.imutil.service.AdminUserService;
|
||||
import com.imutil.service.SourceAppService;
|
||||
import com.imutil.tencent.TencentImClient;
|
||||
import com.imutil.entity.MigrateTask;
|
||||
import com.imutil.service.MigrateService;
|
||||
import org.noear.solon.core.handle.UploadedFile;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
@@ -53,6 +62,15 @@ public class AdminController {
|
||||
@Inject
|
||||
private UsageStatMapper usageStatMapper;
|
||||
|
||||
@Inject
|
||||
private SourceAppService sourceAppService;
|
||||
|
||||
@Inject
|
||||
private TencentImClient tencentImClient;
|
||||
|
||||
@Inject
|
||||
private MigrateService migrateService;
|
||||
|
||||
// ==================== 登录 / 登出 ====================
|
||||
|
||||
@Get
|
||||
@@ -234,6 +252,121 @@ public class AdminController {
|
||||
return mv;
|
||||
}
|
||||
|
||||
// ==================== 老应用配置(数据迁移源) ====================
|
||||
|
||||
@Get
|
||||
@Mapping("/sourceapp")
|
||||
public Object sourceAppPage(@Param(defaultValue = "") String msg) {
|
||||
ModelAndView mv = view("sourceapp.ftl", "老应用配置", "sourceapp");
|
||||
mv.put("apps", sourceAppService.list());
|
||||
mv.put("tenants", tenantMapper.selectList(null));
|
||||
mv.put("msg", msg);
|
||||
return mv;
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/sourceapp/save")
|
||||
public void sourceAppSave(SourceApp app, Context ctx) throws Throwable {
|
||||
if (app.getSdkAppId() == null || app.getSdkAppId().isEmpty()) {
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=sdkappid_required");
|
||||
return;
|
||||
}
|
||||
if (app.getTenantId() == null || app.getTenantId().isEmpty()) {
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=tenant_required");
|
||||
return;
|
||||
}
|
||||
sourceAppService.save(app);
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=saved");
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/sourceapp/toggle")
|
||||
public void sourceAppToggle(@Param Long id, @Param Integer status, Context ctx) throws Throwable {
|
||||
sourceAppService.toggle(id, status);
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=toggled");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试老应用连通性:用老应用密钥调 account_import(幂等,导入 administrator,无实质副作用),
|
||||
* 验证 SDKAppId/密钥/sig 正确。腾讯返回业务级响应(非 70003)即说明 sig 鉴权通过、连通成功。
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/sourceapp/test")
|
||||
public void sourceAppTest(@Param Long id, Context ctx) throws Throwable {
|
||||
SourceApp app = sourceAppService.getById(id);
|
||||
if (app == null) {
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=notfound");
|
||||
return;
|
||||
}
|
||||
// account_import:幂等导入 administrator(主应用/老应用本就需要该管理员账号),必返 ActionStatus=OK
|
||||
String body = "{\"Identifier\":\"administrator\"}";
|
||||
String resp = tencentImClient.callApiAs("im_open_login_svc/account_import", body,
|
||||
Long.parseLong(app.getSdkAppId()), app.getSecretKey());
|
||||
// 连通判定:网络异常(ErrorCode=-1,doCallApi 兜底)→失败;70003=UserSig 错误(密钥不匹配);
|
||||
// 其余任何腾讯业务级响应都说明请求已达腾讯且 sig 鉴权通过 → 连通成功
|
||||
String msg;
|
||||
if (resp.contains("\"ErrorCode\":-1")) {
|
||||
msg = "test_fail";
|
||||
} else if (resp.contains("70003")) {
|
||||
msg = "test_sigfail";
|
||||
} else {
|
||||
msg = "test_ok";
|
||||
}
|
||||
log.info("老应用连通测试 id={} sdk={} resp={}", id, app.getSdkAppId(), resp);
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=" + msg);
|
||||
}
|
||||
|
||||
// ==================== 数据迁移(T14) ====================
|
||||
|
||||
@Get
|
||||
@Mapping("/migrate")
|
||||
public Object migratePage(@Param(defaultValue = "") String msg,
|
||||
@Param(defaultValue = "") String imported) {
|
||||
ModelAndView mv = view("migrate.ftl", "数据迁移", "migrate");
|
||||
mv.put("tasks", migrateService.listTasks());
|
||||
mv.put("apps", sourceAppService.list());
|
||||
mv.put("msg", msg);
|
||||
mv.put("imported", imported);
|
||||
return mv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户迁移:上传用户清单文件(每行一个老 UserID)或粘贴列表 → 加前缀 → 批量导入主应用 → 写映射 → 校验
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/migrate/users")
|
||||
public void migrateUsers(@Param Long sourceAppId,
|
||||
@Param(defaultValue = "") String userText,
|
||||
Context ctx) throws Throwable {
|
||||
List<String> userIds = new ArrayList<>();
|
||||
UploadedFile file = ctx.file("file");
|
||||
if (file != null) {
|
||||
parseUserIds(new String(file.getContentAsBytes(), StandardCharsets.UTF_8), userIds);
|
||||
} else if (!userText.isEmpty()) {
|
||||
parseUserIds(userText, userIds);
|
||||
}
|
||||
if (sourceAppId == null) {
|
||||
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||
return;
|
||||
}
|
||||
if (userIds.isEmpty()) {
|
||||
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||||
return;
|
||||
}
|
||||
MigrateTask task = migrateService.migrateUsers(sourceAppId, userIds);
|
||||
ctx.redirect(basePath + "/admin/migrate?msg=done&imported=" + task.getProcessedCount());
|
||||
}
|
||||
|
||||
/** 解析用户清单:每行一个 UserID,空行与 # 注释跳过 */
|
||||
private void parseUserIds(String content, List<String> out) {
|
||||
for (String line : content.split("\\r?\\n")) {
|
||||
line = line.trim();
|
||||
if (!line.isEmpty() && !line.startsWith("#")) {
|
||||
out.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公共:构造页面模型 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.imutil.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 迁移任务记录实体(支持断点续传与校验)
|
||||
* <p>
|
||||
* 一次迁移对应一条记录:按对象类型(USER/GROUP/MSG/ROSTER)从老应用迁到主应用。
|
||||
* pos_cursor 记录断点游标,任务中断后可从游标续传;verify_result 存老 vs 新条数对比。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Data
|
||||
@TableName("migrate_task")
|
||||
public class MigrateTask {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 source_app.id */
|
||||
private Long sourceAppId;
|
||||
|
||||
/** 对象类型:USER/GROUP/MSG/ROSTER */
|
||||
private String objectType;
|
||||
|
||||
/** 0=pending 1=running 2=paused 3=done 4=failed */
|
||||
private Integer status;
|
||||
|
||||
/** 应处理总数 */
|
||||
private Long totalCount;
|
||||
|
||||
/** 已处理数 */
|
||||
private Long processedCount;
|
||||
|
||||
/** 断点游标(时间戳/会话+seq,按对象类型语义不同) */
|
||||
private String posCursor;
|
||||
|
||||
/** 校验结果 JSON(老 vs 新 条数对比) */
|
||||
private String verifyResult;
|
||||
|
||||
/** 失败原因 */
|
||||
private String errorMsg;
|
||||
|
||||
private OffsetDateTime startedAt;
|
||||
|
||||
private OffsetDateTime finishedAt;
|
||||
|
||||
private OffsetDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.imutil.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 老应用配置实体(数据迁移源)
|
||||
* <p>
|
||||
* 每个老应用对应主应用一个租户(决定 UserID 前缀)。迁移时用本应用的 sdkAppId/secretKey
|
||||
* 调腾讯 API 拉取存量数据,加前缀后导入主应用。
|
||||
* <p>
|
||||
* 密钥仅本工具持有,禁止下发到业务系统。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Data
|
||||
@TableName("source_app")
|
||||
public class SourceApp {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 老应用名称 */
|
||||
private String appName;
|
||||
|
||||
/** 老应用 SDKAppID */
|
||||
private String sdkAppId;
|
||||
|
||||
/** 老应用密钥(迁移拉取用) */
|
||||
private String secretKey;
|
||||
|
||||
/** 对应主应用租户(决定 UserID 前缀),关联 tenant.tenant_id */
|
||||
private String tenantId;
|
||||
|
||||
/** 1=启用 0=停用 */
|
||||
private Integer status;
|
||||
|
||||
private String remark;
|
||||
|
||||
private OffsetDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.imutil.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imutil.entity.MigrateTask;
|
||||
|
||||
/**
|
||||
* 迁移任务记录 Mapper
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface MigrateTaskMapper extends BaseMapper<MigrateTask> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.imutil.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imutil.entity.SourceApp;
|
||||
|
||||
/**
|
||||
* 老应用配置 Mapper
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface SourceAppMapper extends BaseMapper<SourceApp> {
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.imutil.service;
|
||||
|
||||
import com.imutil.entity.MigrateTask;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 数据迁移服务(T14)
|
||||
* <p>
|
||||
* 把老应用存量数据迁到主应用:拉取/读取 → 加租户前缀 → 导入主应用 → 写映射 → 校验。
|
||||
* 各对象类型(USER/GROUP/MSG/ROSTER)分方法实现,均记 migrate_task 支持进度与校验。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface MigrateService {
|
||||
|
||||
/**
|
||||
* 用户迁移:老应用用户 ID 清单 → 加前缀 → 批量导入主应用 → 写 user_mapping → 校验。
|
||||
* <p>
|
||||
* 老应用用户清单由调用方提供(后台上传文件解析),因腾讯 IM 无「列出所有用户」API。
|
||||
* 幂等:已导入账号与已存在映射跳过,可重复执行。
|
||||
*
|
||||
* @param sourceAppId 老应用配置 id
|
||||
* @param oldUserIds 老应用用户 ID 清单
|
||||
* @return 迁移任务记录(含进度与校验结果)
|
||||
*/
|
||||
MigrateTask migrateUsers(Long sourceAppId, List<String> oldUserIds);
|
||||
|
||||
/**
|
||||
* 迁移任务列表(最近 100 条,按 id 倒序)
|
||||
*/
|
||||
List<MigrateTask> listTasks();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.imutil.service;
|
||||
|
||||
import com.imutil.entity.SourceApp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 老应用配置服务(数据迁移源)
|
||||
* <p>
|
||||
* 供管理后台 CRUD 与迁移逻辑查询老应用配置(sdkAppId/密钥/对应租户前缀)。
|
||||
* 老应用数量少,不走缓存。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface SourceAppService {
|
||||
|
||||
/** 全部老应用 */
|
||||
List<SourceApp> list();
|
||||
|
||||
/** 按 id 查 */
|
||||
SourceApp getById(Long id);
|
||||
|
||||
/** 新增或更新(id 为空则新增) */
|
||||
void save(SourceApp app);
|
||||
|
||||
/** 启用/停用 */
|
||||
void toggle(Long id, Integer status);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.imutil.common.BizException;
|
||||
import com.imutil.entity.MigrateTask;
|
||||
import com.imutil.entity.SourceApp;
|
||||
import com.imutil.entity.Tenant;
|
||||
import com.imutil.entity.UserMapping;
|
||||
import com.imutil.mapper.MigrateTaskMapper;
|
||||
import com.imutil.mapper.SourceAppMapper;
|
||||
import com.imutil.mapper.TenantMapper;
|
||||
import com.imutil.mapper.UserMappingMapper;
|
||||
import com.imutil.service.MigrateService;
|
||||
import com.imutil.tencent.TencentImClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据迁移服务实现(T14)
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MigrateServiceImpl implements MigrateService {
|
||||
|
||||
@Inject
|
||||
private SourceAppMapper sourceAppMapper;
|
||||
|
||||
@Inject
|
||||
private TenantMapper tenantMapper;
|
||||
|
||||
@Inject
|
||||
private UserMappingMapper userMappingMapper;
|
||||
|
||||
@Inject
|
||||
private MigrateTaskMapper migrateTaskMapper;
|
||||
|
||||
@Inject
|
||||
private TencentImClient tencentImClient;
|
||||
|
||||
@Override
|
||||
public MigrateTask migrateUsers(Long sourceAppId, List<String> oldUserIds) {
|
||||
SourceApp app = sourceAppMapper.selectById(sourceAppId);
|
||||
if (app == null) {
|
||||
throw new BizException("老应用不存在");
|
||||
}
|
||||
Tenant tenant = tenantMapper.selectById(app.getTenantId());
|
||||
if (tenant == null) {
|
||||
throw new BizException("关联租户不存在: " + app.getTenantId());
|
||||
}
|
||||
String prefix = tenant.getPrefixCode();
|
||||
|
||||
// 建迁移任务记录(running)
|
||||
MigrateTask task = new MigrateTask();
|
||||
task.setSourceAppId(sourceAppId);
|
||||
task.setObjectType("USER");
|
||||
task.setStatus(1);
|
||||
task.setTotalCount(oldUserIds == null ? 0L : (long) oldUserIds.size());
|
||||
task.setStartedAt(OffsetDateTime.now());
|
||||
migrateTaskMapper.insert(task);
|
||||
|
||||
try {
|
||||
// 老用户 ID 加租户前缀(对齐 user_mapping: im_user_id = prefix + '_' + biz_user_id)
|
||||
List<String> newImUserIds = new ArrayList<>();
|
||||
Map<String, String> oldToNew = new HashMap<>();
|
||||
if (oldUserIds != null) {
|
||||
for (String oldId : oldUserIds) {
|
||||
if (oldId == null || oldId.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String oldTrim = oldId.trim();
|
||||
String newId = prefix + "_" + oldTrim;
|
||||
newImUserIds.add(newId);
|
||||
oldToNew.put(oldTrim, newId);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量导入主应用(走主应用密钥+限流;幂等,已存在账号亦成功)
|
||||
int imported = tencentImClient.batchImport(newImUserIds);
|
||||
|
||||
// 写 user_mapping(幂等:同 tenant+bizUserId 已存在则跳过)
|
||||
int mapped = 0;
|
||||
for (Map.Entry<String, String> e : oldToNew.entrySet()) {
|
||||
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
|
||||
.eq(UserMapping::getTenantId, app.getTenantId())
|
||||
.eq(UserMapping::getBizUserId, e.getKey())
|
||||
.last("LIMIT 1"));
|
||||
if (exist != null) {
|
||||
continue;
|
||||
}
|
||||
UserMapping um = new UserMapping();
|
||||
um.setTenantId(app.getTenantId());
|
||||
um.setBizUserId(e.getKey());
|
||||
um.setImUserId(e.getValue());
|
||||
um.setIsDefault(false);
|
||||
um.setIsGlobal(false);
|
||||
um.setStatus(1);
|
||||
userMappingMapper.insert(um);
|
||||
mapped++;
|
||||
}
|
||||
|
||||
// 校验:老清单数 vs 导入成功数 vs 新建映射数
|
||||
task.setProcessedCount((long) imported);
|
||||
task.setStatus(3); // done
|
||||
task.setFinishedAt(OffsetDateTime.now());
|
||||
task.setVerifyResult(String.format(
|
||||
"{\"oldCount\":%d,\"imported\":%d,\"mapped\":%d}",
|
||||
oldToNew.size(), imported, mapped));
|
||||
migrateTaskMapper.updateById(task);
|
||||
log.info("用户迁移完成 sourceApp={} old={} imported={} mapped={}",
|
||||
sourceAppId, oldToNew.size(), imported, mapped);
|
||||
return task;
|
||||
} catch (Exception e) {
|
||||
task.setStatus(4); // failed
|
||||
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
task.setFinishedAt(OffsetDateTime.now());
|
||||
migrateTaskMapper.updateById(task);
|
||||
log.error("用户迁移失败 sourceApp={}", sourceAppId, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MigrateTask> listTasks() {
|
||||
return migrateTaskMapper.selectList(Wrappers.<MigrateTask>lambdaQuery()
|
||||
.orderByDesc(MigrateTask::getId)
|
||||
.last("LIMIT 100"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.imutil.entity.SourceApp;
|
||||
import com.imutil.mapper.SourceAppMapper;
|
||||
import com.imutil.service.SourceAppService;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 老应用配置服务实现
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Component
|
||||
public class SourceAppServiceImpl implements SourceAppService {
|
||||
|
||||
@Inject
|
||||
private SourceAppMapper sourceAppMapper;
|
||||
|
||||
@Override
|
||||
public List<SourceApp> list() {
|
||||
return sourceAppMapper.selectList(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SourceApp getById(Long id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return sourceAppMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(SourceApp app) {
|
||||
if (app.getId() == null) {
|
||||
if (app.getStatus() == null) {
|
||||
app.setStatus(1);
|
||||
}
|
||||
sourceAppMapper.insert(app);
|
||||
} else {
|
||||
sourceAppMapper.updateById(app);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggle(Long id, Integer status) {
|
||||
SourceApp app = new SourceApp();
|
||||
app.setId(id);
|
||||
app.setStatus(status == null ? 0 : status);
|
||||
sourceAppMapper.updateById(app);
|
||||
}
|
||||
}
|
||||
@@ -66,14 +66,7 @@ public class TencentImClient {
|
||||
private LocalCache localCache;
|
||||
|
||||
/**
|
||||
* 生成管理员 UserSig(长效,用于调后台 API)
|
||||
*/
|
||||
private String genAdminSig() {
|
||||
return UserSigUtil.genSig(sdkAppId, secretKey, adminUserId, 30L * 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 IM REST API
|
||||
* 调用主应用 IM REST API(用主应用密钥,走出站限流)
|
||||
*
|
||||
* @param command 命令路径,如 im_open_login_svc/account_import
|
||||
* @param bodyJson 请求体 JSON
|
||||
@@ -83,16 +76,38 @@ public class TencentImClient {
|
||||
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
|
||||
String tenantId = TenantContext.get();
|
||||
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
|
||||
|
||||
// 出站腾讯 API 限流:按租户配额 + 全局共享配额(对齐设计 3.2),超限抛 429
|
||||
// system(无租户上下文)用默认配额;补拉等后台任务超限由调用方 catch 跳过本轮
|
||||
rateLimiter.checkApiLimit(tid, resolveTenantQps(tid));
|
||||
return doCallApi(command, bodyJson, sdkAppId, secretKey, tid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用老应用 IM REST API(数据迁移拉取用,指定老应用密钥)
|
||||
* <p>
|
||||
* 老应用拉取消耗的是老应用自身套餐配额(非主应用),故不走主应用限流;
|
||||
* 审计 tid 记 "migrate" 便于与业务调用区分。管理员 identifier 默认 administrator
|
||||
* (腾讯各应用默认管理员账号),如某老应用管理员不同,后续扩展 source_app 字段。
|
||||
*
|
||||
* @param srcSdkAppId 老应用 SDKAppId
|
||||
* @param srcSecretKey 老应用密钥
|
||||
* @return 响应 JSON 字符串
|
||||
*/
|
||||
public String callApiAs(String command, String bodyJson, long srcSdkAppId, String srcSecretKey) {
|
||||
return doCallApi(command, bodyJson, srcSdkAppId, srcSecretKey, "migrate");
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用腾讯 API 的公共实现:生成管理员 UserSig + 拼 URL + POST + 审计落库
|
||||
* <p>
|
||||
* 限流由调用方决定:主应用 callApi 走限流,老应用 callApiAs 不走(消耗老应用配额)。
|
||||
*/
|
||||
private String doCallApi(String command, String bodyJson, long appid, String key, String tid) {
|
||||
String result;
|
||||
try {
|
||||
String adminSig = genAdminSig();
|
||||
String adminSig = UserSigUtil.genSig(appid, key, adminUserId, 30L * 86400);
|
||||
String url = "https://" + apiHost + "/v4/" + command
|
||||
+ "?sdkappid=" + sdkAppId
|
||||
+ "?sdkappid=" + appid
|
||||
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
|
||||
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
|
||||
+ "&contenttype=json&platform=10&apn=1";
|
||||
@@ -182,6 +197,35 @@ public class TencentImClient {
|
||||
return isOk(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入账号(multiaccount_import,每批 ≤ 100,主应用密钥)
|
||||
* <p>
|
||||
* 幂等:已存在账号亦成功。数据迁移用户导入用:老应用 UserID 加租户前缀后批量导入主应用。
|
||||
* 分批调用,返回成功导入数(整批 ActionStatus=OK 记为全部成功;个别非法 ID 场景后续可解析 RetItemList 精确化)。
|
||||
*
|
||||
* @param imUserIds 已加前缀的 IM 用户 ID 列表
|
||||
* @return 成功导入数
|
||||
*/
|
||||
public int batchImport(List<String> imUserIds) {
|
||||
if (imUserIds == null || imUserIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int success = 0;
|
||||
int batchSize = 100; // 腾讯 multiaccount_import 单批上限 100
|
||||
for (int i = 0; i < imUserIds.size(); i += batchSize) {
|
||||
List<String> sub = imUserIds.subList(i, Math.min(i + batchSize, imUserIds.size()));
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("Accounts", sub);
|
||||
String resp = callApi("im_open_login_svc/multiaccount_import", Jsons.stringify(body));
|
||||
if (isOk(resp)) {
|
||||
success += sub.size();
|
||||
} else {
|
||||
log.warn("批量导入失败批次 {} size={} resp={}", i / batchSize, sub.size(), resp);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号状态(只读)
|
||||
* <p>
|
||||
|
||||
@@ -211,3 +211,35 @@ CREATE TABLE IF NOT EXISTS admin_user (
|
||||
CONSTRAINT uk_admin_username UNIQUE (username)
|
||||
);
|
||||
-- 默认管理员账号由应用首次启动时按 app.yml imutil.admin 配置初始化(不在此写死密码hash)
|
||||
|
||||
-- ===== 14. 老应用配置(数据迁移源:每个老应用对应主应用一个租户前缀) =====
|
||||
CREATE TABLE IF NOT EXISTS source_app (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY,
|
||||
app_name varchar(64) NOT NULL, -- 老应用名称
|
||||
sdk_app_id varchar(32) NOT NULL, -- 老应用 SDKAppID
|
||||
secret_key varchar(128) NOT NULL, -- 老应用密钥(仅本工具持有,迁移拉取用)
|
||||
tenant_id varchar(32) NOT NULL, -- 对应主应用租户(决定 UserID 前缀),关联 tenant.tenant_id
|
||||
status smallint NOT NULL DEFAULT 1,-- 1=启用 0=停用
|
||||
remark text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_source_app PRIMARY KEY (id)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_source_app_sdk ON source_app(sdk_app_id);
|
||||
|
||||
-- ===== 15. 迁移任务记录(支持断点续传与校验) =====
|
||||
CREATE TABLE IF NOT EXISTS migrate_task (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY,
|
||||
source_app_id bigint NOT NULL, -- 关联 source_app.id
|
||||
object_type varchar(16) NOT NULL, -- USER/GROUP/MSG/ROSTER
|
||||
status smallint NOT NULL DEFAULT 0, -- 0=pending 1=running 2=paused 3=done 4=failed
|
||||
total_count bigint NOT NULL DEFAULT 0, -- 应处理总数
|
||||
processed_count bigint NOT NULL DEFAULT 0, -- 已处理数
|
||||
pos_cursor varchar(256), -- 断点游标(时间戳/会话+seq,按对象类型语义不同)
|
||||
verify_result text, -- 校验结果 JSON(老 vs 新 条数对比)
|
||||
error_msg text, -- 失败原因
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
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);
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
<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>
|
||||
<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>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="${basePath}/admin/logout" class="logout-link">退出登录</a>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<#import "_macros.ftl" as m>
|
||||
<@m.layout active="migrate" title="数据迁移">
|
||||
<#if msg?has_content>
|
||||
<div class="alert">
|
||||
<#if msg=='done'>✅ 迁移完成(成功导入 ${imported!'0'} 用户,详见任务记录)
|
||||
<#elseif msg=='empty'>❌ 用户清单为空(每行一个 UserID,# 开头为注释)
|
||||
<#elseif msg=='noapp'>❌ 请选择老应用
|
||||
<#else>${msg}
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<div class="stat-card" style="margin-bottom:20px">
|
||||
<div class="modal-title">用户迁移</div>
|
||||
<p class="muted">上传用户清单文件(txt/csv,每行一个老应用 UserID,# 开头注释)或粘贴列表,选择老应用后触发。老 UserID 将加租户前缀后批量导入主应用,并写 user_mapping。幂等可重跑。</p>
|
||||
<form method="post" action="${basePath}/admin/migrate/users" enctype="multipart/form-data" style="margin-top:14px">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>老应用</label>
|
||||
<select name="sourceAppId" required>
|
||||
<option value="">请选择老应用</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>上传用户清单文件(每行一个 UserID)</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
|
||||
<div class="form-group"><label>或粘贴用户 ID 列表(每行一个)</label><textarea name="userText" rows="6" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px"></textarea></div>
|
||||
</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>${t.sourceAppId!}</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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<#import "_macros.ftl" as m>
|
||||
<@m.layout active="sourceapp" title="老应用配置">
|
||||
<#if msg?has_content>
|
||||
<div class="alert">
|
||||
<#if msg=='saved'>保存成功
|
||||
<#elseif msg=='toggled'>状态已切换
|
||||
<#elseif msg=='test_ok'>✅ 连通成功(SDKAppId/密钥/sig 正确)
|
||||
<#elseif msg=='test_sigfail'>❌ 连通失败:UserSig 错误(70003,SDKAppId/密钥不匹配)
|
||||
<#elseif msg=='test_fail'>❌ 连通失败(详见日志)
|
||||
<#elseif msg=='sdkappid_required'>SDKAppID 必填
|
||||
<#elseif msg=='tenant_required'>对应租户必填
|
||||
<#elseif msg=='notfound'>老应用不存在
|
||||
<#else>${msg}
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
<p class="muted">数据迁移源:每个老应用对应主应用一个租户(决定 UserID 前缀)。迁移时用其 SDKAppID/密钥调腾讯 API 拉取存量数据,加前缀后导入主应用。密钥仅本工具持有。</p>
|
||||
<div style="margin-bottom:14px">
|
||||
<button class="btn btn-success" onclick="document.getElementById('addModal').classList.add('show')">➕ 新增老应用</button>
|
||||
</div>
|
||||
<#if apps?has_content>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>应用名称</th><th>SDKAppID</th><th>密钥</th><th>对应租户</th>
|
||||
<th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<#list apps as a>
|
||||
<tr>
|
||||
<td>${a.id!}</td>
|
||||
<td>${a.appName!}</td>
|
||||
<td>${a.sdkAppId!}</td>
|
||||
<td class="muted">••••••••</td>
|
||||
<td>${a.tenantId!}</td>
|
||||
<td>
|
||||
<#if a.status?? && a.status == 1>
|
||||
<span class="badge badge-ok">启用</span>
|
||||
<#else>
|
||||
<span class="badge badge-no">停用</span>
|
||||
</#if>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
<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>
|
||||
<form method="post" action="${basePath}/admin/sourceapp/test" style="display:inline">
|
||||
<input type="hidden" name="id" value="${a.id!}">
|
||||
<button class="btn btn-sm btn-primary">测试连通</button>
|
||||
</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/sourceapp/save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group"><label>应用名称</label><input name="appName" required></div>
|
||||
<div class="form-group"><label>SDKAppID(老应用)</label><input name="sdkAppId" required></div>
|
||||
<div class="form-group"><label>SecretKey(老应用密钥)</label><input name="secretKey" required></div>
|
||||
<div class="form-group">
|
||||
<label>对应租户(决定 UserID 前缀)</label>
|
||||
<select name="tenantId" required>
|
||||
<option value="">请选择租户</option>
|
||||
<#list tenants as t>
|
||||
<option value="${t.tenantId!}">${t.tenantId!}(${t.tenantName!},前缀 ${t.prefixCode!})</option>
|
||||
</#list>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>备注</label><input name="remark" placeholder="如:原XX系统IM应用"></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>
|
||||
Reference in New Issue
Block a user