初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
package com.mokee.admin;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 业务管理服务 — 启动类
* <p>
* 负责系统配置管理、用户管理、角色权限分配、菜单维护、日志查询、定时任务等
*/
@SpringBootApplication(scanBasePackages = {"com.mokee.admin", "com.mokee.common"})
@MapperScan("com.mokee.admin.mapper")
@EnableScheduling
public class AdminApplication {
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
System.out.println("========================================");
System.out.println(" 业务管理服务启动成功! 端口: 9002");
System.out.println(" Knife4j 文档: http://localhost:9002/doc.html");
System.out.println("========================================");
}
}

View File

@@ -0,0 +1,174 @@
package com.mokee.admin.aspect;
import cn.hutool.json.JSONUtil;
import com.mokee.admin.service.LogAsyncService;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysOperationLog;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
/**
* 操作日志切面
* <p>
* 拦截带有 @OperationLog 注解的方法,记录操作人、请求参数、响应状态、耗时等
* 通过 X-User-Id / X-User-Name / X-System-Id 等请求头获取当前用户信息
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private final LogAsyncService logAsyncService;
/**
* 环绕通知:记录操作日志
* <p>
* 计算请求耗时,无论成功或异常都记录日志
*/
@Around("@annotation(com.mokee.common.annotation.OperationLog)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = null;
String errorMsg = null;
int responseStatus = 200;
try {
result = joinPoint.proceed();
return result;
} catch (Throwable e) {
errorMsg = e.getMessage();
responseStatus = 500;
throw e;
} finally {
// 异步保存操作日志(简单起见直接同步写入,避免引入额外线程池配置)
try {
saveOperationLog(joinPoint, startTime, responseStatus, errorMsg, result);
} catch (Exception e) {
log.error("记录操作日志失败", e);
}
}
}
/**
* 保存操作日志
*/
private void saveOperationLog(ProceedingJoinPoint joinPoint, long startTime,
int responseStatus, String errorMsg, Object result) {
// 获取 @OperationLog 注解信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
OperationLog annotation = signature.getMethod().getAnnotation(OperationLog.class);
String module = annotation.module();
String action = annotation.action();
// 获取请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
// 从请求头获取用户信息(由网关 HeaderInjectFilter 注入)
String userIdStr = request.getHeader("X-User-Id");
// X-User-Name 经网关 URL 编码,此处需解码还原中文姓名
String username = decodeHeader(request.getHeader("X-User-Name"));
String systemIdStr = request.getHeader("X-System-Id");
String userId = null;
if (userIdStr != null) {
userId = userIdStr;
}
String systemId = null;
if (systemIdStr != null) {
systemId = systemIdStr;
}
// 获取请求体参数
String requestBody = null;
Object[] args = joinPoint.getArgs();
if (args != null && args.length > 0) {
try {
// 过滤掉 HttpServletRequest / HttpServletResponse 等非业务参数
Object bodyArg = null;
for (Object arg : args) {
if (arg != null && !(arg instanceof HttpServletRequest)
&& !(arg instanceof jakarta.servlet.http.HttpServletResponse)) {
bodyArg = arg;
break;
}
}
if (bodyArg != null) {
requestBody = JSONUtil.toJsonStr(bodyArg);
// 截断过长的请求体
if (requestBody.length() > 2000) {
requestBody = requestBody.substring(0, 2000) + "...";
}
}
} catch (Exception ignored) {
}
}
// 计算耗时
int costTime = (int) (System.currentTimeMillis() - startTime);
// 捕获响应体
String responseBody = null;
if (result != null && responseStatus == 200) {
try {
responseBody = JSONUtil.toJsonStr(result);
if (responseBody.length() > 2000) {
responseBody = responseBody.substring(0, 2000) + "...";
}
} catch (Exception ignored) {}
}
// 构建并保存操作日志
SysOperationLog operationLog = SysOperationLog.builder()
.userId(userId)
.username(username)
.systemId(systemId)
.module(module)
.action(action)
.requestUrl(request.getRequestURI())
.requestMethod(request.getMethod())
.requestBody(requestBody)
.responseBody(responseBody)
.responseStatus(responseStatus)
.errorMsg(errorMsg)
.costTime(costTime)
.createdAt(LocalDateTime.now())
.build();
logAsyncService.saveOperationLog(operationLog);
log.debug("操作日志已记录 - module: {}, action: {}, url: {}, cost: {}ms",
module, action, request.getRequestURI(), costTime);
}
/**
* URL 解码 HTTP 头值
* <p>
* 网关 HeaderInjectFilter 对中文值进行了 URL 编码(防止 HTTP 转发时损坏),
* 此处解码还原原始值。若解码失败或值为 null返回原值。
*/
private String decodeHeader(String value) {
if (value == null) return null;
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (Exception e) {
return value;
}
}
}

View File

@@ -0,0 +1,36 @@
package com.mokee.admin.config;
import java.util.List;
/**
* 当前请求用户上下文ThreadLocal
* <p>
* 由 UserContextInterceptor 在请求进入时设置,请求结束时清除。
* super_admin 角色可查看所有系统的数据,其他角色只能查看自己绑定系统的数据。
*/
public class UserContextHolder {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<Boolean> SUPER_ADMIN = new ThreadLocal<>();
private static final ThreadLocal<List<String>> SYSTEM_IDS = new ThreadLocal<>();
public static void set(String userId, boolean isSuperAdmin, List<String> systemIds) {
USER_ID.set(userId);
SUPER_ADMIN.set(isSuperAdmin);
SYSTEM_IDS.set(systemIds);
}
public static void clear() {
USER_ID.remove();
SUPER_ADMIN.remove();
SYSTEM_IDS.remove();
}
public static String getUserId() { return USER_ID.get(); }
/** 是否为 super_admin可查看全部数据 */
public static boolean isSuperAdmin() { return Boolean.TRUE.equals(SUPER_ADMIN.get()); }
/** 当前用户绑定的系统 ID 列表(非 super_admin 时用于数据过滤,不会为 null */
public static List<String> getSystemIds() { return SYSTEM_IDS.get(); }
}

View File

@@ -0,0 +1,100 @@
package com.mokee.admin.config;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.admin.mapper.SysUserSystemMapper;
import com.mokee.admin.mapper.SysSystemMapper;
import com.mokee.common.dto.response.RoleInfo;
import com.mokee.common.entity.SysSystem;
import com.mokee.common.entity.SysUserSystem;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 用户上下文拦截器 — 从请求头解析用户角色和系统绑定
* <p>
* 每次请求进入时调用,解析网关注入的 X-User-Roles / X-User-Id 头,
* 判断是否为 super_admin查询用户绑定的系统列表
* 写入 UserContextHolder请求结束时清除。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserContextInterceptor implements HandlerInterceptor {
private final SysUserSystemMapper sysUserSystemMapper;
private final SysSystemMapper sysSystemMapper;
/** 网关系统 ID 缓存(避免每次请求都查库) */
private final Map<String, String> gatewayIdCache = new ConcurrentHashMap<>();
private boolean isGatewaySystem(String systemId) {
if (systemId == null) return false;
// 使用缓存避免每次请求查库
String gatewayId = gatewayIdCache.computeIfAbsent("gateway", k -> {
SysSystem gw = sysSystemMapper.selectOne(
new LambdaQueryWrapper<SysSystem>().eq(SysSystem::getSystemCode, "gateway"));
return gw != null ? gw.getId() : "__NONE__";
});
return gatewayId.equals(systemId);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
try {
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.isEmpty()) {
// 匿名请求(如 Open API不设置上下文
return true;
}
// 解析角色列表,判断是否为 super_admin
String rolesJson = request.getHeader("X-User-Roles");
boolean isSuperAdmin = false;
if (rolesJson != null && !rolesJson.isEmpty()) {
List<RoleInfo> roles = JSONUtil.toList(rolesJson, RoleInfo.class);
isSuperAdmin = roles.stream().anyMatch(r -> "super_admin".equals(r.getRoleCode()));
}
// 非 super_admin 时,查询用户绑定的系统列表,排除网关系统
List<String> systemIds = Collections.emptyList();
if (!isSuperAdmin) {
List<SysUserSystem> bindings = sysUserSystemMapper.selectList(
new LambdaQueryWrapper<SysUserSystem>()
.eq(SysUserSystem::getUserId, userId)
);
// 排除网关系统gateway业务管理员不需要看到平台基础设施数据
systemIds = bindings.stream()
.map(SysUserSystem::getSystemId)
.distinct()
.filter(sysId -> !isGatewaySystem(sysId))
.toList();
if (systemIds.isEmpty()) {
systemIds = List.of("__NONE__");
}
}
UserContextHolder.set(userId, isSuperAdmin, systemIds);
} catch (Exception e) {
log.error("设置用户上下文失败", e);
// 失败不阻塞请求,兜底为全部数据可见
UserContextHolder.set(null, true, Collections.emptyList());
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContextHolder.clear();
}
}

View File

@@ -0,0 +1,23 @@
package com.mokee.admin.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置 — 注册用户上下文拦截器
*/
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final UserContextInterceptor userContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/zgapi/v1/admin/**") // 仅管理后台接口
.excludePathPatterns("/zgapi/v1/admin/file/**/view", "/zgapi/v1/admin/file/**/download"); // 文件预览/下载不拦截
}
}

View File

@@ -0,0 +1,69 @@
package com.mokee.admin.controller;
import com.mokee.admin.service.ConfigService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 系统配置控制器
* <p>
* 提供系统级配置的读写接口,管理后台使用。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/config}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/config")
@RequiredArgsConstructor
public class ConfigController {
private final ConfigService configService;
/**
* 获取单个配置值(供前端表单回显等场景使用)
*/
@GetMapping("/{key}")
public Result<SysConfig> getByKey(@PathVariable String key) {
return Result.ok(configService.getByKey(key));
}
/**
* 获取全部配置列表
*/
@GetMapping("/list")
public Result<List<SysConfig>> listAll() {
return Result.ok(configService.listAll());
}
/**
* 保存配置(新增或按 configKey 更新)
*/
@OperationLog(module = "系统配置", action = "编辑")
@PostMapping("/save")
public Result<?> save(@RequestBody SysConfig config) {
if (config.getConfigKey() == null || config.getConfigKey().isEmpty()) {
return Result.fail(400, "configKey 不能为空");
}
configService.save(config);
return Result.ok(Map.of("msg", "保存成功"));
}
/**
* 强制刷新缓存
*/
@OperationLog(module = "系统配置", action = "刷新缓存")
@PostMapping("/refresh-cache")
public Result<?> refreshCache() {
configService.refreshCache();
return Result.ok(Map.of("msg", "缓存已刷新"));
}
}

View File

@@ -0,0 +1,193 @@
package com.mokee.admin.controller;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.common.Result;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/**
* 首页仪表盘控制器
* <p>
* 提供后台管理首页的聚合统计数据,包括:
* <ul>
* <li>系统总数、用户总数(实时从数据库查询)</li>
* <li>今日登录人数/登录次数(从 sys_login_log 统计)</li>
* <li>在线用户数最近30分钟内有成功登录记录的用户数近似</li>
* <li>过去30天每日登录趋势用于折线图</li>
* <li>JVM 运行信息处理器核心数、内存使用、Java版本、操作系统</li>
* <li>3个微服务健康状态通过 TCP Socket 连接检测端口可达性)</li>
* </ul>
* <p>
* 注意:直接使用 JdbcTemplate 执行原生 SQL避免引入额外的依赖或复杂查询。
* 在线用户数用登录日志近似,真实生产环境应接入 Redis 会话或 WebSocket 心跳。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/dashboard}
*
* @author mokee
*/
@RestController
@RequestMapping("/zgapi/v1/admin/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final JdbcTemplate jdbcTemplate;
/** 仪表盘缓存10 秒 TTL避免每次刷新都打 6 次 DB + 健康检查 */
private Cache<String, Map<String, Object>> statsCache;
@PostConstruct
public void init() {
statsCache = Caffeine.newBuilder()
.maximumSize(10)
.expireAfterWrite(Duration.ofSeconds(10))
.build();
}
/**
* 获取仪表盘聚合统计数据
* <p>
* 一次性返回所有仪表盘卡片和图表所需的数据,前端只需一次请求即可渲染整个仪表盘页面。
* 返回的 Map 使用 LinkedHashMap 保证字段顺序稳定。
*
* @return Result 包含统计数据的 Map字段包括
* systemCount、userCount、todayLoginCount、todayLoginTotal、
* onlineCount、loginTrend、serverInfo、services
*/
@GetMapping("/stats")
public Result<Map<String, Object>> stats() {
boolean isSuperAdmin = UserContextHolder.isSuperAdmin();
// 非 super_admin 按用户区分缓存 key
String cacheKey = isSuperAdmin ? "admin" : UserContextHolder.getUserId();
// 优先读本地缓存10 秒 TTL
Map<String, Object> cached = statsCache.getIfPresent(cacheKey);
if (cached != null) {
return Result.ok(cached);
}
Map<String, Object> data = new LinkedHashMap<>();
List<String> systemIds = isSuperAdmin ? null : UserContextHolder.getSystemIds();
// DB 查询系统数、用户数、登录统计、30天趋势
if (isSuperAdmin) {
Integer systemCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_system WHERE is_deleted=0", Integer.class);
data.put("systemCount", systemCount != null ? systemCount : 0);
Integer userCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_user WHERE is_deleted=0", Integer.class);
data.put("userCount", userCount != null ? userCount : 0);
} else {
data.put("systemCount", systemIds != null ? systemIds.size() : 0);
data.put("userCount", countUsersBySystems(systemIds));
}
data.put("todayLoginCount", queryLoginCount(isSuperAdmin, systemIds,
"SELECT COUNT(DISTINCT user_id) FROM sys_login_log WHERE status=1 AND DATE(created_at)=CURDATE()"));
data.put("todayLoginTotal", queryLoginCount(isSuperAdmin, systemIds,
"SELECT COUNT(*) FROM sys_login_log WHERE DATE(created_at)=CURDATE()"));
data.put("onlineCount", queryLoginCount(isSuperAdmin, systemIds,
"SELECT COUNT(DISTINCT user_id) FROM sys_login_log WHERE status=1 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)"));
data.put("loginTrend", queryLoginTrend(isSuperAdmin, systemIds));
// JVM 信息
Runtime rt = Runtime.getRuntime();
Map<String, Object> serverInfo = new LinkedHashMap<>();
serverInfo.put("availableProcessors", rt.availableProcessors());
serverInfo.put("totalMemory", rt.totalMemory() / 1024 / 1024 + " MB");
serverInfo.put("freeMemory", rt.freeMemory() / 1024 / 1024 + " MB");
serverInfo.put("maxMemory", rt.maxMemory() / 1024 / 1024 + " MB");
serverInfo.put("javaVersion", System.getProperty("java.version"));
serverInfo.put("osName", System.getProperty("os.name"));
data.put("serverInfo", serverInfo);
// 微服务健康状态:用 TCP 端口检测替代 HTTP快 10 倍)
List<Map<String, Object>> services = new ArrayList<>();
services.add(checkTcpPort("网关服务 (Gateway)", "127.0.0.1", 9000));
services.add(checkTcpPort("认证服务 (Auth)", "127.0.0.1", 9001));
Map<String, Object> adminSvc = new LinkedHashMap<>();
adminSvc.put("name", "管理服务 (Admin)");
adminSvc.put("port", 9002);
adminSvc.put("status", "UP");
adminSvc.put("responseTime", 0);
services.add(adminSvc);
data.put("services", services);
statsCache.put(cacheKey, data);
return Result.ok(data);
}
/** 非 super_admin 时统计绑定系统下的用户数(排除网关平台) */
private Integer countUsersBySystems(List<String> systemIds) {
if (systemIds == null || systemIds.isEmpty()) return 0;
String inClause = String.join(",", Collections.nCopies(systemIds.size(), "?"));
return jdbcTemplate.queryForObject(
"SELECT COUNT(DISTINCT u.id) FROM sys_user u " +
"INNER JOIN sys_user_system us ON u.id = us.user_id " +
"WHERE u.is_deleted=0 AND us.system_id IN (" + inClause + ")",
Integer.class, systemIds.toArray());
}
/** 登录统计类查询:非 super_admin 追加 system_id IN 过滤 */
private Integer queryLoginCount(boolean isSuperAdmin, List<String> systemIds, String baseSql) {
if (isSuperAdmin) {
return jdbcTemplate.queryForObject(baseSql, Integer.class);
}
if (systemIds == null || systemIds.isEmpty()) return 0;
String inClause = String.join(",", Collections.nCopies(systemIds.size(), "?"));
// 在 WHERE 后追加 AND system_id IN (...)
String sql = baseSql + " AND system_id IN (" + inClause + ")";
return jdbcTemplate.queryForObject(sql, Integer.class, systemIds.toArray());
}
/** 30天登录趋势非 super_admin 按 system_id 过滤 */
private List<Map<String, Object>> queryLoginTrend(boolean isSuperAdmin, List<String> systemIds) {
List<Map<String, Object>> loginTrend = new ArrayList<>();
String baseSql = "SELECT DATE(created_at) as day, COUNT(DISTINCT user_id) as cnt " +
"FROM sys_login_log WHERE status=1 AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)";
List<Map<String, Object>> rows;
if (isSuperAdmin) {
rows = jdbcTemplate.queryForList(baseSql + " GROUP BY DATE(created_at) ORDER BY day");
} else {
if (systemIds == null || systemIds.isEmpty()) return loginTrend;
String inClause = String.join(",", Collections.nCopies(systemIds.size(), "?"));
rows = jdbcTemplate.queryForList(
baseSql + " AND system_id IN (" + inClause + ") GROUP BY DATE(created_at) ORDER BY day",
systemIds.toArray());
}
for (Map<String, Object> row : rows) {
Map<String, Object> point = new LinkedHashMap<>();
point.put("date", row.get("day") != null ? row.get("day").toString() : "");
point.put("count", row.get("cnt"));
loginTrend.add(point);
}
return loginTrend;
}
/** TCP 端口检测:连接成功即 UP比 HTTP 快 10 倍,不走网关转发) */
private Map<String, Object> checkTcpPort(String name, String host, int port) {
Map<String, Object> svc = new LinkedHashMap<>();
svc.put("name", name);
svc.put("port", port);
long start = System.currentTimeMillis();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 2000);
svc.put("status", "UP");
} catch (Exception e) {
svc.put("status", "DOWN");
}
svc.put("responseTime", System.currentTimeMillis() - start);
return svc;
}
}

View File

@@ -0,0 +1,123 @@
package com.mokee.admin.controller;
import com.mokee.admin.service.DictService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysDict;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 字典管理控制器
* <p>
* 管理数据字典的完整 CRUD 操作及按类型查询。
* <p>
* 设计说明:
* <ul>
* <li>字典是全局配置数据,所有业务系统共享(不区分 systemId</li>
* <li>通过 dictType 字段对字典项进行分组(如 "user_status"、"order_state" 等)</li>
* <li>查询字典列表时只返回启用状态status=1的字典项</li>
* <li>支持按排序号sortOrder控制字典项在前端下拉框中的显示顺序</li>
* </ul>
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/dict}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/dict")
@RequiredArgsConstructor
public class DictController {
private final DictService dictService;
/**
* 获取所有字典类型列表(去重后按字母排序)
* <p>
* 遍历全部字典记录,提取唯一的 dictType 值。
* 用于前端字典管理页面的左侧类型树或下拉筛选。
*
* @return 去重后的字典类型字符串列表
*/
@GetMapping("/type/list")
public Result<List<String>> listTypes() {
return Result.ok(dictService.listTypes());
}
/**
* 按字典类型查询字典项列表
* <p>
* 只返回启用状态status=1且未删除的字典项按排序号升序排列。
* 用于前端下拉框、单选框、多选框等组件的选项数据源。
*
* @param type 字典类型代码(如 "user_status"
* @return 该类型下所有启用的字典项列表,按 sortOrder 升序
*/
@GetMapping("/type/{type}")
public Result<List<SysDict>> listByType(@PathVariable String type) {
return Result.ok(dictService.listByType(type));
}
/**
* 查询单个字典项详情
*
* @param id 字典项主键ID
* @return 字典项实体
*/
@GetMapping("/{id}")
public Result<SysDict> getById(@PathVariable String id) {
return Result.ok(dictService.getById(id));
}
/**
* 新增字典项
* <p>
* 注意:需确保同一 dictType 下 dictValue 唯一(业务层面约束)。
*
* @param dict 字典项实体(请求体 JSON
* @return 操作结果
*/
@OperationLog(module = "字典管理", action = "新增")
@PostMapping
public Result<?> add(@RequestBody SysDict dict) {
dictService.add(dict);
return Result.ok();
}
/**
* 编辑字典项
* <p>
* 路径变量 id 用于指定要更新的字典项,同时将 id 设置到请求体中保证一致性。
*
* @param id 要更新的字典项主键ID
* @param dict 字典项实体(请求体 JSON更新的字段
* @return 操作结果
*/
@OperationLog(module = "字典管理", action = "编辑")
@PutMapping("/{id}")
public Result<?> update(@PathVariable String id, @RequestBody SysDict dict) {
// 确保路径中的 id 与实体一致,防止前端传错
dict.setId(id);
dictService.update(dict);
return Result.ok();
}
/**
* 删除字典项(物理删除)
* <p>
* 注意:字典表使用物理删除而非逻辑删除,删除后不可恢复。
*
* @param id 字典项主键ID
* @return 操作结果
*/
@OperationLog(module = "字典管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
dictService.delete(id);
return Result.ok();
}
}

View File

@@ -0,0 +1,184 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.mapper.SysEmailLogMapper;
import com.mokee.admin.mapper.SysOpenTokenLogMapper;
import com.mokee.admin.service.LogAsyncService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysEmailLog;
import com.mokee.common.entity.SysOpenTokenLog;
import com.mokee.common.service.EmailService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 邮件管理控制器
* <p>
* 提供邮件发送和邮件日志查询功能,同时管理 Open API Token 的获取/消费日志。
* <p>
* 核心功能:
* <ul>
* <li><b>发送邮件</b>:支持 HTML 和纯文本两种格式,发送结果记录到 sys_email_log 表</li>
* <li><b>邮件日志查询</b>:分页查询历史发送记录,支持按关键词(收件人/主题)和状态过滤</li>
* <li><b>Token 日志查询</b>:查询 Open API Token 的获取和消费记录,支持按系统编码和消费状态过滤</li>
* </ul>
* <p>
* 所有写操作均记录操作日志(通过 @OperationLog 注解标注)。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/email}
*
* @author mokee
*/
@RestController
@RequestMapping("/zgapi/v1/admin/email")
@RequiredArgsConstructor
public class EmailController {
private final EmailService emailService;
private final SysEmailLogMapper emailLogMapper;
private final SysOpenTokenLogMapper openTokenLogMapper;
private final LogAsyncService logAsyncService;
/**
* 发送邮件
* <p>
* 支持 HTML默认和纯文本两种内容类型。
* 发送流程:
* <ol>
* <li>根据请求参数创建邮件日志实体</li>
* <li>调用 EmailService 发送邮件</li>
* <li>发送成功后记录日志status=1</li>
* <li>发送失败后记录错误信息status=0, errorMsg并返回错误提示</li>
* </ol>
* 操作通过 @OperationLog 记录到 sys_operation_log 表。
*
* @param body 请求体包含字段to收件人、subject主题、content内容、contentTypehtml/text默认html
* @return Result 成功或失败信息
*/
@OperationLog(module = "邮件管理", action = "发送")
@PostMapping("/send")
public Result<?> send(@RequestBody Map<String, String> body) {
// 解析请求参数
String to = body.get("to");
String subject = body.get("subject");
String content = body.get("content");
// contentType 默认为 html
String contentType = body.getOrDefault("contentType", "html");
// 构建邮件日志实体
SysEmailLog log = SysEmailLog.builder()
.toAddress(to)
.subject(subject)
.content(content)
.contentType(contentType)
.createdAt(LocalDateTime.now())
.build();
try {
// 根据内容类型选择发送方式
if ("html".equals(contentType)) {
emailService.sendHtml(to, subject, content);
} else {
emailService.sendText(to, subject, content);
}
// 发送成功:记录状态为 1
log.setStatus(1);
logAsyncService.saveEmailLog(log);
return Result.ok();
} catch (Exception e) {
// 发送失败:记录状态为 0并保存错误信息
log.setStatus(0);
log.setErrorMsg(e.getMessage());
logAsyncService.saveEmailLog(log);
return Result.fail(500, "发送失败: " + e.getMessage());
}
}
/**
* 分页查询邮件发送记录
* <p>
* 支持按关键词(模糊匹配收件人地址或邮件主题)和发送状态过滤。
* 使用 MyBatis-Plus LambdaQueryWrapper 构建动态查询条件。
*
* @param params 请求参数 Map支持字段page, pageSize, keyword收件人/主题模糊查询), status0失败/1成功
* @return 分页结果,按创建时间倒序
*/
@PostMapping("/page")
public Result<IPage<SysEmailLog>> page(@RequestBody Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer size = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String keyword = params.get("keyword") != null ? params.get("keyword").toString() : null;
Integer status = params.get("status") != null ? ((Number) params.get("status")).intValue() : null;
// 构建动态查询条件
LambdaQueryWrapper<SysEmailLog> w = new LambdaQueryWrapper<>();
if (keyword != null && !keyword.isEmpty()) {
// 关键词同时匹配收件人地址和邮件主题OR 条件)
w.and(wr -> wr.like(SysEmailLog::getToAddress, keyword)
.or().like(SysEmailLog::getSubject, keyword));
}
if (status != null) {
// 按发送状态过滤0-失败1-成功
w.eq(SysEmailLog::getStatus, status);
}
// 按创建时间倒序排列,最新记录在前
w.orderByDesc(SysEmailLog::getCreatedAt);
return Result.ok(emailLogMapper.selectPage(new Page<>(page, size), w));
}
/**
* 查看邮件详情
*
* @param id 邮件日志主键ID
* @return 邮件日志实体(包含完整的邮件内容和错误信息)
*/
@GetMapping("/{id}")
public Result<SysEmailLog> getById(@PathVariable String id) {
return Result.ok(emailLogMapper.selectById(id));
}
/**
* 删除邮件日志记录
*
* @param id 邮件日志主键ID
* @return 操作结果
*/
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
emailLogMapper.deleteById(id);
return Result.ok();
}
/**
* 查询 Open API Token 获取/消费日志
* <p>
* 用于审计第三方系统通过 Open API 获取和消费 Token 的情况。
* 支持按系统编码精确匹配和消费状态consumed: 0-未消费, 1-已消费)过滤。
*
* @param params 请求参数 Map支持字段page, pageSize, systemCode, consumed
* @return 分页结果,按获取时间倒序
*/
@PostMapping("/token-log/page")
public Result<IPage<SysOpenTokenLog>> tokenLogPage(@RequestBody Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer size = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String systemCode = params.get("systemCode") != null ? params.get("systemCode").toString() : null;
Integer consumed = params.get("consumed") != null ? ((Number) params.get("consumed")).intValue() : null;
// 构建动态查询条件
LambdaQueryWrapper<SysOpenTokenLog> w = new LambdaQueryWrapper<>();
// 按系统编码精确过滤
if (systemCode != null && !systemCode.isEmpty()) w.eq(SysOpenTokenLog::getSystemCode, systemCode);
// 按消费状态过滤
if (consumed != null) w.eq(SysOpenTokenLog::getConsumed, consumed);
// 按获取时间倒序排列
w.orderByDesc(SysOpenTokenLog::getAcquiredAt);
return Result.ok(openTokenLogMapper.selectPage(new Page<>(page, size), w));
}
}

View File

@@ -0,0 +1,56 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.service.FileService;
import com.mokee.common.Result;
import com.mokee.common.entity.SysFile;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 文件控制器 — 仅提供元信息查询
* <p>
* 文件上传/下载/删除已迁移至外部文件对象存储服务(file.wg.zgitm.com)
* 前端直调外部服务,本控制器不再提供这些端点。
*
* @author mokee
*/
@RestController
@RequestMapping("/zgapi/v1/admin/file")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
/**
* 分页查询文件列表(仅元信息,不含二进制数据)
*/
@PostMapping("/page")
public Result<IPage<SysFile>> page(@RequestBody java.util.Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer size = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String fileType = (String) params.get("fileType");
String fileName = (String) params.get("fileName");
String uploadUserName = (String) params.get("uploadUserName");
String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null;
return Result.ok(fileService.page(page, size, fileType, fileName, uploadUserName, systemId));
}
/**
* 文件列表查询(不分页)
*/
@GetMapping("/list")
public Result<List<SysFile>> list(@RequestParam(required = false) String fileType,
@RequestParam(required = false) String systemId) {
return Result.ok(fileService.list(fileType, systemId));
}
/**
* 获取文件元信息(不含二进制数据)
*/
@GetMapping("/{id}")
public Result<SysFile> getById(@PathVariable String id) {
return Result.ok(fileService.getMetaById(id));
}
}

View File

@@ -0,0 +1,64 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.service.LogService;
import com.mokee.common.Result;
import com.mokee.common.entity.SysLoginLog;
import com.mokee.common.entity.SysOperationLog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 日志管理控制器
* <p>
* 提供登录日志和操作日志的分页查询接口。
* <p>
* 数据来源:
* <ul>
* <li><b>登录日志</b>:由网关在用户登录成功/失败时写入 sys_login_log 表</li>
* <li><b>操作日志</b>:由 AOP 切面拦截 @OperationLog 注解自动记录到 sys_operation_log 表</li>
* </ul>
* 查询条件通过请求体 Map 传递,支持用户名、状态、时间范围等过滤条件。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/log}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/log")
@RequiredArgsConstructor
public class LogController {
private final LogService logService;
/**
* 登录日志分页查询
* <p>
* 支持按用户名精确匹配、登录状态过滤0失败/1成功、时间范围过滤。
* 查询结果中会通过联表查询填充 systemName 字段SysLoginLog.systemName 为 @TableField(exist=false) 的 transient 字段)。
*
* @param params 请求参数 Map支持字段page, pageSize, username, status, startTime, endTime
* @return 分页结果
*/
@PostMapping("/login/page")
public Result<IPage<SysLoginLog>> loginLogPage(@RequestBody Map<String, Object> params) {
return Result.ok(logService.loginLogPage(params));
}
/**
* 操作日志分页查询
* <p>
* 支持按操作人用户名精确匹配、操作模块模糊查询、操作类型精确匹配、时间范围过滤。
*
* @param params 请求参数 Map支持字段page, pageSize, username, module, action, startTime, endTime
* @return 分页结果
*/
@PostMapping("/operation/page")
public Result<IPage<SysOperationLog>> operationLogPage(@RequestBody Map<String, Object> params) {
return Result.ok(logService.operationLogPage(params));
}
}

View File

@@ -0,0 +1,115 @@
package com.mokee.admin.controller;
import com.mokee.admin.service.MenuService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.dto.response.MenuRouterVO;
import com.mokee.common.entity.SysMenu;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 菜单管理控制器
* <p>
* 管理各业务系统的菜单树,支持树形查询和 CRUD 操作。
* <p>
* 设计说明:
* <ul>
* <li>菜单通过 parentId 字段形成树形结构(根节点 parentId 为 "0"</li>
* <li>菜单类型1-目录无路由路径、2-菜单有路由路径、3-按钮(权限控制点)</li>
* <li>前端使用 MenuRouterVO 构建动态路由,包含名称、路径、组件、图标、排序号、状态等字段</li>
* <li>菜单的 sortOrder 控制同级菜单在侧边栏中的显示顺序</li>
* <li>菜单的 status 控制是否在前端显示0-隐藏1-显示)</li>
* </ul>
* <p>
* PUT 方法已修复:使用 {@code @PutMapping("/{id}")} + 手动 setter。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/menu}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/menu")
@RequiredArgsConstructor
public class MenuController {
private final MenuService menuService;
/**
* 查询指定系统的完整菜单树
* <p>
* 从数据库查询该系统的所有菜单记录,在内存中递归构建树形结构。
* 返回的 MenuRouterVO 包含前端路由所需的全部字段name, path, component, meta 等)。
*
* @param systemId 业务系统ID
* @return 树形菜单列表(根节点集合),包含嵌套的 children
*/
@GetMapping("/tree")
public Result<List<MenuRouterVO>> getMenuTree(@RequestParam String systemId) {
return Result.ok(menuService.getMenuTree(systemId));
}
/**
* 查询菜单详情
*
* @param id 菜单主键ID
* @return 菜单实体
*/
@GetMapping("/{id}")
public Result<SysMenu> getById(@PathVariable String id) {
return Result.ok(menuService.getById(id));
}
/**
* 新增菜单节点
* <p>
* 自动设置状态为启用,创建和更新时间自动填充。
*
* @param sysMenu 菜单实体(请求体 JSON
* @return 操作结果
*/
@OperationLog(module = "菜单管理", action = "新增")
@PostMapping
public Result<?> add(@RequestBody SysMenu sysMenu) {
menuService.add(sysMenu);
return Result.ok();
}
/**
* 编辑菜单节点
* <p>
* 【已修复】使用 {@code @PutMapping("/{id}")} 明确接收路径参数,
* 并将 id 手动设置到实体中,避免 MyBatis-Plus updateById 因 id 为 null 而失败。
*
* @param id 要编辑的菜单主键ID路径参数
* @param sysMenu 菜单实体(请求体 JSON包含要更新的字段
* @return 操作结果
*/
@OperationLog(module = "菜单管理", action = "编辑")
@PutMapping("/{id}")
public Result<?> update(@PathVariable String id, @RequestBody SysMenu sysMenu) {
// 确保实体 id 与路径参数一致
sysMenu.setId(id);
menuService.update(sysMenu);
return Result.ok();
}
/**
* 删除菜单节点
* <p>
* 注意:删除父菜单不会自动级联删除子菜单,需业务层面保证。
*
* @param id 菜单主键ID
* @return 操作结果
*/
@OperationLog(module = "菜单管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
menuService.delete(id);
return Result.ok();
}
}

View File

@@ -0,0 +1,452 @@
package com.mokee.admin.controller;
import cn.hutool.core.io.IoUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.service.LogAsyncService;
import com.mokee.admin.service.SystemCacheService;
import com.mokee.common.Result;
import com.mokee.common.entity.SysEmailLog;
import com.mokee.common.entity.SysOpenTokenLog;
import com.mokee.common.entity.SysSystem;
import com.mokee.common.service.EmailService;
import com.mokee.common.utils.IpUtil;
import com.mokee.common.utils.JwtUtil;
import com.mokee.common.utils.RedisUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 对外开放 API
* <p>
* Token 一次消费:每个 token 只能用一次,消费后立即失效
* API 文档地址http://服务地址:9002/doc.html → "对外开放API" 分组
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/open")
@RequiredArgsConstructor
@Tag(name = "对外开放API", description = "使用 systemCode 获取一次性 TokenToken 消费后立即失效。文档: /doc.html")
public class OpenApiController {
private final SystemCacheService systemCacheService;
private final EmailService emailService;
private final LogAsyncService logAsyncService;
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
private static final int OPEN_TOKEN_TTL_MINUTES = 720; // 12小时
// ==================== Token 获取 ====================
/**
* 获取一次性 API Token
* <p>
* 每个 Token 只能消费一次(调用任意 /zgapi/v1/open/* 接口即为消费),消费后立即失效。
* Token 有效期 2 小时,过期未消费也会失效。
*/
@PostMapping("/token")
@Operation(summary = "获取一次性API Token",
description = "传入 systemCode 换取 JWT Token。Token 有效期2小时只能消费一次消费后立即失效。")
public Result<Map<String, Object>> getToken(HttpServletRequest request) {
long t0 = System.currentTimeMillis();
Map<String, String> body;
try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); }
String systemCode = body.get("systemCode");
if (systemCode == null || systemCode.isEmpty()) {
return Result.fail(400, "缺少 systemCode");
}
long t1 = System.currentTimeMillis();
SysSystem system = systemCacheService.getByCode(systemCode);
if (system == null) {
return Result.fail(401, "systemCode 无效或系统已停用");
}
long t2 = System.currentTimeMillis();
String tokenId = UUID.randomUUID().toString();
String token = jwtUtil.generateOpenToken(system.getId(), system.getSystemCode(), tokenId, OPEN_TOKEN_TTL_MINUTES * 60L);
long t3 = System.currentTimeMillis();
// 缓存到 Redis
Map<String, Object> tokenInfo = Map.of(
"systemId", system.getId(),
"systemCode", system.getSystemCode(),
"systemName", system.getSystemName(),
"tokenId", tokenId,
"type", "open_api"
);
redisUtil.set(RedisUtil.key("open:token:" + tokenId), JSONUtil.toJsonStr(tokenInfo), OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES);
long t4 = System.currentTimeMillis();
// 记录获取日志
SysOpenTokenLog tokenLog = SysOpenTokenLog.builder()
.systemId(system.getId())
.systemCode(system.getSystemCode())
.tokenId(tokenId)
.tokenPreview(token.substring(0, 20) + "..." + token.substring(token.length() - 10))
.acquiredAt(LocalDateTime.now())
.consumed(0)
.build();
logAsyncService.saveOpenTokenLog(tokenLog);
long t5 = System.currentTimeMillis();
long total = t5 - t0;
log.info("open/token 耗时分解 - 读JSON:{}ms DB:{}ms JWT:{}ms Redis:{}ms 日志:{}ms 总计:{}ms",
t1 - t0, t2 - t1, t3 - t2, t4 - t3, t5 - t4, total);
return Result.ok(Map.of(
"token", token,
"expiresIn", OPEN_TOKEN_TTL_MINUTES * 60,
"systemName", system.getSystemName(),
"usageNote", "此 Token 只能消费一次,消费后立即失效"
));
}
// ==================== Token 校验(不消费) ====================
/**
* 校验 Token 是否有效(不消费)
* <p>
* 仅检查 Token 是否存在且未被消费,不做消费标记。
* 用于调用方在实际调用前预先确认 Token 可用。
*/
@PostMapping("/token/validate")
@Operation(summary = "校验Token是否有效不消费",
description = "传入 token 检查是否存在且未被消费。注意:此接口不会消费 Token仅做状态查询。")
public Result<Map<String, Object>> validateToken(HttpServletRequest request) {
Map<String, String> body;
try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); }
String token = body.get("token");
if (token == null || token.isEmpty()) {
return Result.fail(400, "缺少 token 参数");
}
String tokenId;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) {
return Result.fail(401, "Token 类型错误,非开放 API Token");
}
tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) {
return Result.fail(401, "Token 无效");
}
} catch (Exception e) {
return Result.fail(401, "Token 无效或已过期");
}
// 检查 Redis 中是否存在
if (!redisUtil.exists(RedisUtil.key("open:token:" + tokenId))) {
return Result.fail(401, "Token 不存在或已过期");
}
// 检查是否已消费
if (redisUtil.exists(RedisUtil.key("open:token:" + tokenId) + ":consumed")) {
return Result.fail(401, "Token 已被消费");
}
// 从 Redis 获取 Token 信息
String redisJson = redisUtil.get(RedisUtil.key("open:token:" + tokenId));
String systemCode = "";
if (redisJson != null) {
cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(redisJson);
systemCode = info.getStr("systemCode", "");
}
return Result.ok(Map.of(
"valid", true,
"systemCode", systemCode,
"msg", "Token 有效,未被消费"
));
}
// ==================== 查询 Token 系统信息(不消费) ====================
/**
* 根据 Token 查询所属系统信息(不消费 Token
* <p>
* 仅从 JWT 中解析 systemId/systemCode从 Redis 补充 systemName。
* 不检查是否已消费,不消耗 Token。用于外部系统拿到 Token 后查询其归属。
*/
@GetMapping("/token/info")
@Operation(summary = "查询Token中的系统信息不消费",
description = "传入 token 返回所属系统的 systemId/systemCode/systemName。不消费 Token不检查已消费状态。")
public Result<Map<String, Object>> getTokenInfo(@RequestParam("token") String token) {
if (token == null || token.isEmpty()) {
return Result.fail(400, "缺少 token 参数");
}
// 1. 解析 JWT获取 systemId/systemCode
String systemId;
String systemCode;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) {
return Result.fail(401, "Token 类型错误,非开放 API Token");
}
systemId = claims.get("systemId", String.class);
systemCode = claims.get("systemCode", String.class);
if (systemId == null || systemCode == null) {
return Result.fail(401, "Token 中缺少系统信息");
}
} catch (Exception e) {
return Result.fail(401, "Token 无效或已过期");
}
// 2. 从 Redis 补充 systemName如有
// tokenId 从 claims 提取,但这里不强制要求 Redis 中存在(已消费的 token Redis 可能已被清理)
String systemName = "";
try {
String tokenId = jwtUtil.getTokenId(
jwtUtil.validateToken(token));
if (tokenId != null) {
String redisJson = redisUtil.get(RedisUtil.key("open:token:" + tokenId));
if (redisJson != null) {
cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(redisJson);
systemName = info.getStr("systemName", "");
}
}
} catch (Exception ignored) {
// Redis 查询失败不影响主流程
}
// 3. Redis 中无 systemName 时,从数据库查询
if (systemName.isEmpty()) {
SysSystem sys = systemCacheService.getById(systemId);
if (sys != null) {
systemName = sys.getSystemName();
}
}
return Result.ok(Map.of(
"systemId", systemId,
"systemCode", systemCode,
"systemName", systemName
));
}
// ==================== 标记 Token 已消费 ====================
/**
* 标记 Token 为已消费(供外部系统回调)
* <p>
* 外部系统在自己的业务中消费了 Token 后,调用此接口告知网关标记已消费,
* 防止该 Token 被重复使用。
*/
@PostMapping("/token/consume")
@Operation(summary = "标记Token已消费",
description = "外部系统调用此接口主动标记 Token 已消费。Token 被标记后不可再使用。")
public Result<Map<String, Object>> markConsumed(HttpServletRequest request) {
Map<String, String> body;
try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); }
String token = body.get("token");
String action = body.get("action");
if (token == null || token.isEmpty()) return Result.fail(400, "缺少 token 参数");
if (action == null || action.isEmpty()) return Result.fail(400, "缺少 action 参数(如:发送邮件/上传文件/下载文件)");
// 验证 Token
String tokenId;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) return Result.fail(401, "Token 类型错误");
tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) return Result.fail(401, "Token 无效");
} catch (Exception e) {
return Result.fail(401, "Token 无效或已过期");
}
if (!redisUtil.exists(RedisUtil.key("open:token:" + tokenId))) {
return Result.fail(401, "Token 不存在或已过期");
}
if (redisUtil.exists(RedisUtil.key("open:token:" + tokenId) + ":consumed")) {
return Result.fail(401, "Token 已被消费");
}
// 标记已消费
consumeToken(tokenId);
recordConsumption(tokenId, request, action);
return Result.ok(Map.of(
"tokenConsumed", true,
"action", action,
"msg", "Token 已标记为已消费"
));
}
// ==================== 邮件发送(消费 Token ====================
/**
* 发送邮件 — 消费 Token
* <p>
* 只有邮件发送成功后才消费 Token发送失败不消费允许重试。
*/
@PostMapping("/email/send")
@Operation(summary = "发送邮件消费Token",
description = "调用此接口将消费 TokenToken 只能用一次。邮件发送成功后 Token 立即失效,发送失败不消费 Token。")
public Result<?> sendEmail(HttpServletRequest request) {
// 第1步验证 Token不消费只校验有效性
String tokenId = validateOpenToken(request);
if (tokenId == null) {
return Result.fail(401, "Token 无效或已消费");
}
// 第2步读取请求体兼容 UTF-8/GBK 编码
Map<String, String> body;
try {
body = readJsonBody(request);
} catch (Exception e) {
return Result.fail(400, "请求体 JSON 解析失败: " + e.getMessage());
}
String to = body.get("to");
String subject = body.get("subject");
String content = body.get("content");
String contentType = body.getOrDefault("contentType", "html");
if (to == null || subject == null || content == null) {
return Result.fail(400, "缺少必填参数: to, subject, content");
}
// 第3步先发邮件成功后再消费 Token
SysEmailLog log = SysEmailLog.builder()
.toAddress(to)
.subject(subject)
.content(content)
.contentType(contentType)
.createdAt(LocalDateTime.now())
.build();
try {
if ("html".equals(contentType)) {
emailService.sendHtml(to, subject, content);
} else {
emailService.sendText(to, subject, content);
}
log.setStatus(1);
logAsyncService.saveEmailLog(log);
// 发邮件成功 → 消费 Token标记已用不可重试
consumeToken(tokenId);
recordConsumption(tokenId, request, "/zgapi/v1/open/email/send");
return Result.ok(Map.of("msg", "发送成功", "tokenConsumed", true));
} catch (Exception e) {
log.setStatus(0);
log.setErrorMsg(e.getMessage());
logAsyncService.saveEmailLog(log);
// 发送失败不消费 token允许重试
return Result.fail(500, "发送失败: " + e.getMessage());
}
}
// ==================== 私有方法 ====================
/**
* 验证 Open API Token 有效性(不消费)
* <p>仅校验 JWT 合法性 + Redis 中存在且未被消费,不做消费标记
*/
private String validateOpenToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) return null;
String token = authHeader.substring(7);
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) return null;
// 检查 Redis 中 token 是否存在
if (!redisUtil.exists(RedisUtil.key("open:token:" + tokenId))) return null;
// 检查是否已消费
String consumedKey = RedisUtil.key("open:token:" + tokenId) + ":consumed";
if (redisUtil.exists(consumedKey)) return null;
return tokenId;
} catch (Exception e) {
return null;
}
}
/**
* 消费 Token标记为已消费业务操作成功后调用
*/
private void consumeToken(String tokenId) {
redisUtil.set(RedisUtil.key("open:token:" + tokenId) + ":consumed", "1", OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES);
}
/**
* 记录 Token 消费日志
*/
private void recordConsumption(String tokenId, HttpServletRequest request, String api) {
try {
SysOpenTokenLog log = new SysOpenTokenLog();
log.setTokenId(tokenId);
log.setConsumed(1);
log.setConsumedAt(LocalDateTime.now());
log.setConsumeApi(api);
log.setConsumeIp(IpUtil.getClientIp(request));
// 根据 tokenId 更新记录
LambdaQueryWrapper<SysOpenTokenLog> w = new LambdaQueryWrapper<>();
w.eq(SysOpenTokenLog::getTokenId, tokenId);
logAsyncService.updateOpenTokenLog(log, w);
} catch (Exception ignored) {}
}
/**
* 验证 Open API Token 有效性(不消费),从原始 token 字符串直接验证
* <p>用于 token 不在 Authorization 头的场景(如 form 字段、query 参数)
*/
private String validateOpenTokenFromValue(String token) {
if (token == null || token.isEmpty()) return null;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) return null;
String tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) return null;
// 检查 Redis 中 token 是否存在且未被消费
if (!redisUtil.exists(RedisUtil.key("open:token:" + tokenId))) return null;
if (redisUtil.exists(RedisUtil.key("open:token:" + tokenId) + ":consumed")) return null;
return tokenId;
} catch (Exception e) {
return null;
}
}
/**
* 从 HttpServletRequest 读取 JSON 请求体,自动检测 UTF-8/GBK 编码
*/
@SuppressWarnings("unchecked")
private Map<String, String> readJsonBody(HttpServletRequest request) throws java.io.IOException {
byte[] bytes = IoUtil.readBytes(request.getInputStream());
if (bytes == null || bytes.length == 0) return java.util.Collections.emptyMap();
String json = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
if (json.indexOf('<27>') >= 0) {
json = new String(bytes, java.nio.charset.Charset.forName("GBK"));
}
return JSONUtil.toBean(json, Map.class);
}
}

View File

@@ -0,0 +1,147 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.service.RoleService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysRole;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 角色管理控制器
* <p>
* 管理各业务系统的角色定义,支持 CRUD 和菜单权限分配。
* <p>
* 重要设计:
* <ul>
* <li>角色隶属于具体的业务系统(通过 systemId 关联),不同系统的角色相互隔离</li>
* <li>角色包含 role_code如 "super_admin"、"admin"、"user"),用于网关的权限判断</li>
* <li>角色通过 sys_role_menu 中间表与菜单关联,实现 RBAC 权限模型</li>
* <li>分配菜单权限后会自动清除拥有该角色用户的 Redis 会话,强制重新登录刷新权限</li>
* </ul>
* <p>
* PUT 方法已修复:使用 {@code @PutMapping("/{id}")} + 手动 setter确保路径参数与实体一致。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/role}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/role")
@RequiredArgsConstructor
public class RoleController {
private final RoleService roleService;
/**
* 分页查询角色列表
* <p>
* 支持按 systemId精确匹配和 roleName模糊查询过滤。
* 查询结果中会通过联表查询填充 systemName 字段。
*
* @param params 请求参数 Map支持字段page, pageSize, systemId, roleName
* @return 分页结果
*/
@PostMapping("/page")
public Result<IPage<SysRole>> page(@RequestBody Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null;
String roleName = params.get("roleName") != null ? params.get("roleName").toString() : null;
return Result.ok(roleService.page(page, pageSize, systemId, roleName));
}
/**
* 查询角色详情
*
* @param id 角色主键ID
* @return 角色实体
*/
@GetMapping("/{id}")
public Result<SysRole> getById(@PathVariable String id) {
return Result.ok(roleService.getById(id));
}
/**
* 新增角色
* <p>
* 自动设置状态为启用,创建和更新时间自动填充。
*
* @param sysRole 角色实体(请求体 JSON
* @return 操作结果
*/
@OperationLog(module = "角色管理", action = "新增")
@PostMapping
public Result<?> add(@RequestBody SysRole sysRole) {
roleService.add(sysRole);
return Result.ok();
}
/**
* 编辑角色
* <p>
* 【已修复】使用 {@code @PutMapping("/{id}")} 明确接收路径参数,
* 并将 id 手动设置到实体中,避免 MyBatis-Plus updateById 因 id 为 null 而失败。
*
* @param id 要编辑的角色主键ID路径参数
* @param sysRole 角色实体(请求体 JSON包含要更新的字段
* @return 操作结果
*/
@OperationLog(module = "角色管理", action = "编辑")
@PutMapping("/{id}")
public Result<?> update(@PathVariable String id, @RequestBody SysRole sysRole) {
// 确保实体 id 与路径参数一致
sysRole.setId(id);
roleService.update(sysRole);
return Result.ok();
}
/**
* 删除角色逻辑删除is_deleted 标记为 1
*
* @param id 角色主键ID
* @return 操作结果
*/
@OperationLog(module = "角色管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
roleService.delete(id);
return Result.ok();
}
/**
* 查询角色已分配的菜单ID列表
* <p>
* 用于前端角色编辑页面中回显已选中的菜单树节点。
*
* @param id 角色主键ID
* @return 该角色拥有的菜单ID集合
*/
@GetMapping("/{id}/menu")
public Result<List<String>> getRoleMenus(@PathVariable String id) {
return Result.ok(roleService.getMenuIdsByRoleId(id));
}
/**
* 为角色分配菜单权限
* <p>
* 采用全量替换策略:先删除角色所有旧菜单关联,再批量插入新关联。
* 分配完成后会清除拥有该角色用户的 Redis 会话,强制重新登录以获取最新菜单权限。
*
* @param id 角色主键ID
* @param menuIds 菜单ID列表前端提交的完整选中集合
* @return 操作结果
*/
@OperationLog(module = "角色管理", action = "分配菜单")
@PostMapping("/{id}/menu")
public Result<?> assignMenus(@PathVariable String id, @RequestBody List<String> menuIds) {
roleService.assignMenus(id, menuIds);
return Result.ok();
}
}

View File

@@ -0,0 +1,117 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.service.SystemApiService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysApi;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 系统 API 管理控制器
* <p>
* 管理各业务系统的放行接口列表,支持分页查询和 CRUD。
* <p>
* 设计说明:
* <ul>
* <li>每个业务系统可以配置自己的公开 API 列表(无需鉴权的接口)</li>
* <li>这些 API 路径存储在 sys_api 表中,由网关在请求匹配时读取</li>
* <li>网关会检查请求路径是否在白名单中,在则跳过鉴权直接放行</li>
* <li>systemId 可选:不传时查询所有系统的 API传了则只查指定系统的</li>
* </ul>
* <p>
* PUT 方法已修复:使用 {@code @PutMapping("/{id}")} + 手动 setter。
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/system/api}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/system/api")
@RequiredArgsConstructor
public class SystemApiController {
private final SystemApiService systemApiService;
/**
* 分页查询 API 列表
* <p>
* systemId 为可选参数:不传时查询所有系统的 API传了则只查指定系统的。
* 用于配合前端系统选择器动态过滤。
*
* @param params 请求参数 Map支持字段page, pageSize, systemId可选
* @return 分页结果
*/
@PostMapping("/page")
public Result<IPage<SysApi>> page(@RequestBody Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null;
return Result.ok(systemApiService.page(page, pageSize, systemId));
}
/**
* 查询单个 API 详情
*
* @param id API 主键ID
* @return API 实体
*/
@GetMapping("/{id}")
public Result<SysApi> getById(@PathVariable String id) {
return Result.ok(systemApiService.getById(id));
}
/**
* 新增 API 放行接口
* <p>
* 新增后网关会在下次请求匹配时生效(网关从数据库加载白名单)。
*
* @param sysApi API 实体(请求体 JSON需包含 systemId、apiPath 等)
* @return 操作结果
*/
@OperationLog(module = "接口管理", action = "新增")
@PostMapping
public Result<?> add(@RequestBody SysApi sysApi) {
systemApiService.add(sysApi);
return Result.ok();
}
/**
* 编辑 API 接口
* <p>
* 【已修复】使用 {@code @PutMapping("/{id}")} 明确接收路径参数,
* 并将 id 手动设置到实体中,避免 MyBatis-Plus updateById 因 id 为 null 而失败。
*
* @param id 要编辑的 API 主键ID路径参数
* @param sysApi API 实体(请求体 JSON包含要更新的字段
* @return 操作结果
*/
@OperationLog(module = "接口管理", action = "编辑")
@PutMapping("/{id}")
public Result<?> update(@PathVariable String id, @RequestBody SysApi sysApi) {
// 确保实体 id 与路径参数一致
sysApi.setId(id);
systemApiService.update(sysApi);
return Result.ok();
}
/**
* 删除 API 接口
* <p>
* 删除后该路径将不再被网关白名单匹配,下次请求将需要鉴权。
*
* @param id API 主键ID
* @return 操作结果
*/
@OperationLog(module = "接口管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
systemApiService.delete(id);
return Result.ok();
}
}

View File

@@ -0,0 +1,179 @@
package com.mokee.admin.controller;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.service.SystemService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.dto.response.RoleInfo;
import com.mokee.common.dto.response.SystemVO;
import com.mokee.common.entity.SysSystem;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 业务系统管理控制器
* <p>
* 管理可接入 Mokee 网关的业务系统,提供 CRUD 和分页查询功能。
* <p>
* 权限模型:
* <ul>
* <li><b>super_admin @ gateway</b>:可以查看全部未删除的业务系统(调用 {@link #listAll()}</li>
* <li><b>普通管理员 / 应用管理员</b>:只能看到自己绑定的业务系统(调用 {@link #listByUserId(String)}</li>
* <li>权限判断依据网关在请求头中注入的 X-System-Code、X-User-Id、X-User-Roles</li>
* </ul>
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/system}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/system")
@RequiredArgsConstructor
public class SystemController {
private final SystemService systemService;
private final HttpServletRequest request;
/**
* 查询系统列表(按角色分级过滤)
* <p>
* 这是系统选择器/下拉菜单的核心接口。根据当前登录用户的角色决定返回哪些系统:
* <ol>
* <li>从请求头中解析 X-System-Code、X-User-Id、X-User-Roles</li>
* <li>如果当前在 gateway 系统下 且 用户拥有 super_admin 角色 → 返回全部未删除的系统</li>
* <li>否则 → 仅返回用户通过 sys_user_system 关联表绑定的系统</li>
* </ol>
* <p>
* 请求头来源:网关在请求转发时,从 Redis TokenInfo 中解析用户信息并注入到请求头。
*
* @return 包含系统列表的 Result列表项为 {@link SystemVO}
*/
@GetMapping("/list")
public Result<List<SystemVO>> listAll() {
// 从网关注入的请求头中获取权限上下文
String systemCode = request.getHeader("X-System-Code");
String userId = request.getHeader("X-User-Id");
String rolesJson = request.getHeader("X-User-Roles");
// 判断当前用户是否为 super_admin
final boolean isSuperAdmin;
if ("gateway".equals(systemCode) && StrUtil.isNotBlank(rolesJson)) {
List<RoleInfo> roles = JSONUtil.toList(rolesJson, RoleInfo.class);
isSuperAdmin = roles != null && roles.stream()
.anyMatch(r -> "super_admin".equals(r.getRoleCode()));
} else {
isSuperAdmin = false;
}
List<SysSystem> systems;
if (isSuperAdmin) {
// super_admin → 查询全部未删除的系统
systems = systemService.listAll();
} else {
// 普通用户 → 仅查询绑定的系统
systems = StrUtil.isNotBlank(userId) ? systemService.listByUserId(userId) : List.of();
}
// 将 SysSystem 实体转换为前端需要的 SystemVO精简字段不暴露敏感信息
// 非 super_admin 排除网关管理平台(系统下拉框中不应出现 gateway
List<SystemVO> voList = systems.stream()
.filter(s -> isSuperAdmin || !"gateway".equals(s.getSystemCode()))
.map(s -> new SystemVO(s.getId(), s.getSystemCode(), s.getSystemName(),
s.getFrontUrl(), s.getIcon(), s.getDescription()))
.collect(Collectors.toList());
return Result.ok(voList);
}
/**
* 通过文档令牌查询系统信息(公开接口,无需登录认证)
* <p>
* 用于文档页面通过 URL 中的 token 参数获取对应的业务系统信息。
*
* @param token 文档访问令牌(系统创建时自动生成的 16 位 UUID 子串)
* @return 系统实体,找不到时返回 null
*/
@GetMapping("/doc/{token}")
public Result<SysSystem> getByDocToken(@PathVariable String token) {
return Result.ok(systemService.getByDocToken(token));
}
/**
* 分页查询业务系统列表(支持按名称模糊查询和编码精确查询)
*
* @param params 请求参数 Map包含 page页码、pageSize每页条数
* systemName系统名称可选、systemCode系统编码可选
* @return 分页结果
*/
@PostMapping("/page")
public Result<IPage<SysSystem>> page(@RequestBody Map<String, Object> params) {
// 安全地从 Map 中提取各参数,提供默认值
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String systemName = params.get("systemName") != null ? params.get("systemName").toString() : null;
String systemCode = params.get("systemCode") != null ? params.get("systemCode").toString() : null;
return Result.ok(systemService.page(page, pageSize, systemName, systemCode));
}
/**
* 查询系统详情
*
* @param id 系统主键ID
* @return 系统实体
*/
@GetMapping("/{id}")
public Result<SysSystem> getById(@PathVariable String id) {
return Result.ok(systemService.getById(id));
}
/**
* 新增业务系统
* <p>
* 系统创建时自动生成文档令牌docToken默认状态为启用。
*
* @param sysSystem 系统实体(请求体 JSON
* @return 操作结果
*/
@OperationLog(module = "系统管理", action = "新增")
@PostMapping
public Result<?> add(@RequestBody SysSystem sysSystem) {
systemService.add(sysSystem);
return Result.ok();
}
/**
* 编辑业务系统
* <p>
* 使用 PUT 无路径参数方式id 包含在请求体中。
*
* @param sysSystem 系统实体(请求体 JSONid 必填)
* @return 操作结果
*/
@OperationLog(module = "系统管理", action = "编辑")
@PutMapping
public Result<?> update(@RequestBody SysSystem sysSystem) {
systemService.update(sysSystem);
return Result.ok();
}
/**
* 删除业务系统MyBatis-Plus 逻辑删除is_deleted 标记为 1
*
* @param id 系统主键ID
* @return 操作结果
*/
@OperationLog(module = "系统管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
systemService.delete(id);
return Result.ok();
}
}

View File

@@ -0,0 +1,222 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.admin.service.UserService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysUser;
import com.mokee.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 用户管理控制器
* <p>
* 管理平台用户的完整生命周期,包括 CRUD、绑定/解绑业务系统、分配角色等操作。
* <p>
* 核心功能:
* <ul>
* <li><b>分页查询</b>:支持按用户名、真实姓名、邮箱模糊查询</li>
* <li><b>新增用户</b>:密码使用 BCrypt 加密存储,默认状态启用</li>
* <li><b>编辑用户</b>:不更新密码字段(密码通过独立的密码修改接口处理)</li>
* <li><b>绑定系统</b>用户与业务系统的多对多关联sys_user_system 中间表)</li>
* <li><b>分配角色</b>用户在某系统下的角色分配sys_user_role 中间表),先删后增全量替换</li>
* <li><b>Redis 同步</b>:编辑用户信息后同步更新 Redis 中的 TokenInfo确保前端实时获取最新数据</li>
* </ul>
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/user}
*
* @author mokee
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/admin/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 分页查询用户列表
* <p>
* 支持按用户名、真实姓名、邮箱三个字段进行模糊查询,按创建时间倒序排列。
* 邮箱过滤参数email为扩展的查询条件用于支持按邮箱搜索用户。
*
* @param params 请求参数 Map支持字段page, pageSize, username, realName, email
* @return 分页结果
*/
@PostMapping("/page")
public Result<IPage<SysUser>> page(@RequestBody Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String username = params.get("username") != null ? params.get("username").toString() : null;
String realName = params.get("realName") != null ? params.get("realName").toString() : null;
// email 过滤:支持按邮箱搜索用户(例如找回账号场景)
String email = params.get("email") != null ? params.get("email").toString() : null;
return Result.ok(userService.page(page, pageSize, username, realName, email));
}
/**
* 查询用户详情
*
* @param id 用户主键ID
* @return 用户实体
*/
@GetMapping("/{id}")
public Result<SysUser> getById(@PathVariable String id) {
return Result.ok(userService.getById(id));
}
/**
* 新增用户
* <p>
* 密码在 Service 层使用 BCrypt 加密后存储,不会保存明文密码。
* 默认状态为启用status=1登录失败次数为 0。
*
* @param sysUser 用户实体(请求体 JSONpassword 为明文,由 Service 层加密)
* @return 操作结果
*/
@OperationLog(module = "用户管理", action = "新增")
@PostMapping
public Result<?> add(@RequestBody SysUser sysUser) {
String id = userService.add(sysUser);
return Result.ok(java.util.Collections.singletonMap("id", id));
}
/**
* 编辑用户信息
* <p>
* 不更新密码字段(密码通过单独的密码修改接口处理)。
* 编辑后同步刷新 Redis 中的 TokenInfo确保前端 fetchUserInfo 拿到最新数据。
*
* @param sysUser 用户实体(请求体 JSONid 必填)
* @return 操作结果
*/
@OperationLog(module = "用户管理", action = "编辑")
@PutMapping
public Result<?> update(@RequestBody SysUser sysUser) {
userService.update(sysUser);
return Result.ok();
}
/**
* 删除用户逻辑删除is_deleted 标记为 1
*
* @param id 用户主键ID
* @return 操作结果
*/
@OperationLog(module = "用户管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) {
userService.delete(id);
return Result.ok();
}
/**
* 用户绑定业务系统(全量替换)
* <p>
* 先删除用户所有旧系统绑定,再批量插入新绑定。
* 使用数据库事务保证原子性。
*
* @param id 用户主键ID
* @param systemIds 要绑定的系统ID列表
* @return 操作结果
*/
@OperationLog(module = "用户管理", action = "绑定系统")
@PostMapping("/{id}/system")
public Result<?> bindSystems(@PathVariable String id, @RequestBody List<String> systemIds) {
userService.bindSystems(id, systemIds);
return Result.ok();
}
/**
* 查询用户已绑定的系统ID列表
* <p>
* 用于前端用户编辑页面回显已绑定的系统。
*
* @param id 用户主键ID
* @return 用户绑定的系统ID列表
*/
@GetMapping("/{id}/system")
public Result<List<String>> getUserSystems(@PathVariable String id) {
return Result.ok(userService.getUserSystemIds(id));
}
/**
* 查询用户在指定系统下的角色ID列表
* <p>
* 用于前端用户编辑页面中回显用户在特定系统下已分配的角色。
*
* @param id 用户主键ID
* @param systemId 系统ID
* @return 用户在该系统下拥有的角色ID列表
*/
@GetMapping("/{id}/role/{systemId}")
public Result<List<String>> getUserRoles(@PathVariable String id, @PathVariable String systemId) {
return Result.ok(userService.getUserRoleIds(id, systemId));
}
/**
* 用户解绑所有业务系统
* <p>
* 清除该用户在 sys_user_system 表中的所有关联记录。
* 用于将用户从所有业务系统中移除。
*
* @param id 用户主键ID
* @return 操作结果
*/
@DeleteMapping("/{id}/system")
public Result<?> unbindSystems(@PathVariable String id) {
userService.unbindSystems(id);
return Result.ok();
}
/**
* 管理员重置用户密码
* <p>
* 仅 super_admin 角色可调用。接收目标用户 ID 和新密码,
* 使用 BCrypt 加密后存储,同时清除目标用户的 Redis 会话使其重新登录。
*
* @param params 请求参数 Map包含 id用户ID和 password新密码明文
* @return 操作结果
*/
@OperationLog(module = "用户管理", action = "重置密码")
@PutMapping("/password")
public Result<?> resetPassword(@RequestBody Map<String, Object> params) {
// 仅 super_admin 可重置他人密码
if (!UserContextHolder.isSuperAdmin()) {
throw new BusinessException(403, "仅超级管理员可重置用户密码");
}
String userId = params.get("id") != null ? params.get("id").toString() : null;
String password = params.get("password") != null ? params.get("password").toString() : null;
if (userId == null || password == null || password.isEmpty()) {
throw new BusinessException(400, "用户ID和新密码不能为空");
}
userService.resetPassword(userId, password);
return Result.ok();
}
/**
* 为指定用户在指定系统中分配角色
* <p>
* 采用先删后增的全量替换策略:删除用户所有旧角色关联,再插入新角色。
* 分配后清除用户 Redis 会话,强制重新登录以应用新权限。
*
* @param id 用户主键ID
* @param systemId 系统ID作为请求参数传递
* @param roleIds 角色ID列表
* @return 操作结果
*/
@PostMapping("/{id}/role")
public Result<?> assignRoles(@PathVariable String id,
@RequestParam String systemId,
@RequestBody List<String> roleIds) {
userService.assignRoles(id, systemId, roleIds);
return Result.ok();
}
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysApi;
import org.apache.ibatis.annotations.Mapper;
/**
* 系统 API Mapper
*/
@Mapper
public interface SysApiMapper extends BaseMapper<SysApi> {
}

View File

@@ -0,0 +1,14 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* 系统配置 Mapper
*
* @author mokee
*/
@Mapper
public interface SysConfigMapper extends BaseMapper<SysConfig> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysDict;
import org.apache.ibatis.annotations.Mapper;
/**
* 字典 Mapper
*/
@Mapper
public interface SysDictMapper extends BaseMapper<SysDict> {
}

View File

@@ -0,0 +1,9 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysEmailLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysEmailLogMapper extends BaseMapper<SysEmailLog> {
}

View File

@@ -0,0 +1,10 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysFile;
import org.apache.ibatis.annotations.Mapper;
/** 文件 Mapper */
@Mapper
public interface SysFileMapper extends BaseMapper<SysFile> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysLoginLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 登录日志 Mapper
*/
@Mapper
public interface SysLoginLogMapper extends BaseMapper<SysLoginLog> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysMenu;
import org.apache.ibatis.annotations.Mapper;
/**
* 菜单 Mapper
*/
@Mapper
public interface SysMenuMapper extends BaseMapper<SysMenu> {
}

View File

@@ -0,0 +1,9 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysOpenTokenLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysOpenTokenLogMapper extends BaseMapper<SysOpenTokenLog> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysOperationLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 操作日志 Mapper
*/
@Mapper
public interface SysOperationLogMapper extends BaseMapper<SysOperationLog> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysRole;
import org.apache.ibatis.annotations.Mapper;
/**
* 角色 Mapper
*/
@Mapper
public interface SysRoleMapper extends BaseMapper<SysRole> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysRoleMenu;
import org.apache.ibatis.annotations.Mapper;
/**
* 角色菜单关联 Mapper
*/
@Mapper
public interface SysRoleMenuMapper extends BaseMapper<SysRoleMenu> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysSystem;
import org.apache.ibatis.annotations.Mapper;
/**
* 系统信息 Mapper
*/
@Mapper
public interface SysSystemMapper extends BaseMapper<SysSystem> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户 Mapper
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysUserRole;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户角色关联 Mapper
*/
@Mapper
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
}

View File

@@ -0,0 +1,12 @@
package com.mokee.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mokee.common.entity.SysUserSystem;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户系统关联 Mapper
*/
@Mapper
public interface SysUserSystemMapper extends BaseMapper<SysUserSystem> {
}

View File

@@ -0,0 +1,49 @@
package com.mokee.admin.service;
import com.mokee.common.entity.SysConfig;
import java.util.List;
/**
* 系统配置服务接口
* <p>
* 提供键值对形式的系统配置读写,核心方法 getValue() 从内存缓存读取O(1)),避免每次查库。
*
* @author mokee
*/
public interface ConfigService {
/**
* 获取配置值(从内存缓存)
*
* @param key 配置键
* @param defaultValue 默认值key 不存在时返回)
* @return 配置值
*/
String getValue(String key, String defaultValue);
/**
* 按 key 查询完整实体
*/
SysConfig getByKey(String key);
/**
* 查询全部配置列表
*/
List<SysConfig> listAll();
/**
* 新增或更新配置(按 configKey upsert
*/
void save(SysConfig config);
/**
* 删除配置
*/
void delete(String id);
/**
* 刷新内存缓存(查询全表重新加载)
*/
void refreshCache();
}

View File

@@ -0,0 +1,83 @@
package com.mokee.admin.service;
import com.mokee.common.entity.SysDict;
import java.util.List;
/**
* 字典服务接口
* <p>
* 提供数据字典的完整 CRUD 操作及按类型查询功能。
* <p>
* 数据字典是全局配置数据,不区分业务系统。典型应用场景:
* <ul>
* <li>前端下拉框、单选框、复选框的选项数据源</li>
* <li>状态码与显示名称的映射(如 user_status: 0=禁用, 1=启用)</li>
* <li>业务类型枚举值的动态配置</li>
* </ul>
* <p>
* 查询逻辑:
* <ul>
* <li><b>listTypes()</b> — 从全表提取去重的 dictType 值,用于前端类型筛选</li>
* <li><b>listByType(String)</b> — 按类型查询启用的字典项,按 sortOrder 升序排列</li>
* </ul>
*
* @author mokee
* @see com.mokee.admin.service.impl.DictServiceImpl
*/
public interface DictService {
/**
* 获取所有字典类型列表(去重后按字母排序)
* <p>
* 遍历全部字典记录提取唯一的 dictType 值。
* 用于前端字典管理的类型筛选下拉框。
*
* @return 去重并排序后的字典类型字符串列表
*/
List<String> listTypes();
/**
* 按字典类型查询启用的字典项列表
* <p>
* 过滤条件dictType 匹配 + status=1启用
* 排序:按 sortOrder 升序,确保前端下拉框选项顺序可控。
*
* @param type 字典类型代码(如 "user_status"
* @return 该类型下所有启用的字典项,按排序号升序
*/
List<SysDict> listByType(String type);
/**
* 根据主键ID查询字典项
*
* @param id 字典项ID
* @return 字典项实体,不存在返回 null
*/
SysDict getById(String id);
/**
* 新增字典项
* <p>
* 注意:需在业务层面确保同一 dictType 下 dictValue 唯一。
*
* @param dict 字典项实体
*/
void add(SysDict dict);
/**
* 编辑字典项
*
* @param dict 字典项实体id 必填)
*/
void update(SysDict dict);
/**
* 删除字典项(物理删除,不可恢复)
* <p>
* 注意:与用户/角色/系统的逻辑删除不同,字典表使用物理删除。
*
* @param id 字典项ID
*/
void delete(String id);
}

View File

@@ -0,0 +1,28 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysFile;
import java.util.List;
/**
* 文件服务接口 — 仅提供元信息查询
* <p>
* 文件上传/下载/删除已迁移至外部文件对象存储服务。
*
* @author mokee
*/
public interface FileService {
/** 分页查询(不含 file_data支持 fileName/uploadUserName 模糊搜索 */
IPage<SysFile> page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId);
/** 列表查询(不含 file_data */
List<SysFile> list(String fileType, String systemId);
/** 查询完整记录(含 file_data 兜底,存量兼容) */
SysFile getById(String id);
/** 查询元信息(不含 file_data */
SysFile getMetaById(String id);
}

View File

@@ -0,0 +1,77 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.admin.mapper.SysEmailLogMapper;
import com.mokee.admin.mapper.SysOpenTokenLogMapper;
import com.mokee.admin.mapper.SysOperationLogMapper;
import com.mokee.common.entity.SysEmailLog;
import com.mokee.common.entity.SysOpenTokenLog;
import com.mokee.common.entity.SysOperationLog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 异步日志写入服务
* <p>
* 将审计日志Token日志、邮件日志、操作日志的写入从主业务线程剥离
* 避免数据库日志写入阻塞接口响应,提升接口性能。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LogAsyncService {
private final SysOpenTokenLogMapper openTokenLogMapper;
private final SysEmailLogMapper emailLogMapper;
private final SysOperationLogMapper operationLogMapper;
/**
* 异步写入 Open API Token 获取日志
*/
@Async("logTaskExecutor")
public void saveOpenTokenLog(SysOpenTokenLog logEntity) {
try {
openTokenLogMapper.insert(logEntity);
} catch (Exception e) {
log.error("异步写入 Token 日志失败 - tokenId: {}", logEntity.getTokenId(), e);
}
}
/**
* 异步更新 Open API Token 日志(标记消费状态)
*/
@Async("logTaskExecutor")
public void updateOpenTokenLog(SysOpenTokenLog logEntity, LambdaQueryWrapper<SysOpenTokenLog> wrapper) {
try {
openTokenLogMapper.update(logEntity, wrapper);
} catch (Exception e) {
log.error("异步更新 Token 日志失败 - tokenId: {}", logEntity.getTokenId(), e);
}
}
/**
* 异步写入邮件发送日志
*/
@Async("logTaskExecutor")
public void saveEmailLog(SysEmailLog logEntity) {
try {
emailLogMapper.insert(logEntity);
} catch (Exception e) {
log.error("异步写入邮件日志失败 - to: {}", logEntity.getToAddress(), e);
}
}
/**
* 异步写入操作日志
*/
@Async("logTaskExecutor")
public void saveOperationLog(SysOperationLog logEntity) {
try {
operationLogMapper.insert(logEntity);
} catch (Exception e) {
log.error("异步写入操作日志失败 - url: {}", logEntity.getRequestUrl(), e);
}
}
}

View File

@@ -0,0 +1,67 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysLoginLog;
import com.mokee.common.entity.SysOperationLog;
import java.util.Map;
/**
* 日志服务接口
* <p>
* 提供登录日志和操作日志的分页查询功能。
* <p>
* 设计说明:
* <ul>
* <li>参数统一使用 {@code Map<String, Object>} 传递,便于前端动态组合查询条件</li>
* <li>登录日志查询完成后,需填充 systemName 字段SysLoginLog.systemName 为数据库不存在的 transient 字段)</li>
* <li>操作日志的 module 字段支持模糊查询action 字段支持精确匹配</li>
* </ul>
* <p>
* 日志写入侧:
* <ul>
* <li>登录日志由网关的登录过滤器写入(不经过此 Service</li>
* <li>操作日志由 AOP 切面(@OperationLog 注解)自动写入</li>
* </ul>
*
* @author mokee
* @see com.mokee.admin.service.impl.LogServiceImpl
*/
public interface LogService {
/**
* 登录日志分页查询
* <p>
* 支持的查询参数(全部可选):
* <ul>
* <li>page / pageSize — 分页参数</li>
* <li>username — 用户名精确匹配</li>
* <li>status — 登录状态过滤0=失败, 1=成功)</li>
* <li>startTime / endTime — 时间范围过滤格式yyyy-MM-dd HH:mm:ss</li>
* </ul>
* 结果按 created_at 倒序排列(最新登录记录在前)。
* 每条记录的 systemName 字段会通过联表查询自动填充。
*
* @param params 查询参数 Map
* @return 分页结果,包含已填充 systemName 的 SysLoginLog 列表
*/
IPage<SysLoginLog> loginLogPage(Map<String, Object> params);
/**
* 操作日志分页查询
* <p>
* 支持的查询参数(全部可选):
* <ul>
* <li>page / pageSize — 分页参数</li>
* <li>username — 操作人用户名精确匹配</li>
* <li>module — 操作模块模糊查询LIKE '%module%'</li>
* <li>action — 操作类型精确匹配(如 "新增"、"编辑"、"删除"</li>
* <li>startTime / endTime — 时间范围过滤</li>
* </ul>
* 结果按 created_at 倒序排列。
*
* @param params 查询参数 Map
* @return 分页结果
*/
IPage<SysOperationLog> operationLogPage(Map<String, Object> params);
}

View File

@@ -0,0 +1,51 @@
package com.mokee.admin.service;
import com.mokee.common.dto.response.MenuRouterVO;
import com.mokee.common.entity.SysMenu;
import java.util.List;
/**
* 菜单管理服务接口
* <p>
* 管理各业务系统的菜单树,支持树形查询和 CRUD 操作
*/
public interface MenuService {
/**
* 查询指定系统的完整菜单树(递归构建)
*
* @param systemId 系统ID
* @return 菜单路由树列表(根节点为 parentId=0 的菜单)
*/
List<MenuRouterVO> getMenuTree(String systemId);
/**
* 根据主键ID查询菜单详情
*
* @param id 菜单ID
* @return 菜单实体
*/
SysMenu getById(String id);
/**
* 新增菜单(默认状态为启用)
*
* @param sysMenu 菜单实体
*/
void add(SysMenu sysMenu);
/**
* 编辑菜单信息
*
* @param sysMenu 菜单实体根据ID更新
*/
void update(SysMenu sysMenu);
/**
* 删除菜单
*
* @param id 菜单ID
*/
void delete(String id);
}

View File

@@ -0,0 +1,67 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysRole;
import java.util.List;
/**
* 角色管理服务接口
* <p>
* 管理各业务系统的角色支持分页查询、CRUD 和菜单权限分配
*/
public interface RoleService {
/**
* 分页查询角色列表
*
* @param page 当前页码
* @param size 每页条数
* @param systemId 系统ID可选
* @param roleName 角色名称(模糊查询,可选)
* @return 分页结果
*/
IPage<SysRole> page(Integer page, Integer size, String systemId, String roleName);
/**
* 根据主键ID查询角色详情
*
* @param id 角色ID
* @return 角色实体
*/
SysRole getById(String id);
/**
* 新增角色(默认状态为启用)
*
* @param sysRole 角色实体
*/
void add(SysRole sysRole);
/**
* 编辑角色信息
*
* @param sysRole 角色实体根据ID更新
*/
void update(SysRole sysRole);
/**
* 删除角色(逻辑删除)
*
* @param id 角色ID
*/
void delete(String id);
/**
* 为角色分配菜单权限
* <p>
* 先清除旧菜单关联再插入新关联,实现全量替换
*
* @param roleId 角色ID
* @param menuIds 菜单ID列表
*/
void assignMenus(String roleId, List<String> menuIds);
/** 查询角色已分配的菜单ID列表 */
List<String> getMenuIdsByRoleId(String roleId);
}

View File

@@ -0,0 +1,48 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysApi;
/**
* 系统 API 服务接口
* <p>
* 管理各业务系统在网关中放行的 API 接口列表支持按系统ID查询和 CRUD 操作
*/
public interface SystemApiService {
/**
* 分页查询指定系统的 API 列表
*
* @param page 当前页码
* @param size 每页条数
* @param systemId 系统ID
* @return 分页结果
*/
IPage<SysApi> page(Integer page, Integer size, String systemId);
/**
* 根据 ID 查询单个 API
*/
SysApi getById(String id);
/**
* 新增 API 接口(默认状态为启用)
*
* @param sysApi API实体
*/
void add(SysApi sysApi);
/**
* 编辑 API 接口信息
*
* @param sysApi API实体根据ID更新
*/
void update(SysApi sysApi);
/**
* 删除 API 接口
*
* @param id API ID
*/
void delete(String id);
}

View File

@@ -0,0 +1,71 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.mokee.admin.mapper.SysSystemMapper;
import com.mokee.common.entity.SysSystem;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* 系统信息本地缓存服务
* <p>
* 用 Caffeine 缓存 systemCode → SysSystem 映射,避免每次 Token 请求都跨子网查 DB。
* TTL 5 分钟,系统信息变更后可通过 {@link #evict(String)} 主动失效。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SystemCacheService {
private final SysSystemMapper sysSystemMapper;
private Cache<String, SysSystem> cache;
@PostConstruct
public void init() {
cache = Caffeine.newBuilder()
.maximumSize(200)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
}
/**
* 按 systemCode 获取系统信息(优先缓存,缓存未命中时查 DB 并回填)
*/
public SysSystem getByCode(String systemCode) {
SysSystem system = cache.getIfPresent(systemCode);
if (system != null) {
return system;
}
system = sysSystemMapper.selectOne(
new LambdaQueryWrapper<SysSystem>()
.eq(SysSystem::getSystemCode, systemCode)
.eq(SysSystem::getStatus, 1)
);
if (system != null) {
cache.put(systemCode, system);
}
return system;
}
/**
* 按 ID 获取系统信息(直接查 DB低频调用不做缓存
*/
public SysSystem getById(String systemId) {
return sysSystemMapper.selectById(systemId);
}
/**
* 主动失效指定 systemCode 的缓存(系统信息变更后调用)
*/
public void evict(String systemCode) {
cache.invalidate(systemCode);
}
}

View File

@@ -0,0 +1,103 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysSystem;
/**
* 业务系统服务接口
* <p>
* 定义业务系统的增删改查及分页查询方法。
* <p>
* 关键方法说明:
* <ul>
* <li><b>listAll()</b> — 查询全部未删除的系统,仅供 super_admin 角色使用</li>
* <li><b>listByUserId(String)</b> — 通过 sys_user_system 中间表 JOIN 查询用户绑定的系统,供普通管理员使用</li>
* <li><b>getByDocToken(String)</b> — 公开接口,无需认证,通过文档令牌查询系统信息</li>
* <li><b>add(SysSystem)</b> — 新增时自动生成 16 位文档令牌和设置默认状态</li>
* </ul>
* <p>
* 权限模型Controller 层根据用户角色决定调用 listAll 还是 listByUserId
* Service 层不感知权限逻辑,只负责数据查询。
*
* @author mokee
* @see com.mokee.admin.service.impl.SystemServiceImpl
*/
public interface SystemService {
/**
* 分页查询业务系统列表
*
* @param page 当前页码(从 1 开始)
* @param size 每页条数
* @param systemName 系统名称(模糊查询,可选,传 null 则不筛选)
* @param systemCode 系统编码(精确查询,可选,传 null 则不筛选)
* @return 分页结果,按 sort_order 升序排列
*/
IPage<SysSystem> page(Integer page, Integer size, String systemName, String systemCode);
/**
* 根据主键ID查询系统详情
*
* @param id 系统IDUUID 字符串)
* @return 系统实体,不存在返回 null
*/
SysSystem getById(String id);
/**
* 新增业务系统
* <p>
* 自动设置创建/更新时间、启用状态status=1、生成文档访问令牌。
*
* @param sysSystem 系统实体id 可为空,由 MyBatis-Plus 自动生成)
*/
void add(SysSystem sysSystem);
/**
* 编辑业务系统信息
* <p>
* 根据实体中的 id 更新对应记录,自动刷新 updatedAt 时间戳。
*
* @param sysSystem 系统实体id 必填,其余字段为要更新的值)
*/
void update(SysSystem sysSystem);
/**
* 通过文档令牌查询系统信息(公开接口,无需登录认证)
* <p>
* 只返回启用状态status=1的系统。
*
* @param token 文档访问令牌(系统创建时自动生成的 16 位字符串)
* @return 系统实体,不存在或已停用返回 null
*/
SysSystem getByDocToken(String token);
/**
* 查询全部未删除的系统
* <p>
* 仅 super_admin 角色使用,返回所有 is_deleted=0 的系统记录,
* 按 system_code 升序排列。
*
* @return 全部未删除的系统列表
*/
java.util.List<SysSystem> listAll();
/**
* 查询用户绑定的系统列表
* <p>
* 通过 INNER JOIN sys_user_system 表查询当前用户有权限访问的系统。
* 使用原生 SQL 执行 JOIN 查询,因为 MyBatis-Plus 的关联查询能力有限。
*
* @param userId 用户ID
* @return 用户绑定的系统列表(按 system_code 升序)
*/
java.util.List<SysSystem> listByUserId(String userId);
/**
* 删除业务系统MyBatis-Plus 逻辑删除)
* <p>
* 将 is_deleted 标记为 1不会物理删除记录。
*
* @param id 系统ID
*/
void delete(String id);
}

View File

@@ -0,0 +1,142 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysUser;
import java.util.List;
/**
* 用户管理服务接口
* <p>
* 管理平台用户的完整生命周期包括分页查询、CRUD、绑定/解绑业务系统、分配角色等操作。
* <p>
* 核心功能概要:
* <ul>
* <li><b>分页查询</b>:支持按 username、realName、email 三个字段模糊查询,按创建时间倒序</li>
* <li><b>新增用户</b>:密码使用 BCrypt 加密后存储,不会保存明文</li>
* <li><b>编辑用户</b>:不更新密码字段(密码通过独立的密码修改接口处理),编辑后同步 Redis TokenInfo</li>
* <li><b>绑定系统</b>:全量替换策略(先删旧关联,再插新关联),@Transactional 保证原子性</li>
* <li><b>分配角色</b>:先删旧角色,再插新角色,然后清除 Redis 会话强制重新登录</li>
* </ul>
*
* @author mokee
* @see com.mokee.admin.service.impl.UserServiceImpl
*/
public interface UserService {
/**
* 分页查询用户列表
*
* @param page 当前页码(从 1 开始)
* @param size 每页条数
* @param username 用户名(模糊查询,可选)
* @param realName 真实姓名(模糊查询,可选)
* @param email 邮箱(模糊查询,可选,用于按邮箱搜索用户)
* @return 分页结果,按创建时间倒序排列
*/
IPage<SysUser> page(Integer page, Integer size, String username, String realName, String email);
/**
* 根据主键ID查询用户详情
*
* @param id 用户ID
* @return 用户实体
*/
SysUser getById(String id);
/**
* 新增用户
* <p>
* 密码在 Service 层使用 BCrypt 加密后存储。
* 默认状态为启用status=1登录失败次数为 0。
*
* @param sysUser 用户实体password 为明文,由 Service 层加密)
* @return 新用户的 IDUUID
*/
String add(SysUser sysUser);
/**
* 编辑用户信息
* <p>
* 不更新密码字段(密码通过独立的密码修改接口处理)。
* 编辑后同步刷新 Redis 中的 TokenInfo确保前端实时获取最新用户信息。
*
* @param sysUser 用户实体id 必填)
*/
void update(SysUser sysUser);
/**
* 删除用户逻辑删除is_deleted 标记为 1
*
* @param id 用户ID
*/
void delete(String id);
/**
* 为用户绑定业务系统(全量替换)
* <p>
* 先删除用户所有旧系统绑定,再批量插入新绑定。
* 使用 @Transactional 保证原子性。
*
* @param userId 用户ID
* @param systemIds 要绑定的系统ID列表
*/
void bindSystems(String userId, List<String> systemIds);
/**
* 解除用户的所有业务系统绑定
* <p>
* 删除 sys_user_system 表中该用户的所有关联记录。
*
* @param userId 用户ID
*/
void unbindSystems(String userId);
/**
* 为用户在指定系统中分配角色
* <p>
* 采用先删后增的全量替换策略:
* <ol>
* <li>删除该用户的所有旧角色关联(不限于指定系统)</li>
* <li>逐条插入新的角色关联</li>
* <li>清除用户 Redis 会话,强制重新登录以应用新权限</li>
* </ol>
*
* @param userId 用户ID
* @param systemId 系统ID
* @param roleIds 角色ID列表
*/
void assignRoles(String userId, String systemId, List<String> roleIds);
/**
* 查询用户绑定的系统ID列表
* <p>
* 从 sys_user_system 表中查询该用户的所有关联记录。
*
* @param userId 用户ID
* @return 系统ID列表
*/
List<String> getUserSystemIds(String userId);
/**
* 查询用户在指定系统下的角色ID列表
* <p>
* 从 sys_user_role 表中查询该用户的所有角色关联。
* 注意:当前实现未按 systemId 过滤简化处理返回用户所有角色ID。
*
* @param userId 用户ID
* @param systemId 系统ID当前未使用保留参数兼容
* @return 角色ID列表
*/
List<String> getUserRoleIds(String userId, String systemId);
/**
* 管理员重置用户密码
* <p>
* 仅 super_admin 角色可调用。密码使用 BCrypt 加密后存储。
*
* @param userId 目标用户ID
* @param newPassword 新密码(明文,由 Service 层 BCrypt 加密)
*/
void resetPassword(String userId, String newPassword);
}

View File

@@ -0,0 +1,91 @@
package com.mokee.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.admin.mapper.SysConfigMapper;
import com.mokee.admin.service.ConfigService;
import com.mokee.common.entity.SysConfig;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 系统配置服务实现
* <p>
* 使用 ConcurrentHashMap 内存缓存全部配置项,启动时加载。
* getValue() 为热点方法直接从内存读取O(1),无 DB 查询)。
* save/delete 操作后自动刷新缓存。
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ConfigServiceImpl implements ConfigService {
private final SysConfigMapper configMapper;
/** 内存缓存configKey → configValue */
private final Map<String, String> cache = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
refreshCache();
log.info("系统配置缓存已加载 — {} 项", cache.size());
}
@Override
public String getValue(String key, String defaultValue) {
return cache.getOrDefault(key, defaultValue);
}
@Override
public SysConfig getByKey(String key) {
return configMapper.selectOne(
new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
}
@Override
public List<SysConfig> listAll() {
return configMapper.selectList(
new LambdaQueryWrapper<SysConfig>().orderByAsc(SysConfig::getConfigKey));
}
@Override
public void save(SysConfig config) {
// 按 configKey 查重,存在则更新
SysConfig existing = configMapper.selectOne(
new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, config.getConfigKey()));
if (existing != null) {
config.setId(existing.getId());
config.setUpdatedAt(LocalDateTime.now());
configMapper.updateById(config);
} else {
config.setUpdatedAt(LocalDateTime.now());
configMapper.insert(config);
}
refreshCache();
}
@Override
public void delete(String id) {
configMapper.deleteById(id);
refreshCache();
}
@Override
public void refreshCache() {
cache.clear();
List<SysConfig> all = configMapper.selectList(null);
for (SysConfig c : all) {
if (c.getConfigValue() != null) {
cache.put(c.getConfigKey(), c.getConfigValue());
}
}
}
}

View File

@@ -0,0 +1,130 @@
package com.mokee.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.admin.mapper.SysDictMapper;
import com.mokee.admin.service.DictService;
import com.mokee.common.entity.SysDict;
import com.mokee.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 字典服务实现
* <p>
* 基于 MyBatis-Plus 实现数据字典的 CRUD 和按类型查询。
* <p>
* 实现细节:
* <ul>
* <li><b>listTypes()</b>:先查询全表,再在内存中做流式去重和排序。
* 全表数据量通常很小(几百条),内存处理性能足够。</li>
* <li><b>listByType()</b>:使用 LambdaQueryWrapper 精确过滤 dictType 和 status
* 按 sortOrder 升序排列。前端下拉框按照此顺序展示。</li>
* </ul>
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DictServiceImpl implements DictService {
private final SysDictMapper sysDictMapper;
/**
* 获取所有字典类型列表(去重后按字母排序)
* <p>
* 实现方式:查询全表 → 提取 dictType → 去重 → 排序。
* 假设字典表数据量不大(通常 < 500 条),全表扫描性能可接受。
*
* @return 去重并排序后的字典类型列表
*/
@Override
public List<String> listTypes() {
return sysDictMapper.selectList(null).stream()
.map(SysDict::getDictType) // 提取字典类型字段
.distinct() // 去重
.sorted() // 按字母排序
.collect(java.util.stream.Collectors.toList());
}
/**
* 按字典类型查询启用的字典项,按排序号升序排列
* <p>
* 过滤条件:
* <ul>
* <li>dictType 精确匹配</li>
* <li>status = 1仅返回启用状态的字典项</li>
* </ul>
* 排序sortOrder 升序,数值小的排在前面。
*
* @param type 字典类型代码
* @return 该类型的启用字典项列表
*/
@Override
public List<SysDict> listByType(String type) {
LambdaQueryWrapper<SysDict> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysDict::getDictType, type); // 按字典类型精确匹配
wrapper.eq(SysDict::getStatus, 1); // 只返回启用状态的项
wrapper.orderByAsc(SysDict::getSortOrder); // 按排序号升序
return sysDictMapper.selectList(wrapper);
}
/**
* 根据主键ID查询字典项
*
* @param id 字典项ID
* @return 字典项实体
*/
@Override
public SysDict getById(String id) {
return sysDictMapper.selectById(id);
}
/**
* 新增字典项
*
* @param dict 字典项实体
*/
@Override
public void add(SysDict dict) {
// 检查同一字典类型下值唯一性
if (sysDictMapper.selectCount(
new LambdaQueryWrapper<SysDict>()
.eq(SysDict::getDictType, dict.getDictType())
.eq(SysDict::getDictValue, dict.getDictValue())) > 0) {
throw new BusinessException("该字典类型下值已存在: " + dict.getDictType() + " / " + dict.getDictValue());
}
sysDictMapper.insert(dict);
log.info("新增字典项 - dictType: {}, dictLabel: {}", dict.getDictType(), dict.getDictLabel());
}
/**
* 编辑字典项
* <p>
* 根据实体中的 id 更新对应记录。
*
* @param dict 字典项实体id 必填)
*/
@Override
public void update(SysDict dict) {
sysDictMapper.updateById(dict);
log.info("编辑字典项 - id: {}", dict.getId());
}
/**
* 删除字典项(物理删除,直接从数据库中移除)
* <p>
* 注意:与系统的其他实体不同,字典表不使用逻辑删除。
* 删除后数据不可恢复。
*
* @param id 字典项ID
*/
@Override
public void delete(String id) {
sysDictMapper.deleteById(id);
log.info("删除字典项 - id: {}", id);
}
}

View File

@@ -0,0 +1,75 @@
package com.mokee.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.admin.mapper.SysFileMapper;
import com.mokee.admin.service.FileService;
import com.mokee.common.entity.SysFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 文件服务实现 — 仅提供元信息查询
* <p>
* 文件上传/下载/删除已迁移至外部文件对象存储服务(file.wg.zgitm.com)
* 外部服务直接写入 sys_file 表,本服务只做查询。
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileServiceImpl implements FileService {
private final SysFileMapper sysFileMapper;
@Override
public IPage<SysFile> page(Integer page, Integer size, String fileType,
String fileName, String uploadUserName, String systemId) {
LambdaQueryWrapper<SysFile> w = new LambdaQueryWrapper<>();
if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType);
if (fileName != null && !fileName.isEmpty()) w.like(SysFile::getFileName, fileName);
if (uploadUserName != null && !uploadUserName.isEmpty()) w.like(SysFile::getUploadUserName, uploadUserName);
if (systemId != null) w.eq(SysFile::getSystemId, systemId);
// 数据权限:非 super_admin 只能看到绑定系统的文件
if (!UserContextHolder.isSuperAdmin()) {
List<String> scope = UserContextHolder.getSystemIds();
if (scope != null && !scope.isEmpty()) {
w.in(SysFile::getSystemId, scope);
} else {
w.eq(SysFile::getSystemId, "__NONE__");
}
}
w.orderByDesc(SysFile::getCreatedAt);
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
return sysFileMapper.selectPage(new Page<>(page, size), w);
}
@Override
public List<SysFile> list(String fileType, String systemId) {
LambdaQueryWrapper<SysFile> w = new LambdaQueryWrapper<>();
if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType);
if (systemId != null) w.eq(SysFile::getSystemId, systemId);
w.orderByDesc(SysFile::getCreatedAt);
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
return sysFileMapper.selectList(w);
}
@Override
public SysFile getById(String id) {
return sysFileMapper.selectById(id);
}
@Override
public SysFile getMetaById(String id) {
LambdaQueryWrapper<SysFile> w = new LambdaQueryWrapper<>();
w.eq(SysFile::getId, id);
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
return sysFileMapper.selectOne(w);
}
}

View File

@@ -0,0 +1,190 @@
package com.mokee.admin.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.admin.mapper.SysLoginLogMapper;
import com.mokee.admin.mapper.SysOperationLogMapper;
import com.mokee.admin.service.LogService;
import com.mokee.common.entity.SysLoginLog;
import com.mokee.common.entity.SysOperationLog;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 日志服务实现
* <p>
* 基于 MyBatis-Plus 实现登录日志和操作日志的分页查询。
* <p>
* 关键实现细节:
* <ul>
* <li><b>参数提取</b>:使用私有辅助方法 intVal/strVal 安全地从 Map 中提取参数并处理 null 情况</li>
* <li><b>登录日志 systemName 填充</b>:查询分页结果后,遍历每条记录,通过 systemId 联表查询 sys_system 表获取系统名称,
* 填充到 SysLoginLog 的 transient 字段 systemName 中。每条记录单独查询,在数据量较小时可接受,
* 大数据量场景可优化为批量查询(一次性查出所有 systemId 对应的名称后填充)</li>
* <li><b>操作日志 module 模糊查询</b>:使用 LIKE 匹配,支持部分关键词搜索</li>
* <li><b>时间范围过滤</b>:使用 ge>= startTime和 le<= endTime支持只传开始或只传结束时间</li>
* </ul>
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LogServiceImpl implements LogService {
private final SysLoginLogMapper sysLoginLogMapper;
private final SysOperationLogMapper sysOperationLogMapper;
private final JdbcTemplate jdbcTemplate;
/**
* 从参数 Map 中安全获取整数值
* <p>
* 处理逻辑key 不存在或值为 null → 返回默认值;值存在 → 转为 int。
*
* @param p 参数 Map
* @param key 参数键名
* @param fallback 默认值(当 key 不存在或值为 null 时返回)
* @return 整数值
*/
private int intVal(Map<String, Object> p, String key, int fallback) {
Object v = p.get(key);
return v != null ? ((Number) v).intValue() : fallback;
}
/**
* 从参数 Map 中安全获取字符串值
*
* @param p 参数 Map
* @param key 参数键名
* @return 字符串值key 不存在或值为 null 时返回 null
*/
private String strVal(Map<String, Object> p, String key) {
Object v = p.get(key);
return v != null ? v.toString() : null;
}
/**
* 登录日志分页查询
* <p>
* 查询流程:
* <ol>
* <li>从 params 中提取分页和过滤参数</li>
* <li>使用 LambdaQueryWrapper 构建动态查询条件(只添加非空的过滤条件)</li>
* <li>执行分页查询(按创建时间倒序)</li>
* <li>遍历结果集,通过 systemId 查询 sys_system 表填充 systemName 字段</li>
* </ol>
* <p>
* systemName 填充说明:
* SysLoginLog.systemName 是 @TableField(exist=false) 标记的 transient 字段,
* 不存储在数据库中。前端展示时需要显示系统名称而非系统ID
* 因此在查询后通过 JdbcTemplate 逐条查询补充。
* 异常被忽略catch ignored即使某条记录的系统已删除也不会影响整体查询。
*
* @param params 查询参数 Map
* @return 分页结果,包含已填充 systemName 的日志列表
*/
@Override
public IPage<SysLoginLog> loginLogPage(Map<String, Object> params) {
int page = intVal(params, "page", 1);
int size = intVal(params, "pageSize", 10);
String username = strVal(params, "username");
String status = strVal(params, "status");
String startTime = strVal(params, "startTime");
String endTime = strVal(params, "endTime");
// 构建动态查询条件
LambdaQueryWrapper<SysLoginLog> wrapper = new LambdaQueryWrapper<>();
if (StrUtil.isNotBlank(username)) {
// 用户名精确匹配
wrapper.eq(SysLoginLog::getUsername, username);
}
if (StrUtil.isNotBlank(status)) {
// 登录状态过滤:前端传递字符串 "0" 或 "1",转为整数
wrapper.eq(SysLoginLog::getStatus, Integer.parseInt(status));
}
if (StrUtil.isNotBlank(startTime)) {
// 起始时间 >= startTime
wrapper.ge(SysLoginLog::getCreatedAt, startTime);
}
if (StrUtil.isNotBlank(endTime)) {
// 结束时间 <= endTime
wrapper.le(SysLoginLog::getCreatedAt, endTime);
}
// 数据权限:非 super_admin 只能查看绑定系统的日志
if (!UserContextHolder.isSuperAdmin()) {
java.util.List<String> scope = UserContextHolder.getSystemIds();
if (scope != null && !scope.isEmpty()) {
wrapper.in(SysLoginLog::getSystemId, scope);
}
}
wrapper.orderByDesc(SysLoginLog::getCreatedAt);
IPage<SysLoginLog> result = sysLoginLogMapper.selectPage(new Page<>(page, size), wrapper);
// 遍历结果集逐条填充系统名称SysLoginLog.systemName 为 transient 字段)
for (SysLoginLog log : result.getRecords()) {
if (StrUtil.isNotBlank(log.getSystemId())) {
try {
// 通过 systemId 查询 sys_system 表获取系统名称
String name = jdbcTemplate.queryForObject(
"SELECT system_name FROM sys_system WHERE id=? AND is_deleted=0",
String.class, log.getSystemId());
log.setSystemName(name);
} catch (Exception ignored) {
// 忽略单条查询异常(如系统已被删除),不影响整体分页结果
}
}
}
return result;
}
/**
* 操作日志分页查询
* <p>
* 支持按操作人用户名(精确)、操作模块(模糊)、操作类型(精确)、时间范围过滤。
* 操作日志不需要填充 systemName因为操作日志表本身不关联系统。
*
* @param params 查询参数 Map
* @return 分页结果,按创建时间倒序
*/
@Override
public IPage<SysOperationLog> operationLogPage(Map<String, Object> params) {
int page = intVal(params, "page", 1);
int size = intVal(params, "pageSize", 10);
String username = strVal(params, "username");
String module = strVal(params, "module");
String action = strVal(params, "action");
String startTime = strVal(params, "startTime");
String endTime = strVal(params, "endTime");
// 构建动态查询条件
LambdaQueryWrapper<SysOperationLog> wrapper = new LambdaQueryWrapper<>();
if (StrUtil.isNotBlank(username)) {
// 操作人用户名精确匹配
wrapper.eq(SysOperationLog::getUsername, username);
}
if (StrUtil.isNotBlank(module)) {
// 操作模块模糊查询LIKE '%module%',支持部分关键词搜索
wrapper.like(SysOperationLog::getModule, module);
}
if (StrUtil.isNotBlank(action)) {
// 操作类型精确匹配(如 "新增"、"编辑"、"删除"
wrapper.eq(SysOperationLog::getAction, action);
}
if (StrUtil.isNotBlank(startTime)) {
wrapper.ge(SysOperationLog::getCreatedAt, startTime);
}
if (StrUtil.isNotBlank(endTime)) {
wrapper.le(SysOperationLog::getCreatedAt, endTime);
}
// 操作日志不做数据权限过滤(所有角色可见全部操作日志)
wrapper.orderByDesc(SysOperationLog::getCreatedAt);
return sysOperationLogMapper.selectPage(new Page<>(page, size), wrapper);
}
}

View File

@@ -0,0 +1,190 @@
package com.mokee.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.admin.mapper.SysMenuMapper;
import com.mokee.admin.service.MenuService;
import com.mokee.common.dto.response.MenuMetaVO;
import com.mokee.common.dto.response.MenuRouterVO;
import com.mokee.common.entity.SysMenu;
import com.mokee.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* 菜单管理服务实现
* <p>
* 支持树形菜单查询和 CRUD 操作。
* <p>
* 关键实现细节:
* <ul>
* <li><b>菜单树构建 buildMenuTree()</b>递归算法从根节点parentId="0")开始,
* 遍历全部菜单列表,找出每个节点的子节点,递归构建子树。</li>
* <li><b>sortOrder 排序</b>:同级菜单按 sortOrder 升序排列,控制侧边栏菜单的显示顺序。
* 数据库查询时已排序,递归构建时保持该顺序。</li>
* <li><b>status 状态</b>MenuRouterVO 会传递 status 字段给前端,前端根据 status 决定是否
* 渲染该菜单路由0=隐藏1=显示)。但当前实现未在查询时过滤 status所有菜单都返回给前端。</li>
* <li><b>MenuMetaVO</b>:包含菜单名称和图标,用于前端路由的 meta 信息。</li>
* <li><b>children 为 null</b>:叶子节点(无子节点)时 children 设为 null 而非空列表,
* 前端据此判断是否渲染展开箭头。</li>
* </ul>
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MenuServiceImpl implements MenuService {
private final SysMenuMapper sysMenuMapper;
/** 校验 systemId 是否在用户的绑定系统范围内 */
private void checkSystemScope(String systemId) {
if (!UserContextHolder.isSuperAdmin()) {
List<String> scope = UserContextHolder.getSystemIds();
if (scope == null || scope.isEmpty() || !scope.contains(systemId)) {
throw new BusinessException(403, "无权访问该系统数据");
}
}
}
/**
* 查询指定系统的完整菜单树
* <p>
* 查询流程:
* <ol>
* <li>从数据库查询该系统下所有菜单(按 sortOrder 排序)</li>
* <li>调用 buildMenuTree 从根节点parentId="0")递归构建树形结构</li>
* <li>返回根节点列表(每个根节点包含嵌套的子树)</li>
* </ol>
*
* @param systemId 业务系统ID
* @return 树形菜单列表(根节点集合),每个节点包含 children 子树
*/
@Override
public List<MenuRouterVO> getMenuTree(String systemId) {
checkSystemScope(systemId);
// 查询指定系统的所有菜单,按 sortOrder 排序
LambdaQueryWrapper<SysMenu> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysMenu::getSystemId, systemId);
wrapper.orderByAsc(SysMenu::getSortOrder); // 按排序号升序
List<SysMenu> menus = sysMenuMapper.selectList(wrapper);
// 从根节点parentId="0")开始递归构建树
return buildMenuTree(menus, "0");
}
/**
* 递归构建菜单树
* <p>
* 算法说明:
* <ol>
* <li>遍历 allMenus 列表,筛选出 parentId 匹配的节点</li>
* <li>为每个节点创建 MenuRouterVO 并填充字段</li>
* <li>递归调用自身构建子节点树</li>
* <li>如果子节点列表为空children 设为 null前端据此判断是否显示展开箭头</li>
* </ol>
* 注意:此算法时间复杂度为 O(n^2),在菜单数量较少(< 500时性能可接受。
* 大规模场景可优化为:先用 Map<parentId, List<Menu>> 分组再构建树O(n))。
*
* @param allMenus 该系统的全部菜单列表
* @param parentId 当前层级的父节点ID
* @return 当前层级下的菜单节点列表MenuRouterVO
*/
private List<MenuRouterVO> buildMenuTree(List<SysMenu> allMenus, String parentId) {
List<MenuRouterVO> list = new ArrayList<>();
for (SysMenu menu : allMenus) {
// 找到当前父节点下的子节点
if (menu.getParentId().equals(parentId)) {
MenuRouterVO vo = new MenuRouterVO();
// 基础信息
vo.setId(menu.getId());
vo.setMenuName(menu.getMenuName());
vo.setMenuType(menu.getMenuType()); // 1=目录, 2=菜单, 3=按钮
vo.setIcon(menu.getIcon());
vo.setPermission(menu.getPermission()); // 权限标识(如 "system:user:list"
// 路由信息
vo.setPath(menu.getMenuPath()); // 前端路由路径
vo.setName(menu.getMenuName()); // 路由名称(兼容旧字段)
vo.setComponent(menu.getComponent()); // Vue 组件路径
// 排序和状态
vo.setSortOrder(menu.getSortOrder()); // 控制菜单排列顺序
vo.setStatus(menu.getStatus()); // 0=隐藏, 1=显示
// 元数据(前端路由 meta
vo.setMeta(new MenuMetaVO(menu.getMenuName(), menu.getIcon()));
// 递归构建子节点
List<MenuRouterVO> children = buildMenuTree(allMenus, menu.getId());
// 叶子节点 children 设为 null而非空列表前端据此判断不显示展开箭头
vo.setChildren(children.isEmpty() ? null : children);
list.add(vo);
}
}
return list;
}
/**
* 根据ID查询菜单详情
*
* @param id 菜单ID
* @return 菜单实体
*/
@Override
public SysMenu getById(String id) {
return sysMenuMapper.selectById(id);
}
/**
* 新增菜单节点
* <p>
* 自动设置状态为启用status=1创建和更新时间自动填充。
*
* @param sysMenu 菜单实体
*/
@Override
public void add(SysMenu sysMenu) {
checkSystemScope(sysMenu.getSystemId());
sysMenu.setStatus(1); // 默认启用
sysMenu.setCreatedAt(LocalDateTime.now());
sysMenu.setUpdatedAt(LocalDateTime.now());
sysMenuMapper.insert(sysMenu);
log.info("新增菜单 - menuName: {}", sysMenu.getMenuName());
}
/**
* 更新菜单信息
* <p>
* 自动刷新更新时间戳。
*
* @param sysMenu 菜单实体id 必填)
*/
@Override
public void update(SysMenu sysMenu) {
SysMenu existing = sysMenuMapper.selectById(sysMenu.getId());
if (existing != null) checkSystemScope(existing.getSystemId());
sysMenu.setUpdatedAt(LocalDateTime.now());
sysMenuMapper.updateById(sysMenu);
log.info("更新菜单 - id: {}", sysMenu.getId());
}
/**
* 删除菜单
* <p>
* 注意:当前实现不会级联删除子菜单,需在业务层面先删除子菜单再删除父菜单。
*
* @param id 菜单ID
*/
@Override
public void delete(String id) {
SysMenu existing = sysMenuMapper.selectById(id);
if (existing != null) checkSystemScope(existing.getSystemId());
sysMenuMapper.deleteById(id);
log.info("删除菜单 - id: {}", id);
}
}

View File

@@ -0,0 +1,279 @@
package com.mokee.admin.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.mapper.SysRoleMapper;
import com.mokee.admin.mapper.SysRoleMenuMapper;
import com.mokee.admin.mapper.SysUserRoleMapper;
import com.mokee.admin.service.RoleService;
import com.mokee.common.entity.SysRole;
import com.mokee.common.entity.SysRoleMenu;
import com.mokee.common.entity.SysUserRole;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.common.exception.BusinessException;
import com.mokee.common.utils.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 角色管理服务实现
* <p>
* 基于 MyBatis-Plus 实现角色的增删改查和菜单权限分配。
* <p>
* 关键设计:
* <ul>
* <li><b>分页查询 systemName 填充</b>:与 LogServiceImpl 同理,角色的 systemName 也是 transient 字段,
* 查询后通过 systemId 逐条联表查询 sys_system 表获取系统名称并填充</li>
* <li><b>菜单权限分配 assignMenus()</b>:采用"先删后增"的全量替换策略 ——
* 先删除 sys_role_menu 表中该角色的所有记录,再批量插入新的菜单关联。
* 这比"差异计算"简单可靠,避免了逐个比对增减的复杂逻辑</li>
* <li><b>Redis 会话失效 invalidateSessionsForRole()</b>:分配菜单后,
* 需要让拥有该角色的所有用户的 Redis 会话失效,强制用户重新登录以获取最新菜单权限。
* 流程:查 sys_user_role 找到拥有该角色的用户 → 查 auth:user_session:{userId} 获取 tokenId →
* 删除 auth:token:{tokenId} 和 session key</li>
* <li><b>事务保证</b>assignMenus 使用 @Transactional确保删除旧关联和插入新关联的原子性</li>
* </ul>
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoleServiceImpl implements RoleService {
private final SysRoleMapper sysRoleMapper;
private final SysRoleMenuMapper sysRoleMenuMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final RedisUtil redisUtil;
private final JdbcTemplate jdbcTemplate;
/**
* 分页查询角色列表
* <p>
* 支持按系统ID精确匹配和角色名称模糊查询过滤。
* 查询完成后遍历结果集,通过 systemId 查询 sys_system 表填充 systemName 字段。
*
* @param page 当前页码
* @param size 每页条数
* @param systemId 系统ID可选精确匹配
* @param roleName 角色名称(可选,模糊查询)
* @return 分页结果,包含已填充 systemName 的角色列表,按创建时间倒序
*/
@Override
public IPage<SysRole> page(Integer page, Integer size, String systemId, String roleName) {
LambdaQueryWrapper<SysRole> wrapper = new LambdaQueryWrapper<>();
// 按所属系统过滤
if (systemId != null) {
wrapper.eq(SysRole::getSystemId, systemId);
}
// 按角色名称模糊查询
if (StrUtil.isNotBlank(roleName)) {
wrapper.like(SysRole::getRoleName, roleName);
}
// 数据权限:非 super_admin 只能查看绑定系统的数据
if (!UserContextHolder.isSuperAdmin()) {
List<String> scope = UserContextHolder.getSystemIds();
if (scope != null && !scope.isEmpty()) {
wrapper.in(SysRole::getSystemId, scope);
}
}
wrapper.orderByDesc(SysRole::getCreatedAt);
IPage<SysRole> result = sysRoleMapper.selectPage(new Page<>(page, size), wrapper);
// 遍历结果集逐条填充所属系统名称SysRole.systemName 为 transient 字段)
for (SysRole role : result.getRecords()) {
if (StrUtil.isNotBlank(role.getSystemId())) {
try {
// 通过 systemId 查询系统名称
String name = jdbcTemplate.queryForObject(
"SELECT system_name FROM sys_system WHERE id=? AND is_deleted=0",
String.class, role.getSystemId());
// 设为空字符串而非 null避免前端显示问题
role.setSystemName(name != null ? name : "");
} catch (Exception ignored) {
// 忽略单条查询异常(如系统已被删除)
}
}
}
return result;
}
/**
* 根据ID查询角色详情
*
* @param id 角色ID
* @return 角色实体
*/
@Override
public SysRole getById(String id) {
return sysRoleMapper.selectById(id);
}
/**
* 新增角色
* <p>
* 自动设置状态为启用status=1创建和更新时间自动填充。
*
* @param sysRole 角色实体
*/
@Override
public void add(SysRole sysRole) {
// 检查角色编码在系统内唯一性
if (sysRoleMapper.selectCount(
new LambdaQueryWrapper<SysRole>()
.eq(SysRole::getSystemId, sysRole.getSystemId())
.eq(SysRole::getRoleCode, sysRole.getRoleCode())) > 0) {
throw new BusinessException("该系统中角色编码已存在: " + sysRole.getRoleCode());
}
// 默认启用状态
sysRole.setStatus(1);
sysRole.setCreatedAt(LocalDateTime.now());
sysRole.setUpdatedAt(LocalDateTime.now());
sysRoleMapper.insert(sysRole);
log.info("新增角色 - roleName: {}", sysRole.getRoleName());
}
/**
* 更新角色信息
* <p>
* 自动刷新更新时间戳。
*
* @param sysRole 角色实体id 必填)
*/
@Override
public void update(SysRole sysRole) {
sysRole.setUpdatedAt(LocalDateTime.now());
sysRoleMapper.updateById(sysRole);
log.info("更新角色 - id: {}", sysRole.getId());
}
/**
* 删除角色MyBatis-Plus 逻辑删除)
*
* @param id 角色ID
*/
@Override
public void delete(String id) {
sysRoleMapper.deleteById(id);
log.info("删除角色 - id: {}", id);
}
/**
* 为角色分配菜单权限
* <p>
* 采用"先删后增"的全量替换策略:
* <ol>
* <li>删除 sys_role_menu 表中该角色的所有旧关联记录</li>
* <li>遍历 menuIds逐条插入新的菜单关联</li>
* <li>清除所有拥有此角色用户的 Redis 会话,强制重新登录获取新权限</li>
* </ol>
* 使用 @Transactional 保证"删旧 + 插新"的原子性。
* <p>
* 为什么用全量替换而非增量更新?
* <ul>
* <li>前端通常提交完整的选中菜单列表,用全量替换最直接</li>
* <li>避免复杂的差异计算(哪些新增、哪些删除)</li>
* <li>在事务中性能可接受(角色菜单数量通常不多)</li>
* </ul>
*
* @param roleId 角色ID
* @param menuIds 菜单ID列表前端提交的完整选中集合
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void assignMenus(String roleId, List<String> menuIds) {
// 第一步:清除角色的旧菜单关联
LambdaQueryWrapper<SysRoleMenu> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.eq(SysRoleMenu::getRoleId, roleId);
sysRoleMenuMapper.delete(deleteWrapper);
// 第二步:插入新的菜单关联(每条关联记录创建时间)
for (String menuId : menuIds) {
SysRoleMenu roleMenu = SysRoleMenu.builder()
.roleId(roleId)
.menuId(menuId)
.createdAt(LocalDateTime.now())
.build();
sysRoleMenuMapper.insert(roleMenu);
}
// 第三步:清除所有拥有此角色的用户的 Redis 会话
// 因为菜单权限变更了,用户需要重新登录才能获取最新菜单
invalidateSessionsForRole(roleId);
log.info("角色分配菜单 - roleId: {}, menuIds: {}", roleId, menuIds);
}
/**
* 清除所有拥有指定角色的用户的 Redis 会话(跳过当前操作用户)
* <p>
* 流程:
* <ol>
* <li>从 sys_user_role 表查询拥有该角色的所有用户ID去重</li>
* <li>排除当前操作用户(避免把自己踢下线)</li>
* <li>对每个其他在线用户,删除 Redis session 和 token强制重新登录以获取最新菜单权限</li>
* </ol>
* <p>
* 异常处理:整个方法被 try-catch 包裹,清除会话失败不影响主流程(角色菜单已成功分配)。
*
* @param roleId 角色ID
*/
private void invalidateSessionsForRole(String roleId) {
try {
// 查询拥有该角色的所有用户
List<SysUserRole> userRoles = sysUserRoleMapper.selectList(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getRoleId, roleId)
);
// 去重用户ID一个用户可能有多条 user_role 记录,但只需清除一次会话)
String currentUserId = UserContextHolder.getUserId();
List<String> userIds = userRoles.stream()
.map(SysUserRole::getUserId)
.distinct()
.filter(uid -> !uid.equals(currentUserId)) // 跳过当前操作用户,避免把自己踢下线
.collect(Collectors.toList());
// 逐个清除用户的 Redis 会话
for (String userId : userIds) {
// Redis key 格式auth:user_session:{userId},值为 tokenId
String sessionKey = "auth:user_session:" + userId;
String tokenId = redisUtil.get(sessionKey);
if (tokenId != null) {
// 删除 TokenInfo 和 session 映射
redisUtil.delete(RedisUtil.key("auth:token:" + tokenId));
redisUtil.delete(sessionKey);
log.info("已清除用户 Redis 会话(角色菜单变更) - userId: {}, roleId: {}", userId, roleId);
}
}
} catch (Exception e) {
// 清除会话失败不影响主流程(角色菜单已成功分配)
// 用户下次登录时自然会获取最新权限
log.warn("清除角色关联用户会话失败(不影响主流程)- roleId: {}, error: {}", roleId, e.getMessage());
}
}
/**
* 查询角色已分配的菜单ID列表
* <p>
* 用于前端角色编辑页面回显已选中的菜单树节点。
*
* @param roleId 角色ID
* @return 该角色拥有的菜单ID列表
*/
@Override
public List<String> getMenuIdsByRoleId(String roleId) {
LambdaQueryWrapper<SysRoleMenu> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysRoleMenu::getRoleId, roleId);
return sysRoleMenuMapper.selectList(wrapper).stream()
.map(SysRoleMenu::getMenuId)
.toList();
}
}

View File

@@ -0,0 +1,92 @@
package com.mokee.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.admin.mapper.SysApiMapper;
import com.mokee.admin.service.SystemApiService;
import com.mokee.common.entity.SysApi;
import com.mokee.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* 系统 API 服务实现
* <p>
* 基于 MyBatis-Plus 实现系统 API 的分页查询和 CRUD 操作
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SystemApiServiceImpl implements SystemApiService {
private final SysApiMapper sysApiMapper;
/**
* 分页查询指定系统的 API 列表,按路径升序排列
*/
@Override
public IPage<SysApi> page(Integer page, Integer size, String systemId) {
LambdaQueryWrapper<SysApi> wrapper = new LambdaQueryWrapper<>();
if (systemId != null) {
wrapper.eq(SysApi::getSystemId, systemId);
}
// 数据权限:非 super_admin 只能查看绑定系统的 API
if (!UserContextHolder.isSuperAdmin()) {
java.util.List<String> scope = UserContextHolder.getSystemIds();
if (scope != null && !scope.isEmpty()) {
wrapper.in(SysApi::getSystemId, scope);
}
}
wrapper.orderByAsc(SysApi::getApiPath);
return sysApiMapper.selectPage(new Page<>(page, size), wrapper);
}
@Override
public SysApi getById(String id) {
return sysApiMapper.selectById(id);
}
/**
* 新增 API 接口,自动设置创建/更新时间并启用
*/
@Override
public void add(SysApi sysApi) {
// 检查同一系统下接口路径+请求方式唯一性
if (sysApiMapper.selectCount(
new LambdaQueryWrapper<SysApi>()
.eq(SysApi::getSystemId, sysApi.getSystemId())
.eq(SysApi::getApiPath, sysApi.getApiPath())
.eq(SysApi::getApiMethod, sysApi.getApiMethod())) > 0) {
throw new BusinessException("该系统中接口已存在: " + sysApi.getApiPath() + " [" + sysApi.getApiMethod() + "]");
}
sysApi.setCreatedAt(LocalDateTime.now());
sysApi.setUpdatedAt(LocalDateTime.now());
sysApi.setStatus(1);
sysApiMapper.insert(sysApi);
log.info("新增API - systemId: {}, path: {}, method: {}", sysApi.getSystemId(), sysApi.getApiPath(), sysApi.getApiMethod());
}
/**
* 更新 API 接口信息,自动刷新更新时间
*/
@Override
public void update(SysApi sysApi) {
sysApi.setUpdatedAt(LocalDateTime.now());
sysApiMapper.updateById(sysApi);
log.info("更新API - id: {}", sysApi.getId());
}
/**
* 删除 API 接口
*/
@Override
public void delete(String id) {
sysApiMapper.deleteById(id);
log.info("删除API - id: {}", id);
}
}

View File

@@ -0,0 +1,234 @@
package com.mokee.admin.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.mapper.SysSystemMapper;
import com.mokee.admin.service.SystemService;
import com.mokee.admin.config.UserContextHolder;
import com.mokee.common.entity.SysSystem;
import com.mokee.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* 业务系统服务实现
* <p>
* 基于 MyBatis-Plus 实现业务系统的增删改查及分页查询。
* <p>
* 核心实现细节:
* <ul>
* <li><b>分页查询</b>:使用 LambdaQueryWrapper 构建动态条件systemName 模糊查询systemCode 精确查询</li>
* <li><b>查询全部系统 listAll()</b>:过滤 is_deleted=0按 system_code 升序,仅查询未删除记录</li>
* <li><b>查询用户绑定系统 listByUserId()</b>:使用原生 SQL 的 INNER JOIN 查询 sys_user_system 中间表,
* 通过 JdbcTemplate 手动映射 ResultSet因为 MyBatis-Plus 不擅长多表 JOIN</li>
* <li><b>新增系统</b>:自动生成 16 位文档令牌UUID 去掉横线取前 16 位),用于公开文档访问</li>
* </ul>
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SystemServiceImpl implements SystemService {
private final SysSystemMapper sysSystemMapper;
private final JdbcTemplate jdbcTemplate;
/**
* 分页查询业务系统列表
* <p>
* systemName 使用 LIKE 模糊匹配systemCode 使用 EQ 精确匹配。
* 结果按 sort_order 升序排列,确保前端列表顺序可控。
*
* @param page 当前页码
* @param size 每页条数
* @param systemName 系统名称模糊查询(可选)
* @param systemCode 系统编码精确查询(可选)
* @return 分页结果
*/
@Override
public IPage<SysSystem> page(Integer page, Integer size, String systemName, String systemCode) {
LambdaQueryWrapper<SysSystem> wrapper = new LambdaQueryWrapper<>();
// 系统名称模糊查询LIKE '%name%'
if (StrUtil.isNotBlank(systemName)) {
wrapper.like(SysSystem::getSystemName, systemName);
}
// 系统编码精确查询(= code
if (StrUtil.isNotBlank(systemCode)) {
wrapper.eq(SysSystem::getSystemCode, systemCode);
}
// 数据权限:非 super_admin 只能查看绑定系统的数据,并排除网关系统本身
if (!UserContextHolder.isSuperAdmin()) {
java.util.List<String> scope = UserContextHolder.getSystemIds();
if (scope != null && !scope.isEmpty()) {
wrapper.in(SysSystem::getId, scope);
} else {
wrapper.eq(SysSystem::getId, "__NONE__");
}
// 网关系统是平台基础设施,业务管理员无需查看
wrapper.ne(SysSystem::getSystemCode, "gateway");
}
// 按排序号升序,数值小的排在前面
wrapper.orderByAsc(SysSystem::getSortOrder);
return sysSystemMapper.selectPage(new Page<>(page, size), wrapper);
}
/**
* 根据ID查询系统详情
*
* @param id 系统ID
* @return 系统实体
*/
@Override
public SysSystem getById(String id) {
return sysSystemMapper.selectById(id);
}
/**
* 新增业务系统
* <p>
* 自动完成以下操作:
* <ol>
* <li>设置创建时间和更新时间为当前时间</li>
* <li>设置状态为启用status=1</li>
* <li>如未提供文档令牌,自动生成 16 位 UUID 子串作为 docToken</li>
* <li>记录操作日志</li>
* </ol>
*
* @param sysSystem 系统实体
*/
@Override
public void add(SysSystem sysSystem) {
// 检查 system_code 唯一性(数据库有唯一约束,提前校验给出友好提示)
if (sysSystemMapper.selectCount(
new LambdaQueryWrapper<SysSystem>().eq(SysSystem::getSystemCode, sysSystem.getSystemCode())) > 0) {
throw new BusinessException("系统编码已存在: " + sysSystem.getSystemCode());
}
sysSystem.setCreatedAt(LocalDateTime.now());
sysSystem.setUpdatedAt(LocalDateTime.now());
sysSystem.setStatus(1);
// 自动生成文档访问令牌UUID 去掉横线取前 16 位,保证唯一性和简洁性
if (sysSystem.getDocToken() == null || sysSystem.getDocToken().isEmpty()) {
sysSystem.setDocToken(java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16));
}
// gatewayUrl 字段已废弃但 DB 有唯一约束,生成唯一值避免空串冲突
if (sysSystem.getGatewayUrl() == null || sysSystem.getGatewayUrl().isEmpty()) {
sysSystem.setGatewayUrl("gw-" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12));
}
sysSystemMapper.insert(sysSystem);
log.info("新增业务系统 - systemCode: {}, docToken: {}", sysSystem.getSystemCode(), sysSystem.getDocToken());
}
/**
* 更新业务系统信息
* <p>
* 只更新传入的非 null 字段MyBatis-Plus updateById 默认行为),
* 自动刷新 updatedAt 时间戳。
*
* @param sysSystem 系统实体id 必填)
*/
@Override
public void update(SysSystem sysSystem) {
sysSystem.setUpdatedAt(LocalDateTime.now());
sysSystemMapper.updateById(sysSystem);
log.info("更新业务系统 - id: {}", sysSystem.getId());
}
/**
* 通过文档令牌查询系统信息
* <p>
* 公开接口,无需登录认证。同时校验令牌匹配和系统启用状态。
*
* @param token 文档访问令牌
* @return 系统实体,令牌不匹配或系统已停用返回 null
*/
@Override
public SysSystem getByDocToken(String token) {
return sysSystemMapper.selectOne(
new LambdaQueryWrapper<SysSystem>()
.eq(SysSystem::getDocToken, token) // 令牌精确匹配
.eq(SysSystem::getStatus, 1) // 只返回启用状态的系统
);
}
/**
* 查询全部未删除的系统
* <p>
* 仅 super_admin 角色使用。返回所有 is_deleted=0 的记录。
* 使用 LambdaQueryWrapper 过滤逻辑删除标记。
*
* @return 全部未删除的系统列表,按 system_code 升序排列
*/
@Override
public List<SysSystem> listAll() {
return sysSystemMapper.selectList(
new LambdaQueryWrapper<SysSystem>()
.eq(SysSystem::getIsDeleted, 0) // 过滤已逻辑删除的记录
.orderByAsc(SysSystem::getSystemCode) // 按系统编码升序
);
}
/**
* 查询用户绑定的系统(通过 sys_user_system 关联表 INNER JOIN 查询)
* <p>
* 使用原生 SQL 而非 MyBatis-Plus 的关联查询,原因:
* <ul>
* <li>MyBatis-Plus 的 LambdaQueryWrapper 不直接支持多表 JOIN</li>
* <li>原生 SQL 更直观,能精确控制查询逻辑</li>
* <li>INNER JOIN 确保只返回用户确有绑定的系统</li>
* </ul>
* SQL: SELECT s.* FROM sys_system s INNER JOIN sys_user_system us ON s.id = us.system_id WHERE us.user_id = ? AND s.is_deleted = 0
* <p>
* 手动映射 ResultSet 到 SysSystem 实体,因为 JdbcTemplate 不能自动映射。
*
* @param userId 用户ID
* @return 用户绑定的系统列表(按 system_code 升序),用户无绑定时返回空列表
*/
@Override
public List<SysSystem> listByUserId(String userId) {
// 使用 Java 15+ 的文本块Text Block语法编写可读的多行 SQL
String sql = """
SELECT s.* FROM sys_system s
INNER JOIN sys_user_system us ON s.id = us.system_id
WHERE us.user_id = ? AND s.is_deleted = 0
ORDER BY s.system_code
""";
// 手动映射 ResultSet 到 SysSystem 实体
return jdbcTemplate.query(sql, (rs, rowNum) -> {
SysSystem s = new SysSystem();
s.setId(rs.getString("id"));
s.setSystemCode(rs.getString("system_code"));
s.setSystemName(rs.getString("system_name"));
s.setFrontUrl(rs.getString("front_url"));
s.setBackendUrl(rs.getString("backend_url"));
s.setIcon(rs.getString("icon"));
s.setDescription(rs.getString("description"));
s.setStatus(rs.getInt("status"));
s.setGatewayUrl(rs.getString("gateway_url"));
s.setDocToken(rs.getString("doc_token"));
s.setSortOrder(rs.getInt("sort_order"));
return s;
}, userId);
}
/**
* 删除业务系统MyBatis-Plus 逻辑删除)
* <p>
* 将 is_deleted 标记为 1不会物理删除数据库记录。
* SQL 等价于: UPDATE sys_system SET is_deleted=1 WHERE id=?
*
* @param id 系统ID
*/
@Override
public void delete(String id) {
sysSystemMapper.deleteById(id);
log.info("删除业务系统 - id: {}", id);
}
}

View File

@@ -0,0 +1,478 @@
package com.mokee.admin.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.json.JSONUtil;
import com.mokee.admin.config.UserContextHolder;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.mapper.SysRoleMapper;
import com.mokee.admin.mapper.SysUserMapper;
import com.mokee.admin.mapper.SysUserRoleMapper;
import com.mokee.admin.mapper.SysUserSystemMapper;
import com.mokee.admin.service.UserService;
import com.mokee.common.dto.response.RoleInfo;
import com.mokee.common.dto.response.TokenInfo;
import com.mokee.common.exception.BusinessException;
import com.mokee.common.entity.SysRole;
import com.mokee.common.entity.SysUser;
import com.mokee.common.entity.SysUserRole;
import com.mokee.common.entity.SysUserSystem;
import com.mokee.common.utils.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 用户管理服务实现
* <p>
* 新增用户时密码使用 BCrypt 加密,支持绑定/解绑系统、分配角色。
* <p>
* 关键实现细节:
* <ul>
* <li><b>BCrypt 密码加密</b>:使用 Hutool 的 BCrypt.hashpw(),不存储明文密码</li>
* <li><b>编辑不更新密码</b>update() 中将 password 置为 nullMyBatis-Plus updateById 会忽略 null 字段</li>
* <li><b>Redis TokenInfo 同步</b>:编辑用户后,如果用户当前在线,同步更新 Redis 中缓存的 TokenInfo
* 确保前端 fetchUserInfo 拿到的实时数据。同步保持原有 TTL。</li>
* <li><b>绑定系统 - 全量替换</b>:先删旧关联再插新关联,@Transactional 保证原子性</li>
* <li><b>分配角色 - 全量替换 + 会话失效</b>:先删旧角色再插新角色,然后清除 Redis 会话强制重新登录</li>
* <li><b>邮箱过滤</b>page() 方法支持 email 参数模糊查询,用于按邮箱搜索用户</li>
* </ul>
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final SysUserMapper sysUserMapper;
private final SysUserSystemMapper sysUserSystemMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysRoleMapper sysRoleMapper;
private final RedisUtil redisUtil;
/**
* 分页查询用户列表
* <p>
* 支持按用户名、真实姓名、邮箱三个字段进行模糊查询LIKE
* 使用 LambdaQueryWrapper 构建动态条件,只添加非空的过滤条件。
* 结果按创建时间倒序排列(最新用户在最前面)。
*
* @param page 当前页码
* @param size 每页条数
* @param username 用户名模糊查询(可选)
* @param realName 真实姓名模糊查询(可选)
* @param email 邮箱模糊查询(可选,用于按邮箱搜索用户)
* @return 分页结果
*/
@Override
public IPage<SysUser> page(Integer page, Integer size, String username, String realName, String email) {
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
// 按用户名模糊查询
if (StrUtil.isNotBlank(username)) {
wrapper.like(SysUser::getUsername, username);
}
// 按真实姓名模糊查询
if (StrUtil.isNotBlank(realName)) {
wrapper.like(SysUser::getRealName, realName);
}
// 按邮箱模糊查询(扩展的查询条件)
if (StrUtil.isNotBlank(email)) {
wrapper.like(SysUser::getEmail, email);
}
// 数据权限:非 super_admin 只能查看绑定系统的用户(通过 sys_user_system 关联)
if (!UserContextHolder.isSuperAdmin()) {
// 排除拥有 super_admin 角色的用户
java.util.List<String> superAdminUserIds = sysUserRoleMapper.selectList(
new LambdaQueryWrapper<SysUserRole>()
.inSql(SysUserRole::getRoleId,
"SELECT id FROM sys_role WHERE role_code = 'super_admin'")
).stream().map(SysUserRole::getUserId).distinct().toList();
if (!superAdminUserIds.isEmpty()) {
wrapper.notIn(SysUser::getId, superAdminUserIds);
}
java.util.List<String> scope = UserContextHolder.getSystemIds();
if (scope != null && !scope.isEmpty()) {
java.util.List<String> scopeUserIds = sysUserSystemMapper.selectList(
new LambdaQueryWrapper<SysUserSystem>().in(SysUserSystem::getSystemId, scope)
).stream().map(SysUserSystem::getUserId).distinct().toList();
if (scopeUserIds.isEmpty()) {
wrapper.eq(SysUser::getId, "__NONE__");
} else {
wrapper.in(SysUser::getId, scopeUserIds);
}
}
}
wrapper.orderByDesc(SysUser::getCreatedAt);
return sysUserMapper.selectPage(new Page<>(page, size), wrapper);
}
/**
* 根据ID查询用户详情
*
* @param id 用户ID
* @return 用户实体
*/
@Override
public SysUser getById(String id) {
return sysUserMapper.selectById(id);
}
/**
* 新增用户
* <p>
* 处理流程:
* <ol>
* <li>使用 BCrypt 加密密码Hash 后不可逆,验证时使用 BCrypt.checkpw</li>
* <li>初始化登录失败次数为 0</li>
* <li>设置状态为启用status=1</li>
* <li>设置创建/更新时间</li>
* </ol>
* 注意:请求体中的 password 应为明文,由本方法加密后存储。
*
* @param sysUser 用户实体password 为明文)
*/
@Override
public String add(SysUser sysUser) {
// 检查用户名/邮箱是否已存在(数据库有唯一约束,提前校验给出友好提示)
if (sysUserMapper.selectCount(
new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, sysUser.getUsername())) > 0) {
throw new BusinessException("用户名已存在: " + sysUser.getUsername());
}
// 空邮箱设为 null避免多个空串违反唯一约束
if (sysUser.getEmail() != null && sysUser.getEmail().isEmpty()) {
sysUser.setEmail(null);
}
if (sysUser.getEmail() != null
&& sysUserMapper.selectCount(
new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, sysUser.getEmail())) > 0) {
throw new BusinessException("邮箱已被使用: " + sysUser.getEmail());
}
// BCrypt 加密密码Hash 后不可逆,验证时使用 BCrypt.checkpw(明文, Hash)
sysUser.setPassword(BCrypt.hashpw(sysUser.getPassword()));
sysUser.setLoginFailCount(0); // 初始登录失败次数为 0
sysUser.setStatus(1); // 默认启用
sysUser.setCreatedAt(LocalDateTime.now());
sysUser.setUpdatedAt(LocalDateTime.now());
sysUserMapper.insert(sysUser);
log.info("新增用户 - username: {}", sysUser.getUsername());
return sysUser.getId();
}
/**
* 编辑用户信息
* <p>
* 重要设计:
* <ul>
* <li><b>不更新密码</b>:将 password 置为 nullMyBatis-Plus updateById 默认策略会忽略 null 字段,
* 因此不会覆盖数据库中的密码。密码通过独立的密码修改接口处理。</li>
* <li><b>Redis 同步</b>:编辑后同步更新 Redis 中的 TokenInfo。
* 因为网关从 Redis 读取用户信息注入请求头X-User-Account 等),
* 如果只更新数据库不同步 Redis在线的用户会看到旧数据直到重新登录。</li>
* </ul>
*
* @param sysUser 用户实体id 必填password 会被忽略)
*/
@Override
public void update(SysUser sysUser) {
// 编辑时不更新密码(密码通过单独的密码修改接口处理)
// MyBatis-Plus updateById 默认忽略 null 字段
sysUser.setPassword(null);
sysUser.setUpdatedAt(LocalDateTime.now());
sysUserMapper.updateById(sysUser);
// 同步更新 Redis 中的 TokenInfo如果用户当前在线
// 确保前端 fetchUserInfo 拿到最新数据
syncRedisTokenInfo(sysUser);
log.info("更新用户 - id: {}", sysUser.getId());
}
/**
* 同步更新 Redis TokenInfo 中的用户信息字段
* <p>
* 流程:
* <ol>
* <li>通过 auth:user_session:{userId} 查找 tokenId用户是否在线</li>
* <li>如果在线,读取 auth:token:{tokenId} 中的 TokenInfo JSON</li>
* <li>将用户更新的字段username, realName, email, phone, avatar, post同步到 TokenInfo</li>
* <li>保持原有的 TTL过期时间写回 Redis</li>
* </ol>
* 异常被忽略:同步失败不影响主流程(数据库已更新),用户重新登录后会获取最新数据。
*
* @param updatedUser 更新后的用户实体
*/
private void syncRedisTokenInfo(SysUser updatedUser) {
try {
// 查找用户当前是否在线session key 是否存在)
String sessionKey = "auth:user_session:" + updatedUser.getId();
String tokenId = redisUtil.get(sessionKey);
if (tokenId == null) return; // 用户未在线,无需同步
// 读取 Redis 中缓存的 TokenInfo
String tokenKey = RedisUtil.key("auth:token:" + tokenId);
String jsonStr = redisUtil.get(tokenKey);
if (jsonStr == null) return;
// 反序列化 TokenInfo 并更新字段
TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class);
// 只同步非 null 的字段null 表示该字段没有被修改)
// username 也需同步,网关会在 X-User-Account 头中传递
if (updatedUser.getUsername() != null) tokenInfo.setUsername(updatedUser.getUsername());
if (updatedUser.getRealName() != null) tokenInfo.setRealName(updatedUser.getRealName());
if (updatedUser.getEmail() != null) tokenInfo.setEmail(updatedUser.getEmail());
if (updatedUser.getPhone() != null) tokenInfo.setPhone(updatedUser.getPhone());
if (updatedUser.getAvatar() != null) tokenInfo.setAvatar(updatedUser.getAvatar());
if (updatedUser.getPost() != null) tokenInfo.setPost(updatedUser.getPost());
// 保持原有 TTL写回 Redis
Long ttl = redisUtil.getExpire(tokenKey);
if (ttl != null && ttl > 0) {
redisUtil.set(tokenKey, JSONUtil.toJsonStr(tokenInfo), ttl, TimeUnit.SECONDS);
}
log.debug("同步 Redis TokenInfo 成功 - userId: {}", updatedUser.getId());
} catch (Exception e) {
// 同步失败不影响主流程(数据库已更新)
log.warn("同步 Redis TokenInfo 失败(不影响主流程)- userId: {}, error: {}",
updatedUser.getId(), e.getMessage());
}
}
/**
* 删除用户MyBatis-Plus 逻辑删除)
* <p>
* 将 is_deleted 标记为 1不会物理删除记录。
*
* @param id 用户ID
*/
@Override
public void delete(String id) {
sysUserMapper.deleteById(id);
log.info("删除用户 - id: {}", id);
}
/**
* 管理员重置用户密码
* <p>
* 仅 super_admin 可调用此方法(调用层需先校验角色)。
* 密码使用 BCrypt 加密后直接覆盖数据库中的旧密码,
* 同时清除该用户的 Redis 会话强制重新登录。
*
* @param userId 目标用户ID
* @param newPassword 新密码(明文)
*/
@Override
public void resetPassword(String userId, String newPassword) {
SysUser user = sysUserMapper.selectById(userId);
if (user == null) {
throw new BusinessException(404, "用户不存在");
}
// BCrypt 加密新密码
user.setPassword(BCrypt.hashpw(newPassword));
user.setUpdatedAt(LocalDateTime.now());
sysUserMapper.updateById(user);
// 清除该用户的 Redis 会话,强制重新登录
try {
String sessionKey = "auth:user_session:" + userId;
String tokenId = redisUtil.get(sessionKey);
if (tokenId != null) {
redisUtil.delete(RedisUtil.key("auth:token:" + tokenId));
redisUtil.delete(sessionKey);
}
} catch (Exception e) {
log.warn("清除用户 Redis 会话失败(不影响密码重置结果) - userId: {}", userId, e);
}
log.info("管理员重置用户密码 - userId: {}, username: {}", userId, user.getUsername());
}
/**
* 为用户绑定业务系统(全量替换:先删后增)
* <p>
* 使用 @Transactional 保证"删旧 + 插新"的原子性。
* 前端提交完整的系统ID列表后端全量替换。
*
* @param userId 用户ID
* @param systemIds 要绑定的系统ID列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void bindSystems(String userId, List<String> systemIds) {
// 第一步:清除旧绑定
LambdaQueryWrapper<SysUserSystem> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.eq(SysUserSystem::getUserId, userId);
sysUserSystemMapper.delete(deleteWrapper);
// 第二步:插入新绑定(每条关联记录创建时间)
for (String systemId : systemIds) {
SysUserSystem userSystem = SysUserSystem.builder()
.userId(userId)
.systemId(systemId)
.createdAt(LocalDateTime.now())
.build();
sysUserSystemMapper.insert(userSystem);
}
log.info("用户绑定系统 - userId: {}, systemIds: {}", userId, systemIds);
}
/**
* 解除用户所有业务系统绑定
* <p>
* 删除 sys_user_system 表中该用户的所有关联记录。
*
* @param userId 用户ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindSystems(String userId) {
LambdaQueryWrapper<SysUserSystem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUserSystem::getUserId, userId);
sysUserSystemMapper.delete(wrapper);
log.info("用户解绑所有系统 - userId: {}", userId);
}
/**
* 为用户在指定系统中分配角色(全量替换策略)
* <p>
* 当前实现简化处理:删除该用户的所有角色关联(不限于指定系统),
* 再插入新的角色关联。如需严格按系统隔离,应只删除该系统下的角色。
* <p>
* 流程:
* <ol>
* <li>删除 sys_user_role 中该用户的所有旧角色关联</li>
* <li>逐条插入新的角色关联</li>
* <li>清除用户 Redis 会话,强制重新登录以应用新权限</li>
* </ol>
*
* @param userId 用户ID
* @param systemId 系统ID当前用于日志记录和后续扩展的隔离过滤
* @param roleIds 角色ID列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void assignRoles(String userId, String systemId, List<String> roleIds) {
// 先清除用户的所有旧角色关联
// 注意:当前简化实现删除所有角色,未按 systemId 过滤
// 如需精确隔离:应通过 role 表的 system_id 字段过滤,只删除指定系统的角色
LambdaQueryWrapper<SysUserRole> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.eq(SysUserRole::getUserId, userId);
sysUserRoleMapper.delete(deleteWrapper);
// 插入新角色关联
for (String roleId : roleIds) {
SysUserRole userRole = SysUserRole.builder()
.userId(userId)
.roleId(roleId)
.createdAt(LocalDateTime.now())
.build();
sysUserRoleMapper.insert(userRole);
}
// 同步更新 Redis 会话中的角色信息(而不是清除会话强制重登)
// 修复:之前 invalidateUserSession 会把用户自己的 token 删掉,导致 401 跳登录
try {
String sessionKey = "auth:user_session:" + userId;
String tokenId = redisUtil.get(sessionKey);
if (tokenId != null) {
String tokenKey = RedisUtil.key("auth:token:" + tokenId);
String jsonStr = redisUtil.get(tokenKey);
if (jsonStr != null) {
TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class);
// 从数据库重新查询用户全部角色,防止覆盖其他系统的角色
// 修复:之前 tokenInfo.setRoles(当前系统角色) 会用当前系统角色
// 覆盖掉其他系统的角色,导致 super_admin 等身份丢失,引发 403
if (tokenInfo.getRoles() != null) {
List<SysUserRole> allUserRoles = sysUserRoleMapper.selectList(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId));
List<RoleInfo> allRoles = new java.util.ArrayList<>();
for (SysUserRole ur : allUserRoles) {
SysRole role = sysRoleMapper.selectById(ur.getRoleId());
if (role != null) {
allRoles.add(new RoleInfo(role.getId(), role.getRoleName(), role.getRoleCode()));
}
}
tokenInfo.setRoles(allRoles);
}
Long ttl = redisUtil.getExpire(tokenKey);
if (ttl != null && ttl > 0) {
redisUtil.set(tokenKey, JSONUtil.toJsonStr(tokenInfo), ttl, java.util.concurrent.TimeUnit.SECONDS);
}
}
}
} catch (Exception e) {
log.warn("同步角色到Redis会话失败 - userId: {}, error: {}", userId, e.getMessage());
}
log.info("用户分配角色 - userId: {}, systemId: {}, roleIds: {}", userId, systemId, roleIds);
}
/**
* 清除用户的 Redis 会话(强制重新登录)
* <p>
* 通过删除 Redis 中的 TokenInfo 和 session 映射实现。
* 下次用户请求时网关发现 token 不存在,返回 401 强制前端跳转登录页。
*
* @param userId 用户ID
*/
private void invalidateUserSession(String userId) {
try {
// Redis key 格式auth:user_session:{userId},值为 tokenId
String sessionKey = "auth:user_session:" + userId;
String tokenId = redisUtil.get(sessionKey);
if (tokenId != null) {
// 删除 TokenInfo 和 session 映射
redisUtil.delete(RedisUtil.key("auth:token:" + tokenId));
redisUtil.delete(sessionKey);
log.info("已清除用户 Redis 会话 - userId: {}", userId);
}
} catch (Exception e) {
// 清除会话失败不影响主流程(角色已成功分配)
log.warn("清除用户会话失败(不影响主流程)- userId: {}, error: {}", userId, e.getMessage());
}
}
/**
* 查询用户绑定的系统ID列表
* <p>
* 从 sys_user_system 中间表查询所有关联记录,提取 systemId。
*
* @param userId 用户ID
* @return 系统ID列表
*/
@Override
public List<String> getUserSystemIds(String userId) {
LambdaQueryWrapper<SysUserSystem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUserSystem::getUserId, userId);
return sysUserSystemMapper.selectList(wrapper).stream()
.map(SysUserSystem::getSystemId)
.collect(java.util.stream.Collectors.toList());
}
/**
* 查询用户在指定系统下的角色ID列表
* <p>
* 注意:当前实现未按 systemId 过滤简化处理返回用户所有角色ID。
* 如需严格隔离,应 JOIN sys_role 表过滤 system_id。
*
* @param userId 用户ID
* @param systemId 系统ID当前未使用保留参数兼容
* @return 角色ID列表
*/
@Override
public List<String> getUserRoleIds(String userId, String systemId) {
LambdaQueryWrapper<SysUserRole> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUserRole::getUserId, userId);
return sysUserRoleMapper.selectList(wrapper).stream()
.map(SysUserRole::getRoleId)
.collect(java.util.stream.Collectors.toList());
}
}

View File

@@ -0,0 +1,81 @@
package com.mokee.admin.task;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.mokee.admin.mapper.SysLoginLogMapper;
import com.mokee.admin.mapper.SysOperationLogMapper;
import com.mokee.admin.mapper.SysUserMapper;
import com.mokee.common.entity.SysLoginLog;
import com.mokee.common.entity.SysOperationLog;
import com.mokee.common.entity.SysUser;
import com.mokee.common.enums.StatusEnum;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 定时任务
* <p>
* 凌晨执行用户不活跃锁定和过期日志清理
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ScheduledTask {
private final SysUserMapper sysUserMapper;
private final SysLoginLogMapper sysLoginLogMapper;
private final SysOperationLogMapper sysOperationLogMapper;
/**
* 锁定不活跃用户
* <p>
* 每天凌晨 3:00 执行:将超过 365 天未登录的启用用户状态改为锁定
*/
@Scheduled(cron = "0 0 3 * * ?")
public void lockInactiveUsers() {
log.info("定时任务 - 开始锁定不活跃用户");
try {
LocalDateTime oneYearAgo = LocalDateTime.now().minusDays(365);
LambdaUpdateWrapper<SysUser> wrapper = new LambdaUpdateWrapper<>();
wrapper.set(SysUser::getStatus, StatusEnum.LOCKED.getCode())
.lt(SysUser::getLastLoginTime, oneYearAgo)
.eq(SysUser::getStatus, StatusEnum.ENABLED.getCode());
int updated = sysUserMapper.update(null, wrapper);
log.info("定时任务 - 锁定不活跃用户完成, 已更新 {} 条", updated);
} catch (Exception e) {
log.error("定时任务 - 锁定不活跃用户失败", e);
}
}
/**
* 清理过期日志
* <p>
* 每天凌晨 4:00 执行:删除 6 个月前的操作日志和 12 个月前的登录日志
*/
@Scheduled(cron = "0 0 4 * * ?")
public void cleanExpiredLogs() {
log.info("定时任务 - 开始清理过期日志");
try {
// 删除 6 个月前的操作日志
LocalDateTime sixMonthsAgo = LocalDateTime.now().minusMonths(6);
int deletedOps = sysOperationLogMapper.delete(
new LambdaUpdateWrapper<SysOperationLog>()
.lt(SysOperationLog::getCreatedAt, sixMonthsAgo)
);
log.info("定时任务 - 清理操作日志完成, 删除: {} 条", deletedOps);
// 删除 12 个月前的登录日志
LocalDateTime twelveMonthsAgo = LocalDateTime.now().minusMonths(12);
int deletedLogin = sysLoginLogMapper.delete(
new LambdaUpdateWrapper<SysLoginLog>()
.lt(SysLoginLog::getCreatedAt, twelveMonthsAgo)
);
log.info("定时任务 - 清理登录日志完成, 删除: {} 条", deletedLogin);
} catch (Exception e) {
log.error("定时任务 - 清理过期日志失败", e);
}
}
}

View File

@@ -0,0 +1,40 @@
app:
env-prefix: "[PROD]"
spring:
mail:
host: smtp.qiye.aliyun.com
port: 465
username: admin@zgitm.com
password: mokee2016.
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.socketFactory.port: 465
mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
datasource:
url: jdbc:mysql://db1.prod.zgitm.com:3306/mokeegateway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: mokeegateway
password: mokee.
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: redis.db1.zgitm.com
port: 6379
password: mokee2016.
database: 1
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
logging:
level:
com.mokee: INFO
org.springframework.web.servlet.DispatcherServlet: DEBUG

View File

@@ -0,0 +1,39 @@
app:
env-prefix: "[QA]"
spring:
mail:
host: smtp.qiye.aliyun.com
port: 465
username: admin@zgitm.com
password: mokee2016.
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.socketFactory.port: 465
mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
datasource:
url: jdbc:mysql://db1.qa.zgitm.com:3306/mokeegatewayqa?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: mokeegatewayqa
password: mokee.
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: redis.db2.zgitm.com
port: 6379
password: mokee2016.
database: 2
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.mokee: INFO

View File

@@ -0,0 +1,34 @@
server:
port: 9002
spring:
application:
name: mokee-gateway-admin
profiles:
active: @spring.profiles.active@
servlet:
multipart:
max-file-size: 2048MB
max-request-size: 2048MB
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.mokee.common.entity
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
jwt:
secret: mokee-gateway-jwt-secret-key-2024-must-be-256-bits!!
expiration: 7200
knife4j:
enable: true
openapi:
title: 业务管理服务
description: 系统配置/日志/字典/定时任务
version: 1.0.0