24 Commits
hot-fix ... dev

Author SHA1 Message Date
Chuck1sn
87c4706ca7 优化AOP日志模拟数据,删除不必要的删除接口逻辑,简化响应返回值。 2025-07-08 16:29:15 +08:00
Chuck1sn
deece30554 更新AopLogQueryDto中的时间字段类型为OffsetDateTime,优化AopLogRepository中的时间范围查询逻辑,调整前端模拟数据生成器以支持新的时间格式,修复日期选择器样式,优化日志管理页面的用户体验。 2025-07-08 14:36:51 +08:00
Chuck1sn
eecc8bedae 重构AOP日志功能,新增日志查询、删除接口及相关页面,优化日志管理体验;更新前端组件以支持日志展示和操作。 2025-07-08 13:03:59 +08:00
Chuck1sn
0a0174c01e add curl field 2025-07-08 10:48:20 +08:00
Chuck1sn
36d285a61d init aop log 2025-07-07 16:13:52 +08:00
Chuck1sn
5c685d4f74 更新页面标题为“知路 AI 后台管理”,修改头部组件中的标题以保持一致性,同时更新 group.png 图片文件。 2025-07-02 09:05:12 +08:00
ccmjga
1bd50f5de2 更新聊天功能,调整调用 chat 函数以支持传递知识库名称,提升知识库使用体验。 2025-06-29 11:12:14 +08:00
ccmjga
e646bdffa8 优化表单提交错误处理逻辑,调整错误提示的属性名称;移除未使用的文档获取函数,简化知识查询逻辑。 2025-06-28 22:51:52 +08:00
ccmjga
a767613b58 publicEndPointMatcher fix 2025-06-28 22:41:24 +08:00
ccmjga
152f0fe07c Merge branch 'dev' 2025-06-28 22:31:49 +08:00
ccmjga
b6ecc929b0 新增 ChatDto 数据传输对象,更新聊天接口以支持知识库功能,优化聊天服务逻辑,调整前端组件以提升用户体验。 2025-06-28 22:31:20 +08:00
Chuck1sn
3e1d7e6fee 新增表单和对话框组件,优化头像处理逻辑,更新相关工具函数,提升用户界面和交互体验。 2025-06-28 09:16:36 +08:00
Chuck1sn
f9c8e3808b 新增 CardBase 组件,重构知识文档卡片、知识库卡片和分段卡片以使用新组件,优化按钮组件并更新相关页面以提升用户体验。 2025-06-28 08:43:54 +08:00
Chuck1sn
56d6a992f8 新增知识库相关组件,包括文档卡片、知识库卡片、状态徽章和分段卡片,优化日期格式化工具函数,更新文档管理和知识库管理页面以使用新组件。 2025-06-28 08:12:59 +08:00
Chuck1sn
6ec07686a9 Long libraryId 2025-06-27 22:57:00 +08:00
Chuck1sn
4d70b49e61 fix parse bugs 2025-06-27 18:47:17 +08:00
Chuck1sn
8ed0b795f3 新增知识库管理功能,包括知识库和文档的增删改查,优化文档上传和状态管理,更新相关API接口和前端页面,添加知识库和文档的视图组件。 2025-06-27 16:51:48 +08:00
Chuck1sn
2fb08968ee description 2025-06-26 19:55:53 +08:00
Chuck1sn
2f7259ca9d fix endpoint 2025-06-26 19:27:51 +08:00
Chuck1sn
19090b9c94 add async 2025-06-26 18:03:00 +08:00
Chuck1sn
cbfbd6c5dd remove data_count 2025-06-26 15:59:56 +08:00
Chuck1sn
09f51fa91f add desc 2025-06-26 15:58:17 +08:00
Chuck1sn
5494181ae0 init library 2025-06-26 15:54:38 +08:00
Chuck1sn
8d285e1abc add document ingestor 2025-06-25 15:10:02 +08:00
107 changed files with 7226 additions and 368 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

After

Width:  |  Height:  |  Size: 576 KiB

View File

@@ -64,6 +64,8 @@ dependencies {
implementation("dev.langchain4j:langchain4j-open-ai:1.0.0")
implementation("dev.langchain4j:langchain4j-pgvector:1.0.1-beta6")
implementation("dev.langchain4j:langchain4j-community-zhipu-ai:1.0.1-beta6")
implementation("dev.langchain4j:langchain4j-document-parser-apache-tika:1.1.0-beta7")
implementation("dev.langchain4j:langchain4j-document-loader-amazon-s3:1.1.0-beta7")
implementation("io.projectreactor:reactor-core:3.7.6")
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
@@ -168,14 +170,8 @@ jooq {
}
forcedTypes {
forcedType {
name = "varchar"
includeExpression = ".*"
includeTypes = "JSONB?"
}
forcedType {
name = "varchar"
includeExpression = ".*"
includeTypes = "INET"
isJsonConverter = true
includeTypes = "(?i:JSON|JSONB)"
}
}
}

View File

@@ -2,7 +2,9 @@ package com.zl.mjga;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync
@SpringBootApplication(scanBasePackages = {"com.zl.mjga", "org.jooq.generated"})
public class ApplicationService {

View File

@@ -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日志记录注解
*
* <p>在方法上添加此注解该方法将不会被AOP日志切面拦截和记录。
*
* <p>使用场景:
*
* <ul>
* <li>敏感操作方法,不希望记录日志
* <li>高频调用方法,避免产生过多日志
* <li>内部工具方法,不需要业务日志记录
* </ul>
*
* <p>使用示例:
*
* <pre>{@code
* @SkipAopLog
* public void sensitiveMethod() {
* // 此方法不会被AOP日志记录
* }
* }</pre>
*
* @author AOP Log System
* @since 1.0
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SkipAopLog {
/**
* 跳过日志记录的原因说明(可选)
*
* @return 跳过原因
*/
String reason() default "";
}

View File

@@ -0,0 +1,312 @@
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.io.BufferedReader;
import java.io.IOException;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.Enumeration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
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.boot.autoconfigure.condition.ConditionalOnProperty;
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
@ConditionalOnProperty(name = "aop.logging.enabled", havingValue = "true", matchIfMissing = true)
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);
// }
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 = authentication.getName();
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"));
aopLog.setCurl(generateCurlCommand(request));
}
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();
}
}
public String generateCurlCommand(HttpServletRequest request) {
try {
StringBuilder curl = new StringBuilder("curl -X ");
curl.append(request.getMethod());
String url = getFullRequestUrl(request);
curl.append(" '").append(url).append("'");
appendHeaders(curl, request);
if (hasRequestBody(request.getMethod())) {
appendRequestBody(curl, request);
}
return curl.toString();
} catch (Exception e) {
log.error("Failed to generate curl command", e);
return "curl command generation failed: " + e.getMessage();
}
}
private String getFullRequestUrl(HttpServletRequest request) {
StringBuilder url = new StringBuilder();
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
if (scheme == null) {
scheme = "http";
}
if (serverName == null) {
serverName = "localhost";
}
url.append(scheme).append("://").append(serverName);
if ((scheme.equals("http") && serverPort != 80)
|| (scheme.equals("https") && serverPort != 443)) {
url.append(":").append(serverPort);
}
url.append(request.getRequestURI());
if (request.getQueryString() != null) {
url.append("?").append(request.getQueryString());
}
return url.toString();
}
private void appendHeaders(StringBuilder curl, HttpServletRequest request) {
Enumeration<String> headerNames = request.getHeaderNames();
for (String headerName : Collections.list(headerNames)) {
if (shouldSkipHeader(headerName)) {
continue;
}
String headerValue = request.getHeader(headerName);
curl.append(" -H '").append(headerName).append(": ").append(headerValue).append("'");
}
}
private boolean shouldSkipHeader(String headerName) {
String lowerName = headerName.toLowerCase();
return lowerName.equals("host")
|| lowerName.equals("content-length")
|| lowerName.equals("connection")
|| lowerName.startsWith("sec-")
|| lowerName.equals("upgrade-insecure-requests");
}
private boolean hasRequestBody(String method) {
return "POST".equalsIgnoreCase(method)
|| "PUT".equalsIgnoreCase(method)
|| "PATCH".equalsIgnoreCase(method);
}
private void appendRequestBody(StringBuilder curl, HttpServletRequest request) {
try {
String contentType = request.getContentType();
if (StringUtils.contains(contentType, "application/json")) {
String body = getRequestBody(request);
if (StringUtils.isNotEmpty(body)) {
curl.append(" -d '").append(body.replace("'", "\\'")).append("'");
}
} else if (StringUtils.contains(contentType, "application/x-www-form-urlencoded")) {
appendFormData(curl, request);
}
} catch (Exception e) {
log.warn("Failed to append request body to curl command", e);
}
}
private String getRequestBody(HttpServletRequest request) {
try (BufferedReader reader = request.getReader()) {
if (reader == null) {
return null;
}
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
return body.toString();
} catch (IOException e) {
log.warn("Failed to read request body", e);
return null;
}
}
private void appendFormData(StringBuilder curl, HttpServletRequest request) {
Enumeration<String> paramNames = request.getParameterNames();
StringBuilder formData = new StringBuilder();
while (paramNames.hasMoreElements()) {
String paramName = paramNames.nextElement();
String[] paramValues = request.getParameterValues(paramName);
for (String paramValue : paramValues) {
if (!formData.isEmpty()) {
formData.append("&");
}
formData.append(paramName).append("=").append(paramValue);
}
}
if (!formData.isEmpty()) {
curl.append(" -d '").append(formData).append("'");
}
}
}

View File

@@ -0,0 +1,35 @@
package com.zl.mjga.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import org.jooq.JSON;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder ->
builder
.serializationInclusion(JsonInclude.Include.USE_DEFAULTS)
.serializers(new JooqJsonSerializer());
}
private static class JooqJsonSerializer extends StdSerializer<JSON> {
public JooqJsonSerializer() {
super(JSON.class);
}
@Override
public void serialize(JSON value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeRawValue(value.data());
}
}
}

View File

@@ -1,11 +1,17 @@
package com.zl.mjga.config.ai;
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;
import com.zl.mjga.component.PromptConfiguration;
import com.zl.mjga.service.LlmService;
import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingStore;
import lombok.RequiredArgsConstructor;
import org.jooq.generated.mjga.enums.LlmCodeEnum;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
@@ -54,11 +60,26 @@ public class ChatModelInitializer {
@Bean
@DependsOn("flywayInitializer")
public AiChatAssistant zhiPuChatAssistant(ZhipuAiStreamingChatModel zhipuChatModel) {
public AiChatAssistant zhiPuChatAssistant(
ZhipuAiStreamingChatModel zhipuChatModel,
EmbeddingStore<TextSegment> zhiPuLibraryEmbeddingStore,
EmbeddingModel zhipuEmbeddingModel) {
return AiServices.builder(AiChatAssistant.class)
.streamingChatModel(zhipuChatModel)
.systemMessageProvider(chatMemoryId -> promptConfiguration.getSystem())
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
.contentRetriever(
EmbeddingStoreContentRetriever.builder()
.embeddingStore(zhiPuLibraryEmbeddingStore)
.embeddingModel(zhipuEmbeddingModel)
.minScore(0.75)
.maxResults(5)
.dynamicFilter(
query -> {
String libraryId = (String) query.metadata().chatMemoryId();
return metadataKey("libraryId").isEqualTo(libraryId);
})
.build())
.build();
}

View File

@@ -1,7 +1,10 @@
package com.zl.mjga.config.ai;
import com.zl.mjga.config.minio.MinIoConfig;
import com.zl.mjga.service.LlmService;
import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel;
import dev.langchain4j.data.document.loader.amazon.s3.AmazonS3DocumentLoader;
import dev.langchain4j.data.document.loader.amazon.s3.AwsCredentials;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
@@ -42,7 +45,7 @@ public class EmbeddingInitializer {
}
@Bean
public EmbeddingStore<TextSegment> zhiPuEmbeddingStore(EmbeddingModel zhipuEmbeddingModel) {
public EmbeddingStore<TextSegment> zhiPuEmbeddingStore() {
String hostPort = env.getProperty("DATABASE_HOST_PORT");
String host = hostPort.split(":")[0];
return PgVectorEmbeddingStore.builder()
@@ -55,4 +58,28 @@ public class EmbeddingInitializer {
.dimension(2048)
.build();
}
@Bean
public EmbeddingStore<TextSegment> zhiPuLibraryEmbeddingStore() {
String hostPort = env.getProperty("DATABASE_HOST_PORT");
String host = hostPort.split(":")[0];
return PgVectorEmbeddingStore.builder()
.host(host)
.port(env.getProperty("DATABASE_EXPOSE_PORT", Integer.class))
.database(env.getProperty("DATABASE_DB"))
.user(env.getProperty("DATABASE_USER"))
.password(env.getProperty("DATABASE_PASSWORD"))
.table("mjga.zhipu_library_embedding_store")
.dimension(2048)
.build();
}
@Bean
public AmazonS3DocumentLoader amazonS3DocumentLoader(MinIoConfig minIoConfig) {
return AmazonS3DocumentLoader.builder()
.endpointUrl(minIoConfig.getEndpoint())
.forcePathStyle(true)
.awsCredentials(new AwsCredentials(minIoConfig.getAccessKey(), minIoConfig.getSecretKey()))
.build();
}
}

View File

@@ -2,13 +2,14 @@ package com.zl.mjga.controller;
import com.zl.mjga.dto.PageRequestDto;
import com.zl.mjga.dto.PageResponseDto;
import com.zl.mjga.dto.ai.ChatDto;
import com.zl.mjga.dto.ai.LlmQueryDto;
import com.zl.mjga.dto.ai.LlmVm;
import com.zl.mjga.exception.BusinessException;
import com.zl.mjga.repository.*;
import com.zl.mjga.service.AiChatService;
import com.zl.mjga.service.EmbeddingService;
import com.zl.mjga.service.LlmService;
import com.zl.mjga.service.RagService;
import dev.langchain4j.service.TokenStream;
import jakarta.validation.Valid;
import java.security.Principal;
@@ -35,7 +36,7 @@ public class AiController {
private final AiChatService aiChatService;
private final LlmService llmService;
private final EmbeddingService embeddingService;
private final RagService ragService;
private final UserRepository userRepository;
private final DepartmentRepository departmentRepository;
private final PositionRepository positionRepository;
@@ -72,9 +73,9 @@ public class AiController {
}
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(Principal principal, @RequestBody String userMessage) {
public Flux<String> chat(Principal principal, @RequestBody ChatDto chatDto) {
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
TokenStream chat = aiChatService.chatPrecedenceLlmWith(principal.getName(), userMessage);
TokenStream chat = aiChatService.chat(principal.getName(), chatDto);
chat.onPartialResponse(
text ->
sink.tryEmitNext(
@@ -109,7 +110,7 @@ public class AiController {
if (!aiLlmConfig.getEnable()) {
throw new BusinessException("命令模型未启用,请开启后再试。");
}
return embeddingService.searchAction(message);
return ragService.searchAction(message);
}
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")

View File

@@ -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<List<AopLogRespDto>> 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<Long> 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);
}
}

View File

@@ -1,6 +1,6 @@
package com.zl.mjga.controller;
import com.zl.mjga.config.minio.MinIoConfig;
import com.zl.mjga.annotation.SkipAopLog;
import com.zl.mjga.dto.PageRequestDto;
import com.zl.mjga.dto.PageResponseDto;
import com.zl.mjga.dto.department.DepartmentBindDto;
@@ -13,17 +13,11 @@ 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 io.minio.MinioClient;
import io.minio.PutObjectArgs;
import com.zl.mjga.service.UploadService;
import jakarta.validation.Valid;
import java.awt.image.BufferedImage;
import java.security.Principal;
import java.time.Instant;
import java.util.List;
import javax.imageio.ImageIO;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.jooq.generated.mjga.tables.pojos.User;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -41,8 +35,7 @@ public class IdentityAccessController {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final MinioClient minioClient;
private final MinIoConfig minIoConfig;
private final UploadService uploadService;
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
@PostMapping(
@@ -50,40 +43,7 @@ public class IdentityAccessController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
public String uploadAvatar(@RequestPart("file") MultipartFile multipartFile) throws Exception {
String originalFilename = multipartFile.getOriginalFilename();
if (StringUtils.isEmpty(originalFilename)) {
throw new BusinessException("文件名不能为空");
}
String contentType = multipartFile.getContentType();
String extension = "";
if ("image/jpeg".equals(contentType)) {
extension = ".jpg";
} else if ("image/png".equals(contentType)) {
extension = ".png";
}
String objectName =
String.format(
"/avatar/%d%s%s",
Instant.now().toEpochMilli(),
RandomStringUtils.insecure().nextAlphabetic(6),
extension);
if (multipartFile.isEmpty()) {
throw new BusinessException("上传的文件不能为空");
}
long size = multipartFile.getSize();
if (size > 200 * 1024) {
throw new BusinessException("头像文件大小不能超过200KB");
}
BufferedImage img = ImageIO.read(multipartFile.getInputStream());
if (img == null) {
throw new BusinessException("非法的上传文件");
}
minioClient.putObject(
PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream(
multipartFile.getInputStream(), size, -1)
.contentType(multipartFile.getContentType())
.build());
return objectName;
return uploadService.uploadAvatarFile(multipartFile);
}
@GetMapping("/me")
@@ -97,6 +57,7 @@ public class IdentityAccessController {
}
@PostMapping("/me")
@SkipAopLog
void upsertMe(Principal principal, @RequestBody UserUpsertDto userUpsertDto) {
String name = principal.getName();
User user = userRepository.fetchOneByUsername(name);
@@ -106,6 +67,7 @@ public class IdentityAccessController {
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
@PostMapping("/user")
@SkipAopLog
void upsertUser(@RequestBody @Valid UserUpsertDto userUpsertDto) {
identityAccessService.upsertUser(userUpsertDto);
}

View File

@@ -0,0 +1,90 @@
package com.zl.mjga.controller;
import com.zl.mjga.dto.knowledge.DocUpdateDto;
import com.zl.mjga.dto.knowledge.LibraryUpsertDto;
import com.zl.mjga.repository.LibraryDocRepository;
import com.zl.mjga.repository.LibraryDocSegmentRepository;
import com.zl.mjga.repository.LibraryRepository;
import com.zl.mjga.service.RagService;
import com.zl.mjga.service.UploadService;
import jakarta.validation.Valid;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jooq.generated.mjga.tables.pojos.Library;
import org.jooq.generated.mjga.tables.pojos.LibraryDoc;
import org.jooq.generated.mjga.tables.pojos.LibraryDocSegment;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/knowledge")
@RequiredArgsConstructor
@Slf4j
public class LibraryController {
private final UploadService uploadService;
private final RagService ragService;
private final LibraryRepository libraryRepository;
private final LibraryDocRepository libraryDocRepository;
private final LibraryDocSegmentRepository libraryDocSegmentRepository;
@GetMapping("/libraries")
public List<Library> queryLibraries() {
return libraryRepository.findAll().stream()
.sorted(Comparator.comparing(Library::getId).reversed())
.toList();
}
@GetMapping("/docs")
public List<LibraryDoc> queryLibraryDocs(@RequestParam Long libraryId) {
return libraryDocRepository.fetchByLibId(libraryId).stream()
.sorted(Comparator.comparing(LibraryDoc::getId).reversed())
.toList();
}
@GetMapping("/segments")
public List<LibraryDocSegment> queryLibraryDocSegments(@RequestParam Long libraryDocId) {
return libraryDocSegmentRepository.fetchByDocId(libraryDocId);
}
@PostMapping("/library")
public void upsertLibrary(@RequestBody @Valid LibraryUpsertDto libraryUpsertDto) {
Library library = new Library();
library.setId(libraryUpsertDto.id());
library.setName(libraryUpsertDto.name());
library.setDescription(libraryUpsertDto.description());
libraryRepository.merge(library);
}
@DeleteMapping("/library")
public void deleteLibrary(@RequestParam Long libraryId) {
ragService.deleteLibraryBy(libraryId);
}
@DeleteMapping("/doc")
public void deleteLibraryDoc(@RequestParam Long libraryDocId) {
ragService.deleteDocBy(libraryDocId);
}
@PutMapping("/doc")
public void updateLibraryDoc(@RequestBody @Valid DocUpdateDto docUpdateDto) {
LibraryDoc exist = libraryDocRepository.fetchOneById(docUpdateDto.id());
exist.setEnable(docUpdateDto.enable());
libraryDocRepository.merge(exist);
}
@PostMapping(value = "/doc/upload", produces = MediaType.TEXT_PLAIN_VALUE)
public String uploadLibraryDoc(
@RequestPart("libraryId") String libraryId, @RequestPart("file") MultipartFile multipartFile)
throws Exception {
String objectName = uploadService.uploadLibraryDoc(multipartFile);
Long libraryDocId =
ragService.createLibraryDocBy(
Long.valueOf(libraryId), objectName, multipartFile.getOriginalFilename());
ragService.embeddingAndCreateDocSegment(Long.valueOf(libraryId), libraryDocId, objectName);
return objectName;
}
}

View File

@@ -1,5 +1,6 @@
package com.zl.mjga.controller;
import com.zl.mjga.annotation.SkipAopLog;
import com.zl.mjga.config.security.Jwt;
import com.zl.mjga.dto.sign.SignInDto;
import com.zl.mjga.dto.sign.SignUpDto;
@@ -22,6 +23,7 @@ public class SignController {
@ResponseStatus(HttpStatus.OK)
@PostMapping("/sign-in")
@SkipAopLog
void signIn(
HttpServletRequest request,
HttpServletResponse response,

View File

@@ -0,0 +1,7 @@
package com.zl.mjga.dto.ai;
import com.zl.mjga.model.urp.ChatMode;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
public record ChatDto(@NotNull ChatMode mode, Long libraryId, @NotEmpty String message) {}

View File

@@ -0,0 +1,45 @@
package com.zl.mjga.dto.aoplog;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
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 OffsetDateTime startTime;
/** 结束时间 */
private OffsetDateTime endTime;
/** 最小执行时间(毫秒) */
private Long minExecutionTime;
/** 最大执行时间(毫秒) */
private Long maxExecutionTime;
}

View File

@@ -0,0 +1,56 @@
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;
/** curl命令 */
private String curl;
/** 创建时间 */
private OffsetDateTime createTime;
}

View File

@@ -0,0 +1,5 @@
package com.zl.mjga.dto.knowledge;
import jakarta.validation.constraints.NotNull;
public record DocUpdateDto(@NotNull Long id, @NotNull Long libId, @NotNull Boolean enable) {}

View File

@@ -0,0 +1,5 @@
package com.zl.mjga.dto.knowledge;
import jakarta.validation.constraints.NotEmpty;
public record LibraryUpsertDto(Long id, @NotEmpty String name, String description) {}

View File

@@ -0,0 +1,6 @@
package com.zl.mjga.model.urp;
public enum ChatMode {
NORMAL,
WITH_LIBRARY
}

View File

@@ -0,0 +1,125 @@
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<Record> pageFetchBy(PageRequestDto pageRequestDto, AopLogQueryDto queryDto) {
return selectByWithoutReturnValue(queryDto)
.orderBy(pageRequestDto.getSortFields())
.limit(pageRequestDto.getSize())
.offset(pageRequestDto.getOffset())
.fetch();
}
public List<AopLog> fetchBy(AopLogQueryDto queryDto) {
return selectBy(queryDto).fetchInto(AopLog.class);
}
public SelectConditionStep<Record> 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));
}
public SelectConditionStep<Record> selectByWithoutReturnValue(AopLogQueryDto queryDto) {
return ctx()
.select(AOP_LOG.asterisk().except(AOP_LOG.RETURN_VALUE, AOP_LOG.METHOD_ARGS), 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) {
condition = condition.and(AOP_LOG.CREATE_TIME.ge(queryDto.getStartTime()));
}
if (queryDto.getEndTime() != null) {
condition = condition.and(AOP_LOG.CREATE_TIME.le(queryDto.getEndTime()));
}
// 执行时间范围
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<Long> 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();
}
}

View File

@@ -0,0 +1,14 @@
package com.zl.mjga.repository;
import org.jooq.Configuration;
import org.jooq.generated.mjga.tables.daos.LibraryDocDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
@Repository
public class LibraryDocRepository extends LibraryDocDao {
@Autowired
public LibraryDocRepository(Configuration configuration) {
super(configuration);
}
}

View File

@@ -0,0 +1,14 @@
package com.zl.mjga.repository;
import org.jooq.Configuration;
import org.jooq.generated.mjga.tables.daos.LibraryDocSegmentDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
@Repository
public class LibraryDocSegmentRepository extends LibraryDocSegmentDao {
@Autowired
public LibraryDocSegmentRepository(Configuration configuration) {
super(configuration);
}
}

View File

@@ -0,0 +1,15 @@
package com.zl.mjga.repository;
import org.jooq.Configuration;
import org.jooq.generated.mjga.tables.daos.LibraryDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
@Repository
public class LibraryRepository extends LibraryDao {
@Autowired
public LibraryRepository(Configuration configuration) {
super(configuration);
}
}

View File

@@ -2,6 +2,7 @@ package com.zl.mjga.service;
import com.zl.mjga.config.ai.AiChatAssistant;
import com.zl.mjga.config.ai.SystemToolAssistant;
import com.zl.mjga.dto.ai.ChatDto;
import com.zl.mjga.exception.BusinessException;
import dev.langchain4j.service.TokenStream;
import java.util.Optional;
@@ -39,8 +40,20 @@ public class AiChatService {
};
}
public TokenStream chatPrecedenceLlmWith(String sessionIdentifier, String userMessage) {
public TokenStream chat(String sessionIdentifier, ChatDto chatDto) {
return switch (chatDto.mode()) {
case NORMAL -> chatWithPrecedenceLlm(sessionIdentifier, chatDto);
case WITH_LIBRARY -> chatWithLibrary(chatDto.libraryId(), chatDto);
};
}
public TokenStream chatWithLibrary(Long libraryId, ChatDto chatDto) {
return zhiPuChatAssistant.chat(String.valueOf(libraryId), chatDto.message());
}
public TokenStream chatWithPrecedenceLlm(String sessionIdentifier, ChatDto chatDto) {
LlmCodeEnum code = getPrecedenceLlmCode();
String userMessage = chatDto.message();
return switch (code) {
case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage);
case DEEP_SEEK -> deepSeekChatAssistant.chat(sessionIdentifier, userMessage);

View File

@@ -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<List<AopLogRespDto>> pageQueryAopLogs(
PageRequestDto pageRequestDto, AopLogQueryDto queryDto) {
Result<Record> records = aopLogRepository.pageFetchBy(pageRequestDto, queryDto);
if (records.isEmpty()) {
return PageResponseDto.empty();
}
List<AopLogRespDto> 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<Record> selectStep = aopLogRepository.selectBy(queryDto);
return selectStep.fetchOneInto(AopLogRespDto.class);
}
@Transactional
public int deleteLogsBeforeTime(OffsetDateTime beforeTime) {
return aopLogRepository.deleteBeforeTime(beforeTime);
}
}

View File

@@ -1,73 +0,0 @@
package com.zl.mjga.service;
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;
import com.zl.mjga.config.ai.ZhiPuEmbeddingModelConfig;
import com.zl.mjga.model.urp.Actions;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.filter.Filter;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
@Configuration
@RequiredArgsConstructor
@Service
public class EmbeddingService {
private final EmbeddingModel zhipuEmbeddingModel;
private final EmbeddingStore<TextSegment> zhiPuEmbeddingStore;
private final ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig;
public Map<String, String> searchAction(String message) {
Map<String, String> result = new HashMap<>();
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(zhipuEmbeddingModel.embed(message).content())
.minScore(0.89)
.build();
EmbeddingSearchResult<TextSegment> embeddingSearchResult =
zhiPuEmbeddingStore.search(embeddingSearchRequest);
if (!embeddingSearchResult.matches().isEmpty()) {
Metadata metadata = embeddingSearchResult.matches().getFirst().embedded().metadata();
result.put(Actions.INDEX_KEY, metadata.getString(Actions.INDEX_KEY));
}
return result;
}
@PostConstruct
public void initActionIndex() {
if (!zhiPuEmbeddingModelConfig.getEnable()) {
return;
}
for (Actions action : Actions.values()) {
Embedding queryEmbedding = zhipuEmbeddingModel.embed(action.getContent()).content();
Filter createUserFilter = metadataKey(Actions.INDEX_KEY).isEqualTo(action.getCode());
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.filter(createUserFilter)
.build();
EmbeddingSearchResult<TextSegment> embeddingSearchResult =
zhiPuEmbeddingStore.search(embeddingSearchRequest);
if (embeddingSearchResult.matches().isEmpty()) {
TextSegment segment =
TextSegment.from(
action.getContent(), Metadata.metadata(Actions.INDEX_KEY, action.getCode()));
Embedding embedding = zhipuEmbeddingModel.embed(segment).content();
zhiPuEmbeddingStore.add(embedding, segment);
}
}
}
}

View File

@@ -0,0 +1,181 @@
package com.zl.mjga.service;
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zl.mjga.config.ai.ZhiPuEmbeddingModelConfig;
import com.zl.mjga.config.minio.MinIoConfig;
import com.zl.mjga.model.urp.Actions;
import com.zl.mjga.repository.LibraryDocRepository;
import com.zl.mjga.repository.LibraryRepository;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.document.loader.amazon.s3.AmazonS3DocumentLoader;
import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.output.Response;
import dev.langchain4j.store.embedding.*;
import dev.langchain4j.store.embedding.filter.Filter;
import jakarta.annotation.PostConstruct;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.jooq.JSON;
import org.jooq.generated.mjga.enums.LibraryDocStatusEnum;
import org.jooq.generated.mjga.tables.daos.LibraryDocSegmentDao;
import org.jooq.generated.mjga.tables.pojos.LibraryDoc;
import org.jooq.generated.mjga.tables.pojos.LibraryDocSegment;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Configuration
@RequiredArgsConstructor
@Service
@Slf4j
public class RagService {
private final EmbeddingModel zhipuEmbeddingModel;
private final EmbeddingStore<TextSegment> zhiPuEmbeddingStore;
private final EmbeddingStore<TextSegment> zhiPuLibraryEmbeddingStore;
private final ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig;
private final AmazonS3DocumentLoader amazonS3DocumentLoader;
private final MinIoConfig minIoConfig;
private final LibraryRepository libraryRepository;
private final LibraryDocRepository libraryDocRepository;
private final LibraryDocSegmentDao libraryDocSegmentDao;
public void deleteLibraryBy(Long libraryId) {
List<LibraryDoc> libraryDocs = libraryDocRepository.fetchByLibId(libraryId);
List<Long> docIds = libraryDocs.stream().map(LibraryDoc::getId).toList();
for (Long docId : docIds) {
deleteDocBy(docId);
}
libraryRepository.deleteById(libraryId);
}
public void deleteDocBy(Long docId) {
List<LibraryDocSegment> libraryDocSegments = libraryDocSegmentDao.fetchByDocId(docId);
List<String> embeddingIdList =
libraryDocSegments.stream().map(LibraryDocSegment::getEmbeddingId).toList();
if (CollectionUtils.isNotEmpty(embeddingIdList)) {
zhiPuLibraryEmbeddingStore.removeAll(embeddingIdList);
}
libraryDocRepository.deleteById(docId);
}
public Long createLibraryDocBy(Long libraryId, String objectName, String originalName)
throws JsonProcessingException {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
String identify =
String.format(
"%d%s_%s",
Instant.now().toEpochMilli(),
RandomStringUtils.insecure().nextAlphabetic(6),
originalName);
Map<String, String> meta = new HashMap<>();
meta.put("uploader", username);
LibraryDoc libraryDoc = new LibraryDoc();
ObjectMapper objectMapper = new ObjectMapper();
String metaJson = objectMapper.writeValueAsString(meta);
libraryDoc.setMeta(JSON.valueOf(metaJson));
libraryDoc.setPath(objectName);
libraryDoc.setName(originalName);
libraryDoc.setIdentify(identify);
libraryDoc.setLibId(libraryId);
libraryDoc.setStatus(LibraryDocStatusEnum.INDEXING);
libraryDoc.setEnable(Boolean.TRUE);
libraryDocRepository.insert(libraryDoc);
return libraryDocRepository.fetchOneByIdentify(identify).getId();
}
@Async
public void embeddingAndCreateDocSegment(Long libraryId, Long libraryDocId, String objectName) {
Document document =
amazonS3DocumentLoader.loadDocument(
minIoConfig.getDefaultBucket(), objectName, new ApacheTikaDocumentParser());
List<LibraryDocSegment> libraryDocSegments = new ArrayList<>();
DocumentByParagraphSplitter documentByParagraphSplitter =
new DocumentByParagraphSplitter(500, 150);
documentByParagraphSplitter
.split(document)
.forEach(
textSegment -> {
Response<Embedding> embed = zhipuEmbeddingModel.embed(textSegment);
Integer tokenUsage = embed.tokenUsage().totalTokenCount();
Embedding vector = embed.content();
textSegment.metadata().put("libraryId", libraryId);
String embeddingId = zhiPuLibraryEmbeddingStore.add(vector, textSegment);
LibraryDocSegment libraryDocSegment = new LibraryDocSegment();
libraryDocSegment.setEmbeddingId(embeddingId);
libraryDocSegment.setContent(textSegment.text());
libraryDocSegment.setTokenUsage(tokenUsage);
libraryDocSegment.setDocId(libraryDocId);
libraryDocSegments.add(libraryDocSegment);
});
libraryDocSegmentDao.insert(libraryDocSegments);
LibraryDoc libraryDoc = libraryDocRepository.fetchOneById(libraryDocId);
libraryDoc.setStatus(LibraryDocStatusEnum.SUCCESS);
libraryDocRepository.update(libraryDoc);
}
public Map<String, String> searchAction(String message) {
Map<String, String> result = new HashMap<>();
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(zhipuEmbeddingModel.embed(message).content())
.minScore(0.89)
.build();
EmbeddingSearchResult<TextSegment> embeddingSearchResult =
zhiPuEmbeddingStore.search(embeddingSearchRequest);
if (!embeddingSearchResult.matches().isEmpty()) {
Metadata metadata = embeddingSearchResult.matches().getFirst().embedded().metadata();
result.put(Actions.INDEX_KEY, metadata.getString(Actions.INDEX_KEY));
}
return result;
}
@PostConstruct
public void initActionIndex() {
if (!zhiPuEmbeddingModelConfig.getEnable()) {
return;
}
for (Actions action : Actions.values()) {
Embedding queryEmbedding = zhipuEmbeddingModel.embed(action.getContent()).content();
Filter createUserFilter = metadataKey(Actions.INDEX_KEY).isEqualTo(action.getCode());
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.filter(createUserFilter)
.build();
EmbeddingSearchResult<TextSegment> embeddingSearchResult =
zhiPuEmbeddingStore.search(embeddingSearchRequest);
if (embeddingSearchResult.matches().isEmpty()) {
TextSegment segment =
TextSegment.from(
action.getContent(), Metadata.metadata(Actions.INDEX_KEY, action.getCode()));
Embedding embedding = zhipuEmbeddingModel.embed(segment).content();
zhiPuEmbeddingStore.add(embedding, segment);
}
}
}
}

View File

@@ -0,0 +1,81 @@
package com.zl.mjga.service;
import com.zl.mjga.config.minio.MinIoConfig;
import com.zl.mjga.exception.BusinessException;
import io.minio.*;
import java.awt.image.BufferedImage;
import java.time.Instant;
import javax.imageio.ImageIO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
@RequiredArgsConstructor
@Slf4j
public class UploadService {
private final MinioClient minioClient;
private final MinIoConfig minIoConfig;
public String uploadAvatarFile(MultipartFile multipartFile) throws Exception {
String originalFilename = multipartFile.getOriginalFilename();
if (StringUtils.isEmpty(originalFilename)) {
throw new BusinessException("文件名不能为空");
}
String contentType = multipartFile.getContentType();
String extension = "";
if ("image/jpeg".equals(contentType)) {
extension = ".jpg";
} else if ("image/png".equals(contentType)) {
extension = ".png";
}
String objectName =
String.format(
"/library/%d%s%s",
Instant.now().toEpochMilli(),
RandomStringUtils.insecure().nextAlphabetic(6),
extension);
if (multipartFile.isEmpty()) {
throw new BusinessException("上传的文件不能为空");
}
long size = multipartFile.getSize();
if (size > 200 * 1024) {
throw new BusinessException("头像大小不能超过200KB");
}
BufferedImage img = ImageIO.read(multipartFile.getInputStream());
if (img == null) {
throw new BusinessException("非法的上传文件");
}
minioClient.putObject(
PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream(
multipartFile.getInputStream(), size, -1)
.contentType(multipartFile.getContentType())
.build());
return objectName;
}
public String uploadLibraryDoc(MultipartFile multipartFile) throws Exception {
String originalFilename = multipartFile.getOriginalFilename();
if (StringUtils.isEmpty(originalFilename)) {
throw new BusinessException("文件名不能为空");
}
String objectName = String.format("/library/%s", originalFilename);
if (multipartFile.isEmpty()) {
throw new BusinessException("上传的文件不能为空");
}
long size = multipartFile.getSize();
if (size > 1024 * 1024) {
throw new BusinessException("知识库文档大小不能超过1MB");
}
minioClient.putObject(
PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream(
multipartFile.getInputStream(), size, -1)
.contentType(multipartFile.getContentType())
.build());
return objectName;
}
}

View File

@@ -42,3 +42,6 @@ minio:
access-key: ${MINIO_ROOT_USER}
secret-key: ${MINIO_ROOT_PASSWORD}
default-bucket: ${MINIO_DEFAULT_BUCKETS}
aop:
logging:
enabled: true

View File

@@ -4,7 +4,7 @@ CREATE TABLE mjga.user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE,
avatar VARCHAR,
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
password VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT TRUE
);
@@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map (
CREATE TABLE mjga.department (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR NOT NULL UNIQUE,
parent_id BIGINT,
FOREIGN KEY (parent_id)
REFERENCES mjga.department(id)
@@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map (
CREATE TABLE mjga.position (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
name VARCHAR NOT NULL UNIQUE
);
CREATE TABLE mjga.user_position_map (
@@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM (
CREATE TABLE mjga.ai_llm_config (
id BIGSERIAL NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR NOT NULL UNIQUE,
code mjga.llm_code_enum NOT NULL UNIQUE,
model_name VARCHAR(255) NOT NULL,
model_name VARCHAR NOT NULL,
type LLM_TYPE_ENUM NOT NULL,
api_key VARCHAR(255) NOT NULL,
url VARCHAR(255) NOT NULL,
api_key VARCHAR NOT NULL,
url VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true,
priority SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY(id)

View File

@@ -0,0 +1,34 @@
CREATE TABLE mjga.library (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL UNIQUE,
description VARCHAR,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TYPE mjga.library_doc_status_enum AS ENUM (
'SUCCESS',
'INDEXING'
);
CREATE TABLE mjga.library_doc (
id BIGSERIAL PRIMARY KEY,
lib_id BIGINT NOT NULL,
name VARCHAR NOT NULL,
identify VARCHAR NOT NULL UNIQUE,
path VARCHAR NOT NULL,
meta JSON NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true,
status mjga.library_doc_status_enum NOT NULL,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMPTZ,
FOREIGN KEY (lib_id) REFERENCES mjga.library (id) ON DELETE CASCADE
);
CREATE TABLE mjga.library_doc_segment (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT NOT NULL,
embedding_id VARCHAR NOT NULL UNIQUE,
content TEXT,
token_usage INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (doc_id) REFERENCES mjga.library_doc (id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,22 @@
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,
curl 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);

View File

@@ -0,0 +1,483 @@
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.InjectMocks;
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;
@InjectMocks LoggingAspect loggingAspect;
@BeforeEach
void setUp() {
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<RequestContextHolder> 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<RequestContextHolder> 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);
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);
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<RequestContextHolder> 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<RequestContextHolder> 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<RequestContextHolder> 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<RequestContextHolder> 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<RequestContextHolder> 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<RequestContextHolder> setupRequestContext(
String ipAddress, String userAgent) {
MockedStatic<RequestContextHolder> 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<AopLog> logVerifier) {
ArgumentCaptor<AopLog> 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
void logController_givenHttpRequest_shouldGenerateCurlCommand() 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);
setupSerialization("[\"arg1\"]", "\"success\"");
// Setup HTTP request mocks before setupRequestContext
when(httpServletRequest.getMethod()).thenReturn("POST");
when(httpServletRequest.getScheme()).thenReturn("http");
when(httpServletRequest.getServerName()).thenReturn("localhost");
when(httpServletRequest.getServerPort()).thenReturn(8080);
when(httpServletRequest.getRequestURI()).thenReturn("/api/test");
when(httpServletRequest.getQueryString()).thenReturn("param1=value1");
when(httpServletRequest.getContentType()).thenReturn("application/json");
when(httpServletRequest.getHeaderNames())
.thenReturn(
java.util.Collections.enumeration(
java.util.Arrays.asList("Content-Type", "Authorization")));
when(httpServletRequest.getHeader("Content-Type")).thenReturn("application/json");
when(httpServletRequest.getHeader("Authorization")).thenReturn("Bearer token123");
try (MockedStatic<RequestContextHolder> mockedRequestContextHolder =
setupRequestContext("127.0.0.1", "Test-Agent")) {
// action
Object result = loggingAspect.logController(joinPoint);
// assert
assertThat(result).isEqualTo(expectedResult);
verifyLogSaved(
log -> {
assertThat(log.getCurl()).isNotNull();
assertThat(log.getCurl()).contains("curl -X POST");
assertThat(log.getCurl()).contains("'http://localhost:8080/api/test?param1=value1'");
assertThat(log.getCurl()).contains("-H 'Content-Type: application/json'");
assertThat(log.getCurl()).contains("-H 'Authorization: Bearer token123'");
});
}
}
// 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";
}
}
}

View File

@@ -0,0 +1,187 @@
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<List<AopLogRespDto>> 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<Long> 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<Long> 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")
.curl("curl -X GET 'http://localhost:8080/test' -H 'Content-Type: application/json'")
.createTime(OffsetDateTime.now())
.build();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<Record> 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<AopLog> 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<AopLog> 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<AopLog> 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<Long> idsToDelete = List.of(1L, 3L);
// action
int deletedCount = aopLogRepository.deleteByIds(idsToDelete);
// assert
assertThat(deletedCount).isEqualTo(2);
// verify remaining record
List<AopLog> 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<AopLog> remaining = aopLogRepository.findAll();
assertThat(remaining).hasSize(1);
assertThat(remaining.get(0).getId()).isEqualTo(3L);
}
@Test
void deleteByIds_givenEmptyList_shouldReturnZero() {
// arrange
List<Long> 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);
}
}

View File

@@ -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 {

View File

@@ -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<Record> 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<List<AopLogRespDto>> 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<List<AopLogRespDto>> 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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
# LoggingAspect generateCurlCommand 方法单元测试
## 测试概述
本测试文件 `LoggingAspectCurlGenerationTest.java` 专门针对 `LoggingAspect` 类中的 `generateCurlCommand` 方法进行全面的单元测试,验证该方法在各种场景下生成 curl 命令的正确性。
## 测试架构
测试采用 **嵌套测试类** 的结构,按功能模块组织:
### 1. GET 请求测试 (`GetRequestTests`)
- ✅ 基本 GET 请求 - 无查询参数
- ✅ GET 请求 - 包含查询参数
- ✅ GET 请求 - HTTPS 协议
- ✅ GET 请求 - 自定义端口
### 2. POST 请求测试 (`PostRequestTests`)
- ✅ POST 请求 - JSON 请求体
- ✅ POST 请求 - 空 JSON 请求体
- ✅ POST 请求 - 包含单引号的 JSON
### 3. PUT 和 PATCH 请求测试 (`PutAndPatchRequestTests`)
- ✅ PUT 请求 - JSON 请求体
- ✅ PATCH 请求 - JSON 请求体
### 4. 表单数据请求测试 (`FormDataRequestTests`)
- ✅ POST 请求 - 表单数据
- ✅ POST 请求 - 多值表单参数
- ✅ POST 请求 - 空表单数据
### 5. 请求头处理测试 (`HeaderProcessingTests`)
- ✅ 包含常规请求头
- ✅ 跳过特定请求头
- ✅ 验证跳过的请求头不会出现在 curl 命令中
### 6. 异常情况测试 (`ExceptionHandlingTests`)
- ✅ 读取请求体时发生 IOException
- ✅ 请求参数为 null
- ✅ 请求方法为 null
- ✅ 服务器信息为 null
### 7. 边界用例测试 (`BoundaryTests`)
- ✅ 最小化 GET 请求
- ✅ 复杂查询参数 - 包含特殊字符
- ✅ DELETE 请求 - 不应包含请求体
- ✅ HTTPS 请求 - 标准端口 443
- ✅ JSON 请求体为 null
## 测试覆盖的功能点
### 核心功能验证
1. **HTTP 方法处理**: GET, POST, PUT, PATCH, DELETE
2. **URL 构建**: 协议、主机名、端口、路径、查询参数
3. **请求头处理**: 包含/排除特定请求头
4. **请求体处理**: JSON、表单数据、空请求体
5. **异常处理**: 各种异常情况的优雅处理
### 特殊场景验证
1. **端口处理**: 标准端口省略,非标准端口包含
2. **字符转义**: JSON 中的单引号转义
3. **空值处理**: null 值的安全处理
4. **多值参数**: 表单中同名参数的多个值
## 测试技术特点
### 使用的测试技术
- **JUnit 5**: 现代化的测试框架
- **Mockito**: Mock 对象和行为验证
- **AssertJ**: 流畅的断言 API
- **嵌套测试**: 清晰的测试组织结构
### Mock 策略
- Mock `HttpServletRequest` 对象模拟各种 HTTP 请求场景
- Mock 依赖服务避免外部依赖
- 精确控制测试数据和行为
### 断言策略
- 验证生成的 curl 命令包含预期内容
- 验证不应包含的内容确实被排除
- 验证异常情况的错误消息
## 运行测试
```bash
# 运行所有 generateCurlCommand 相关测试
./gradlew test --tests "com.zl.mjga.unit.LoggingAspectCurlGenerationTest"
# 运行特定测试类别
./gradlew test --tests "com.zl.mjga.unit.LoggingAspectCurlGenerationTest\$GetRequestTests"
```
## 测试价值
这套测试确保了 `generateCurlCommand` 方法在各种复杂场景下都能正确工作,为 AOP 日志功能的 curl 命令生成提供了可靠的质量保证。通过全面的测试覆盖,可以:
1. **防止回归**: 代码修改时及时发现问题
2. **文档作用**: 测试用例本身就是最好的使用文档
3. **重构支持**: 安全地进行代码重构
4. **质量保证**: 确保功能在各种边界条件下正常工作
## 测试结果
所有 **24 个测试用例** 均通过,覆盖了 `generateCurlCommand` 方法的所有主要功能和边界情况。

View File

@@ -4,7 +4,7 @@ CREATE TABLE mjga.user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE,
avatar VARCHAR,
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
password VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT TRUE
);
@@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map (
CREATE TABLE mjga.department (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR NOT NULL UNIQUE,
parent_id BIGINT,
FOREIGN KEY (parent_id)
REFERENCES mjga.department(id)
@@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map (
CREATE TABLE mjga.position (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
name VARCHAR NOT NULL UNIQUE
);
CREATE TABLE mjga.user_position_map (
@@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM (
CREATE TABLE mjga.ai_llm_config (
id BIGSERIAL NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR NOT NULL UNIQUE,
code mjga.llm_code_enum NOT NULL UNIQUE,
model_name VARCHAR(255) NOT NULL,
model_name VARCHAR NOT NULL,
type LLM_TYPE_ENUM NOT NULL,
api_key VARCHAR(255) NOT NULL,
url VARCHAR(255) NOT NULL,
api_key VARCHAR NOT NULL,
url VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true,
priority SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY(id)

View File

@@ -0,0 +1,28 @@
CREATE TABLE mjga.library (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL UNIQUE,
data_count INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE mjga.library_doc (
id BIGSERIAL PRIMARY KEY,
lib_id BIGINT NOT NULL,
name VARCHAR NOT NULL,
identify VARCHAR NOT NULL UNIQUE,
path VARCHAR NOT NULL,
meta JSON NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMPTZ,
FOREIGN KEY (lib_id) REFERENCES mjga.library (id) ON DELETE CASCADE
);
CREATE TABLE mjga.library_doc_segment (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT NOT NULL,
embedding_id VARCHAR NOT NULL UNIQUE,
content TEXT,
token_usage INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (doc_id) REFERENCES mjga.library_doc (id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,22 @@
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,
curl 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);

2
frontend/.gitignore vendored
View File

@@ -186,3 +186,5 @@ compose.yaml
Dockerfile
Caddyfile
start.sh
.cursor

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/java.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>知路后台管理</title>
<title>知路 AI 后台管理</title>
</head>
<body>
<div id="app"></div>

View File

@@ -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 });
}),
];

View File

@@ -0,0 +1,77 @@
import { faker } from "@faker-js/faker";
import { http, HttpResponse } from "msw";
// 生成AOP日志数据
const generateAopLog = () => ({
id: faker.number.int({ min: 1, max: 1000 }),
className: faker.helpers.arrayElement([
"com.example.controller.UserController",
"com.example.service.UserService",
"com.example.controller.RoleController",
"com.example.service.RoleService",
"com.example.controller.DepartmentController",
"com.example.service.DepartmentService",
]),
methodName: faker.helpers.arrayElement([
"findById",
"save",
"update",
"delete",
"findAll",
"findByName",
"pageQuery",
]),
methodArgs: JSON.stringify([
{ name: "id", value: faker.number.int({ min: 1, max: 100 }) },
{ name: "name", value: faker.person.fullName() },
]),
returnValue: JSON.stringify({
id: faker.number.int({ min: 1, max: 100 }),
name: faker.person.fullName(),
success: true,
}),
executionTime: faker.number.int({ min: 10, max: 5000 }),
success: faker.datatype.boolean(0.9), // 90%成功率
errorMessage: faker.helpers.maybe(() => faker.lorem.sentence(), {
probability: 0.1,
}),
userId: faker.number.int({ min: 1, max: 100 }),
username: faker.internet.userName(),
ipAddress: faker.internet.ip(),
userAgent: faker.internet.userAgent(),
curl: `curl -X GET "${faker.internet.url()}" -H "Authorization: Bearer ${faker.string.alphanumeric(32)}"`,
createTime: faker.date.recent({ days: 30 }).toISOString(),
});
export default [
// 分页查询AOP日志
http.get("/aop-log/page-query", () => {
const mockData = {
data: faker.helpers.multiple(generateAopLog, { count: 10 }),
total: 100,
};
return HttpResponse.json(mockData);
}),
// 查询单条日志详情
http.get("/aop-log/:id", ({ params }) => {
const id = params.id;
return HttpResponse.json({
...generateAopLog(),
id: Number(id),
});
}),
// 删除单条日志
http.delete("/aop-log/:id", ({ params }) => {
console.log(`Captured a "DELETE /aop-log/${params.id}" request`);
return HttpResponse.json({ success: true });
}),
// 批量删除日志
http.delete("/aop-log/batch", async ({ request }) => {
const ids = await request.json();
console.log(`Captured a "DELETE /aop-log/batch" request with ids: ${ids}`);
return HttpResponse.json(1);
}),
];

View File

@@ -0,0 +1,97 @@
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");
}),
];

View File

@@ -1,12 +1,15 @@
import { setupWorker } from "msw/browser";
import authHandlers from "./authHandlers";
import jobHandlers from "./schedulerHandlers";
import permissionHandlers from "./permissionHandlers";
import roleHandlers from "./roleHandlers";
import userHandlers from "./iamHandlers";
import departmentHandlers from "./departmentHandlers";
import positionHandlers from "./positionHandlers";
import aiHandlers from "./aiHandlers";
import aopLogHandlers from "./aopLogHandlers";
import authHandlers from "./authHandlers";
import departmentHandlers from "./departmentHandlers";
import userHandlers from "./iamHandlers";
import knowledgeHandlers from "./knowledgeHandlers";
import permissionHandlers from "./permissionHandlers";
import positionHandlers from "./positionHandlers";
import roleHandlers from "./roleHandlers";
import jobHandlers from "./schedulerHandlers";
export const worker = setupWorker(
...userHandlers,
...authHandlers,
@@ -16,4 +19,6 @@ export const worker = setupWorker(
...departmentHandlers,
...positionHandlers,
...aiHandlers,
...knowledgeHandlers,
...aopLogHandlers,
);

Some files were not shown because too many files have changed in this diff Show More