diff --git a/healthlink-his-server/core-common/src/main/java/com/core/common/constant/CacheConstants.java b/healthlink-his-server/core-common/src/main/java/com/core/common/constant/CacheConstants.java index 618732edd..2d7137020 100755 --- a/healthlink-his-server/core-common/src/main/java/com/core/common/constant/CacheConstants.java +++ b/healthlink-his-server/core-common/src/main/java/com/core/common/constant/CacheConstants.java @@ -46,6 +46,16 @@ public class CacheConstants { */ public static final String LOGIN_SELECTED_TENANT = "login_selected_tenant:"; + /** + * Token黑名单 redis key + */ + public static final String TOKEN_BLACKLIST_KEY = "token_blacklist:"; + + /** + * 登录失败锁定 redis key + */ + public static final String LOGIN_FAIL_LOCK_KEY = "login_fail_lock:"; + /** * 超出上限,排番失败(时间:{},KEY:{} */ diff --git a/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/LoginFailLockService.java b/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/LoginFailLockService.java new file mode 100644 index 000000000..169d73598 --- /dev/null +++ b/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/LoginFailLockService.java @@ -0,0 +1,79 @@ +package com.core.framework.web.service; + +import com.core.common.constant.CacheConstants; +import com.core.common.core.redis.RedisCache; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * 登录失败锁定服务 + * + * @author system + */ +@Component +public class LoginFailLockService { + @Autowired + private RedisCache redisCache; + + @Value("${user.password.maxRetryCount:5}") + private int maxRetryCount; + + @Value("${user.password.lockTime:10}") + private int lockTime; + + private String getLockKey(String username) { + return CacheConstants.LOGIN_FAIL_LOCK_KEY + username; + } + + /** + * 检查用户是否被锁定 + * + * @param username 用户名 + * @return 是否被锁定 + */ + public boolean isLocked(String username) { + Integer failCount = redisCache.getCacheObject(getLockKey(username)); + return failCount != null && failCount >= maxRetryCount; + } + + /** + * 记录登录失败次数 + * + * @param username 用户名 + */ + public void recordLoginFailure(String username) { + String key = getLockKey(username); + Integer count = redisCache.getCacheObject(key); + if (count == null) { + count = 0; + } + count++; + redisCache.setCacheObject(key, count, lockTime, TimeUnit.MINUTES); + } + + /** + * 清除登录失败记录(登录成功时调用) + * + * @param username 用户名 + */ + public void clearLoginFailure(String username) { + redisCache.deleteObject(getLockKey(username)); + } + + /** + * 获取剩余锁定时间(分钟) + * + * @param username 用户名 + * @return 剩余分钟数 + */ + public int getRemainingLockMinutes(String username) { + Long ttl = redisCache.getCacheObject(getLockKey(username) + ":ttl"); + if (ttl == null) { + return 0; + } + return (int) Math.ceil(ttl / 60.0); + } +} diff --git a/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/SysLoginService.java b/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/SysLoginService.java index 1e09b9baa..150798666 100755 --- a/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/SysLoginService.java +++ b/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/SysLoginService.java @@ -66,6 +66,9 @@ public class SysLoginService { @Autowired private ISysTenantOptionService sysTenantOptionService; + @Autowired + private LoginFailLockService loginFailLockService; + /** * 登录验证 * @@ -77,6 +80,11 @@ public class SysLoginService { * @return 结果 */ public String login(String username, String password, String code, String uuid, Integer tenantId) { + // 登录失败锁定检查 + if (loginFailLockService.isLocked(username)) { + throw new com.core.common.exception.ServiceException("账号已被锁定,请稍后再试"); + } + // 验证码校验 // validateCaptcha(username, code, uuid); @@ -96,6 +104,8 @@ public class SysLoginService { // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager.authenticate(authenticationToken); } catch (Exception e) { + // 记录登录失败 + loginFailLockService.recordLoginFailure(username); if (e instanceof BadCredentialsException ex) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match"))); @@ -108,6 +118,8 @@ public class SysLoginService { } finally { AuthenticationContextHolder.clearContext(); } + // 登录成功,清除失败记录 + loginFailLockService.clearLoginFailure(username); AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success"))); LoginUser loginUser = (LoginUser)authentication.getPrincipal(); diff --git a/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/TokenService.java b/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/TokenService.java index 4b90ac8a8..479dc3d07 100755 --- a/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/TokenService.java +++ b/healthlink-his-server/core-framework/src/main/java/com/core/framework/web/service/TokenService.java @@ -59,6 +59,10 @@ public class TokenService { String token = getToken(request); if (StringUtils.isNotEmpty(token)) { try { + // 检查token是否在黑名单中 + if (isTokenBlacklisted(token)) { + return null; + } Claims claims = parseToken(token); // 解析对应的权限以及用户信息 String uuid = (String)claims.get(Constants.LOGIN_USER_KEY); @@ -113,6 +117,49 @@ public class TokenService { } } + /** + * 将token加入黑名单 + * + * @param token 令牌 + * @param expireMinutes 过期时间(分钟) + */ + public void blacklistToken(String token, long expireMinutes) { + if (StringUtils.isNotEmpty(token)) { + String blacklistKey = CacheConstants.TOKEN_BLACKLIST_KEY + token; + redisCache.setCacheObject(blacklistKey, "1", (int) expireMinutes, TimeUnit.MINUTES); + } + } + + /** + * 检查token是否在黑名单中 + * + * @param token 令牌 + * @return 是否被黑名单 + */ + public boolean isTokenBlacklisted(String token) { + if (StringUtils.isEmpty(token)) { + return false; + } + String blacklistKey = CacheConstants.TOKEN_BLACKLIST_KEY + token; + return redisCache.getCacheObject(blacklistKey) != null; + } + + /** + * 刷新令牌(生成新token) + * + * @param loginUser 用户信息 + * @return 新令牌 + */ + public String refreshAccessToken(LoginUser loginUser) { + if (StringUtils.isNull(loginUser) || StringUtils.isEmpty(loginUser.getToken())) { + return null; + } + // 将旧token加入黑名单 + blacklistToken(loginUser.getToken(), expireTime); + // 创建新token + return createToken(loginUser); + } + /** * 创建令牌 * diff --git a/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/appservice/IAuditEnhanceAppService.java b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/appservice/IAuditEnhanceAppService.java new file mode 100644 index 000000000..8b76a8f6d --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/appservice/IAuditEnhanceAppService.java @@ -0,0 +1,29 @@ +package com.healthlink.his.web.system.appservice; + +import com.core.common.core.domain.R; +import com.healthlink.his.sys.domain.SysAuditLog; + +import java.util.Map; + +/** + * 审计日志增强服务接口 + */ +public interface IAuditEnhanceAppService { + + /** + * 分页查询增强审计日志 + */ + R getEnhancedLogs(String userName, String module, String action, String riskLevel, + String businessType, String startDate, String endDate, + Integer pageNo, Integer pageSize); + + /** + * 获取审计日志统计数据 + */ + R getAuditStats(String startDate, String endDate); + + /** + * 记录增强审计日志 + */ + void recordEnhancedLog(SysAuditLog log); +} diff --git a/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/appservice/impl/AuditEnhanceAppServiceImpl.java b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/appservice/impl/AuditEnhanceAppServiceImpl.java new file mode 100644 index 000000000..64e8df4dd --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/appservice/impl/AuditEnhanceAppServiceImpl.java @@ -0,0 +1,109 @@ +package com.healthlink.his.web.system.appservice.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.core.common.core.domain.R; +import com.healthlink.his.sys.domain.SysAuditLog; +import com.healthlink.his.sys.mapper.SysAuditLogMapper; +import com.healthlink.his.web.system.appservice.IAuditEnhanceAppService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * 审计日志增强服务实现 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuditEnhanceAppServiceImpl implements IAuditEnhanceAppService { + + private final SysAuditLogMapper auditLogMapper; + + @Override + public R getEnhancedLogs(String userName, String module, String action, String riskLevel, + String businessType, String startDate, String endDate, + Integer pageNo, Integer pageSize) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StringUtils.hasText(userName), SysAuditLog::getUserName, userName) + .eq(StringUtils.hasText(module), SysAuditLog::getModule, module) + .eq(StringUtils.hasText(action), SysAuditLog::getAction, action) + .eq(StringUtils.hasText(riskLevel), SysAuditLog::getRiskLevel, riskLevel) + .eq(StringUtils.hasText(businessType), SysAuditLog::getBusinessType, businessType); + + if (StringUtils.hasText(startDate)) { + try { + Date start = new SimpleDateFormat("yyyy-MM-dd").parse(startDate); + wrapper.ge(SysAuditLog::getCreateTime, start); + } catch (ParseException e) { + log.warn("日期解析失败: {}", startDate); + } + } + if (StringUtils.hasText(endDate)) { + try { + Date end = new SimpleDateFormat("yyyy-MM-dd").parse(endDate); + wrapper.le(SysAuditLog::getCreateTime, end); + } catch (ParseException e) { + log.warn("日期解析失败: {}", endDate); + } + } + + wrapper.orderByDesc(SysAuditLog::getCreateTime); + Page page = auditLogMapper.selectPage(new Page<>(pageNo, pageSize), wrapper); + return R.ok(page); + } + + @Override + public R getAuditStats(String startDate, String endDate) { + Map stats = new HashMap<>(); + + // 按模块统计 + String moduleSql = "SELECT module, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'"; + // 按风险级别统计 + String riskSql = "SELECT risk_level, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'"; + // 按业务类型统计 + String businessSql = "SELECT business_type, COUNT(*) as count FROM sys_audit_log WHERE delete_flag = '0'"; + + if (StringUtils.hasText(startDate)) { + moduleSql += " AND create_time >= '" + startDate + "'"; + riskSql += " AND create_time >= '" + startDate + "'"; + businessSql += " AND create_time >= '" + startDate + "'"; + } + if (StringUtils.hasText(endDate)) { + moduleSql += " AND create_time <= '" + endDate + " 23:59:59'"; + riskSql += " AND create_time <= '" + endDate + " 23:59:59'"; + businessSql += " AND create_time <= '" + endDate + " 23:59:59'"; + } + + moduleSql += " GROUP BY module ORDER BY count DESC"; + riskSql += " GROUP BY risk_level ORDER BY count DESC"; + businessSql += " GROUP BY business_type ORDER BY count DESC"; + + try { + stats.put("moduleStats", auditLogMapper.selectMaps( + new LambdaQueryWrapper().last(moduleSql))); + stats.put("riskStats", auditLogMapper.selectMaps( + new LambdaQueryWrapper().last(riskSql))); + stats.put("businessStats", auditLogMapper.selectMaps( + new LambdaQueryWrapper().last(businessSql))); + } catch (Exception e) { + log.error("统计审计日志失败", e); + } + + return R.ok(stats); + } + + @Override + public void recordEnhancedLog(SysAuditLog auditLog) { + try { + auditLogMapper.insert(auditLog); + } catch (Exception e) { + log.error("记录审计日志失败: {}", e.getMessage()); + } + } +} diff --git a/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/controller/AuditEnhanceController.java b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/controller/AuditEnhanceController.java new file mode 100644 index 000000000..ca992cc05 --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/system/controller/AuditEnhanceController.java @@ -0,0 +1,48 @@ +package com.healthlink.his.web.system.controller; + +import com.core.common.core.domain.R; +import com.healthlink.his.web.system.appservice.IAuditEnhanceAppService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * 审计日志增强 Controller + */ +@RestController +@RequestMapping("/audit/enhanced") +@RequiredArgsConstructor +public class AuditEnhanceController { + + private final IAuditEnhanceAppService auditEnhanceAppService; + + /** + * 分页查询增强审计日志 + */ + @GetMapping("/logs") + @PreAuthorize("@ss.hasPermi('system:audit:list')") + public R getEnhancedLogs( + @RequestParam(value = "userName", required = false) String userName, + @RequestParam(value = "module", required = false) String module, + @RequestParam(value = "action", required = false) String action, + @RequestParam(value = "riskLevel", required = false) String riskLevel, + @RequestParam(value = "businessType", required = false) String businessType, + @RequestParam(value = "startDate", required = false) String startDate, + @RequestParam(value = "endDate", required = false) String endDate, + @RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) { + return auditEnhanceAppService.getEnhancedLogs(userName, module, action, riskLevel, + businessType, startDate, endDate, pageNo, pageSize); + } + + /** + * 获取审计日志统计数据 + */ + @GetMapping("/stats") + @PreAuthorize("@ss.hasPermi('system:audit:list')") + public R getAuditStats( + @RequestParam(value = "startDate", required = false) String startDate, + @RequestParam(value = "endDate", required = false) String endDate) { + return auditEnhanceAppService.getAuditStats(startDate, endDate); + } +} diff --git a/healthlink-his-server/healthlink-his-application/src/main/resources/db/migration/V84__audit_enhancement.sql b/healthlink-his-server/healthlink-his-application/src/main/resources/db/migration/V84__audit_enhancement.sql new file mode 100644 index 000000000..c345f54da --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/resources/db/migration/V84__audit_enhancement.sql @@ -0,0 +1,21 @@ +-- V84: 审计日志增强 - 扩展字段 +-- 为系统操作审计日志添加增强字段 + +-- 添加新字段 +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS user_agent VARCHAR(512) DEFAULT NULL COMMENT '用户代理'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS request_method VARCHAR(10) DEFAULT NULL COMMENT '请求方法(GET/POST/PUT/DELETE)'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS response_code INT DEFAULT NULL COMMENT '响应状态码'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS response_time_ms BIGINT DEFAULT NULL COMMENT '响应时间(毫秒)'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS client_ip VARCHAR(50) DEFAULT NULL COMMENT '客户端IP'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(100) DEFAULT NULL COMMENT '租户名称'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS org_name VARCHAR(100) DEFAULT NULL COMMENT '科室名称'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS risk_level VARCHAR(20) DEFAULT 'LOW' COMMENT '风险级别(LOW/MEDIUM/HIGH/CRITICAL)'; +ALTER TABLE sys_audit_log ADD COLUMN IF NOT EXISTS business_type VARCHAR(50) DEFAULT NULL COMMENT '业务类型(login/logout/query/insert/update/delete/export)'; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON sys_audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_module ON sys_audit_log(module); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON sys_audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_log_create_time ON sys_audit_log(create_time); +CREATE INDEX IF NOT EXISTS idx_audit_log_risk_level ON sys_audit_log(risk_level); +CREATE INDEX IF NOT EXISTS idx_audit_log_business_type ON sys_audit_log(business_type); diff --git a/healthlink-his-server/healthlink-his-domain/src/main/java/com/healthlink/his/sys/domain/SysAuditLog.java b/healthlink-his-server/healthlink-his-domain/src/main/java/com/healthlink/his/sys/domain/SysAuditLog.java index bd7a3d702..f646c4962 100644 --- a/healthlink-his-server/healthlink-his-domain/src/main/java/com/healthlink/his/sys/domain/SysAuditLog.java +++ b/healthlink-his-server/healthlink-his-domain/src/main/java/com/healthlink/his/sys/domain/SysAuditLog.java @@ -25,4 +25,13 @@ public class SysAuditLog extends HisBaseEntity { private String result; private String errorMsg; private Integer durationMs; + private String userAgent; + private String requestMethod; + private Integer responseCode; + private Long responseTimeMs; + private String clientIp; + private String tenantName; + private String orgName; + private String riskLevel; + private String businessType; } \ No newline at end of file