From 36d285a61dc8cb6220c3208785f7f36ef3902173 Mon Sep 17 00:00:00 2001 From: Chuck1sn Date: Mon, 7 Jul 2025 16:13:52 +0800 Subject: [PATCH] init aop log --- .../com/zl/mjga/annotation/SkipAopLog.java | 45 ++ .../com/zl/mjga/aspect/LoggingAspect.java | 182 ++++++++ .../zl/mjga/controller/AopLogController.java | 73 +++ .../zl/mjga/dto/aoplog/AopLogQueryDto.java | 43 ++ .../com/zl/mjga/dto/aoplog/AopLogRespDto.java | 53 +++ .../java/com/zl/mjga/model/urp/ChatMode.java | 4 +- .../zl/mjga/repository/AopLogRepository.java | 117 +++++ .../com/zl/mjga/service/AopLogService.java | 61 +++ .../db/migration/V1_0_4__init_aop_log.sql | 21 + .../integration/aspect/LoggingAspectTest.java | 440 ++++++++++++++++++ .../integration/mvc/AopLogControllerTest.java | 186 ++++++++ .../mvc/JacksonAnnotationMvcTest.java | 2 + .../mvc/UserRolePermissionMvcTest.java | 2 + .../persistence/AopLogRepositoryTest.java | 192 ++++++++ .../AuthenticationAndAuthorityTest.java | 2 + .../com/zl/mjga/unit/AopLogServiceTest.java | 134 ++++++ .../migration/test/V1_0_4__init_aop_log.sql | 23 + frontend/src/api/mocks/aiHandlers.ts | 26 ++ frontend/src/api/mocks/knowledgeHandlers.ts | 92 ++++ frontend/src/api/mocks/setup.ts | 3 + frontend/src/components/layout/Headbar.vue | 2 +- .../src/components/tables/TableFormLayout.vue | 82 ++-- 22 files changed, 1741 insertions(+), 44 deletions(-) create mode 100644 backend/src/main/java/com/zl/mjga/annotation/SkipAopLog.java create mode 100644 backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java create mode 100644 backend/src/main/java/com/zl/mjga/controller/AopLogController.java create mode 100644 backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogQueryDto.java create mode 100644 backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java create mode 100644 backend/src/main/java/com/zl/mjga/repository/AopLogRepository.java create mode 100644 backend/src/main/java/com/zl/mjga/service/AopLogService.java create mode 100644 backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql create mode 100644 backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java create mode 100644 backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java create mode 100644 backend/src/test/java/com/zl/mjga/integration/persistence/AopLogRepositoryTest.java create mode 100644 backend/src/test/java/com/zl/mjga/unit/AopLogServiceTest.java create mode 100644 backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql create mode 100644 frontend/src/api/mocks/knowledgeHandlers.ts diff --git a/backend/src/main/java/com/zl/mjga/annotation/SkipAopLog.java b/backend/src/main/java/com/zl/mjga/annotation/SkipAopLog.java new file mode 100644 index 0000000..40dd158 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/annotation/SkipAopLog.java @@ -0,0 +1,45 @@ +package com.zl.mjga.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 跳过AOP日志记录注解 + * + *

在方法上添加此注解,该方法将不会被AOP日志切面拦截和记录。 + * + *

使用场景: + * + *

+ * + *

使用示例: + * + *

{@code
+ * @SkipAopLog
+ * public void sensitiveMethod() {
+ *     // 此方法不会被AOP日志记录
+ * }
+ * }
+ * + * @author AOP Log System + * @since 1.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SkipAopLog { + + /** + * 跳过日志记录的原因说明(可选) + * + * @return 跳过原因 + */ + String reason() default ""; +} diff --git a/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java b/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java new file mode 100644 index 0000000..8ff72cc --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/aspect/LoggingAspect.java @@ -0,0 +1,182 @@ +package com.zl.mjga.aspect; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zl.mjga.annotation.SkipAopLog; +import com.zl.mjga.repository.UserRepository; +import com.zl.mjga.service.AopLogService; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Method; +import java.time.Duration; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +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.jooq.generated.mjga.tables.pojos.AopLog; +import org.jooq.generated.mjga.tables.pojos.User; +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; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class LoggingAspect { + + private final AopLogService aopLogService; + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + + @Around("execution(* com.zl.mjga.controller..*(..))") + public Object logController(ProceedingJoinPoint joinPoint) throws Throwable { + AopLog aopLog = new AopLog(); + setRequestInfo(aopLog); + return processWithLogging(joinPoint, aopLog); + } + + @Around("execution(* com.zl.mjga.service..*(..))") + public Object logService(ProceedingJoinPoint joinPoint) throws Throwable { + AopLog aopLog = new AopLog(); + return processWithLogging(joinPoint, aopLog); + } + + @Around("execution(* com.zl.mjga.repository..*(..))") + public Object logRepository(ProceedingJoinPoint joinPoint) throws Throwable { + AopLog aopLog = new AopLog(); + return processWithLogging(joinPoint, aopLog); + } + + private Object processWithLogging(ProceedingJoinPoint joinPoint, AopLog aopLog) throws Throwable { + if (shouldSkipLogging(joinPoint) || !isUserAuthenticated()) { + return joinPoint.proceed(); + } + return logMethodExecution(joinPoint, aopLog); + } + + private boolean shouldSkipLogging(ProceedingJoinPoint joinPoint) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + return method.isAnnotationPresent(SkipAopLog.class); + } + + private boolean isUserAuthenticated() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null + && authentication.isAuthenticated() + && !"anonymousUser".equals(authentication.getName()); + } + + private Long getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = (String) authentication.getPrincipal(); + User user = userRepository.fetchOneByUsername(username); + return user.getId(); + } + + private Object logMethodExecution(ProceedingJoinPoint joinPoint, AopLog aopLog) throws Throwable { + Instant startTime = Instant.now(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + populateBasicLogInfo(aopLog, className, methodName, joinPoint.getArgs()); + + Object result = null; + Exception executionException = null; + + try { + result = joinPoint.proceed(); + aopLog.setReturnValue(serializeReturnValue(result)); + } catch (Exception e) { + executionException = e; + aopLog.setErrorMessage(e.getMessage()); + log.error("Method execution failed: {}.{}", className, methodName, e); + } finally { + aopLog.setExecutionTime(Duration.between(startTime, Instant.now()).toMillis()); + aopLog.setSuccess(executionException == null); + saveLogSafely(aopLog); + } + + if (executionException != null) { + throw executionException; + } + + return result; + } + + private void populateBasicLogInfo( + AopLog aopLog, String className, String methodName, Object[] args) { + aopLog.setClassName(className); + aopLog.setMethodName(methodName); + aopLog.setMethodArgs(serializeArgs(args)); + aopLog.setUserId(getCurrentUserId()); + } + + private void saveLogSafely(AopLog aopLog) { + try { + aopLogService.saveLogAsync(aopLog); + } catch (Exception e) { + log.error( + "Failed to save AOP log for {}.{}", aopLog.getClassName(), aopLog.getMethodName(), e); + } + } + + private void setRequestInfo(AopLog aopLog) { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return; + } + + HttpServletRequest request = attributes.getRequest(); + aopLog.setIpAddress(getClientIpAddress(request)); + aopLog.setUserAgent(request.getHeader("User-Agent")); + } + + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null + && !xForwardedFor.isEmpty() + && !"unknown".equalsIgnoreCase(xForwardedFor)) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty() && !"unknown".equalsIgnoreCase(xRealIp)) { + return xRealIp; + } + + return request.getRemoteAddr(); + } + + private String serializeArgs(Object[] args) { + if (ArrayUtils.isEmpty(args)) { + return null; + } else { + return serializeObject(args); + } + } + + private String serializeReturnValue(Object returnValue) { + if (returnValue == null) { + return null; + } else { + return serializeObject(returnValue); + } + } + + private String serializeObject(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("Failed to serialize {} ", obj, e); + return e.getMessage(); + } + } +} diff --git a/backend/src/main/java/com/zl/mjga/controller/AopLogController.java b/backend/src/main/java/com/zl/mjga/controller/AopLogController.java new file mode 100644 index 0000000..12350d8 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/controller/AopLogController.java @@ -0,0 +1,73 @@ +package com.zl.mjga.controller; + +import com.zl.mjga.dto.PageRequestDto; +import com.zl.mjga.dto.PageResponseDto; +import com.zl.mjga.dto.aoplog.AopLogQueryDto; +import com.zl.mjga.dto.aoplog.AopLogRespDto; +import com.zl.mjga.repository.AopLogRepository; +import com.zl.mjga.service.AopLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.OffsetDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/aop-log") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "AOP日志管理", description = "AOP日志查看和管理接口") +public class AopLogController { + + private final AopLogService aopLogService; + private final AopLogRepository aopLogRepository; + + @GetMapping("/page-query") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "分页查询AOP日志", description = "支持多种条件筛选的分页查询") + @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).READ_USER_ROLE_PERMISSION)") + public PageResponseDto> pageQueryAopLogs( + @ModelAttribute @Valid PageRequestDto pageRequestDto, + @ModelAttribute AopLogQueryDto queryDto) { + return aopLogService.pageQueryAopLogs(pageRequestDto, queryDto); + } + + @GetMapping("/{id}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "查询日志详情", description = "根据ID查询单条日志的详细信息") + @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).READ_USER_ROLE_PERMISSION)") + public AopLogRespDto getAopLogById(@Parameter(description = "日志ID") @PathVariable Long id) { + return aopLogService.getAopLogById(id); + } + + @DeleteMapping("/batch") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "批量删除日志", description = "根据ID列表批量删除日志") + @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") + public int deleteAopLogs(@Parameter(description = "日志ID列表") @RequestBody List ids) { + return aopLogRepository.deleteByIds(ids); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "删除单条日志", description = "根据ID删除单条日志") + @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") + public void deleteAopLog(@Parameter(description = "日志ID") @PathVariable Long id) { + aopLogRepository.deleteById(id); + } + + @DeleteMapping("/before") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "删除指定时间前的日志", description = "删除指定时间之前的所有日志") + @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") + public int deleteLogsBeforeTime( + @Parameter(description = "截止时间") @RequestParam OffsetDateTime beforeTime) { + return aopLogService.deleteLogsBeforeTime(beforeTime); + } +} diff --git a/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogQueryDto.java b/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogQueryDto.java new file mode 100644 index 0000000..05d67c3 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogQueryDto.java @@ -0,0 +1,43 @@ +package com.zl.mjga.dto.aoplog; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** AOP日志查询DTO */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AopLogQueryDto { + + /** ID */ + private Long id; + + /** 类名 */ + private String className; + + /** 方法名 */ + private String methodName; + + /** 是否成功 */ + private Boolean success; + + /** 用户ID */ + private Long userId; + + /** IP地址 */ + private String ipAddress; + + /** 开始时间 */ + private LocalDateTime startTime; + + /** 结束时间 */ + private LocalDateTime endTime; + + /** 最小执行时间(毫秒) */ + private Long minExecutionTime; + + /** 最大执行时间(毫秒) */ + private Long maxExecutionTime; +} diff --git a/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java b/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java new file mode 100644 index 0000000..4335c41 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/dto/aoplog/AopLogRespDto.java @@ -0,0 +1,53 @@ +package com.zl.mjga.dto.aoplog; + +import java.time.OffsetDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AopLogRespDto { + + /** 主键ID */ + private Long id; + + /** 类名 */ + private String className; + + /** 方法名 */ + private String methodName; + + /** 方法参数 */ + private String methodArgs; + + /** 返回值 */ + private String returnValue; + + /** 执行时间(毫秒) */ + private Long executionTime; + + /** 是否成功 */ + private Boolean success; + + /** 错误信息 */ + private String errorMessage; + + /** 用户ID */ + private Long userId; + + /** 用户名 */ + private String username; + + /** IP地址 */ + private String ipAddress; + + /** 用户代理 */ + private String userAgent; + + /** 创建时间 */ + private OffsetDateTime createTime; +} diff --git a/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java b/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java index 72184bf..8773718 100644 --- a/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java +++ b/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java @@ -1,6 +1,6 @@ package com.zl.mjga.model.urp; public enum ChatMode { - NORMAL, - WITH_LIBRARY + NORMAL, + WITH_LIBRARY } diff --git a/backend/src/main/java/com/zl/mjga/repository/AopLogRepository.java b/backend/src/main/java/com/zl/mjga/repository/AopLogRepository.java new file mode 100644 index 0000000..a7ca660 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/repository/AopLogRepository.java @@ -0,0 +1,117 @@ +package com.zl.mjga.repository; + +import static org.jooq.generated.mjga.tables.AopLog.AOP_LOG; +import static org.jooq.generated.mjga.tables.User.USER; +import static org.jooq.impl.DSL.*; + +import com.zl.mjga.dto.PageRequestDto; +import com.zl.mjga.dto.aoplog.AopLogQueryDto; +import java.time.OffsetDateTime; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.jooq.*; +import org.jooq.Record; +import org.jooq.generated.mjga.tables.daos.AopLogDao; +import org.jooq.generated.mjga.tables.pojos.AopLog; +import org.jooq.impl.DSL; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +/** AOP日志Repository */ +@Repository +public class AopLogRepository extends AopLogDao { + + @Autowired + public AopLogRepository(Configuration configuration) { + super(configuration); + } + + public Result pageFetchBy(PageRequestDto pageRequestDto, AopLogQueryDto queryDto) { + return selectBy(queryDto) + .orderBy(pageRequestDto.getSortFields()) + .limit(pageRequestDto.getSize()) + .offset(pageRequestDto.getOffset()) + .fetch(); + } + + public List fetchBy(AopLogQueryDto queryDto) { + return selectBy(queryDto).fetchInto(AopLog.class); + } + + public SelectConditionStep selectBy(AopLogQueryDto queryDto) { + return ctx() + .select(AOP_LOG.asterisk(), USER.USERNAME, DSL.count().over().as("total_count")) + .from(AOP_LOG) + .leftJoin(USER) + .on(AOP_LOG.USER_ID.eq(USER.ID)) + .where(buildConditions(queryDto)); + } + + private Condition buildConditions(AopLogQueryDto queryDto) { + Condition condition = noCondition(); + + if (queryDto == null) { + return condition; + } + + // ID精确查询 + if (queryDto.getId() != null) { + condition = condition.and(AOP_LOG.ID.eq(queryDto.getId())); + } + + // 类名模糊查询 + if (StringUtils.isNotBlank(queryDto.getClassName())) { + condition = condition.and(AOP_LOG.CLASS_NAME.like("%" + queryDto.getClassName() + "%")); + } + + // 方法名模糊查询 + if (StringUtils.isNotBlank(queryDto.getMethodName())) { + condition = condition.and(AOP_LOG.METHOD_NAME.like("%" + queryDto.getMethodName() + "%")); + } + + // 成功状态 + if (queryDto.getSuccess() != null) { + condition = condition.and(AOP_LOG.SUCCESS.eq(queryDto.getSuccess())); + } + + // 用户ID + if (queryDto.getUserId() != null) { + condition = condition.and(AOP_LOG.USER_ID.eq(queryDto.getUserId())); + } + + // IP地址模糊查询 + if (StringUtils.isNotBlank(queryDto.getIpAddress())) { + condition = condition.and(AOP_LOG.IP_ADDRESS.like("%" + queryDto.getIpAddress() + "%")); + } + + // 时间范围查询 + if (queryDto.getStartTime() != null) { + OffsetDateTime startTime = queryDto.getStartTime().atOffset(OffsetDateTime.now().getOffset()); + condition = condition.and(AOP_LOG.CREATE_TIME.ge(startTime)); + } + + if (queryDto.getEndTime() != null) { + OffsetDateTime endTime = queryDto.getEndTime().atOffset(OffsetDateTime.now().getOffset()); + condition = condition.and(AOP_LOG.CREATE_TIME.le(endTime)); + } + + // 执行时间范围 + if (queryDto.getMinExecutionTime() != null) { + condition = condition.and(AOP_LOG.EXECUTION_TIME.ge(queryDto.getMinExecutionTime())); + } + + if (queryDto.getMaxExecutionTime() != null) { + condition = condition.and(AOP_LOG.EXECUTION_TIME.le(queryDto.getMaxExecutionTime())); + } + + return condition; + } + + public int deleteByIds(List ids) { + return ctx().deleteFrom(AOP_LOG).where(AOP_LOG.ID.in(ids)).execute(); + } + + public int deleteBeforeTime(OffsetDateTime beforeTime) { + return ctx().deleteFrom(AOP_LOG).where(AOP_LOG.CREATE_TIME.lt(beforeTime)).execute(); + } +} diff --git a/backend/src/main/java/com/zl/mjga/service/AopLogService.java b/backend/src/main/java/com/zl/mjga/service/AopLogService.java new file mode 100644 index 0000000..b781770 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/service/AopLogService.java @@ -0,0 +1,61 @@ +package com.zl.mjga.service; + +import com.zl.mjga.dto.PageRequestDto; +import com.zl.mjga.dto.PageResponseDto; +import com.zl.mjga.dto.aoplog.AopLogQueryDto; +import com.zl.mjga.dto.aoplog.AopLogRespDto; +import com.zl.mjga.repository.AopLogRepository; +import java.time.OffsetDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jooq.Record; +import org.jooq.Result; +import org.jooq.SelectConditionStep; +import org.jooq.generated.mjga.tables.pojos.AopLog; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AopLogService { + + private final AopLogRepository aopLogRepository; + + @Async + public void saveLogAsync(AopLog aopLog) { + try { + aopLogRepository.insert(aopLog); + } catch (Exception e) { + log.error("Failed to save AOP log asynchronously", e); + } + } + + public PageResponseDto> pageQueryAopLogs( + PageRequestDto pageRequestDto, AopLogQueryDto queryDto) { + Result records = aopLogRepository.pageFetchBy(pageRequestDto, queryDto); + + if (records.isEmpty()) { + return PageResponseDto.empty(); + } + + List aopLogs = records.map((record -> record.into(AopLogRespDto.class))); + Long totalCount = records.get(0).getValue("total_count", Long.class); + + return new PageResponseDto<>(totalCount, aopLogs); + } + + public AopLogRespDto getAopLogById(Long id) { + AopLogQueryDto queryDto = new AopLogQueryDto(); + queryDto.setId(id); + SelectConditionStep selectStep = aopLogRepository.selectBy(queryDto); + return selectStep.fetchOneInto(AopLogRespDto.class); + } + + @Transactional + public int deleteLogsBeforeTime(OffsetDateTime beforeTime) { + return aopLogRepository.deleteBeforeTime(beforeTime); + } +} diff --git a/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql b/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql new file mode 100644 index 0000000..a98a4e3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1_0_4__init_aop_log.sql @@ -0,0 +1,21 @@ +CREATE TABLE mjga.aop_log ( + id BIGSERIAL PRIMARY KEY, + class_name VARCHAR NOT NULL, + method_name VARCHAR NOT NULL, + method_args VARCHAR, + return_value VARCHAR, + execution_time BIGINT NOT NULL, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_message VARCHAR, + user_id BIGINT, + ip_address VARCHAR, + user_agent VARCHAR, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES mjga.user(id) ON DELETE SET NULL +); + +CREATE INDEX idx_aop_log_class_name ON mjga.aop_log(class_name); +CREATE INDEX idx_aop_log_method_name ON mjga.aop_log(method_name); +CREATE INDEX idx_aop_log_create_time ON mjga.aop_log(create_time); +CREATE INDEX idx_aop_log_user_id ON mjga.aop_log(user_id); +CREATE INDEX idx_aop_log_success ON mjga.aop_log(success); diff --git a/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java b/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java new file mode 100644 index 0000000..1574d57 --- /dev/null +++ b/backend/src/test/java/com/zl/mjga/integration/aspect/LoggingAspectTest.java @@ -0,0 +1,440 @@ +package com.zl.mjga.integration.aspect; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zl.mjga.annotation.SkipAopLog; +import com.zl.mjga.aspect.LoggingAspect; +import com.zl.mjga.repository.UserRepository; +import com.zl.mjga.service.AopLogService; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Method; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.jooq.generated.mjga.tables.pojos.AopLog; +import org.jooq.generated.mjga.tables.pojos.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class LoggingAspectTest { + + @Mock private AopLogService aopLogService; + @Mock private ObjectMapper objectMapper; + @Mock private UserRepository userRepository; + @Mock private ProceedingJoinPoint joinPoint; + @Mock private MethodSignature methodSignature; + @Mock private SecurityContext securityContext; + @Mock private Authentication authentication; + @Mock private ServletRequestAttributes servletRequestAttributes; + @Mock private HttpServletRequest httpServletRequest; + + private LoggingAspect loggingAspect; + + @BeforeEach + void setUp() { + loggingAspect = new LoggingAspect(aopLogService, objectMapper, userRepository); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void logController_givenSuccessfulExecution_shouldSaveSuccessLog() throws Throwable { + // arrange + TestController target = new TestController(); + Object[] args = {"arg1", "arg2"}; + String expectedResult = "success"; + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "testMethod", args, expectedResult); + setupSerialization("[\"arg1\",\"arg2\"]", "\"success\""); + + try (MockedStatic mockedRequestContextHolder = + setupRequestContext("192.168.1.1", "Test-Agent")) { + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verifyLogSaved( + log -> { + assertThat(log.getClassName()).isEqualTo("TestController"); + assertThat(log.getMethodName()).isEqualTo("testMethod"); + assertThat(log.getMethodArgs()).isEqualTo("[\"arg1\",\"arg2\"]"); + assertThat(log.getReturnValue()).isEqualTo("\"success\""); + assertThat(log.getSuccess()).isTrue(); + assertThat(log.getUserId()).isEqualTo(123L); + assertThat(log.getIpAddress()).isEqualTo("192.168.1.1"); + assertThat(log.getUserAgent()).isEqualTo("Test-Agent"); + assertThat(log.getExecutionTime()).isGreaterThanOrEqualTo(0L); + }); + } + } + + @Test + void logController_givenFailedExecution_shouldSaveFailLog() throws Throwable { + // arrange + TestController target = new TestController(); + Object[] args = {"arg1"}; + RuntimeException exception = new RuntimeException("Test error"); + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "failMethod", args, exception); + setupSerialization("[\"arg1\"]", null); + + try (MockedStatic mockedRequestContextHolder = + setupRequestContext("192.168.1.1", "Test-Agent")) { + // action & assert + assertThatThrownBy(() -> loggingAspect.logController(joinPoint)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Test error"); + + verifyLogSaved( + log -> { + assertThat(log.getClassName()).isEqualTo("TestController"); + assertThat(log.getMethodName()).isEqualTo("failMethod"); + assertThat(log.getSuccess()).isFalse(); + assertThat(log.getErrorMessage()).isEqualTo("Test error"); + assertThat(log.getReturnValue()).isNull(); + assertThat(log.getUserId()).isEqualTo(123L); + }); + } + } + + @Test + void logService_givenSuccessfulExecution_shouldSaveSuccessLogWithoutRequestInfo() + throws Throwable { + // arrange + TestService target = new TestService(); + Object[] args = {"serviceArg"}; + String expectedResult = "serviceResult"; + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "serviceMethod", args, expectedResult); + setupSerialization("[\"serviceArg\"]", "\"serviceResult\""); + + // action + Object result = loggingAspect.logService(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verifyLogSaved( + log -> { + assertThat(log.getClassName()).isEqualTo("TestService"); + assertThat(log.getMethodName()).isEqualTo("serviceMethod"); + assertThat(log.getSuccess()).isTrue(); + assertThat(log.getUserId()).isEqualTo(123L); + // Service层不应该有请求信息 + assertThat(log.getIpAddress()).isNull(); + assertThat(log.getUserAgent()).isNull(); + }); + } + + @Test + void logRepository_givenSuccessfulExecution_shouldSaveSuccessLogWithoutRequestInfo() + throws Throwable { + // arrange + TestRepository target = new TestRepository(); + Object[] args = {1L}; + Object expectedResult = new Object(); + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "findById", args, expectedResult); + setupSerialization("[1]", "{}"); + + // action + Object result = loggingAspect.logRepository(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verifyLogSaved( + log -> { + assertThat(log.getClassName()).isEqualTo("TestRepository"); + assertThat(log.getMethodName()).isEqualTo("findById"); + assertThat(log.getSuccess()).isTrue(); + assertThat(log.getUserId()).isEqualTo(123L); + // Repository层不应该有请求信息 + assertThat(log.getIpAddress()).isNull(); + assertThat(log.getUserAgent()).isNull(); + }); + } + + @Test + void logController_givenUnauthenticatedUser_shouldNotLog() throws Throwable { + // arrange + TestController target = new TestController(); + String expectedResult = "success"; + Method testMethod = TestController.class.getMethod("testMethod"); + + when(joinPoint.getTarget()).thenReturn(target); + when(joinPoint.proceed()).thenReturn(expectedResult); + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getMethod()).thenReturn(testMethod); + + // Mock SecurityContextHolder to return null authentication + when(securityContext.getAuthentication()).thenReturn(null); + + try (MockedStatic mockedRequestContextHolder = + mockStatic(RequestContextHolder.class)) { + mockedRequestContextHolder.when(RequestContextHolder::getRequestAttributes).thenReturn(null); + + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verify(aopLogService, never()).saveLogAsync(any()); + } + } + + @Test + void logController_givenAnonymousUser_shouldNotLog() throws Throwable { + // arrange + TestController target = new TestController(); + String expectedResult = "success"; + Method testMethod = TestController.class.getMethod("testMethod"); + + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("anonymousUser"); + when(joinPoint.getTarget()).thenReturn(target); + when(joinPoint.proceed()).thenReturn(expectedResult); + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getMethod()).thenReturn(testMethod); + + // Mock SecurityContextHolder to return anonymous authentication + when(securityContext.getAuthentication()).thenReturn(authentication); + + try (MockedStatic mockedRequestContextHolder = + mockStatic(RequestContextHolder.class)) { + mockedRequestContextHolder.when(RequestContextHolder::getRequestAttributes).thenReturn(null); + + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verify(aopLogService, never()).saveLogAsync(any()); + } + } + + @Test + void logController_givenSkipAopLogAnnotation_shouldNotLog() throws Throwable { + // arrange + TestController target = new TestController(); + String expectedResult = "success"; + + when(joinPoint.getTarget()).thenReturn(target); + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getMethod()).thenReturn(getSkipLogMethod()); + when(joinPoint.proceed()).thenReturn(expectedResult); + + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verify(aopLogService, never()).saveLogAsync(any()); + } + + @Test + void logController_givenNullArgs_shouldHandleGracefully() throws Throwable { + // arrange + TestController target = new TestController(); + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "noArgsMethod", null, "result"); + setupSerialization(null, "\"result\""); + + try (MockedStatic mockedRequestContextHolder = + setupRequestContext("127.0.0.1", "Test-Agent")) { + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo("result"); + verifyLogSaved( + log -> { + assertThat(log.getMethodArgs()).isNull(); + assertThat(log.getSuccess()).isTrue(); + }); + } + } + + @Test + void logController_givenEmptyArgs_shouldHandleGracefully() throws Throwable { + // arrange + TestController target = new TestController(); + Object[] emptyArgs = {}; + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "noArgsMethod", emptyArgs, "result"); + setupSerialization(null, "\"result\""); + + try (MockedStatic mockedRequestContextHolder = + setupRequestContext("127.0.0.1", "Test-Agent")) { + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo("result"); + verifyLogSaved( + log -> { + assertThat(log.getMethodArgs()).isNull(); + assertThat(log.getSuccess()).isTrue(); + }); + } + } + + @Test + void logController_givenSerializationError_shouldHandleGracefully() throws Throwable { + // arrange + TestController target = new TestController(); + Object[] args = {"arg1"}; + String expectedResult = "success"; + User mockUser = createMockUser(123L, "testUser"); + + setupAuthenticatedUser("testUser", mockUser); + setupJoinPoint(target, "testMethod", args, expectedResult); + + // Mock serialization error + when(objectMapper.writeValueAsString(args)) + .thenThrow(new JsonProcessingException("Serialization failed") {}); + when(objectMapper.writeValueAsString(expectedResult)).thenReturn("\"success\""); + + try (MockedStatic mockedRequestContextHolder = + setupRequestContext("127.0.0.1", "Test-Agent")) { + // action + Object result = loggingAspect.logController(joinPoint); + + // assert + assertThat(result).isEqualTo(expectedResult); + verifyLogSaved( + log -> { + assertThat(log.getMethodArgs()).isEqualTo("Serialization failed"); + assertThat(log.getSuccess()).isTrue(); + }); + } + } + + // Helper methods + private User createMockUser(Long id, String username) { + User user = new User(); + user.setId(id); + user.setUsername(username); + return user; + } + + private void setupAuthenticatedUser(String username, User user) { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn(username); + when(authentication.getPrincipal()).thenReturn(username); + when(userRepository.fetchOneByUsername(username)).thenReturn(user); + } + + private void setupJoinPoint(Object target, String methodName, Object[] args, Object result) + throws Throwable { + when(joinPoint.getTarget()).thenReturn(target); + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getName()).thenReturn(methodName); + when(methodSignature.getMethod()).thenReturn(getTestMethod()); + when(joinPoint.getArgs()).thenReturn(args); + + if (result instanceof Throwable) { + when(joinPoint.proceed()).thenThrow((Throwable) result); + } else { + when(joinPoint.proceed()).thenReturn(result); + } + } + + private void setupSerialization(String argsJson, String resultJson) + throws JsonProcessingException { + if (argsJson != null) { + when(objectMapper.writeValueAsString(any(Object[].class))).thenReturn(argsJson); + } + if (resultJson != null) { + when(objectMapper.writeValueAsString(argThat(arg -> !(arg instanceof Object[])))) + .thenReturn(resultJson); + } + } + + private MockedStatic setupRequestContext( + String ipAddress, String userAgent) { + MockedStatic mockedRequestContextHolder = + mockStatic(RequestContextHolder.class); + mockedRequestContextHolder + .when(RequestContextHolder::getRequestAttributes) + .thenReturn(servletRequestAttributes); + when(servletRequestAttributes.getRequest()).thenReturn(httpServletRequest); + when(httpServletRequest.getHeader("X-Forwarded-For")).thenReturn(ipAddress); + when(httpServletRequest.getHeader("User-Agent")).thenReturn(userAgent); + when(httpServletRequest.getRemoteAddr()).thenReturn("127.0.0.1"); + return mockedRequestContextHolder; + } + + private void verifyLogSaved(java.util.function.Consumer logVerifier) { + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(AopLog.class); + verify(aopLogService, times(1)).saveLogAsync(logCaptor.capture()); + logVerifier.accept(logCaptor.getValue()); + } + + private java.lang.reflect.Method getTestMethod() throws NoSuchMethodException { + return TestController.class.getMethod("testMethod"); + } + + private java.lang.reflect.Method getSkipLogMethod() throws NoSuchMethodException { + return TestController.class.getMethod("skipLogMethod"); + } + + // Test classes for mocking + private static class TestController { + public String testMethod() { + return "test"; + } + + @SkipAopLog(reason = "测试跳过日志记录") + public String skipLogMethod() { + return "test"; + } + } + + private static class TestService { + public String testMethod() { + return "test"; + } + } + + private static class TestRepository { + public String testMethod() { + return "test"; + } + } +} diff --git a/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java b/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java new file mode 100644 index 0000000..873636c --- /dev/null +++ b/backend/src/test/java/com/zl/mjga/integration/mvc/AopLogControllerTest.java @@ -0,0 +1,186 @@ +package com.zl.mjga.integration.mvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zl.mjga.config.minio.MinIoConfig; +import com.zl.mjga.config.security.HttpFireWallConfig; +import com.zl.mjga.controller.AopLogController; +import com.zl.mjga.dto.PageRequestDto; +import com.zl.mjga.dto.PageResponseDto; +import com.zl.mjga.dto.aoplog.AopLogQueryDto; +import com.zl.mjga.dto.aoplog.AopLogRespDto; +import com.zl.mjga.repository.AopLogRepository; +import com.zl.mjga.repository.PermissionRepository; +import com.zl.mjga.repository.RoleRepository; +import com.zl.mjga.repository.UserRepository; +import com.zl.mjga.service.AopLogService; +import io.minio.MinioClient; +import java.time.OffsetDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(value = {AopLogController.class}) +@Import({HttpFireWallConfig.class}) +public class AopLogControllerTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @MockBean private AopLogService aopLogService; + @MockBean private AopLogRepository aopLogRepository; + + @MockBean private UserRepository userRepository; + @MockBean private RoleRepository roleRepository; + @MockBean private PermissionRepository permissionRepository; + @MockBean private MinioClient minioClient; + @MockBean private MinIoConfig minIoConfig; + + @Test + @WithMockUser(authorities = "READ_USER_ROLE_PERMISSION") + void pageQueryAopLogs_givenValidRequest_shouldReturnOk() throws Exception { + // arrange + PageResponseDto> mockResponse = + new PageResponseDto<>(1L, List.of(createTestAopLogRespDto())); + when(aopLogService.pageQueryAopLogs(any(PageRequestDto.class), any(AopLogQueryDto.class))) + .thenReturn(mockResponse); + + // action & assert + mockMvc + .perform( + get("/aop-log/page-query") + .param("page", "1") + .param("size", "10") + .param("className", "TestController")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].className").value("TestController")); + } + + @Test + void pageQueryAopLogs_givenNoAuth_shouldReturnUnauthorized() throws Exception { + // action & assert + mockMvc + .perform(get("/aop-log/page-query").param("page", "1").param("size", "10")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_USER_ROLE_PERMISSION") + void getAopLogById_givenValidId_shouldReturnOk() throws Exception { + // arrange + Long id = 1L; + AopLogRespDto mockResponse = createTestAopLogRespDto(); + when(aopLogService.getAopLogById(id)).thenReturn(mockResponse); + + // action & assert + mockMvc + .perform(get("/aop-log/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.className").value("TestController")) + .andExpect(jsonPath("$.methodName").value("testMethod")); + } + + @Test + @WithMockUser(authorities = "READ_USER_ROLE_PERMISSION") + void getAopLogById_givenNonExistingId_shouldReturnOkWithNull() throws Exception { + // arrange + Long id = 999L; + when(aopLogService.getAopLogById(id)).thenReturn(null); + + // action & assert + mockMvc + .perform(get("/aop-log/{id}", id)) + .andExpect(status().isOk()) + .andExpect(content().string("")); + } + + @Test + @WithMockUser(authorities = "WRITE_USER_ROLE_PERMISSION") + void deleteAopLogs_givenValidIds_shouldReturnOk() throws Exception { + // arrange + List ids = List.of(1L, 2L, 3L); + when(aopLogRepository.deleteByIds(ids)).thenReturn(3); + + // action & assert + mockMvc + .perform( + delete("/aop-log/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ids)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(content().string("3")); + } + + @Test + void deleteAopLogs_givenNoAuth_shouldReturnUnauthorized() throws Exception { + // arrange + List ids = List.of(1L, 2L, 3L); + + // action & assert + mockMvc + .perform( + delete("/aop-log/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ids)) + .with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "WRITE_USER_ROLE_PERMISSION") + void deleteAopLog_givenValidId_shouldReturnOk() throws Exception { + // arrange + Long id = 1L; + when(aopLogRepository.deleteByIds(List.of(id))).thenReturn(1); + + // action & assert + mockMvc.perform(delete("/aop-log/{id}", id).with(csrf())).andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_USER_ROLE_PERMISSION") + void deleteLogsBeforeTime_givenValidTime_shouldReturnOk() throws Exception { + // arrange + OffsetDateTime beforeTime = OffsetDateTime.now().minusDays(7); + when(aopLogService.deleteLogsBeforeTime(beforeTime)).thenReturn(5); + + // action & assert + mockMvc + .perform(delete("/aop-log/before").param("beforeTime", beforeTime.toString()).with(csrf())) + .andExpect(status().isOk()) + .andExpect(content().string("5")); + } + + private AopLogRespDto createTestAopLogRespDto() { + return AopLogRespDto.builder() + .id(1L) + .className("TestController") + .methodName("testMethod") + .methodArgs("[\"arg1\"]") + .returnValue("\"result\"") + .executionTime(100L) + .success(true) + .userId(1L) + .username("testUser") + .ipAddress("127.0.0.1") + .userAgent("Test Agent") + .createTime(OffsetDateTime.now()) + .build(); + } +} diff --git a/backend/src/test/java/com/zl/mjga/integration/mvc/JacksonAnnotationMvcTest.java b/backend/src/test/java/com/zl/mjga/integration/mvc/JacksonAnnotationMvcTest.java index 25a1671..cd10944 100644 --- a/backend/src/test/java/com/zl/mjga/integration/mvc/JacksonAnnotationMvcTest.java +++ b/backend/src/test/java/com/zl/mjga/integration/mvc/JacksonAnnotationMvcTest.java @@ -16,6 +16,7 @@ import com.zl.mjga.repository.PermissionRepository; import com.zl.mjga.repository.RoleRepository; import com.zl.mjga.repository.UserRepository; import com.zl.mjga.service.IdentityAccessService; +import com.zl.mjga.service.UploadService; import io.minio.MinioClient; import java.util.List; import org.junit.jupiter.api.Test; @@ -38,6 +39,7 @@ public class JacksonAnnotationMvcTest { @MockBean private PermissionRepository permissionRepository; @MockBean private MinioClient minioClient; @MockBean private MinIoConfig minIoConfig; + @MockBean private UploadService uploadService; @Test @WithMockUser diff --git a/backend/src/test/java/com/zl/mjga/integration/mvc/UserRolePermissionMvcTest.java b/backend/src/test/java/com/zl/mjga/integration/mvc/UserRolePermissionMvcTest.java index 4bf49df..71e5ebc 100644 --- a/backend/src/test/java/com/zl/mjga/integration/mvc/UserRolePermissionMvcTest.java +++ b/backend/src/test/java/com/zl/mjga/integration/mvc/UserRolePermissionMvcTest.java @@ -17,6 +17,7 @@ import com.zl.mjga.repository.PermissionRepository; import com.zl.mjga.repository.RoleRepository; import com.zl.mjga.repository.UserRepository; import com.zl.mjga.service.IdentityAccessService; +import com.zl.mjga.service.UploadService; import io.minio.MinioClient; import java.util.List; import org.jooq.generated.mjga.tables.pojos.User; @@ -40,6 +41,7 @@ class UserRolePermissionMvcTest { @MockBean private PermissionRepository permissionRepository; @MockBean private MinioClient minioClient; @MockBean private MinIoConfig minIoConfig; + @MockBean private UploadService uploadService; @Test @WithMockUser diff --git a/backend/src/test/java/com/zl/mjga/integration/persistence/AopLogRepositoryTest.java b/backend/src/test/java/com/zl/mjga/integration/persistence/AopLogRepositoryTest.java new file mode 100644 index 0000000..eb50ba0 --- /dev/null +++ b/backend/src/test/java/com/zl/mjga/integration/persistence/AopLogRepositoryTest.java @@ -0,0 +1,192 @@ +package com.zl.mjga.integration.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zl.mjga.dto.PageRequestDto; +import com.zl.mjga.dto.aoplog.AopLogQueryDto; +import com.zl.mjga.repository.AopLogRepository; +import java.time.OffsetDateTime; +import java.util.List; +import org.jooq.Record; +import org.jooq.Result; +import org.jooq.generated.mjga.tables.pojos.AopLog; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; + +public class AopLogRepositoryTest extends AbstractDataAccessLayerTest { + + @Autowired private AopLogRepository aopLogRepository; + + @Test + @Sql( + statements = { + "INSERT INTO mjga.user (id, username, password) VALUES (1, 'testUser', 'password')", + "INSERT INTO mjga.aop_log (id, class_name, method_name, method_args, return_value," + + " execution_time, success, user_id, ip_address) VALUES (1, 'TestController'," + + " 'testMethod', '[\"arg1\"]', '\"result\"', 100, true, 1, '127.0.0.1')", + "INSERT INTO mjga.aop_log (id, class_name, method_name, method_args, return_value," + + " execution_time, success, error_message) VALUES (2, 'TestService', 'failMethod'," + + " '[\"arg2\"]', null, 200, false, 'Test error')", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (3," + + " 'TestRepository', 'queryMethod', 50, true)" + }) + void pageFetchBy_givenValidQuery_shouldReturnCorrectResults() { + // arrange + AopLogQueryDto queryDto = new AopLogQueryDto(); + queryDto.setClassName("Test"); + PageRequestDto pageRequestDto = PageRequestDto.of(1, 10); + + // action + Result result = aopLogRepository.pageFetchBy(pageRequestDto, queryDto); + + // assert + assertThat(result).hasSize(3); + assertThat(result.get(0).getValue("total_count", Long.class)).isEqualTo(3L); + } + + @Test + @Sql( + statements = { + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (1," + + " 'TestController', 'method1', 100, true)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (2," + + " 'TestService', 'method2', 200, false)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (3," + + " 'TestRepository', 'method3', 50, true)" + }) + void fetchBy_givenClassNameQuery_shouldReturnFilteredResults() { + // arrange + AopLogQueryDto queryDto = new AopLogQueryDto(); + queryDto.setClassName("TestController"); + + // action + List result = aopLogRepository.fetchBy(queryDto); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getClassName()).isEqualTo("TestController"); + assertThat(result.get(0).getMethodName()).isEqualTo("method1"); + } + + @Test + @Sql( + statements = { + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (1," + + " 'TestController', 'method1', 100, true)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (2," + + " 'TestService', 'method2', 200, false)" + }) + void fetchBy_givenSuccessQuery_shouldReturnOnlySuccessfulLogs() { + // arrange + AopLogQueryDto queryDto = new AopLogQueryDto(); + queryDto.setSuccess(true); + + // action + List result = aopLogRepository.fetchBy(queryDto); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getSuccess()).isTrue(); + } + + @Test + @Sql( + statements = { + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (1," + + " 'TestController', 'method1', 50, true)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (2," + + " 'TestService', 'method2', 150, false)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (3," + + " 'TestRepository', 'method3', 250, true)" + }) + void fetchBy_givenExecutionTimeRange_shouldReturnFilteredResults() { + // arrange + AopLogQueryDto queryDto = new AopLogQueryDto(); + queryDto.setMinExecutionTime(100L); + queryDto.setMaxExecutionTime(200L); + + // action + List result = aopLogRepository.fetchBy(queryDto); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).getExecutionTime()).isEqualTo(150L); + } + + @Test + @Sql( + statements = { + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (1," + + " 'TestController', 'method1', 100, true)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (2," + + " 'TestService', 'method2', 200, false)", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success) VALUES (3," + + " 'TestRepository', 'method3', 50, true)" + }) + void deleteByIds_givenValidIds_shouldDeleteCorrectRecords() { + // arrange + List idsToDelete = List.of(1L, 3L); + + // action + int deletedCount = aopLogRepository.deleteByIds(idsToDelete); + + // assert + assertThat(deletedCount).isEqualTo(2); + + // verify remaining record + List remaining = aopLogRepository.findAll(); + assertThat(remaining).hasSize(1); + assertThat(remaining.get(0).getId()).isEqualTo(2L); + } + + @Test + @Sql( + statements = { + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success," + + " create_time) VALUES (1, 'TestController', 'method1', 100, true, '2023-01-01" + + " 00:00:00+00')", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success," + + " create_time) VALUES (2, 'TestService', 'method2', 200, false, '2023-06-01" + + " 00:00:00+00')", + "INSERT INTO mjga.aop_log (id, class_name, method_name, execution_time, success," + + " create_time) VALUES (3, 'TestRepository', 'method3', 50, true, '2023-12-01" + + " 00:00:00+00')" + }) + void deleteBeforeTime_givenValidTime_shouldDeleteOldRecords() { + // arrange + OffsetDateTime cutoffTime = OffsetDateTime.parse("2023-07-01T00:00:00Z"); + + // action + int deletedCount = aopLogRepository.deleteBeforeTime(cutoffTime); + + // assert + assertThat(deletedCount).isEqualTo(2); + + // verify remaining record + List remaining = aopLogRepository.findAll(); + assertThat(remaining).hasSize(1); + assertThat(remaining.get(0).getId()).isEqualTo(3L); + } + + @Test + void deleteByIds_givenEmptyList_shouldReturnZero() { + // arrange + List emptyIds = List.of(); + + // action + int deletedCount = aopLogRepository.deleteByIds(emptyIds); + + // assert + assertThat(deletedCount).isEqualTo(0); + } + + @Test + void deleteByIds_givenNullList_shouldReturnZero() { + // arrange & action + int deletedCount = aopLogRepository.deleteByIds(null); + + // assert + assertThat(deletedCount).isEqualTo(0); + } +} diff --git a/backend/src/test/java/com/zl/mjga/security/AuthenticationAndAuthorityTest.java b/backend/src/test/java/com/zl/mjga/security/AuthenticationAndAuthorityTest.java index 8523b53..2041182 100644 --- a/backend/src/test/java/com/zl/mjga/security/AuthenticationAndAuthorityTest.java +++ b/backend/src/test/java/com/zl/mjga/security/AuthenticationAndAuthorityTest.java @@ -20,6 +20,7 @@ import com.zl.mjga.repository.RoleRepository; import com.zl.mjga.repository.UserRepository; import com.zl.mjga.service.IdentityAccessService; import com.zl.mjga.service.SignService; +import com.zl.mjga.service.UploadService; import io.minio.MinioClient; import jakarta.servlet.http.HttpServletRequest; import java.util.Collections; @@ -53,6 +54,7 @@ public class AuthenticationAndAuthorityTest { @MockBean private PermissionRepository permissionRepository; @MockBean private MinioClient minioClient; @MockBean private MinIoConfig minIoConfig; + @MockBean private UploadService uploadService; @Test public void givenRequestOnPublicService_shouldSucceedWith200() throws Exception { diff --git a/backend/src/test/java/com/zl/mjga/unit/AopLogServiceTest.java b/backend/src/test/java/com/zl/mjga/unit/AopLogServiceTest.java new file mode 100644 index 0000000..bf3c86d --- /dev/null +++ b/backend/src/test/java/com/zl/mjga/unit/AopLogServiceTest.java @@ -0,0 +1,134 @@ +package com.zl.mjga.unit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.zl.mjga.dto.PageRequestDto; +import com.zl.mjga.dto.PageResponseDto; +import com.zl.mjga.dto.aoplog.AopLogQueryDto; +import com.zl.mjga.dto.aoplog.AopLogRespDto; +import com.zl.mjga.repository.AopLogRepository; +import com.zl.mjga.service.AopLogService; +import java.time.OffsetDateTime; +import java.util.List; +import org.jooq.Record; +import org.jooq.Result; +import org.jooq.generated.mjga.tables.pojos.AopLog; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class AopLogServiceTest { + + @Mock private AopLogRepository aopLogRepository; + + @Mock private Result mockResult; + + @Mock private Record mockRecord; + + @InjectMocks private AopLogService aopLogService; + + @Test + void saveLogAsync_givenValidAopLog_shouldCallRepositoryInsert() { + // arrange + AopLog aopLog = createTestAopLog(); + + // action + aopLogService.saveLogAsync(aopLog); + + // assert + verify(aopLogRepository, times(1)).insert(aopLog); + } + + @Test + void pageQueryAopLogs_givenValidRequest_shouldReturnPageResponse() { + // arrange + PageRequestDto pageRequestDto = PageRequestDto.of(1, 10); + AopLogQueryDto queryDto = new AopLogQueryDto(); + + when(aopLogRepository.pageFetchBy(pageRequestDto, queryDto)).thenReturn(mockResult); + when(mockResult.isEmpty()).thenReturn(false); + when(mockResult.map(any())).thenReturn(List.of(createTestAopLogRespDto())); + when(mockResult.get(0)).thenReturn(mockRecord); + when(mockRecord.getValue("total_count", Long.class)).thenReturn(1L); + + // action + PageResponseDto> result = + aopLogService.pageQueryAopLogs(pageRequestDto, queryDto); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getTotal()).isEqualTo(1L); + assertThat(result.getData()).hasSize(1); + verify(aopLogRepository, times(1)).pageFetchBy(pageRequestDto, queryDto); + } + + @Test + void pageQueryAopLogs_givenEmptyResult_shouldReturnEmptyPage() { + // arrange + PageRequestDto pageRequestDto = PageRequestDto.of(1, 10); + AopLogQueryDto queryDto = new AopLogQueryDto(); + + when(aopLogRepository.pageFetchBy(pageRequestDto, queryDto)).thenReturn(mockResult); + when(mockResult.isEmpty()).thenReturn(true); + + // action + PageResponseDto> result = + aopLogService.pageQueryAopLogs(pageRequestDto, queryDto); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getTotal()).isEqualTo(0L); + assertThat(result.getData()).isNull(); + } + + @Test + void deleteLogsBeforeTime_givenValidTime_shouldReturnDeletedCount() { + // arrange + OffsetDateTime beforeTime = OffsetDateTime.now().minusDays(30); + when(aopLogRepository.deleteBeforeTime(beforeTime)).thenReturn(10); + + // action + int result = aopLogService.deleteLogsBeforeTime(beforeTime); + + // assert + assertThat(result).isEqualTo(10); + verify(aopLogRepository, times(1)).deleteBeforeTime(beforeTime); + } + + private AopLog createTestAopLog() { + AopLog aopLog = new AopLog(); + aopLog.setClassName("TestController"); + aopLog.setMethodName("testMethod"); + aopLog.setMethodArgs("[\"arg1\"]"); + aopLog.setReturnValue("\"result\""); + aopLog.setExecutionTime(100L); + aopLog.setSuccess(true); + aopLog.setUserId(1L); + aopLog.setIpAddress("127.0.0.1"); + aopLog.setUserAgent("Test Agent"); + aopLog.setCreateTime(OffsetDateTime.now()); + return aopLog; + } + + private AopLogRespDto createTestAopLogRespDto() { + return AopLogRespDto.builder() + .id(1L) + .className("TestController") + .methodName("testMethod") + .methodArgs("[\"arg1\"]") + .returnValue("\"result\"") + .executionTime(100L) + .success(true) + .userId(1L) + .username("testUser") + .ipAddress("127.0.0.1") + .userAgent("Test Agent") + .createTime(OffsetDateTime.now()) + .build(); + } +} diff --git a/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql b/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql new file mode 100644 index 0000000..d9f29ab --- /dev/null +++ b/backend/src/test/resources/db/migration/test/V1_0_4__init_aop_log.sql @@ -0,0 +1,23 @@ +-- AOP日志表 +CREATE TABLE mjga.aop_log ( + id BIGSERIAL PRIMARY KEY, + class_name VARCHAR NOT NULL, + method_name VARCHAR NOT NULL, + method_args TEXT, + return_value TEXT, + execution_time BIGINT NOT NULL, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_message TEXT, + user_id BIGINT, + ip_address VARCHAR(45), + user_agent TEXT, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES mjga.user(id) ON DELETE SET NULL +); + +-- 创建索引以提高查询性能 +CREATE INDEX idx_aop_log_class_name ON mjga.aop_log(class_name); +CREATE INDEX idx_aop_log_method_name ON mjga.aop_log(method_name); +CREATE INDEX idx_aop_log_create_time ON mjga.aop_log(create_time); +CREATE INDEX idx_aop_log_user_id ON mjga.aop_log(user_id); +CREATE INDEX idx_aop_log_success ON mjga.aop_log(success); diff --git a/frontend/src/api/mocks/aiHandlers.ts b/frontend/src/api/mocks/aiHandlers.ts index 09476ad..0f6044d 100644 --- a/frontend/src/api/mocks/aiHandlers.ts +++ b/frontend/src/api/mocks/aiHandlers.ts @@ -45,4 +45,30 @@ export default [ message: "Llm updated successfully", }); }), + http.post("/ai/chat/refresh", () => { + return HttpResponse.json({ + success: true, + message: "Conversation cleared successfully", + }); + }), + http.post("/ai/action/execute", () => { + const response = new HttpResponse(`data: ${faker.lorem.paragraph()}\n\n`, { + headers: { + "Content-Type": "text/event-stream", + }, + }); + return response; + }), + http.delete("/ai/action/department", () => { + return HttpResponse.json({ success: true }); + }), + http.delete("/ai/action/position", () => { + return HttpResponse.json({ success: true }); + }), + http.delete("/ai/action/role", () => { + return HttpResponse.json({ success: true }); + }), + http.delete("/ai/action/permission", () => { + return HttpResponse.json({ success: true }); + }), ]; diff --git a/frontend/src/api/mocks/knowledgeHandlers.ts b/frontend/src/api/mocks/knowledgeHandlers.ts new file mode 100644 index 0000000..139ff45 --- /dev/null +++ b/frontend/src/api/mocks/knowledgeHandlers.ts @@ -0,0 +1,92 @@ +import { faker } from "@faker-js/faker"; +import { http, HttpResponse } from "msw"; + +// 生成模拟的知识库数据 +const generateLibrary = () => ({ + id: faker.number.int({ min: 1, max: 100 }), + name: faker.lorem.words(2), + description: faker.lorem.sentence(), + createTime: faker.date.recent().toISOString() +}); + +// 生成模拟的文档数据 +const generateDoc = (libId: number) => ({ + id: faker.number.int({ min: 1, max: 1000 }), + libId, + name: faker.system.fileName(), + identify: faker.string.uuid(), + path: faker.system.filePath(), + meta: {}, + enable: faker.datatype.boolean(), + status: faker.helpers.arrayElement(["SUCCESS", "INDEXING"]), + createTime: faker.date.recent().toISOString(), + updateTime: faker.date.recent().toISOString() +}); + +// 生成模拟的文档段落数据 +const generateSegment = (docId: number) => ({ + id: faker.number.int({ min: 1, max: 10000 }), + docId, + embeddingId: faker.string.uuid(), + content: faker.lorem.paragraphs(), + tokenUsage: faker.number.int({ min: 10, max: 1000 }) +}); + +export default [ + // 获取知识库列表 + http.get("/knowledge/libraries", () => { + const libraries = faker.helpers.multiple(generateLibrary, { count: 5 }); + return HttpResponse.json(libraries); + }), + + // 获取文档列表 + http.get("/knowledge/docs", ({ request }) => { + const url = new URL(request.url); + const libraryId = Number(url.searchParams.get("libraryId")); + + if (Number.isNaN(libraryId)) { + return new HttpResponse(null, { status: 400 }); + } + + const docs = faker.helpers.multiple(() => generateDoc(libraryId), { count: 8 }); + return HttpResponse.json(docs); + }), + + // 获取文档段落 + http.get("/knowledge/segments", ({ request }) => { + const url = new URL(request.url); + const libraryDocId = Number(url.searchParams.get("libraryDocId")); + + if (Number.isNaN(libraryDocId)) { + return new HttpResponse(null, { status: 400 }); + } + + const segments = faker.helpers.multiple(() => generateSegment(libraryDocId), { count: 12 }); + return HttpResponse.json(segments); + }), + + // 创建/更新知识库 + http.post("/knowledge/library", async () => { + return HttpResponse.json({ success: true }); + }), + + // 删除知识库 + http.delete("/knowledge/library", () => { + return HttpResponse.json({ success: true }); + }), + + // 更新文档 + http.put("/knowledge/doc", async () => { + return HttpResponse.json({ success: true }); + }), + + // 删除文档 + http.delete("/knowledge/doc", () => { + return HttpResponse.json({ success: true }); + }), + + // 上传文档 + http.post("/knowledge/doc/upload", async () => { + return HttpResponse.text("upload-success"); + }), +]; diff --git a/frontend/src/api/mocks/setup.ts b/frontend/src/api/mocks/setup.ts index 6d477ec..1e22a06 100644 --- a/frontend/src/api/mocks/setup.ts +++ b/frontend/src/api/mocks/setup.ts @@ -7,6 +7,8 @@ import userHandlers from "./iamHandlers"; import departmentHandlers from "./departmentHandlers"; import positionHandlers from "./positionHandlers"; import aiHandlers from "./aiHandlers"; +import knowledgeHandlers from "./knowledgeHandlers"; + export const worker = setupWorker( ...userHandlers, ...authHandlers, @@ -16,4 +18,5 @@ export const worker = setupWorker( ...departmentHandlers, ...positionHandlers, ...aiHandlers, + ...knowledgeHandlers, ); diff --git a/frontend/src/components/layout/Headbar.vue b/frontend/src/components/layout/Headbar.vue index 7511619..c77f2bc 100644 --- a/frontend/src/components/layout/Headbar.vue +++ b/frontend/src/components/layout/Headbar.vue @@ -42,7 +42,7 @@ -