初始化
This commit is contained in:
@@ -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("========================================");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"); // 文件预览/下载不拦截
|
||||
}
|
||||
}
|
||||
@@ -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", "缓存已刷新"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(内容)、contentType(html/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(收件人/主题模糊查询), status(0失败/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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 获取一次性 Token,Token 消费后立即失效。文档: /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 = "调用此接口将消费 Token,Token 只能用一次。邮件发送成功后 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 系统实体(请求体 JSON,id 必填)
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@@ -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 用户实体(请求体 JSON,password 为明文,由 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 用户实体(请求体 JSON,id 必填)
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 系统ID(UUID 字符串)
|
||||
* @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);
|
||||
}
|
||||
@@ -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 新用户的 ID(UUID)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 置为 null,MyBatis-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 置为 null,MyBatis-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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
mokee-gateway-admin/src/main/resources/application-prod.yml
Normal file
40
mokee-gateway-admin/src/main/resources/application-prod.yml
Normal 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
|
||||
39
mokee-gateway-admin/src/main/resources/application-qa.yml
Normal file
39
mokee-gateway-admin/src/main/resources/application-qa.yml
Normal 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
|
||||
34
mokee-gateway-admin/src/main/resources/application.yml
Normal file
34
mokee-gateway-admin/src/main/resources/application.yml
Normal 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
|
||||
Reference in New Issue
Block a user