diff --git a/src/main/java/com/imutil/controller/AdminController.java b/src/main/java/com/imutil/controller/AdminController.java index 7d3756f..09a3655 100644 --- a/src/main/java/com/imutil/controller/AdminController.java +++ b/src/main/java/com/imutil/controller/AdminController.java @@ -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 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 out) { + for (String line : content.split("\\r?\\n")) { + line = line.trim(); + if (!line.isEmpty() && !line.startsWith("#")) { + out.add(line); + } + } + } + // ==================== 公共:构造页面模型 ==================== /** diff --git a/src/main/java/com/imutil/entity/MigrateTask.java b/src/main/java/com/imutil/entity/MigrateTask.java new file mode 100644 index 0000000..5431af1 --- /dev/null +++ b/src/main/java/com/imutil/entity/MigrateTask.java @@ -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; + +/** + * 迁移任务记录实体(支持断点续传与校验) + *

+ * 一次迁移对应一条记录:按对象类型(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; +} diff --git a/src/main/java/com/imutil/entity/SourceApp.java b/src/main/java/com/imutil/entity/SourceApp.java new file mode 100644 index 0000000..6b2d057 --- /dev/null +++ b/src/main/java/com/imutil/entity/SourceApp.java @@ -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; + +/** + * 老应用配置实体(数据迁移源) + *

+ * 每个老应用对应主应用一个租户(决定 UserID 前缀)。迁移时用本应用的 sdkAppId/secretKey + * 调腾讯 API 拉取存量数据,加前缀后导入主应用。 + *

+ * 密钥仅本工具持有,禁止下发到业务系统。 + * + * @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; +} diff --git a/src/main/java/com/imutil/mapper/MigrateTaskMapper.java b/src/main/java/com/imutil/mapper/MigrateTaskMapper.java new file mode 100644 index 0000000..eb9e82d --- /dev/null +++ b/src/main/java/com/imutil/mapper/MigrateTaskMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/SourceAppMapper.java b/src/main/java/com/imutil/mapper/SourceAppMapper.java new file mode 100644 index 0000000..111f12e --- /dev/null +++ b/src/main/java/com/imutil/mapper/SourceAppMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/service/MigrateService.java b/src/main/java/com/imutil/service/MigrateService.java new file mode 100644 index 0000000..33634bf --- /dev/null +++ b/src/main/java/com/imutil/service/MigrateService.java @@ -0,0 +1,33 @@ +package com.imutil.service; + +import com.imutil.entity.MigrateTask; + +import java.util.List; + +/** + * 数据迁移服务(T14) + *

+ * 把老应用存量数据迁到主应用:拉取/读取 → 加租户前缀 → 导入主应用 → 写映射 → 校验。 + * 各对象类型(USER/GROUP/MSG/ROSTER)分方法实现,均记 migrate_task 支持进度与校验。 + * + * @author imutil + */ +public interface MigrateService { + + /** + * 用户迁移:老应用用户 ID 清单 → 加前缀 → 批量导入主应用 → 写 user_mapping → 校验。 + *

+ * 老应用用户清单由调用方提供(后台上传文件解析),因腾讯 IM 无「列出所有用户」API。 + * 幂等:已导入账号与已存在映射跳过,可重复执行。 + * + * @param sourceAppId 老应用配置 id + * @param oldUserIds 老应用用户 ID 清单 + * @return 迁移任务记录(含进度与校验结果) + */ + MigrateTask migrateUsers(Long sourceAppId, List oldUserIds); + + /** + * 迁移任务列表(最近 100 条,按 id 倒序) + */ + List listTasks(); +} diff --git a/src/main/java/com/imutil/service/SourceAppService.java b/src/main/java/com/imutil/service/SourceAppService.java new file mode 100644 index 0000000..f8eaec8 --- /dev/null +++ b/src/main/java/com/imutil/service/SourceAppService.java @@ -0,0 +1,28 @@ +package com.imutil.service; + +import com.imutil.entity.SourceApp; + +import java.util.List; + +/** + * 老应用配置服务(数据迁移源) + *

+ * 供管理后台 CRUD 与迁移逻辑查询老应用配置(sdkAppId/密钥/对应租户前缀)。 + * 老应用数量少,不走缓存。 + * + * @author imutil + */ +public interface SourceAppService { + + /** 全部老应用 */ + List list(); + + /** 按 id 查 */ + SourceApp getById(Long id); + + /** 新增或更新(id 为空则新增) */ + void save(SourceApp app); + + /** 启用/停用 */ + void toggle(Long id, Integer status); +} diff --git a/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java b/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java new file mode 100644 index 0000000..3ed3b6c --- /dev/null +++ b/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java @@ -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 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 newImUserIds = new ArrayList<>(); + Map 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 e : oldToNew.entrySet()) { + UserMapping exist = userMappingMapper.selectOne(Wrappers.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 listTasks() { + return migrateTaskMapper.selectList(Wrappers.lambdaQuery() + .orderByDesc(MigrateTask::getId) + .last("LIMIT 100")); + } +} diff --git a/src/main/java/com/imutil/service/impl/SourceAppServiceImpl.java b/src/main/java/com/imutil/service/impl/SourceAppServiceImpl.java new file mode 100644 index 0000000..7affdd9 --- /dev/null +++ b/src/main/java/com/imutil/service/impl/SourceAppServiceImpl.java @@ -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 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); + } +} diff --git a/src/main/java/com/imutil/tencent/TencentImClient.java b/src/main/java/com/imutil/tencent/TencentImClient.java index dad45f1..fcab12b 100644 --- a/src/main/java/com/imutil/tencent/TencentImClient.java +++ b/src/main/java/com/imutil/tencent/TencentImClient.java @@ -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(数据迁移拉取用,指定老应用密钥) + *

+ * 老应用拉取消耗的是老应用自身套餐配额(非主应用),故不走主应用限流; + * 审计 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 + 审计落库 + *

+ * 限流由调用方决定:主应用 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,主应用密钥) + *

+ * 幂等:已存在账号亦成功。数据迁移用户导入用:老应用 UserID 加租户前缀后批量导入主应用。 + * 分批调用,返回成功导入数(整批 ActionStatus=OK 记为全部成功;个别非法 ID 场景后续可解析 RetItemList 精确化)。 + * + * @param imUserIds 已加前缀的 IM 用户 ID 列表 + * @return 成功导入数 + */ + public int batchImport(List 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 sub = imUserIds.subList(i, Math.min(i + batchSize, imUserIds.size())); + Map 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; + } + /** * 检查账号状态(只读) *

diff --git a/src/main/resources/db/init.sql b/src/main/resources/db/init.sql index bee36b5..cc22944 100644 --- a/src/main/resources/db/init.sql +++ b/src/main/resources/db/init.sql @@ -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); diff --git a/src/main/resources/templates/_macros.ftl b/src/main/resources/templates/_macros.ftl index f4e3a36..f4882eb 100644 --- a/src/main/resources/templates/_macros.ftl +++ b/src/main/resources/templates/_macros.ftl @@ -66,6 +66,8 @@ 跨租户授权 队列监控 用量报表 + 老应用配置 + 数据迁移