主体(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>
810 lines
32 KiB
Java
810 lines
32 KiB
Java
package com.imutil.controller;
|
||
|
||
import cn.dev33.satoken.stp.StpUtil;
|
||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||
import com.imutil.common.Ids;
|
||
import com.imutil.entity.AdminUser;
|
||
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.service.TenantService;
|
||
import com.imutil.tencent.TencentImClient;
|
||
import com.imutil.entity.ImMessage;
|
||
import com.imutil.entity.MigrateTask;
|
||
import com.imutil.service.MigrateService;
|
||
import com.imutil.service.SyncService;
|
||
import com.imutil.mapper.ImMessageMapper;
|
||
import org.noear.solon.core.handle.UploadedFile;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.time.format.DateTimeFormatter;
|
||
import java.time.LocalDate;
|
||
import java.time.OffsetDateTime;
|
||
import java.time.ZoneOffset;
|
||
import java.util.ArrayList;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.noear.solon.annotation.Controller;
|
||
import org.noear.solon.annotation.Get;
|
||
import org.noear.solon.annotation.Inject;
|
||
import org.noear.solon.annotation.Mapping;
|
||
import org.noear.solon.annotation.Param;
|
||
import org.noear.solon.annotation.Post;
|
||
import org.noear.solon.core.handle.Context;
|
||
import org.noear.solon.core.handle.ModelAndView;
|
||
|
||
/**
|
||
* 管理后台控制器
|
||
* <p>
|
||
* 路由前缀 /admin。鉴权由 {@link com.imutil.filter.AdminAuthFilter} 拦截(除 login/logout)。
|
||
* 页面用 FreeMarker 渲染(对齐 yxtech),数据 CRUD 直连对应 Mapper。
|
||
*
|
||
* @author imutil
|
||
*/
|
||
@Mapping("/admin")
|
||
@Controller
|
||
@Slf4j
|
||
public class AdminController {
|
||
|
||
@Inject("${server.contextPath:}")
|
||
private String basePath;
|
||
|
||
@Inject
|
||
private AdminUserService adminUserService;
|
||
|
||
@Inject
|
||
private TenantMapper tenantMapper;
|
||
|
||
@Inject
|
||
private TenantService tenantService;
|
||
|
||
@Inject
|
||
private CrossTenantGrantMapper grantMapper;
|
||
|
||
@Inject
|
||
private DistQueueMapper distQueueMapper;
|
||
|
||
@Inject
|
||
private ImMessageMapper imMessageMapper;
|
||
|
||
@Inject
|
||
private UsageStatMapper usageStatMapper;
|
||
|
||
@Inject
|
||
private SourceAppService sourceAppService;
|
||
|
||
@Inject
|
||
private TencentImClient tencentImClient;
|
||
|
||
@Inject
|
||
private MigrateService migrateService;
|
||
|
||
@Inject
|
||
private SyncService syncService;
|
||
|
||
// ==================== 登录 / 登出 ====================
|
||
|
||
@Get
|
||
@Mapping("")
|
||
public void index(Context ctx) throws Throwable {
|
||
ctx.redirect(basePath + "/admin/home");
|
||
}
|
||
|
||
@Get
|
||
@Mapping("/login")
|
||
public Object loginPage(@Param(defaultValue = "") String error) {
|
||
if (StpUtil.isLogin()) {
|
||
// 已登录不再渲染登录页(重定向由调用方处理,此处仍渲染避免死循环)
|
||
}
|
||
ModelAndView mv = new ModelAndView("login.ftl");
|
||
mv.put("basePath", basePath);
|
||
if ("1".equals(error)) {
|
||
mv.put("errorMsg", "用户名或密码错误");
|
||
}
|
||
return mv;
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/login")
|
||
public void doLogin(@Param(defaultValue = "") String username,
|
||
@Param(defaultValue = "") String password,
|
||
Context ctx) throws Throwable {
|
||
AdminUser u = adminUserService.login(username, password);
|
||
if (u != null) {
|
||
StpUtil.login(u.getId());
|
||
log.info("管理后台登录成功 id={} username={}", u.getId(), username);
|
||
ctx.redirect(basePath + "/admin/home");
|
||
} else {
|
||
log.warn("管理后台登录失败 username={}", username);
|
||
ctx.redirect(basePath + "/admin/login?error=1");
|
||
}
|
||
}
|
||
|
||
@Get
|
||
@Mapping("/logout")
|
||
public void logout(Context ctx) throws Throwable {
|
||
StpUtil.logout();
|
||
ctx.redirect(basePath + "/admin/login");
|
||
}
|
||
|
||
// ==================== 修改密码 ====================
|
||
|
||
@Get
|
||
@Mapping("/password")
|
||
public Object passwordPage(@Param(defaultValue = "") String msg) {
|
||
ModelAndView mv = view("password.ftl", "修改密码", "password");
|
||
mv.put("msg", msg);
|
||
return mv;
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/password")
|
||
public void changePassword(@Param(defaultValue = "") String oldPassword,
|
||
@Param(defaultValue = "") String newPassword,
|
||
@Param(defaultValue = "") String confirmPassword,
|
||
Context ctx) throws Throwable {
|
||
// 两次新密码一致性前端已校验,后端兜底
|
||
if (!newPassword.equals(confirmPassword)) {
|
||
ctx.redirect(basePath + "/admin/password?msg=pwdconfirm");
|
||
return;
|
||
}
|
||
try {
|
||
// 当前登录用户ID(Sa-Token 登录时存的是 AdminUser.id)
|
||
adminUserService.changePassword(StpUtil.getLoginIdAsLong(), oldPassword, newPassword);
|
||
log.info("管理员密码已修改 loginId={}", StpUtil.getLoginIdAsLong());
|
||
ctx.redirect(basePath + "/admin/password?msg=pwddone");
|
||
} catch (Exception e) {
|
||
// 原密码错误 / 新密码不合规统一提示,细节见日志
|
||
log.warn("修改密码失败 : {}", e.getMessage());
|
||
ctx.redirect(basePath + "/admin/password?msg=pwdfail");
|
||
}
|
||
}
|
||
|
||
// ==================== 首页(仪表盘) ====================
|
||
|
||
@Get
|
||
@Mapping("/home")
|
||
public Object home() {
|
||
ModelAndView mv = view("home.ftl", "仪表盘", "home");
|
||
mv.put("tenantCount", tenantMapper.selectCount(null));
|
||
mv.put("queuePending", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
|
||
.eq(DistQueue::getStatus, 0)));
|
||
mv.put("queueDead", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
|
||
.eq(DistQueue::getStatus, 3)));
|
||
mv.put("grantActive", grantMapper.selectCount(Wrappers.<CrossTenantGrant>lambdaQuery()
|
||
.eq(CrossTenantGrant::getStatus, 1)));
|
||
return mv;
|
||
}
|
||
|
||
// ==================== 租户管理 ====================
|
||
|
||
@Get
|
||
@Mapping("/tenant")
|
||
public Object tenantPage(@Param(defaultValue = "") String msg) {
|
||
ModelAndView mv = view("tenant.ftl", "租户管理", "tenant");
|
||
mv.put("tenants", tenantMapper.selectList(null));
|
||
mv.put("msg", msg);
|
||
return mv;
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/tenant/save")
|
||
public void tenantSave(Tenant t, Context ctx) throws Throwable {
|
||
// tenantId 页面不可见:新增时雪花生成,编辑时由 hidden 携带(主键不可改)
|
||
boolean isNew = (t.getTenantId() == null || t.getTenantId().isEmpty());
|
||
if (isNew) {
|
||
// 前缀码(IM 账号前缀)必填且全局唯一
|
||
if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) {
|
||
ctx.redirect(basePath + "/admin/tenant?msg=prefix_code_required");
|
||
return;
|
||
}
|
||
Tenant exist = tenantMapper.selectOne(Wrappers.<Tenant>lambdaQuery()
|
||
.eq(Tenant::getPrefixCode, t.getPrefixCode()).last("LIMIT 1"));
|
||
if (exist != null) {
|
||
ctx.redirect(basePath + "/admin/tenant?msg=prefix_code_duplicate");
|
||
return;
|
||
}
|
||
t.setTenantId(Ids.nextIdStr());
|
||
if (t.getStatus() == null) {
|
||
t.setStatus(1);
|
||
}
|
||
tenantMapper.insert(t);
|
||
} else {
|
||
tenantMapper.updateById(t);
|
||
}
|
||
// 失效缓存,使后续按 id/appKey/prefix 查询拿到最新值
|
||
tenantService.evictCache(t.getTenantId());
|
||
ctx.redirect(basePath + "/admin/tenant?msg=saved");
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/tenant/toggle")
|
||
public void tenantToggle(@Param String tenantId, @Param Integer status, Context ctx) throws Throwable {
|
||
Tenant t = new Tenant();
|
||
t.setTenantId(tenantId);
|
||
t.setStatus(status == null ? 0 : status);
|
||
tenantMapper.updateById(t);
|
||
tenantService.evictCache(tenantId);
|
||
ctx.redirect(basePath + "/admin/tenant?msg=toggled");
|
||
}
|
||
|
||
// ==================== 跨租户授权 ====================
|
||
|
||
@Get
|
||
@Mapping("/grant")
|
||
public Object grantPage(@Param(defaultValue = "") String msg) {
|
||
ModelAndView mv = view("grant.ftl", "跨租户授权", "grant");
|
||
mv.put("grants", grantMapper.selectList(null));
|
||
mv.put("tenants", tenantMapper.selectList(null));
|
||
mv.put("msg", msg);
|
||
return mv;
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/grant/save")
|
||
public void grantSave(CrossTenantGrant g, Context ctx) throws Throwable {
|
||
if (g.getStatus() == null) {
|
||
g.setStatus(1);
|
||
}
|
||
if (g.getDirection() == null) {
|
||
g.setDirection(0);
|
||
}
|
||
if (g.getGrantId() == null) {
|
||
grantMapper.insert(g);
|
||
} else {
|
||
grantMapper.updateById(g);
|
||
}
|
||
ctx.redirect(basePath + "/admin/grant?msg=saved");
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/grant/revoke")
|
||
public void grantRevoke(@Param Long grantId, Context ctx) throws Throwable {
|
||
CrossTenantGrant g = new CrossTenantGrant();
|
||
g.setGrantId(grantId);
|
||
g.setStatus(0);
|
||
grantMapper.updateById(g);
|
||
ctx.redirect(basePath + "/admin/grant?msg=revoked");
|
||
}
|
||
|
||
// ==================== 队列监控 / 死信重发 ====================
|
||
|
||
@Get
|
||
@Mapping("/queue")
|
||
public Object queuePage(@Param(defaultValue = "") String msg) {
|
||
// nextRetryAt 为 OffsetDateTime,FreeMarker ?string 对 java.time 不支持,Controller 端预格式化
|
||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||
List<DistQueue> rawPendings = distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
|
||
.eq(DistQueue::getStatus, 0)
|
||
.orderByAsc(DistQueue::getId)
|
||
.last("LIMIT 50"));
|
||
List<Map<String, Object>> pendings = new ArrayList<>();
|
||
if (rawPendings != null) {
|
||
for (DistQueue q : rawPendings) {
|
||
Map<String, Object> v = new LinkedHashMap<>();
|
||
v.put("id", q.getId());
|
||
v.put("tenantId", q.getTenantId());
|
||
v.put("convId", q.getConvId());
|
||
v.put("retryCount", q.getRetryCount());
|
||
v.put("nextRetryAtStr", q.getNextRetryAt() == null ? "" : q.getNextRetryAt().format(fmt));
|
||
pendings.add(v);
|
||
}
|
||
}
|
||
ModelAndView mv = view("queue.ftl", "队列监控", "queue");
|
||
mv.put("pendings", pendings);
|
||
mv.put("deads", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
|
||
.eq(DistQueue::getStatus, 3)
|
||
.orderByDesc(DistQueue::getId)
|
||
.last("LIMIT 50")));
|
||
mv.put("deadCount", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
|
||
.eq(DistQueue::getStatus, 3)));
|
||
mv.put("msg", msg);
|
||
return mv;
|
||
}
|
||
|
||
@Post
|
||
@Mapping("/queue/redeliver")
|
||
public void redeliver(@Param Long id, Context ctx) throws Throwable {
|
||
int n = distQueueMapper.redeliverDead(id);
|
||
ctx.redirect(basePath + "/admin/queue?msg=" + (n > 0 ? "redelivered" : "not_dead"));
|
||
}
|
||
|
||
// ==================== 用量报表 ====================
|
||
|
||
@Get
|
||
@Mapping("/usage")
|
||
public Object usagePage() {
|
||
// statTime 为 OffsetDateTime,FreeMarker ?string 对 java.time 不支持,Controller 端预格式化
|
||
List<UsageStat> raw = usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery()
|
||
.orderByDesc(UsageStat::getStatTime)
|
||
.last("LIMIT 100"));
|
||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||
List<Map<String, Object>> stats = new ArrayList<>();
|
||
if (raw != null) {
|
||
for (UsageStat s : raw) {
|
||
Map<String, Object> v = new LinkedHashMap<>();
|
||
v.put("tenantId", s.getTenantId());
|
||
v.put("statTimeStr", s.getStatTime() == null ? "" : s.getStatTime().format(fmt));
|
||
v.put("statLevel", s.getStatLevel());
|
||
v.put("imMsgCount", s.getImMsgCount());
|
||
v.put("imDau", s.getImDau());
|
||
v.put("apiCallCount", s.getApiCallCount());
|
||
v.put("trtcDurationSec", s.getTrtcDurationSec());
|
||
stats.add(v);
|
||
}
|
||
}
|
||
ModelAndView mv = view("usage.ftl", "用量报表", "usage");
|
||
mv.put("stats", stats);
|
||
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,
|
||
@Param(defaultValue = "") String migrated) {
|
||
ModelAndView mv = view("migrate.ftl", "数据迁移", "migrate");
|
||
mv.put("tasks", migrateService.listTasks());
|
||
mv.put("apps", sourceAppService.list());
|
||
mv.put("msg", msg);
|
||
mv.put("imported", imported);
|
||
mv.put("migrated", migrated);
|
||
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());
|
||
}
|
||
|
||
/**
|
||
* 群迁移:选老应用 → getAppidGroups 拉群 → 逐群拉成员 → 加前缀建群 → 导入成员 → 写 group_mapping
|
||
*/
|
||
@Post
|
||
@Mapping("/migrate/groups")
|
||
public void migrateGroups(@Param Long sourceAppId, Context ctx) throws Throwable {
|
||
if (sourceAppId == null) {
|
||
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||
return;
|
||
}
|
||
MigrateTask task = migrateService.migrateGroups(sourceAppId);
|
||
ctx.redirect(basePath + "/admin/migrate?msg=gdone&migrated=" + task.getProcessedCount());
|
||
}
|
||
|
||
/**
|
||
* 群消息迁移:上传/粘贴群 ID 清单 → 逐群拉历史消息 → 加前缀 → importGroupMsg
|
||
*/
|
||
@Post
|
||
@Mapping("/migrate/groupmsg")
|
||
public void migrateGroupMsg(@Param Long sourceAppId,
|
||
@Param(defaultValue = "") String userText,
|
||
Context ctx) throws Throwable {
|
||
UploadedFile file = ctx.file("file");
|
||
List<String> groupIds = new ArrayList<>();
|
||
if (file != null) {
|
||
parseUserIds(new String(file.getContentAsBytes(), StandardCharsets.UTF_8), groupIds);
|
||
} else if (!userText.isEmpty()) {
|
||
parseUserIds(userText, groupIds);
|
||
}
|
||
if (sourceAppId == null) {
|
||
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||
return;
|
||
}
|
||
if (groupIds.isEmpty()) {
|
||
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||
return;
|
||
}
|
||
MigrateTask task = migrateService.migrateGroupMessages(sourceAppId, groupIds);
|
||
ctx.redirect(basePath + "/admin/migrate?msg=gmdone&imported=" + task.getProcessedCount());
|
||
}
|
||
|
||
/**
|
||
* 单聊消息迁移:上传会话清单(每行 from,to)或粘贴 → getRoamMsg 拉 → 加前缀 → importMsg
|
||
*/
|
||
@Post
|
||
@Mapping("/migrate/messages")
|
||
public void migrateMessages(@Param Long sourceAppId,
|
||
@Param(defaultValue = "") String convText,
|
||
Context ctx) throws Throwable {
|
||
UploadedFile file = ctx.file("file");
|
||
String content = file != null ? new String(file.getContentAsBytes(), StandardCharsets.UTF_8) : convText;
|
||
List<String[]> pairs = new ArrayList<>();
|
||
parseConvPairs(content, pairs);
|
||
if (sourceAppId == null) {
|
||
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||
return;
|
||
}
|
||
if (pairs.isEmpty()) {
|
||
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||
return;
|
||
}
|
||
MigrateTask task = migrateService.migrateMessages(sourceAppId, pairs);
|
||
ctx.redirect(basePath + "/admin/migrate?msg=mdone&imported=" + task.getProcessedCount());
|
||
}
|
||
|
||
/**
|
||
* 关系链迁移:上传用户清单(每行一个 UserID)或粘贴 → 逐用户拉好友 → 加前缀 → importFriend
|
||
*/
|
||
@Post
|
||
@Mapping("/migrate/roster")
|
||
public void migrateRoster(@Param Long sourceAppId,
|
||
@Param(defaultValue = "") String userText,
|
||
Context ctx) throws Throwable {
|
||
UploadedFile file = ctx.file("file");
|
||
List<String> userIds = new ArrayList<>();
|
||
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.migrateRoster(sourceAppId, userIds);
|
||
ctx.redirect(basePath + "/admin/migrate?msg=rdone&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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 解析会话清单:每行 from,to(逗号或空格分隔),空行与 # 注释跳过 */
|
||
private void parseConvPairs(String content, List<String[]> out) {
|
||
if (content == null || content.isEmpty()) {
|
||
return;
|
||
}
|
||
for (String line : content.split("\\r?\\n")) {
|
||
line = line.trim();
|
||
if (line.isEmpty() || line.startsWith("#")) {
|
||
continue;
|
||
}
|
||
String[] parts = line.split("[,,\\s]+");
|
||
if (parts.length >= 2) {
|
||
out.add(new String[]{parts[0], parts[1]});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 数据同步(T17) ====================
|
||
|
||
/**
|
||
* 数据同步页:只读拉取腾讯 IM 群组/群成员/群消息到本地表,供按租户查看。
|
||
* 与 migrate(带建群/建号写回)不同,本功能在腾讯侧无任何写操作。
|
||
*/
|
||
@Get
|
||
@Mapping("/sync")
|
||
public Object syncPage(@Param(defaultValue = "") String msg) {
|
||
ModelAndView mv = view("sync.ftl", "数据同步", "sync");
|
||
mv.put("tasks", migrateService.listTasks());
|
||
mv.put("apps", sourceAppService.list());
|
||
mv.put("tenants", tenantMapper.selectList(null));
|
||
mv.put("msg", msg);
|
||
return mv;
|
||
}
|
||
|
||
/**
|
||
* 同步群组:sourceAppId 空=主应用(需选 tenantId);非空=老应用(密钥+租户取其配置)
|
||
*/
|
||
@Post
|
||
@Mapping("/sync/groups")
|
||
public void syncGroups(@Param(required = false) Long sourceAppId,
|
||
@Param(defaultValue = "") String tenantId,
|
||
Context ctx) throws Throwable {
|
||
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
|
||
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
|
||
return;
|
||
}
|
||
syncService.syncGroups(sourceAppId, tenantId);
|
||
ctx.redirect(basePath + "/admin/sync?msg=sgdone");
|
||
}
|
||
|
||
/** 同步群成员(→用户):需先同步群组 */
|
||
@Post
|
||
@Mapping("/sync/users")
|
||
public void syncUsers(@Param(required = false) Long sourceAppId,
|
||
@Param(defaultValue = "") String tenantId,
|
||
Context ctx) throws Throwable {
|
||
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
|
||
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
|
||
return;
|
||
}
|
||
syncService.syncGroupMembers(sourceAppId, tenantId);
|
||
ctx.redirect(basePath + "/admin/sync?msg=sudone");
|
||
}
|
||
|
||
/** 同步群消息:需先同步群组 */
|
||
@Post
|
||
@Mapping("/sync/groupmsg")
|
||
public void syncGroupMsg(@Param(required = false) Long sourceAppId,
|
||
@Param(defaultValue = "") String tenantId,
|
||
Context ctx) throws Throwable {
|
||
if (sourceAppId == null && (tenantId == null || tenantId.isEmpty())) {
|
||
ctx.redirect(basePath + "/admin/sync?msg=syncopt");
|
||
return;
|
||
}
|
||
syncService.syncGroupMessages(sourceAppId, tenantId);
|
||
ctx.redirect(basePath + "/admin/sync?msg=smdone");
|
||
}
|
||
|
||
// ==================== 公共:构造页面模型 ====================
|
||
|
||
/**
|
||
* 构造带侧边栏布局的 ModelAndView
|
||
*
|
||
* @param tpl 模板名(如 home.ftl)
|
||
* @param title 页面标题
|
||
* @param active 侧边栏高亮项(home/tenant/grant/queue/usage)
|
||
*/
|
||
// ==================== 消息记录 ====================
|
||
|
||
/**
|
||
* 消息记录列表(已分发消息查询)
|
||
* <p>
|
||
* 默认最近 7 天 + 已分发(dist_status=1);带 msg_time 范围走分区裁剪。
|
||
*/
|
||
@Get
|
||
@Mapping("/messages")
|
||
public Object messagesPage(@Param(defaultValue = "") String tenantId,
|
||
@Param(required = false) Integer convType,
|
||
@Param(required = false) String distStatus,
|
||
@Param(defaultValue = "") String dateFrom,
|
||
@Param(defaultValue = "") String dateTo,
|
||
@Param(defaultValue = "") String keyword,
|
||
@Param(defaultValue = "1") int page,
|
||
@Param(defaultValue = "20") int size) {
|
||
// 默认时间窗:最近 7 天(带 msg_time 范围触发分区裁剪,避免全分区扫描)
|
||
OffsetDateTime now = OffsetDateTime.now();
|
||
OffsetDateTime from = parseDateStart(dateFrom, now.minusDays(7));
|
||
OffsetDateTime to = parseDateEnd(dateTo, now);
|
||
int safePage = Math.max(1, page);
|
||
int safeSize = size <= 0 ? 20 : Math.min(size, 200);
|
||
int offset = (safePage - 1) * safeSize;
|
||
|
||
// distStatus:null=首次进入(默认已分发) / ""=用户选"全部" / "0,1,2"=对应状态
|
||
// 前端"全部"提交空串,Solon 对带空值的 Integer 参数会判为缺失(defaultValue 不兜底),
|
||
// 故改用 String 接收后手动解析,Mapper 以 null 表示不限分发状态。
|
||
Integer distStatusInt;
|
||
if (distStatus == null) {
|
||
distStatusInt = 1;
|
||
} else if (distStatus.isEmpty()) {
|
||
distStatusInt = null;
|
||
} else {
|
||
distStatusInt = Integer.valueOf(distStatus);
|
||
}
|
||
|
||
String tid = (tenantId == null || tenantId.isEmpty()) ? null : tenantId;
|
||
String kw = (keyword == null || keyword.isEmpty()) ? null : keyword;
|
||
List<ImMessage> rawRows = imMessageMapper.selectMessagePage(tid, convType, distStatusInt, from, to, kw, offset, safeSize);
|
||
long total = imMessageMapper.countMessages(tid, convType, distStatusInt, from, to, kw);
|
||
int totalPages = (int) ((total + safeSize - 1) / Math.max(1, safeSize));
|
||
|
||
// 转成 Map 并预格式化时间(FreeMarker 对 java.time OffsetDateTime 格式化支持不稳,Controller 端处理)
|
||
DateTimeFormatter fmtDisp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||
DateTimeFormatter fmtIso = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSxxx");
|
||
List<Map<String, Object>> rows = new ArrayList<>();
|
||
if (rawRows != null) {
|
||
for (ImMessage r : rawRows) {
|
||
Map<String, Object> v = new LinkedHashMap<>();
|
||
v.put("msgKey", r.getMsgKey());
|
||
v.put("msgKeyEnc", r.getMsgKey() == null ? "" : java.net.URLEncoder.encode(r.getMsgKey(), StandardCharsets.UTF_8));
|
||
v.put("msgTimeStr", r.getMsgTime() == null ? "" : r.getMsgTime().format(fmtDisp));
|
||
v.put("msgTimeIso", r.getMsgTime() == null ? "" : java.net.URLEncoder.encode(r.getMsgTime().format(fmtIso), StandardCharsets.UTF_8));
|
||
v.put("tenantId", r.getTenantId());
|
||
v.put("convType", r.getConvType());
|
||
v.put("convId", r.getConvId());
|
||
v.put("fromAccount", r.getFromAccount());
|
||
v.put("toAccount", r.getToAccount());
|
||
v.put("groupId", r.getGroupId());
|
||
v.put("msgType", r.getMsgType());
|
||
v.put("source", r.getSource());
|
||
v.put("isCrossTenant", r.getIsCrossTenant());
|
||
v.put("distStatus", r.getDistStatus());
|
||
rows.add(v);
|
||
}
|
||
}
|
||
|
||
ModelAndView mv = view("messages.ftl", "消息记录", "messages");
|
||
mv.put("rows", rows);
|
||
mv.put("total", total);
|
||
mv.put("page", safePage);
|
||
mv.put("size", safeSize);
|
||
mv.put("totalPages", totalPages);
|
||
mv.put("tenantId", tenantId == null ? "" : tenantId);
|
||
mv.put("convType", convType);
|
||
mv.put("distStatus", distStatusInt);
|
||
mv.put("dateFrom", dateFrom == null ? "" : dateFrom);
|
||
mv.put("dateTo", dateTo == null ? "" : dateTo);
|
||
mv.put("keyword", keyword == null ? "" : keyword);
|
||
mv.put("tenants", tenantMapper.selectList(null));
|
||
return mv;
|
||
}
|
||
|
||
/**
|
||
* 消息详情:完整字段 + msg_body + 关联 dist_queue 分发链路
|
||
*/
|
||
@Get
|
||
@Mapping("/messages/detail")
|
||
public Object messageDetail(@Param String msgKey, @Param String msgTime) {
|
||
ModelAndView mv = view("messages_detail.ftl", "消息详情", "messages");
|
||
if (msgKey == null || msgKey.isEmpty() || msgTime == null || msgTime.isEmpty()) {
|
||
mv.put("msg", null);
|
||
mv.put("dists", java.util.Collections.emptyList());
|
||
return mv;
|
||
}
|
||
OffsetDateTime t;
|
||
try {
|
||
t = OffsetDateTime.parse(msgTime);
|
||
} catch (Exception e) {
|
||
mv.put("msg", null);
|
||
mv.put("dists", java.util.Collections.emptyList());
|
||
return mv;
|
||
}
|
||
ImMessage m = imMessageMapper.selectOne(Wrappers.<ImMessage>lambdaQuery()
|
||
.eq(ImMessage::getMsgKey, msgKey).eq(ImMessage::getMsgTime, t));
|
||
mv.put("msg", m);
|
||
mv.put("msgTimeStr", (m != null && m.getMsgTime() != null)
|
||
? m.getMsgTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")) : "");
|
||
mv.put("msgCreatedStr", (m != null && m.getCreatedAt() != null)
|
||
? m.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : "");
|
||
// dist_queue 分发链路,时间预格式化(同列表原因)
|
||
DateTimeFormatter fmtDisp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||
List<DistQueue> rawDists = distQueueMapper.selectByMsgKey(msgKey);
|
||
List<Map<String, Object>> dists = new ArrayList<>();
|
||
if (rawDists != null) {
|
||
for (DistQueue d : rawDists) {
|
||
Map<String, Object> v = new LinkedHashMap<>();
|
||
v.put("id", d.getId());
|
||
v.put("targetUrl", d.getTargetUrl());
|
||
v.put("tenantId", d.getTenantId());
|
||
v.put("status", d.getStatus());
|
||
v.put("retryCount", d.getRetryCount());
|
||
v.put("createdStr", d.getCreatedAt() == null ? "" : d.getCreatedAt().format(fmtDisp));
|
||
v.put("updatedStr", d.getUpdatedAt() == null ? "" : d.getUpdatedAt().format(fmtDisp));
|
||
dists.add(v);
|
||
}
|
||
}
|
||
mv.put("dists", dists);
|
||
return mv;
|
||
}
|
||
|
||
/** 解析 yyyy-MM-dd 为当天 0 点(+8),空或非法用默认值 */
|
||
private OffsetDateTime parseDateStart(String s, OffsetDateTime def) {
|
||
if (s == null || s.isEmpty()) {
|
||
return def;
|
||
}
|
||
try {
|
||
return LocalDate.parse(s).atStartOfDay(ZoneOffset.ofHours(8)).toOffsetDateTime();
|
||
} catch (Exception e) {
|
||
return def;
|
||
}
|
||
}
|
||
|
||
/** 解析 yyyy-MM-dd 为次日 0 点(+8,开区间覆盖整天),空或非法用默认值 */
|
||
private OffsetDateTime parseDateEnd(String s, OffsetDateTime def) {
|
||
if (s == null || s.isEmpty()) {
|
||
return def;
|
||
}
|
||
try {
|
||
return LocalDate.parse(s).plusDays(1).atStartOfDay(ZoneOffset.ofHours(8)).toOffsetDateTime();
|
||
} catch (Exception e) {
|
||
return def;
|
||
}
|
||
}
|
||
|
||
private ModelAndView view(String tpl, String title, String active) {
|
||
ModelAndView mv = new ModelAndView(tpl);
|
||
mv.put("basePath", basePath);
|
||
mv.put("pageTitle", title);
|
||
mv.put("active", active);
|
||
return mv;
|
||
}
|
||
}
|