diff --git a/backend/pom.xml b/backend/pom.xml index 0fe2850..bcb1635 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -43,6 +43,10 @@ org.springframework.boot spring-boot-starter-cache + + org.springframework.boot + spring-boot-starter-aop + com.baomidou mybatis-plus-spring-boot3-starter diff --git a/backend/src/main/java/com/imeeting/ImeetingApplication.java b/backend/src/main/java/com/imeeting/ImeetingApplication.java index ffe62df..645a28f 100644 --- a/backend/src/main/java/com/imeeting/ImeetingApplication.java +++ b/backend/src/main/java/com/imeeting/ImeetingApplication.java @@ -2,8 +2,10 @@ package com.imeeting; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class ImeetingApplication { public static void main(String[] args) { SpringApplication.run(ImeetingApplication.class, args); diff --git a/backend/src/main/java/com/imeeting/common/annotation/Log.java b/backend/src/main/java/com/imeeting/common/annotation/Log.java new file mode 100644 index 0000000..b9fee6e --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/annotation/Log.java @@ -0,0 +1,11 @@ +package com.imeeting.common.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Log { + String value() default ""; // 操作描述 + String type() default ""; // 资源类型/模块名 +} diff --git a/backend/src/main/java/com/imeeting/common/aspect/LogAspect.java b/backend/src/main/java/com/imeeting/common/aspect/LogAspect.java new file mode 100644 index 0000000..28b5870 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/aspect/LogAspect.java @@ -0,0 +1,118 @@ +package com.imeeting.common.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.annotation.Log; +import com.imeeting.entity.SysLog; +import com.imeeting.security.LoginUser; +import com.imeeting.service.SysLogService; +import jakarta.servlet.http.HttpServletRequest; +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.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; + +@Aspect +@Component +public class LogAspect { + + private final SysLogService sysLogService; + private final ObjectMapper objectMapper; + + public LogAspect(SysLogService sysLogService, ObjectMapper objectMapper) { + this.sysLogService = sysLogService; + this.objectMapper = objectMapper; + } + + @Around("@annotation(com.imeeting.common.annotation.Log)") + public Object around(ProceedingJoinPoint point) throws Throwable { + long start = System.currentTimeMillis(); + Object result = null; + Exception exception = null; + + try { + result = point.proceed(); + return result; + } catch (Exception e) { + exception = e; + throw e; + } finally { + saveLog(point, result, exception, System.currentTimeMillis() - start); + } + } + + private void saveLog(ProceedingJoinPoint joinPoint, Object result, Exception e, long duration) { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) return; + HttpServletRequest request = attributes.getRequest(); + + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Log logAnnotation = method.getAnnotation(Log.class); + + SysLog sysLog = new SysLog(); + sysLog.setOperationType(request.getMethod()); + sysLog.setResourceType(logAnnotation.type().isEmpty() ? logAnnotation.value() : logAnnotation.type()); + + // Capture request parameters + String params = getArgsJson(joinPoint); + sysLog.setDetail(logAnnotation.value() + " | Params: " + params); + + sysLog.setIpAddress(request.getRemoteAddr()); + sysLog.setUserAgent(request.getHeader("User-Agent")); + sysLog.setCreatedAt(LocalDateTime.now()); + + // Get Current User + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof LoginUser) { + LoginUser user = (LoginUser) auth.getPrincipal(); + sysLog.setUserId(user.getUserId()); + sysLog.setUsername(user.getUsername()); + } + + if (e != null) { + sysLog.setStatus(0); + sysLog.setErrorMessage(e.getMessage()); + } else { + sysLog.setStatus(1); + } + + // Record duration in detail or a separate field if we want + // sysLog.setDetail(sysLog.getDetail() + " (Time: " + duration + "ms)"); + + sysLogService.recordLog(sysLog); + } catch (Exception ex) { + // Log the logging error to console only + ex.printStackTrace(); + } + } + + private String getArgsJson(ProceedingJoinPoint joinPoint) { + try { + Object[] args = joinPoint.getArgs(); + if (args == null || args.length == 0) { + return "[]"; + } + Object[] filterArgs = new Object[args.length]; + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof jakarta.servlet.ServletRequest + || args[i] instanceof jakarta.servlet.ServletResponse + || args[i] instanceof org.springframework.web.multipart.MultipartFile) { + continue; + } + filterArgs[i] = args[i]; + } + return objectMapper.writeValueAsString(filterArgs); + } catch (Exception e) { + return "[Error capturing params]"; + } + } +} diff --git a/backend/src/main/java/com/imeeting/controller/RoleController.java b/backend/src/main/java/com/imeeting/controller/RoleController.java index 34b08c1..8e791c7 100644 --- a/backend/src/main/java/com/imeeting/controller/RoleController.java +++ b/backend/src/main/java/com/imeeting/controller/RoleController.java @@ -2,10 +2,13 @@ package com.imeeting.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.imeeting.common.ApiResponse; +import com.imeeting.common.annotation.Log; import com.imeeting.entity.SysRole; import com.imeeting.entity.SysRolePermission; +import com.imeeting.entity.SysUser; import com.imeeting.mapper.SysRolePermissionMapper; import com.imeeting.service.SysRoleService; +import com.imeeting.service.SysUserService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -16,10 +19,12 @@ import java.util.List; @RequestMapping("/api/roles") public class RoleController { private final SysRoleService sysRoleService; + private final SysUserService sysUserService; private final SysRolePermissionMapper sysRolePermissionMapper; - public RoleController(SysRoleService sysRoleService, SysRolePermissionMapper sysRolePermissionMapper) { + public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper) { this.sysRoleService = sysRoleService; + this.sysUserService = sysUserService; this.sysRolePermissionMapper = sysRolePermissionMapper; } @@ -29,6 +34,12 @@ public class RoleController { return ApiResponse.ok(sysRoleService.list()); } + @GetMapping("/{id}/users") + @PreAuthorize("@ss.hasPermi('sys_role:query')") + public ApiResponse> listUsers(@PathVariable Long id) { + return ApiResponse.ok(sysUserService.listUsersByRoleId(id)); + } + @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_role:query')") public ApiResponse get(@PathVariable Long id) { @@ -37,12 +48,14 @@ public class RoleController { @PostMapping @PreAuthorize("@ss.hasPermi('sys_role:create')") + @com.imeeting.common.annotation.Log(value = "新增角色", type = "角色管理") public ApiResponse create(@RequestBody SysRole role) { return ApiResponse.ok(sysRoleService.save(role)); } @PutMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_role:update')") + @com.imeeting.common.annotation.Log(value = "修改角色", type = "角色管理") public ApiResponse update(@PathVariable Long id, @RequestBody SysRole role) { role.setRoleId(id); return ApiResponse.ok(sysRoleService.updateById(role)); @@ -50,6 +63,7 @@ public class RoleController { @DeleteMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_role:delete')") + @Log(value = "删除角色", type = "角色管理") public ApiResponse delete(@PathVariable Long id) { return ApiResponse.ok(sysRoleService.removeById(id)); } diff --git a/backend/src/main/java/com/imeeting/controller/SysLogController.java b/backend/src/main/java/com/imeeting/controller/SysLogController.java new file mode 100644 index 0000000..d251499 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/SysLogController.java @@ -0,0 +1,59 @@ +package com.imeeting.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.imeeting.common.ApiResponse; +import com.imeeting.entity.SysLog; +import com.imeeting.service.SysLogService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@RestController +@RequestMapping("/api/logs") +public class SysLogController { + private final SysLogService sysLogService; + + public SysLogController(SysLogService sysLogService) { + this.sysLogService = sysLogService; + } + + @GetMapping + @PreAuthorize("@ss.hasPermi('sys_log:list')") + public ApiResponse> list( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String username, + @RequestParam(required = false) String operationType, // LOGIN or others + @RequestParam(required = false) Integer status, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate + ) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + + if (username != null && !username.isEmpty()) { + query.like(SysLog::getUsername, username); + } + if (operationType != null && !operationType.isEmpty()) { + if ("LOGIN".equals(operationType)) { + query.eq(SysLog::getOperationType, "LOGIN"); + } else { + query.ne(SysLog::getOperationType, "LOGIN"); + } + } + if (status != null) { + query.eq(SysLog::getStatus, status); + } + // Simplified date range filtering + if (startDate != null && !startDate.isEmpty()) { + query.ge(SysLog::getCreatedAt, startDate + " 00:00:00"); + } + if (endDate != null && !endDate.isEmpty()) { + query.le(SysLog::getCreatedAt, endDate + " 23:59:59"); + } + + query.orderByDesc(SysLog::getCreatedAt); + return ApiResponse.ok(sysLogService.page(new Page<>(current, size), query)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index 16da093..f5c7b4e 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -72,6 +72,7 @@ public class UserController { @PostMapping @PreAuthorize("@ss.hasPermi('sys_user:create')") + @com.imeeting.common.annotation.Log(value = "新增用户", type = "用户管理") public ApiResponse create(@RequestBody SysUser user) { if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) { user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash())); @@ -81,6 +82,7 @@ public class UserController { @PutMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_user:update')") + @com.imeeting.common.annotation.Log(value = "修改用户", type = "用户管理") public ApiResponse update(@PathVariable Long id, @RequestBody SysUser user) { user.setUserId(id); if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) { @@ -91,6 +93,7 @@ public class UserController { @DeleteMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys_user:delete')") + @com.imeeting.common.annotation.Log(value = "删除用户", type = "用户管理") public ApiResponse delete(@PathVariable Long id) { return ApiResponse.ok(sysUserService.removeById(id)); } diff --git a/backend/src/main/java/com/imeeting/entity/BaseEntity.java b/backend/src/main/java/com/imeeting/entity/BaseEntity.java index 8ea44c1..252193f 100644 --- a/backend/src/main/java/com/imeeting/entity/BaseEntity.java +++ b/backend/src/main/java/com/imeeting/entity/BaseEntity.java @@ -2,6 +2,7 @@ package com.imeeting.entity; import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; import lombok.Data; import java.time.LocalDateTime; @@ -10,6 +11,8 @@ import java.time.LocalDateTime; public class BaseEntity { private Long tenantId; private Integer status; + + @TableLogic private Integer isDeleted; @TableField(fill = FieldFill.INSERT) diff --git a/backend/src/main/java/com/imeeting/entity/SysLog.java b/backend/src/main/java/com/imeeting/entity/SysLog.java new file mode 100644 index 0000000..73307ab --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysLog.java @@ -0,0 +1,25 @@ +package com.imeeting.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("sys_log") +public class SysLog { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String username; + private String operationType; // LOGIN, LOGOUT, CREATE, UPDATE, DELETE, QUERY + private String resourceType; // 所属模块/资源 + private Long resourceId; + private String detail; // 操作详情(可以是 JSON) + private String ipAddress; + private String userAgent; + private Integer status; // 1-成功, 0-失败 + private String errorMessage; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysLogMapper.java b/backend/src/main/java/com/imeeting/mapper/SysLogMapper.java new file mode 100644 index 0000000..319ecd8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysLogMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.SysLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysLogMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java index 27fbb1c..0fcbd5a 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java @@ -3,6 +3,12 @@ package com.imeeting.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.imeeting.entity.SysUser; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Param; +import java.util.List; @Mapper -public interface SysUserMapper extends BaseMapper {} +public interface SysUserMapper extends BaseMapper { + @Select("SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.user_id = ur.user_id WHERE ur.role_id = #{roleId}") + List selectUsersByRoleId(@Param("roleId") Long roleId); +} diff --git a/backend/src/main/java/com/imeeting/security/LoginUser.java b/backend/src/main/java/com/imeeting/security/LoginUser.java new file mode 100644 index 0000000..9d9acd0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/security/LoginUser.java @@ -0,0 +1,59 @@ +package com.imeeting.security; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginUser implements UserDetails { + private Long userId; + private String username; + private Set permissions; + + @Override + public Collection getAuthorities() { + if (permissions == null) return null; + return permissions.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/backend/src/main/java/com/imeeting/security/PermissionService.java b/backend/src/main/java/com/imeeting/security/PermissionService.java new file mode 100644 index 0000000..5a1cbdb --- /dev/null +++ b/backend/src/main/java/com/imeeting/security/PermissionService.java @@ -0,0 +1,42 @@ +package com.imeeting.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.Set; + +@Service("ss") +public class PermissionService { + + /** + * 验证用户是否具备某权限 + * + * @param permission 权限字符串 + * @return 用户是否具备某权限 + */ + public boolean hasPermi(String permission) { + if (permission == null || permission.isEmpty()) { + return false; + } + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) { + return false; + } + + LoginUser loginUser = (LoginUser) authentication.getPrincipal(); + + // 超级管理员(ID=1) 拥有所有权限 + if (loginUser.getUserId() != null && loginUser.getUserId() == 1L) { + return true; + } + + Set permissions = loginUser.getPermissions(); + if (CollectionUtils.isEmpty(permissions)) { + return false; + } + + return permissions.contains(permission); + } +} diff --git a/backend/src/main/java/com/imeeting/service/SysLogService.java b/backend/src/main/java/com/imeeting/service/SysLogService.java new file mode 100644 index 0000000..55f0ad0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysLogService.java @@ -0,0 +1,8 @@ +package com.imeeting.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.imeeting.entity.SysLog; + +public interface SysLogService extends IService { + void recordLog(SysLog log); +} diff --git a/backend/src/main/java/com/imeeting/service/SysUserService.java b/backend/src/main/java/com/imeeting/service/SysUserService.java index 62efe5f..f0a00db 100644 --- a/backend/src/main/java/com/imeeting/service/SysUserService.java +++ b/backend/src/main/java/com/imeeting/service/SysUserService.java @@ -1,6 +1,17 @@ package com.imeeting.service; import com.baomidou.mybatisplus.extension.service.IService; + import com.imeeting.entity.SysUser; -public interface SysUserService extends IService {} +import java.util.List; + + + +public interface SysUserService extends IService { + + List listUsersByRoleId(Long roleId); + +} + + diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java index fc0c828..7ba00d2 100644 --- a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java @@ -7,17 +7,17 @@ import com.imeeting.auth.dto.TokenResponse; import com.imeeting.common.RedisKeys; import com.imeeting.common.SysParamKeys; import com.imeeting.entity.Device; +import com.imeeting.entity.SysLog; import com.imeeting.entity.SysUser; -import com.imeeting.service.AuthService; -import com.imeeting.service.DeviceService; -import com.imeeting.service.SysParamService; -import com.imeeting.service.SysUserService; +import com.imeeting.service.*; import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -31,6 +31,8 @@ public class AuthServiceImpl implements AuthService { private final StringRedisTemplate stringRedisTemplate; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final SysLogService sysLogService; + private final HttpServletRequest httpServletRequest; @Value("${app.token.access-default-minutes:30}") private long accessDefaultMinutes; @@ -41,52 +43,75 @@ public class AuthServiceImpl implements AuthService { public AuthServiceImpl(SysUserService sysUserService, DeviceService deviceService, SysParamService sysParamService, StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder, - JwtTokenProvider jwtTokenProvider) { + JwtTokenProvider jwtTokenProvider, SysLogService sysLogService, HttpServletRequest httpServletRequest) { this.sysUserService = sysUserService; this.deviceService = deviceService; this.sysParamService = sysParamService; this.stringRedisTemplate = stringRedisTemplate; this.passwordEncoder = passwordEncoder; this.jwtTokenProvider = jwtTokenProvider; + this.sysLogService = sysLogService; + this.httpServletRequest = httpServletRequest; } @Override public TokenResponse login(LoginRequest request) { - if (isCaptchaEnabled()) { - validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); - } - - SysUser user = sysUserService.getOne(new LambdaQueryWrapper() - .eq(SysUser::getUsername, request.getUsername()) - .eq(SysUser::getIsDeleted, 0) - .eq(SysUser::getStatus, 1)); - if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { - throw new IllegalArgumentException("用户名或密码错误"); - } - - String deviceCode = request.getDeviceCode(); - if (deviceCode != null && !deviceCode.isEmpty()) { - Device device = deviceService.getOne(new LambdaQueryWrapper() - .eq(Device::getUserId, user.getUserId()) - .eq(Device::getDeviceCode, deviceCode) - .eq(Device::getIsDeleted, 0) - .eq(Device::getStatus, 1)); - if (device == null) { - throw new IllegalArgumentException("设备码无效"); + try { + if (isCaptchaEnabled()) { + validateCaptcha(request.getCaptchaId(), request.getCaptchaCode()); } - } - long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes", - String.valueOf(accessDefaultMinutes)), accessDefaultMinutes); - long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days", - String.valueOf(refreshDefaultDays)), refreshDefaultDays); + SysUser user = sysUserService.getOne(new LambdaQueryWrapper() + .eq(SysUser::getUsername, request.getUsername()) + .eq(SysUser::getIsDeleted, 0) + .eq(SysUser::getStatus, 1)); + if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + throw new IllegalArgumentException("用户名或密码错误"); + } - if (deviceCode == null || deviceCode.isEmpty()) { - deviceCode = "default"; + String deviceCode = request.getDeviceCode(); + if (deviceCode != null && !deviceCode.isEmpty()) { + Device device = deviceService.getOne(new LambdaQueryWrapper() + .eq(Device::getUserId, user.getUserId()) + .eq(Device::getDeviceCode, deviceCode) + .eq(Device::getIsDeleted, 0) + .eq(Device::getStatus, 1)); + if (device == null) { + throw new IllegalArgumentException("设备码无效"); + } + } + + long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes", + String.valueOf(accessDefaultMinutes)), accessDefaultMinutes); + long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days", + String.valueOf(refreshDefaultDays)), refreshDefaultDays); + + if (deviceCode == null || deviceCode.isEmpty()) { + deviceCode = "default"; + } + TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays); + cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays); + + recordLoginLog(user.getUserId(), user.getUsername(), 1, "登录成功"); + return tokens; + } catch (Exception e) { + recordLoginLog(null, request.getUsername(), 0, e.getMessage()); + throw e; } - TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays); - cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays); - return tokens; + } + + private void recordLoginLog(Long userId, String username, Integer status, String msg) { + SysLog sysLog = new SysLog(); + sysLog.setUserId(userId); + sysLog.setUsername(username); + sysLog.setOperationType("LOGIN"); + sysLog.setResourceType("认证模块"); + sysLog.setDetail(msg); + sysLog.setStatus(status); + sysLog.setIpAddress(httpServletRequest.getRemoteAddr()); + sysLog.setUserAgent(httpServletRequest.getHeader("User-Agent")); + sysLog.setCreatedAt(LocalDateTime.now()); + sysLogService.recordLog(sysLog); } @Override diff --git a/backend/src/main/java/com/imeeting/service/impl/SysLogServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysLogServiceImpl.java new file mode 100644 index 0000000..35004c2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysLogServiceImpl.java @@ -0,0 +1,18 @@ +package com.imeeting.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.entity.SysLog; +import com.imeeting.mapper.SysLogMapper; +import com.imeeting.service.SysLogService; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +public class SysLogServiceImpl extends ServiceImpl implements SysLogService { + + @Async + @Override + public void recordLog(SysLog log) { + save(log); + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java index cbdefe6..c8951df 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysUserServiceImpl.java @@ -1,10 +1,31 @@ package com.imeeting.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + import com.imeeting.entity.SysUser; + import com.imeeting.mapper.SysUserMapper; + import com.imeeting.service.SysUserService; + import org.springframework.stereotype.Service; +import java.util.List; + + + @Service -public class SysUserServiceImpl extends ServiceImpl implements SysUserService {} + +public class SysUserServiceImpl extends ServiceImpl implements SysUserService { + + @Override + + public List listUsersByRoleId(Long roleId) { + + return baseMapper.selectUsersByRoleId(roleId); + + } + +} + + diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 65178d3..b6134ce 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -18,6 +18,11 @@ spring: mybatis-plus: configuration: map-underscore-to-camel-case: true + global-config: + db-config: + logic-delete-field: isDeleted + logic-delete-value: 1 + logic-not-delete-value: 0 security: jwt: diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 49b3d9c..bbb5f7a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -122,5 +122,15 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) { return resp.data.data as boolean; } +export async function fetchUsersByRoleId(roleId: number) { + const resp = await http.get(`/api/roles/${roleId}/users`); + return resp.data.data as SysUser[]; +} + +export async function fetchLogs(params: any) { + const resp = await http.get("/api/logs", { params }); + return resp.data.data; +} + export * from "./dict"; diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..a76c830 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,171 @@ +import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select } from "antd"; +import { useEffect, useState } from "react"; +import { fetchLogs } from "../api"; +import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; +import dayjs from "dayjs"; + +const { RangePicker } = DatePicker; + +export default function Logs() { + const [activeTab, setActiveTab] = useState("OPERATION"); + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [params, setParams] = useState({ + current: 1, + size: 10, + username: "", + status: undefined, + startDate: "", + endDate: "" + }); + + const loadData = async (currentParams = params) => { + setLoading(true); + try { + const operationType = activeTab === "LOGIN" ? "LOGIN" : "OPERATION"; + const result = await fetchLogs({ ...currentParams, operationType }); + setData(result.records || []); + setTotal(result.total || 0); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, [activeTab, params.current, params.size]); + + const handleSearch = () => { + setParams({ ...params, current: 1 }); + loadData({ ...params, current: 1 }); + }; + + const handleReset = () => { + const resetParams = { + current: 1, + size: 10, + username: "", + status: undefined, + startDate: "", + endDate: "" + }; + setParams(resetParams); + loadData(resetParams); + }; + + const columns = [ + { + title: "用户名", + dataIndex: "username", + key: "username", + width: 120, + render: (text: string) => text || "系统/访客" + }, + { + title: activeTab === "LOGIN" ? "登录模块" : "操作模块", + dataIndex: "resourceType", + key: "resourceType", + width: 150 + }, + { + title: "操作详情", + dataIndex: "detail", + key: "detail", + ellipsis: true + }, + { + title: "IP地址", + dataIndex: "ipAddress", + key: "ipAddress", + width: 140 + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 100, + render: (status: number) => ( + + {status === 1 ? "成功" : "失败"} + + ) + }, + { + title: "操作时间", + dataIndex: "createdAt", + key: "createdAt", + width: 180, + render: (text: string) => text?.replace('T', ' ').substring(0, 19) + } + ]; + + if (activeTab === "OPERATION") { + columns.splice(1, 0, { + title: "请求方式", + dataIndex: "operationType", + key: "operationType", + width: 100, + render: (t: string) => {t} + }); + } + + return ( +
+ + + setParams({ ...params, username: e.target.value })} + /> + } + value={searchText} + onChange={e => setSearchText(e.target.value)} + allowClear + />
- -
-
{role.roleName}
-
{`ID: ${role.roleCode || role.roleId}`}
+
+ ({ + onClick: () => selectRole(record), + className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}` + })} + columns={[ + { + title: '角色', + render: (_, record) => ( +
+
+
{record.roleName}
+
{record.roleCode}
+
+
+ {can("sys_role:update") &&
+
+ ) + } + ]} + /> + + - {renderRolePermissions(role)} - -
- 最后同步 - {role.updatedAt ? "刚刚" : "刚刚"} -
- - ))} - {!data.length && !loading && ( -
暂无角色
- )} - - - -
- -
-
-
- {editing ? "编辑角色" : "创建新角色"} -
-
- - } - footer={ -
- - + } > - 确认并同步到系统 - + + 功能权限} + key="permissions" + > +
+ { + const checked = Array.isArray(keys) ? keys : keys.checked; + const halfChecked = info.halfCheckedKeys || []; + setSelectedPermIds(checked.map(k => Number(k))); + setHalfCheckedIds(halfChecked.map(k => Number(k))); + }} + defaultExpandAll + /> +
+
+ 关联用户 ({roleUsers.length})} + key="users" + > +
( + + +
+
{r.displayName}
+
@{r.username}
+
+
+ ) + }, + { title: '手机号', dataIndex: 'phone' }, + { title: '邮箱', dataIndex: 'email' }, + { + title: '状态', + dataIndex: 'status', + render: s => {s === 1 ? '正常' : '禁用'} + } + ]} + /> + + + + ) : ( + + + + )} + + + + {/* Basic Info Drawer */} + setDrawerOpen(false)} + width={400} + destroyOnClose + footer={ +
+ +
} > -
- - + + + - - + + + + +