init: 2026Technology-Competition initial commit
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ims</groupId>
|
||||
<artifactId>ims-backend</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>ims-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ims</groupId>
|
||||
<artifactId>ims-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ims</groupId>
|
||||
<artifactId>ims-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.17</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-ollama</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
<version>2.9.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-parser-microsoft-module</artifactId>
|
||||
<version>2.9.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.16.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.ims.api.dto.ai.AiConfigRequest;
|
||||
import com.ims.api.dto.agent.AgentConfigRequest;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class AgentConfigService {
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
|
||||
public AgentConfigService(AiConfigService aiConfigService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
}
|
||||
|
||||
public Map<String, Object> getConfig() {
|
||||
com.ims.api.dto.ai.AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("maxSteps", aiConfigService.getEffectiveMaxSteps());
|
||||
result.put("autoExecuteHighRisk", aiConfigService.getEffectiveAutoExecuteHighRisk());
|
||||
result.put("userRateLimit", aiConfigService.getEffectiveUserRateLimit());
|
||||
result.put("provider", cfg.getProvider());
|
||||
return result;
|
||||
}
|
||||
|
||||
public void updateConfig(AgentConfigRequest request) {
|
||||
AiConfigRequest merged = new AiConfigRequest();
|
||||
merged.setAgentMaxSteps(request.getMaxSteps());
|
||||
merged.setAutoExecuteHighRisk(request.getAutoExecuteHighRisk());
|
||||
merged.setUserRateLimit(request.getUserRateLimit());
|
||||
aiConfigService.updateConfig(merged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.api.dto.agent.AgentConfigRequest;
|
||||
import com.ims.api.dto.agent.AgentConfigResponse;
|
||||
import com.ims.api.service.agent.AgentConfigService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class AgentConfigServiceImpl implements AgentConfigService {
|
||||
|
||||
private static final String REDIS_KEY = "agent:config";
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${agent.max-steps:10}")
|
||||
private int defaultMaxSteps;
|
||||
|
||||
@Value("${agent.auto-execute-high-risk:false}")
|
||||
private boolean defaultAutoExecuteHighRisk;
|
||||
|
||||
public AgentConfigServiceImpl(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentConfigResponse getConfig() {
|
||||
String json = redisTemplate.opsForValue().get(REDIS_KEY);
|
||||
if (json != null) {
|
||||
try {
|
||||
AgentConfigRequest request = objectMapper.readValue(json, AgentConfigRequest.class);
|
||||
return toResponse(request);
|
||||
} catch (JsonProcessingException ignored) {}
|
||||
}
|
||||
AgentConfigResponse response = new AgentConfigResponse();
|
||||
response.setMaxSteps(defaultMaxSteps);
|
||||
response.setAutoExecuteHighRisk(defaultAutoExecuteHighRisk);
|
||||
response.setUserRateLimit(10);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateConfig(AgentConfigRequest request) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(REDIS_KEY, objectMapper.writeValueAsString(request), 24, TimeUnit.HOURS);
|
||||
} catch (JsonProcessingException ignored) {}
|
||||
}
|
||||
|
||||
private AgentConfigResponse toResponse(AgentConfigRequest request) {
|
||||
AgentConfigResponse response = new AgentConfigResponse();
|
||||
response.setMaxSteps(request.getMaxSteps());
|
||||
response.setAutoExecuteHighRisk(request.getAutoExecuteHighRisk());
|
||||
response.setUserRateLimit(request.getUserRateLimit());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
+795
@@ -0,0 +1,795 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ims.api.dto.ai.AiConfigResponse;
|
||||
import com.ims.api.dto.agent.AgentExecuteResponse;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.agent.tool.Tool;
|
||||
import com.ims.service.agent.tool.ToolRegistry;
|
||||
import com.ims.service.ai.ModelRoutingService;
|
||||
import com.ims.service.ai.PromptFormatter;
|
||||
import com.ims.service.ai.PromptTemplateEngine;
|
||||
import com.ims.service.ai.SystemContextBuilder;
|
||||
import com.ims.service.entity.AgentMemory;
|
||||
import com.ims.service.entity.AgentPlan;
|
||||
import com.ims.service.entity.Department;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.PromptTemplate;
|
||||
import com.ims.service.entity.ToolExecution;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import com.ims.service.knowledge.SearchService;
|
||||
import com.ims.service.repository.DepartmentRepository;
|
||||
import com.ims.service.repository.AgentPlanRepository;
|
||||
import com.ims.service.repository.AgentMemoryRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.ToolExecutionRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@Service
|
||||
public class AgentOrchestratorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentOrchestratorService.class);
|
||||
private static final String SYS_ROLE_TEMPLATE = "SYS_ROLE_001";
|
||||
private static final String PLAN_TEMPLATE = "PLAN_001";
|
||||
|
||||
private static final String FIELD_SUGGEST_SYSTEM = "你是一个软件质量指摘分析助手。根据用户提供的指摘标题和描述,"
|
||||
+ "从下面的字段枚举值中为每个字段选择最合适的一项,同时结合上下文补全文本与人员字段,仅输出一个JSON对象,不要输出任何其他文字。\n"
|
||||
+ "category: [功能缺陷, UI/UX问题, 性能问题, 安全漏洞, 文档错误]\n"
|
||||
+ "phase: [需求, 设计, 编码, 测试, 部署, 运维]\n"
|
||||
+ "impactLevel: [高, 中, 低]\n"
|
||||
+ "priority: [urgent, high, medium, low]\n"
|
||||
+ "subProject: [用户管理子系统, 权限控制子系统, 数据报表子系统]\n"
|
||||
+ "ngReason: 指摘的NG原因,用简短中文概括\n"
|
||||
+ "impactScope: 影响范围(如:前端适配、数据库查询)\n"
|
||||
+ "deployment: 部署位置或环境\n"
|
||||
+ "pgmNo: 关联PGM编号(如 PGM_AUTH_VIEW_001),无法判断时留空\n"
|
||||
+ "responseContent: 建议的对应内容/整改方案,2-3句话\n"
|
||||
+ "assigneeName, reviewerName, validatorName: 从可选项中选择对应人员姓名,无法确定时留空\n"
|
||||
+ "departmentName: 从可选项中选择归属部门名称,无法确定时留空\n"
|
||||
+ "deadline: 建议的整改截止日期,格式 YYYY-MM-DD,不早于今天\n"
|
||||
+ "输出格式: {\"category\":\"...\",\"phase\":\"...\",\"impactLevel\":\"...\",\"priority\":\"...\",\"subProject\":\"...\","
|
||||
+ "\"ngReason\":\"...\",\"impactScope\":\"...\",\"deployment\":\"...\",\"pgmNo\":\"...\",\"responseContent\":\"...\","
|
||||
+ "\"assigneeName\":\"...\",\"reviewerName\":\"...\",\"validatorName\":\"...\",\"departmentName\":\"...\",\"deadline\":\"YYYY-MM-DD\"}";
|
||||
|
||||
private static final String FIELD_SUGGEST_WITH_CASES_SYSTEM = "你是一个软件质量指摘分析助手。用户提供了指摘标题和描述,"
|
||||
+ "同时附上了知识库检索到的相似案例。请先分析相似案例的处理经验,再结合当前指摘标题和描述,"
|
||||
+ "从下面的字段枚举值中为每个字段选择最合适的一项,同时结合上下文补全文本与人员字段,仅输出一个JSON对象,不要输出任何其他文字。\n"
|
||||
+ "category: [功能缺陷, UI/UX问题, 性能问题, 安全漏洞, 文档错误]\n"
|
||||
+ "phase: [需求, 设计, 编码, 测试, 部署, 运维]\n"
|
||||
+ "impactLevel: [高, 中, 低]\n"
|
||||
+ "priority: [urgent, high, medium, low]\n"
|
||||
+ "subProject: [用户管理子系统, 权限控制子系统, 数据报表子系统]\n"
|
||||
+ "ngReason: 指摘的NG原因,用简短中文概括\n"
|
||||
+ "impactScope: 影响范围(如:前端适配、数据库查询)\n"
|
||||
+ "deployment: 部署位置或环境\n"
|
||||
+ "pgmNo: 关联PGM编号(如 PGM_AUTH_VIEW_001),无法判断时留空\n"
|
||||
+ "responseContent: 建议的对应内容/整改方案,2-3句话\n"
|
||||
+ "assigneeName, reviewerName, validatorName: 从可选项中选择对应人员姓名,无法确定时留空\n"
|
||||
+ "departmentName: 从可选项中选择归属部门名称,无法确定时留空\n"
|
||||
+ "deadline: 建议的整改截止日期,格式 YYYY-MM-DD,不早于今天\n"
|
||||
+ "输出格式: {\"category\":\"...\",\"phase\":\"...\",\"impactLevel\":\"...\",\"priority\":\"...\",\"subProject\":\"...\","
|
||||
+ "\"ngReason\":\"...\",\"impactScope\":\"...\",\"deployment\":\"...\",\"pgmNo\":\"...\",\"responseContent\":\"...\","
|
||||
+ "\"assigneeName\":\"...\",\"reviewerName\":\"...\",\"validatorName\":\"...\",\"departmentName\":\"...\",\"deadline\":\"YYYY-MM-DD\"}";
|
||||
|
||||
private static final Set<String> CATEGORIES = Set.of("功能缺陷", "UI/UX问题", "性能问题", "安全漏洞", "文档错误");
|
||||
private static final Set<String> PHASES = Set.of("需求", "设计", "编码", "测试", "部署", "运维");
|
||||
private static final Set<String> IMPACT_LEVELS = Set.of("高", "中", "低");
|
||||
private static final Set<String> PRIORITIES = Set.of("urgent", "high", "medium", "low");
|
||||
private static final Set<String> SUB_PROJECTS = Set.of("用户管理子系统", "权限控制子系统", "数据报表子系统");
|
||||
|
||||
private final AgentPlanRepository planRepository;
|
||||
private final ToolExecutionRepository toolExecutionRepository;
|
||||
private final IssueRepository issueRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ToolRegistry toolRegistry;
|
||||
private final PromptTemplateEngine promptEngine;
|
||||
private final ModelRoutingService modelRoutingService;
|
||||
private final SystemContextBuilder systemContextBuilder;
|
||||
private final AiConfigService aiConfigService;
|
||||
private final MemoryService memoryService;
|
||||
private final PromptFormatter promptFormatter;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
private final SearchService searchService;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(2, r -> {
|
||||
Thread t = new Thread(r, "async-exec-");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private final ConcurrentHashMap<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
|
||||
public AgentOrchestratorService(AgentPlanRepository planRepository,
|
||||
ToolExecutionRepository toolExecutionRepository,
|
||||
IssueRepository issueRepository,
|
||||
UserRepository userRepository,
|
||||
ToolRegistry toolRegistry,
|
||||
PromptTemplateEngine promptEngine,
|
||||
ModelRoutingService modelRoutingService,
|
||||
SystemContextBuilder systemContextBuilder,
|
||||
AiConfigService aiConfigService,
|
||||
MemoryService memoryService,
|
||||
PromptFormatter promptFormatter,
|
||||
ObjectMapper objectMapper,
|
||||
StringRedisTemplate redisTemplate,
|
||||
TransactionTemplate transactionTemplate,
|
||||
SearchService searchService,
|
||||
DepartmentRepository departmentRepository) {
|
||||
this.planRepository = planRepository;
|
||||
this.toolExecutionRepository = toolExecutionRepository;
|
||||
this.issueRepository = issueRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.promptEngine = promptEngine;
|
||||
this.modelRoutingService = modelRoutingService;
|
||||
this.systemContextBuilder = systemContextBuilder;
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.memoryService = memoryService;
|
||||
this.promptFormatter = promptFormatter;
|
||||
this.objectMapper = objectMapper;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
this.searchService = searchService;
|
||||
this.departmentRepository = departmentRepository;
|
||||
}
|
||||
|
||||
public AgentExecuteResponse execute(Long issueId, String goal, Long userId) {
|
||||
checkRateLimit(userId);
|
||||
Issue issue = null;
|
||||
if (issueId != null) {
|
||||
issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在: " + issueId));
|
||||
}
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
|
||||
AgentPlan plan = new AgentPlan();
|
||||
plan.setIssue(issue);
|
||||
plan.setGoal(goal);
|
||||
plan.setPlanSteps("[]");
|
||||
plan.setStatus("pending");
|
||||
plan.setRequiresApproval(false);
|
||||
plan.setModelProvider(modelRoutingService.currentProvider());
|
||||
plan.setCreatedBy(user);
|
||||
planRepository.save(plan);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(s -> runPlan(plan.getId(), userId));
|
||||
} catch (Exception e) {
|
||||
log.error("Agent执行失败 planId={}", plan.getId(), e);
|
||||
}
|
||||
});
|
||||
return new AgentExecuteResponse(plan.getId(), "running", false);
|
||||
}
|
||||
|
||||
public Map<String, Object> getStatus(Long planId) {
|
||||
AgentPlan plan = planRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("Agent计划不存在: " + planId));
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("planId", plan.getId());
|
||||
result.put("issueId", plan.getIssue() != null ? plan.getIssue().getId() : null);
|
||||
result.put("goal", plan.getGoal());
|
||||
result.put("planSteps", plan.getPlanSteps());
|
||||
result.put("status", plan.getStatus());
|
||||
result.put("requiresApproval", plan.getRequiresApproval());
|
||||
result.put("approvalStatus", plan.getApprovalStatus());
|
||||
result.put("approvalComment", plan.getApprovalComment());
|
||||
result.put("modelProvider", plan.getModelProvider());
|
||||
result.put("agentMessage", plan.getAgentMessage());
|
||||
result.put("createdAt", plan.getCreatedAt() == null ? null : plan.getCreatedAt().toString());
|
||||
result.put("completedAt", plan.getCompletedAt() == null ? null : plan.getCompletedAt().toString());
|
||||
|
||||
result.put("systemTemplateId", SYS_ROLE_TEMPLATE);
|
||||
result.put("systemTemplateVersion", templateVersion(SYS_ROLE_TEMPLATE));
|
||||
result.put("planTemplateId", PLAN_TEMPLATE);
|
||||
result.put("planTemplateVersion", templateVersion(PLAN_TEMPLATE));
|
||||
|
||||
List<ToolExecution> executions = toolExecutionRepository.findByPlanId(planId);
|
||||
result.put("toolExecutions", executions.stream().map(this::toolExecutionToMap).toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
public SseEmitter stream(Long planId) {
|
||||
AgentPlan plan = planRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("Agent计划不存在: " + planId));
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
emitters.put(planId, emitter);
|
||||
emitter.onCompletion(() -> emitters.remove(planId));
|
||||
emitter.onTimeout(() -> emitters.remove(planId));
|
||||
emitter.onError(e -> emitters.remove(planId));
|
||||
|
||||
sendEvent(planId, Map.of("type", "thought", "content", "已连接执行流,等待 Agent 输出…"));
|
||||
sendPromptAndModelInfo(planId);
|
||||
|
||||
String status = plan.getStatus();
|
||||
if (!"pending".equals(status) && !"running".equals(status)) {
|
||||
String message = plan.getAgentMessage() == null ? "" : plan.getAgentMessage();
|
||||
if (!"awaiting_approval".equals(status)) {
|
||||
sendEvent(planId, Map.of("type", "result", "content", message));
|
||||
}
|
||||
emitter.complete();
|
||||
emitters.remove(planId);
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private void sendEvent(Long planId, Map<String, Object> event) {
|
||||
SseEmitter emitter = emitters.get(planId);
|
||||
if (emitter == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("message").data(event));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
emitters.remove(planId);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeStream(Long planId) {
|
||||
SseEmitter emitter = emitters.remove(planId);
|
||||
if (emitter != null) {
|
||||
try {
|
||||
emitter.complete();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendPromptAndModelInfo(Long planId) {
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
String provider = modelRoutingService.currentProvider();
|
||||
String model = "deepseek".equalsIgnoreCase(provider) ? cfg.getDeepseekModel() : cfg.getOllamaChatModel();
|
||||
|
||||
Map<String, Object> systemPromptInfo = new LinkedHashMap<>();
|
||||
systemPromptInfo.put("type", "prompt_info");
|
||||
systemPromptInfo.put("templateId", SYS_ROLE_TEMPLATE);
|
||||
systemPromptInfo.put("version", templateVersion(SYS_ROLE_TEMPLATE));
|
||||
systemPromptInfo.put("variables", List.of("userGoal", "issueContext", "availableTools"));
|
||||
sendEvent(planId, systemPromptInfo);
|
||||
|
||||
Map<String, Object> planPromptInfo = new LinkedHashMap<>();
|
||||
planPromptInfo.put("type", "prompt_info");
|
||||
planPromptInfo.put("templateId", PLAN_TEMPLATE);
|
||||
planPromptInfo.put("version", templateVersion(PLAN_TEMPLATE));
|
||||
planPromptInfo.put("variables", List.of("userGoal", "issueContext", "similarCases", "availableTools"));
|
||||
sendEvent(planId, planPromptInfo);
|
||||
|
||||
Map<String, Object> modelInfo = new LinkedHashMap<>();
|
||||
modelInfo.put("type", "model_info");
|
||||
modelInfo.put("provider", provider);
|
||||
modelInfo.put("model", model);
|
||||
modelInfo.put("endpoint", cfg.getOllamaBaseUrl());
|
||||
sendEvent(planId, modelInfo);
|
||||
}
|
||||
|
||||
public Map<String, Object> approve(Long planId, String comment, Long userId) {
|
||||
AgentPlan plan = planRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("Agent计划不存在: " + planId));
|
||||
if (!"requested".equals(plan.getApprovalStatus())) {
|
||||
throw new BusinessException("该计划不在待审批状态");
|
||||
}
|
||||
plan.setApprovalStatus("approved");
|
||||
plan.setApprovalComment(comment);
|
||||
|
||||
ToolExecution pending = toolExecutionRepository.findByPlanId(planId).stream()
|
||||
.filter(t -> "pending".equals(t.getStatus()))
|
||||
.findFirst().orElse(null);
|
||||
if (pending != null) {
|
||||
try {
|
||||
Map<String, Object> params = objectMapper.readValue(pending.getInputParams(),
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
if ("update_issue".equals(pending.getToolName()) && plan.getIssue() != null) {
|
||||
params.put("issue_id", plan.getIssue().getId());
|
||||
}
|
||||
Object output = runTool(plan, pending.getToolName(), params, pending);
|
||||
if ("create_issue".equals(pending.getToolName())) {
|
||||
linkCreatedIssue(plan, output);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("审批后工具执行失败 planId={}", planId, e);
|
||||
pending.setStatus("failed");
|
||||
pending.setOutputResult(e.getMessage());
|
||||
toolExecutionRepository.save(pending);
|
||||
finishPlan(plan, "failed");
|
||||
completeStream(planId);
|
||||
return getStatus(planId);
|
||||
}
|
||||
}
|
||||
finishPlan(plan, "completed");
|
||||
completeStream(planId);
|
||||
return getStatus(planId);
|
||||
}
|
||||
|
||||
public Map<String, Object> reject(Long planId, String comment) {
|
||||
AgentPlan plan = planRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("Agent计划不存在: " + planId));
|
||||
if (!"requested".equals(plan.getApprovalStatus())) {
|
||||
throw new BusinessException("该计划不在待审批状态");
|
||||
}
|
||||
plan.setApprovalStatus("rejected");
|
||||
plan.setApprovalComment(comment);
|
||||
plan.setStatus("rejected");
|
||||
plan.setCompletedAt(LocalDateTime.now());
|
||||
planRepository.save(plan);
|
||||
|
||||
List<ToolExecution> pending = toolExecutionRepository.findByPlanId(planId).stream()
|
||||
.filter(t -> "pending".equals(t.getStatus())).toList();
|
||||
for (ToolExecution t : pending) {
|
||||
t.setStatus("rejected");
|
||||
toolExecutionRepository.save(t);
|
||||
}
|
||||
|
||||
if (plan.getIssue() != null) {
|
||||
issueRepository.findById(plan.getIssue().getId()).ifPresent(issue -> {
|
||||
issue.setAgentStatus("rejected");
|
||||
issue.setAgentLastPlanId(planId);
|
||||
issueRepository.save(issue);
|
||||
});
|
||||
}
|
||||
return getStatus(planId);
|
||||
}
|
||||
|
||||
public String suggest(Long issueId, String goal, Long userId) {
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在: " + issueId));
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
Map<String, Object> systemVars = systemContextBuilder.build(user, toolDescriptions());
|
||||
String systemPrompt = promptEngine.render(SYS_ROLE_TEMPLATE, systemVars);
|
||||
String userPrompt = "请基于以下指摘上下文,输出字段修改建议(如标题/描述/工程阶段/优先级/影响度等字段的修改建议),仅输出建议内容,不要调用任何工具。\n\n"
|
||||
+ buildIssueContext(issue) + "\n\n用户诉求: " + goal;
|
||||
ModelRoutingService.CallResult result = modelRoutingService.call(systemPrompt, userPrompt);
|
||||
return result.text();
|
||||
}
|
||||
|
||||
public Map<String, Object> suggestFields(String title, String description) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
List<User> users = userRepository.findAll();
|
||||
List<Department> departments = departmentRepository.findAll();
|
||||
String userOptions = users.stream()
|
||||
.map(u -> u.getUsername())
|
||||
.reduce((a, b) -> a + ", " + b).orElse("");
|
||||
String deptOptions = departments.stream()
|
||||
.map(Department::getName)
|
||||
.reduce((a, b) -> a + ", " + b).orElse("");
|
||||
StringBuilder userPrompt = new StringBuilder("指摘标题: ").append(title);
|
||||
if (description != null && !description.isBlank()) {
|
||||
userPrompt.append("\n指摘描述: ").append(description);
|
||||
}
|
||||
userPrompt.append("\n可选人员: ").append(userOptions)
|
||||
.append("\n可选部门: ").append(deptOptions);
|
||||
List<SearchService.SearchResult> similarCases = retrieveSimilarCases(title + (description != null ? " " + description : ""));
|
||||
String system = FIELD_SUGGEST_SYSTEM;
|
||||
if (!similarCases.isEmpty()) {
|
||||
system = FIELD_SUGGEST_WITH_CASES_SYSTEM;
|
||||
userPrompt.append("\n\n知识库相似案例(仅作参考):\n").append(similarCases.stream()
|
||||
.map(c -> String.format("《%s》(相似度 %.2f): %s", c.getDocName(), c.getScore(), c.getContent()))
|
||||
.reduce((a, b) -> a + "\n" + b)
|
||||
.orElse(""));
|
||||
}
|
||||
try {
|
||||
ModelRoutingService.CallResult callResult = modelRoutingService.call(system, userPrompt.toString());
|
||||
ObjectNode json = promptFormatter.extractJsonObject(callResult.text());
|
||||
if (json == null) {
|
||||
return result;
|
||||
}
|
||||
putIfValid(result, json, "category", CATEGORIES);
|
||||
putIfValid(result, json, "phase", PHASES);
|
||||
putIfValid(result, json, "impactLevel", IMPACT_LEVELS);
|
||||
putIfValid(result, json, "priority", PRIORITIES);
|
||||
putIfValid(result, json, "subProject", SUB_PROJECTS);
|
||||
putIfText(result, json, "ngReason", 200);
|
||||
putIfText(result, json, "impactScope", 200);
|
||||
putIfText(result, json, "deployment", 200);
|
||||
putIfText(result, json, "pgmNo", 100);
|
||||
putIfText(result, json, "responseContent", 1000);
|
||||
putIfUser(result, json, "assigneeName", "assigneeId", users);
|
||||
putIfUser(result, json, "reviewerName", "reviewerId", users);
|
||||
putIfUser(result, json, "validatorName", "validatorId", users);
|
||||
putIfDepartment(result, json, "departmentName", "departmentId", departments);
|
||||
putIfDate(result, json, "deadline");
|
||||
} catch (Exception e) {
|
||||
log.warn("字段智能填充失败 title={}", title, e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void putIfText(Map<String, Object> out, ObjectNode node, String key, int maxLen) {
|
||||
JsonNode value = node.get(key);
|
||||
if (value != null && value.isTextual() && !value.asText().isBlank()) {
|
||||
String text = value.asText().trim();
|
||||
out.put(key, text.length() > maxLen ? text.substring(0, maxLen) : text);
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfUser(Map<String, Object> out, ObjectNode node, String nameKey, String idKey, List<User> users) {
|
||||
JsonNode value = node.get(nameKey);
|
||||
if (value == null || !value.isTextual()) {
|
||||
return;
|
||||
}
|
||||
String name = value.asText().trim();
|
||||
users.stream()
|
||||
.filter(u -> u.getUsername().equals(name))
|
||||
.findFirst()
|
||||
.ifPresent(u -> out.put(idKey, u.getId()));
|
||||
}
|
||||
|
||||
private void putIfDepartment(Map<String, Object> out, ObjectNode node, String nameKey, String idKey, List<Department> departments) {
|
||||
JsonNode value = node.get(nameKey);
|
||||
if (value == null || !value.isTextual()) {
|
||||
return;
|
||||
}
|
||||
String name = value.asText().trim();
|
||||
departments.stream()
|
||||
.filter(d -> d.getName().equals(name))
|
||||
.findFirst()
|
||||
.ifPresent(d -> out.put(idKey, d.getId()));
|
||||
}
|
||||
|
||||
private void putIfDate(Map<String, Object> out, ObjectNode node, String key) {
|
||||
JsonNode value = node.get(key);
|
||||
if (value == null || !value.isTextual()) {
|
||||
return;
|
||||
}
|
||||
String text = value.asText().trim();
|
||||
try {
|
||||
LocalDate date = LocalDate.parse(text);
|
||||
if (!date.isBefore(LocalDate.now())) {
|
||||
out.put(key, text);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static final double SIMILARITY_THRESHOLD = 0.0;
|
||||
|
||||
private List<SearchService.SearchResult> retrieveSimilarCases(String query) {
|
||||
try {
|
||||
List<SearchService.SearchResult> hits = searchService.search(query, 3).stream()
|
||||
.filter(r -> r.getScore() >= SIMILARITY_THRESHOLD)
|
||||
.toList();
|
||||
log.info("字段智能填充相似案例检索: query={} hits={}", query, hits.size());
|
||||
return hits;
|
||||
} catch (Exception e) {
|
||||
log.warn("知识库相似案例检索失败,回退直接分析 query={}", query, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfValid(Map<String, Object> out, ObjectNode node, String key, Set<String> allowed) {
|
||||
JsonNode value = node.get(key);
|
||||
if (value != null && value.isTextual()) {
|
||||
String text = value.asText().trim();
|
||||
if (allowed.contains(text)) {
|
||||
out.put(key, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void runPlan(Long planId, Long userId) {
|
||||
AgentPlan plan = planRepository.findById(planId).orElse(null);
|
||||
if (plan == null) {
|
||||
return;
|
||||
}
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
plan.setStatus("running");
|
||||
planRepository.save(plan);
|
||||
sendEvent(planId, Map.of("type", "thought", "content", "正在加载指摘上下文并生成处理方案…"));
|
||||
|
||||
Issue issue = null;
|
||||
if (plan.getIssue() != null) {
|
||||
issue = issueRepository.findById(plan.getIssue().getId()).orElse(null);
|
||||
}
|
||||
if (issue != null) {
|
||||
issue.setAgentStatus("running");
|
||||
issue.setAgentLastPlanId(planId);
|
||||
issueRepository.save(issue);
|
||||
}
|
||||
|
||||
boolean autoExecuteHighRisk = aiConfigService.getEffectiveAutoExecuteHighRisk();
|
||||
String similarCases = buildSimilarCases(issue);
|
||||
List<String> executedSteps = new ArrayList<>();
|
||||
|
||||
try {
|
||||
Map<String, Object> systemVars = systemContextBuilder.build(user, toolDescriptions());
|
||||
String systemPrompt = promptEngine.render(SYS_ROLE_TEMPLATE, systemVars);
|
||||
|
||||
Map<String, Object> planVars = buildPlanVariables(issue, plan.getGoal(),
|
||||
similarCases, executedSteps, 1, 1);
|
||||
String userPrompt = promptEngine.render(PLAN_TEMPLATE, planVars);
|
||||
|
||||
sendPromptAndModelInfo(planId);
|
||||
|
||||
ModelRoutingService.ToolCallResult result = modelRoutingService.callWithTools(systemPrompt, userPrompt);
|
||||
if (result.toolCalls().isEmpty()) {
|
||||
plan.setAgentMessage(result.text());
|
||||
sendEvent(planId, Map.of("type", "result",
|
||||
"content", result.text() == null ? "方案已生成" : result.text()));
|
||||
finishPlan(plan, "completed", executedSteps, issue);
|
||||
completeStream(planId);
|
||||
return;
|
||||
}
|
||||
for (ModelRoutingService.ToolCallInfo info : result.toolCalls()) {
|
||||
String toolName = info.name();
|
||||
Map<String, Object> parameters = parseArguments(info.arguments());
|
||||
boolean isWrite = toolRegistry.isWrite(toolName);
|
||||
if (isWrite && !autoExecuteHighRisk) {
|
||||
sendEvent(planId, Map.of("type", "action", "tool", toolName, "params", parameters, "approval", true));
|
||||
sendEvent(planId, Map.of("type", "observation", "result", "写操作需人工审批,等待审批…"));
|
||||
plan.setRequiresApproval(true);
|
||||
plan.setApprovalStatus("requested");
|
||||
plan.setStatus("awaiting_approval");
|
||||
plan.setPlanSteps(toJson(executedSteps));
|
||||
plan.setAgentMessage("等待人工审批: 调用 " + toolName);
|
||||
planRepository.save(plan);
|
||||
|
||||
ToolExecution pending = createToolExecution(plan, toolName, parameters, "pending");
|
||||
pending.setOutputResult("等待人工审批");
|
||||
toolExecutionRepository.save(pending);
|
||||
|
||||
if (issue != null) {
|
||||
issue.setAgentStatus("awaiting_approval");
|
||||
issueRepository.save(issue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendEvent(planId, Map.of("type", "action", "tool", toolName, "params", parameters));
|
||||
Object output = runTool(plan, toolName, parameters, null);
|
||||
sendEvent(planId, Map.of("type", "observation", "result", truncate(toDisplay(output))));
|
||||
executedSteps.add((executedSteps.size() + 1) + ". " + toolName + " → " + truncate(toJson(parameters)));
|
||||
plan.setPlanSteps(toJson(executedSteps));
|
||||
planRepository.save(plan);
|
||||
if ("create_issue".equals(toolName)) {
|
||||
linkCreatedIssue(plan, output);
|
||||
}
|
||||
}
|
||||
plan.setAgentMessage(result.text());
|
||||
sendEvent(planId, Map.of("type", "result",
|
||||
"content", result.text() == null ? "方案已生成" : result.text()));
|
||||
finishPlan(plan, "completed", executedSteps, issue);
|
||||
completeStream(planId);
|
||||
} catch (Exception e) {
|
||||
log.error("Agent执行失败 planId={}", planId, e);
|
||||
sendEvent(planId, Map.of("type", "error", "message", e.getMessage()));
|
||||
plan.setStatus("failed");
|
||||
plan.setAgentMessage(e.getMessage());
|
||||
planRepository.save(plan);
|
||||
if (issue != null) {
|
||||
issue.setAgentStatus("failed");
|
||||
issueRepository.save(issue);
|
||||
}
|
||||
completeStream(planId);
|
||||
}
|
||||
}
|
||||
|
||||
private Object runTool(AgentPlan plan, String toolName, Map<String, Object> parameters, ToolExecution existing) {
|
||||
long start = System.currentTimeMillis();
|
||||
ToolExecution record = existing;
|
||||
if (record == null) {
|
||||
record = createToolExecution(plan, toolName, parameters, "running");
|
||||
} else {
|
||||
record.setStatus("running");
|
||||
}
|
||||
toolExecutionRepository.save(record);
|
||||
try {
|
||||
Tool tool = toolRegistry.get(toolName);
|
||||
Object output = tool.execute(parameters);
|
||||
record.setStatus("success");
|
||||
record.setOutputResult(truncate(toDisplay(output)));
|
||||
record.setExecutionTimeMs(System.currentTimeMillis() - start);
|
||||
toolExecutionRepository.save(record);
|
||||
return output;
|
||||
} catch (Exception e) {
|
||||
record.setStatus("failed");
|
||||
record.setOutputResult(truncate(e.getMessage()));
|
||||
record.setExecutionTimeMs(System.currentTimeMillis() - start);
|
||||
toolExecutionRepository.save(record);
|
||||
throw new BusinessException("工具 " + toolName + " 执行失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void linkCreatedIssue(AgentPlan plan, Object output) {
|
||||
try {
|
||||
if (output instanceof Map<?, ?> map && map.get("id") instanceof Number number) {
|
||||
issueRepository.findById(number.longValue()).ifPresent(newIssue -> {
|
||||
plan.setIssue(newIssue);
|
||||
planRepository.save(plan);
|
||||
});
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("关联新建指摘到Agent计划失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private ToolExecution createToolExecution(AgentPlan plan, String toolName,
|
||||
Map<String, Object> parameters, String status) {
|
||||
ToolExecution record = new ToolExecution();
|
||||
record.setPlan(plan);
|
||||
record.setToolName(toolName);
|
||||
record.setInputParams(toJson(parameters));
|
||||
record.setStatus(status);
|
||||
return record;
|
||||
}
|
||||
|
||||
private void finishPlan(AgentPlan plan, String status) {
|
||||
finishPlan(plan, status, new ArrayList<>(), null);
|
||||
}
|
||||
|
||||
private void finishPlan(AgentPlan plan, String status, List<String> executedSteps, Issue issue) {
|
||||
plan.setStatus(status);
|
||||
plan.setCompletedAt(LocalDateTime.now());
|
||||
if (!executedSteps.isEmpty()) {
|
||||
plan.setPlanSteps(toJson(executedSteps));
|
||||
}
|
||||
planRepository.save(plan);
|
||||
|
||||
saveMemory(plan);
|
||||
if (issue == null) {
|
||||
if (plan.getIssue() != null) {
|
||||
issueRepository.findById(plan.getIssue().getId()).ifPresent(i -> {
|
||||
i.setAgentStatus(status);
|
||||
i.setAgentLastPlanId(plan.getId());
|
||||
issueRepository.save(i);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
issue.setAgentStatus(status);
|
||||
issueRepository.save(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveMemory(AgentPlan plan) {
|
||||
try {
|
||||
String summary = plan.getGoal();
|
||||
String steps = plan.getPlanSteps();
|
||||
AgentMemory memory = memoryService.add(summary, steps);
|
||||
if (memory != null) {
|
||||
log.info("已保存Agent记忆 memoryId={}", memory.getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("保存Agent记忆失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseArguments(String arguments) {
|
||||
if (arguments == null || arguments.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(arguments, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPlanVariables(Issue issue, String goal, String similarCases,
|
||||
List<String> executedSteps, int step, int maxSteps) {
|
||||
Map<String, Object> vars = new LinkedHashMap<>();
|
||||
vars.put("userGoal", goal);
|
||||
vars.put("issueContext", buildIssueContext(issue));
|
||||
vars.put("similarCases", similarCases);
|
||||
vars.put("availableTools", toolDescriptions());
|
||||
vars.put("executedSteps", executedSteps);
|
||||
vars.put("currentStep", step);
|
||||
vars.put("maxSteps", maxSteps);
|
||||
return vars;
|
||||
}
|
||||
|
||||
private String buildIssueContext(Issue issue) {
|
||||
if (issue == null) {
|
||||
return "(新建指摘场景,指摘尚未创建)";
|
||||
}
|
||||
return "指摘编号: " + issue.getIssueNo() + "\n标题: " + issue.getTitle()
|
||||
+ "\n描述: " + (issue.getDescription() == null ? "" : issue.getDescription())
|
||||
+ "\n状态: " + issue.getStatus() + ", 优先级: " + issue.getPriority()
|
||||
+ "\n阶段: " + (issue.getPhase() == null ? "" : issue.getPhase());
|
||||
}
|
||||
|
||||
private String buildSimilarCases(Issue issue) {
|
||||
if (issue == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
List<AgentMemory> memories = memoryService.searchSimilar(issue.getTitle(), 3);
|
||||
if (memories.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < memories.size(); i++) {
|
||||
AgentMemory m = memories.get(i);
|
||||
sb.append(i + 1).append(". ").append(m.getIssueSummary())
|
||||
.append(" | 步骤: ").append(m.getSolutionSteps()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> toolDescriptions() {
|
||||
List<String> list = new ArrayList<>();
|
||||
toolRegistry.descriptions().forEach((name, desc) -> list.add(name + " - " + desc));
|
||||
return list;
|
||||
}
|
||||
|
||||
private String templateVersion(String templateId) {
|
||||
try {
|
||||
PromptTemplate template = promptEngine.loadActive(templateId);
|
||||
return template.getVersion() == null ? "" : String.valueOf(template.getVersion());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void checkRateLimit(Long userId) {
|
||||
String minuteKey = "agent:limit:" + userId + ":" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
|
||||
Long count = redisTemplate.opsForValue().increment(minuteKey);
|
||||
if (count != null && count == 1) {
|
||||
redisTemplate.expire(minuteKey, java.time.Duration.ofSeconds(60));
|
||||
}
|
||||
int limit = aiConfigService.getEffectiveUserRateLimit();
|
||||
if (count != null && count > limit) {
|
||||
throw new BusinessException("Agent执行过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toolExecutionToMap(ToolExecution t) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", t.getId());
|
||||
m.put("toolName", t.getToolName());
|
||||
m.put("inputParams", t.getInputParams());
|
||||
m.put("outputResult", t.getOutputResult());
|
||||
m.put("status", t.getStatus());
|
||||
m.put("executionTimeMs", t.getExecutionTimeMs());
|
||||
m.put("createdAt", t.getCreatedAt() == null ? null : t.getCreatedAt().toString());
|
||||
return m;
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String toDisplay(Object value) {
|
||||
if (value instanceof String) {
|
||||
return (String) value;
|
||||
}
|
||||
return toJson(value);
|
||||
}
|
||||
|
||||
private String truncate(String text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return text.length() > 3000 ? text.substring(0, 3000) : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.AgentPlan;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.ToolExecution;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.knowledge.SearchService;
|
||||
import com.ims.service.notification.NotificationService;
|
||||
import com.ims.service.repository.AgentPlanRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.ToolExecutionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class AgentService {
|
||||
|
||||
private final AgentPlanRepository agentPlanRepository;
|
||||
private final ToolExecutionRepository toolExecutionRepository;
|
||||
private final IssueRepository issueRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final SearchService searchService;
|
||||
private final OllamaChatService ollamaChatService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AgentService(AgentPlanRepository agentPlanRepository,
|
||||
ToolExecutionRepository toolExecutionRepository,
|
||||
IssueRepository issueRepository,
|
||||
NotificationService notificationService,
|
||||
SearchService searchService,
|
||||
OllamaChatService ollamaChatService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.agentPlanRepository = agentPlanRepository;
|
||||
this.toolExecutionRepository = toolExecutionRepository;
|
||||
this.issueRepository = issueRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.searchService = searchService;
|
||||
this.ollamaChatService = ollamaChatService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> execute(Long issueId, String goal, User user) {
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.filter(i -> !Boolean.TRUE.equals(i.getIsDeleted()))
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在"));
|
||||
|
||||
long searchStart = System.currentTimeMillis();
|
||||
List<SearchService.SearchResult> hits;
|
||||
try {
|
||||
hits = searchService.search(goal, 3);
|
||||
} catch (Exception e) {
|
||||
hits = new ArrayList<>();
|
||||
}
|
||||
long searchMs = System.currentTimeMillis() - searchStart;
|
||||
|
||||
String searchOutput = hits.isEmpty()
|
||||
? "知识库暂无匹配的相似案例"
|
||||
: "知识库命中 " + hits.size() + " 条相似案例:" + hits.stream()
|
||||
.map(h -> String.format("《%s》(相似度 %.2f): %s", h.getDocName(), h.getScore(), truncate(h.getContent(), 60)))
|
||||
.reduce((a, b) -> a + ";" + b)
|
||||
.orElse("");
|
||||
|
||||
String planText;
|
||||
long genMs = 0;
|
||||
String genOutput;
|
||||
boolean genOk = true;
|
||||
if (!hits.isEmpty()) {
|
||||
String system = "你是一个指摘管理系统(IMS)的智能助理。根据用户指令和知识库检索到的相似案例,生成具体、可执行的对应方案。" +
|
||||
"请按以下结构输出:\n1. 问题分析\n2. 处理步骤(分点列出)\n3. 验证方式\n语言简洁专业。";
|
||||
String userContent = "知识库相似案例:\n" + hits.stream()
|
||||
.map(h -> "【案例 " + h.getDocName() + "】" + h.getContent())
|
||||
.reduce((a, b) -> a + "\n" + b).orElse("")
|
||||
+ "\n\n用户指令:" + goal;
|
||||
try {
|
||||
long genStart = System.currentTimeMillis();
|
||||
planText = ollamaChatService.chat(system, userContent);
|
||||
genMs = System.currentTimeMillis() - genStart;
|
||||
genOutput = truncate(planText, 300);
|
||||
} catch (Exception e) {
|
||||
genOk = false;
|
||||
planText = "知识库检索完成,但方案生成失败(Ollama 暂不可用),已保留检索到的相似案例供参考。";
|
||||
genOutput = planText;
|
||||
}
|
||||
} else {
|
||||
planText = "知识库未检索到相似案例,建议人工分析后处理。";
|
||||
genOutput = planText;
|
||||
}
|
||||
|
||||
AgentPlan plan = AgentPlan.builder()
|
||||
.issue(issue)
|
||||
.goal(goal)
|
||||
.planSteps(planSteps(planText))
|
||||
.status("pending")
|
||||
.requiresApproval(true)
|
||||
.approvalStatus("pending")
|
||||
.createdBy(user)
|
||||
.modelProvider("ollama/" + ollamaChatService.getModel())
|
||||
.build();
|
||||
plan = agentPlanRepository.save(plan);
|
||||
|
||||
saveTool(plan, "search_knowledge",
|
||||
"{\"query\":\"" + safeJson(goal) + "\"}",
|
||||
searchOutput, searchMs, "success");
|
||||
saveTool(plan, "generate_response",
|
||||
"{\"goal\":\"" + safeJson(goal) + "\"}",
|
||||
genOutput, genMs, genOk ? "success" : "failed");
|
||||
|
||||
issue.setAgentStatus("awaiting_approval");
|
||||
issue.setAgentLastPlanId(plan.getId());
|
||||
issueRepository.save(issue);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("planId", plan.getId());
|
||||
result.put("status", plan.getStatus());
|
||||
result.put("approvalStatus", plan.getApprovalStatus());
|
||||
result.put("requiresApproval", plan.getRequiresApproval());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Object> status(Long planId) {
|
||||
AgentPlan plan = agentPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("计划不存在"));
|
||||
List<ToolExecution> tools = toolExecutionRepository.findByPlanId(planId);
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("planId", plan.getId());
|
||||
map.put("goal", plan.getGoal());
|
||||
map.put("status", plan.getStatus());
|
||||
map.put("approvalStatus", plan.getApprovalStatus());
|
||||
map.put("requiresApproval", plan.getRequiresApproval());
|
||||
map.put("createdAt", plan.getCreatedAt());
|
||||
map.put("tools", tools.stream().map(t -> {
|
||||
Map<String, Object> tm = new HashMap<>();
|
||||
tm.put("toolName", t.getToolName());
|
||||
tm.put("inputParams", t.getInputParams());
|
||||
tm.put("outputResult", t.getOutputResult());
|
||||
tm.put("status", t.getStatus());
|
||||
tm.put("executionTimeMs", t.getExecutionTimeMs());
|
||||
return tm;
|
||||
}).toList());
|
||||
return map;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void approve(Long planId, String comment) {
|
||||
AgentPlan plan = agentPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("计划不存在"));
|
||||
plan.setApprovalStatus("approved");
|
||||
plan.setStatus("completed");
|
||||
plan.setApprovalComment(comment);
|
||||
plan.setCompletedAt(LocalDateTime.now());
|
||||
agentPlanRepository.save(plan);
|
||||
|
||||
Issue issue = plan.getIssue();
|
||||
issue.setAgentStatus("agent_driven");
|
||||
issueRepository.save(issue);
|
||||
if (plan.getCreatedBy() != null) {
|
||||
notificationService.notify(plan.getCreatedBy(), "Agent 指令已批准",
|
||||
"您的 Agent 指令《" + plan.getGoal() + "》已获批准执行。",
|
||||
"agent", "/issues/" + issue.getId(), issue);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reject(Long planId, String comment) {
|
||||
AgentPlan plan = agentPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new BusinessException("计划不存在"));
|
||||
plan.setApprovalStatus("rejected");
|
||||
plan.setStatus("completed");
|
||||
plan.setApprovalComment(comment);
|
||||
plan.setCompletedAt(LocalDateTime.now());
|
||||
agentPlanRepository.save(plan);
|
||||
|
||||
Issue issue = plan.getIssue();
|
||||
issue.setAgentStatus("human_driven");
|
||||
issueRepository.save(issue);
|
||||
if (plan.getCreatedBy() != null) {
|
||||
notificationService.notify(plan.getCreatedBy(), "Agent 指令被驳回",
|
||||
"您的 Agent 指令《" + plan.getGoal() + "》被驳回。" +
|
||||
(comment != null && !comment.isBlank() ? " 原因: " + comment : ""),
|
||||
"agent", "/issues/" + issue.getId(), issue);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveTool(AgentPlan plan, String toolName, String inputParams, String output, long ms, String status) {
|
||||
ToolExecution t = ToolExecution.builder()
|
||||
.plan(plan)
|
||||
.toolName(toolName)
|
||||
.inputParams(inputParams)
|
||||
.outputResult(output)
|
||||
.status(status)
|
||||
.executionTimeMs(ms)
|
||||
.build();
|
||||
toolExecutionRepository.save(t);
|
||||
}
|
||||
|
||||
private String planSteps(String planText) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(List.of(
|
||||
Map.of("step", 1, "action", "解析用户指令", "status", "done"),
|
||||
Map.of("step", 2, "action", "检索知识库相似案例", "status", "done"),
|
||||
Map.of("step", 3, "action", "生成对应方案", "status", "done"),
|
||||
Map.of("step", 4, "action", "方案如下", "status", "done", "detail", truncate(planText, 500))));
|
||||
} catch (Exception e) {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String s, int max) {
|
||||
if (s == null) return "";
|
||||
return s.length() <= max ? s : s.substring(0, max) + "...";
|
||||
}
|
||||
|
||||
private String safeJson(String s) {
|
||||
return s == null ? "" : s.replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.ims.service.entity.Permission;
|
||||
import com.ims.service.entity.RolePermission;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.entity.UserRole;
|
||||
import com.ims.service.repository.PermissionRepository;
|
||||
import com.ims.service.repository.RolePermissionRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import com.ims.service.repository.UserRoleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AgentToolPermissionService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final RolePermissionRepository rolePermissionRepository;
|
||||
private final PermissionRepository permissionRepository;
|
||||
|
||||
public AgentToolPermissionService(UserRepository userRepository,
|
||||
UserRoleRepository userRoleRepository,
|
||||
RolePermissionRepository rolePermissionRepository,
|
||||
PermissionRepository permissionRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.rolePermissionRepository = rolePermissionRepository;
|
||||
this.permissionRepository = permissionRepository;
|
||||
}
|
||||
|
||||
public boolean canUseTool(String username, String toolName) {
|
||||
if (toolName == null || toolName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
User user = userRepository.findByUsername(username)
|
||||
.or(() -> userRepository.findByUserid(username))
|
||||
.orElse(null);
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
List<Long> roleIds = userRoleRepository.findByUserId(user.getId())
|
||||
.stream().map(UserRole::getRoleId).distinct().toList();
|
||||
if (roleIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
List<Long> permissionIds = rolePermissionRepository.findByRoleIdIn(roleIds)
|
||||
.stream().map(RolePermission::getPermissionId).distinct().toList();
|
||||
String code = "TOOL_" + toolName.toUpperCase().replace("-", "_");
|
||||
return permissionRepository.findAllById(permissionIds).stream()
|
||||
.map(Permission::getCode)
|
||||
.anyMatch(code::equals);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.AgentMemory;
|
||||
import com.ims.service.knowledge.DeepSeekEmbeddingService;
|
||||
import com.ims.service.knowledge.SearchService;
|
||||
import com.ims.service.repository.AgentMemoryRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.Query;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class MemoryService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MemoryService.class);
|
||||
|
||||
private final AgentMemoryRepository memoryRepository;
|
||||
private final DeepSeekEmbeddingService deepSeekEmbeddingService;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
public MemoryService(AgentMemoryRepository memoryRepository,
|
||||
DeepSeekEmbeddingService deepSeekEmbeddingService) {
|
||||
this.memoryRepository = memoryRepository;
|
||||
this.deepSeekEmbeddingService = deepSeekEmbeddingService;
|
||||
}
|
||||
|
||||
public Page<AgentMemory> list(int page, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), pageSize);
|
||||
return memoryRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
public List<AgentMemory> searchSimilar(String text, int topK) {
|
||||
String vector = embed(text);
|
||||
if (vector == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return memoryRepository.findSimilar(vector, topK);
|
||||
} catch (Exception e) {
|
||||
log.warn("记忆向量检索失败: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public AgentMemory add(String issueSummary, String solutionSteps) {
|
||||
String vector = embed(issueSummary);
|
||||
Query q;
|
||||
if (vector == null) {
|
||||
q = entityManager.createNativeQuery(
|
||||
"INSERT INTO agent_memories (issue_summary, solution_steps, effectiveness_score, created_at, updated_at) " +
|
||||
"VALUES (:summary, CAST(:steps AS jsonb), :score, NOW(), NOW()) RETURNING id")
|
||||
.setParameter("summary", issueSummary)
|
||||
.setParameter("steps", solutionSteps)
|
||||
.setParameter("score", BigDecimal.ZERO);
|
||||
} else {
|
||||
q = entityManager.createNativeQuery(
|
||||
"INSERT INTO agent_memories (issue_summary, solution_steps, effectiveness_score, embedding, created_at, updated_at) " +
|
||||
"VALUES (:summary, CAST(:steps AS jsonb), :score, CAST(:embedding AS vector), NOW(), NOW()) RETURNING id")
|
||||
.setParameter("summary", issueSummary)
|
||||
.setParameter("steps", solutionSteps)
|
||||
.setParameter("score", BigDecimal.ZERO)
|
||||
.setParameter("embedding", vector);
|
||||
}
|
||||
Number id = (Number) q.getSingleResult();
|
||||
return memoryRepository.findById(id.longValue())
|
||||
.orElseThrow(() -> new BusinessException("记忆保存失败: " + id));
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
if (!memoryRepository.existsById(id)) {
|
||||
throw new BusinessException("记忆不存在: " + id);
|
||||
}
|
||||
memoryRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public AgentMemory update(Long id, String issueSummary, String solutionSteps) {
|
||||
AgentMemory memory = memoryRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException("记忆不存在: " + id));
|
||||
memory.setIssueSummary(issueSummary);
|
||||
memory.setSolutionSteps(solutionSteps);
|
||||
return memoryRepository.save(memory);
|
||||
}
|
||||
|
||||
private String embed(String text) {
|
||||
try {
|
||||
List<Float> vector = deepSeekEmbeddingService.embed(text);
|
||||
if (vector == null || vector.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return SearchService.vectorToPgvectorString(vector);
|
||||
} catch (Exception e) {
|
||||
log.warn("Agent记忆嵌入失败(需配置DeepSeek Key并保证1536维): {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ims.service.agent;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class OllamaChatService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OllamaChatService.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${ollama.base-url:http://localhost:11434}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${ollama.chat.model:llama3.1:8b}")
|
||||
private String model;
|
||||
|
||||
public OllamaChatService(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public String chat(String system, String user) {
|
||||
String requestBody;
|
||||
try {
|
||||
requestBody = objectMapper.writeValueAsString(Map.of(
|
||||
"model", model,
|
||||
"stream", false,
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", system),
|
||||
Map.of("role", "user", "content", user))));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("构建 Ollama 请求失败: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(baseUrl + "/api/chat").toURL().openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(30000);
|
||||
conn.setReadTimeout(600000);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(requestBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
String json = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode content = root.path("message").path("content");
|
||||
if (content.isMissingNode()) {
|
||||
throw new RuntimeException("Ollama 响应缺少 message.content: " + json);
|
||||
}
|
||||
return content.asText();
|
||||
} catch (Exception e) {
|
||||
log.error("Ollama chat failed", e);
|
||||
throw new RuntimeException("Ollama 调用失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class AgentFunctionCallbackAdapter implements FunctionCallback {
|
||||
|
||||
private final Tool tool;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AgentFunctionCallbackAdapter(Tool tool, ObjectMapper objectMapper) {
|
||||
this.tool = tool;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return tool.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return tool.description();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getInputTypeSchema() {
|
||||
return schemaFor(tool.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String functionArguments) {
|
||||
Map<String, Object> parameters = parseArguments(functionArguments);
|
||||
Object output = tool.execute(parameters);
|
||||
return write(output);
|
||||
}
|
||||
|
||||
private Map<String, Object> parseArguments(String arguments) {
|
||||
if (arguments == null || arguments.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(arguments, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String write(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String schemaFor(String name) {
|
||||
return switch (name) {
|
||||
case "create_issue" -> """
|
||||
{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"phase":{"type":"string"},"priority":{"type":"string"},"impact_level":{"type":"string"},"category":{"type":"string"},"assignee_name":{"type":"string"},"department_id":{"type":"number"}},"required":["title"]}
|
||||
""";
|
||||
case "update_issue" -> """
|
||||
{"type":"object","properties":{"issue_id":{"type":"number"},"field":{"type":"string"},"value":{"type":"string"}},"required":["issue_id","field","value"]}
|
||||
""";
|
||||
case "query_issue" -> """
|
||||
{"type":"object","properties":{"issue_id":{"type":"number"}},"required":["issue_id"]}
|
||||
""";
|
||||
case "search_knowledge" -> """
|
||||
{"type":"object","properties":{"query":{"type":"string"},"top_k":{"type":"number"}},"required":["query"]}
|
||||
""";
|
||||
case "notify_overdue" -> """
|
||||
{"type":"object","properties":{"content":{"type":"string"}}}
|
||||
""";
|
||||
case "assign_pending" -> """
|
||||
{"type":"object","properties":{"assignee_name":{"type":"string"}},"required":["assignee_name"]}
|
||||
""";
|
||||
case "weekly_report" -> """
|
||||
{"type":"object","properties":{}}
|
||||
""";
|
||||
case "ai_analysis" -> """
|
||||
{"type":"object","properties":{"issue_id":{"type":"number"}},"required":["issue_id"]}
|
||||
""";
|
||||
default -> "{\"type\":\"object\",\"properties\":{}}";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.ai.KeywordExtractor;
|
||||
import com.ims.service.ai.ModelRoutingService;
|
||||
import com.ims.service.ai.PromptFormatter;
|
||||
import com.ims.service.ai.PromptTemplateEngine;
|
||||
import com.ims.service.entity.AiAnalysis;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.AiAnalysisRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class AiAnalysisTool implements Tool {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AiAnalysisTool.class);
|
||||
private static final String SYS_ROLE_TEMPLATE = "SYS_ROLE_001";
|
||||
private static final String ANALYSIS_TEMPLATE = "ANALYSIS_ROOT_CAUSE_001";
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
private final AiAnalysisRepository analysisRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PromptTemplateEngine promptEngine;
|
||||
private final ModelRoutingService modelRoutingService;
|
||||
private final PromptFormatter promptFormatter;
|
||||
private final KeywordExtractor keywordExtractor;
|
||||
private final KnowledgeSearchTool knowledgeSearchTool;
|
||||
|
||||
public AiAnalysisTool(IssueRepository issueRepository,
|
||||
AiAnalysisRepository analysisRepository,
|
||||
UserRepository userRepository,
|
||||
PromptTemplateEngine promptEngine,
|
||||
ModelRoutingService modelRoutingService,
|
||||
PromptFormatter promptFormatter,
|
||||
KeywordExtractor keywordExtractor,
|
||||
KnowledgeSearchTool knowledgeSearchTool) {
|
||||
this.issueRepository = issueRepository;
|
||||
this.analysisRepository = analysisRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.promptEngine = promptEngine;
|
||||
this.modelRoutingService = modelRoutingService;
|
||||
this.promptFormatter = promptFormatter;
|
||||
this.keywordExtractor = keywordExtractor;
|
||||
this.knowledgeSearchTool = knowledgeSearchTool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "ai_analysis";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "对指定指摘进行AI根因分析(只读),调用LLM分析问题分类、根因和整改建议,参数:issue_id(数字,必填)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
Long issueId = toLong(parameters.get("issue_id"));
|
||||
if (issueId == null) {
|
||||
throw new BusinessException("ai_analysis 缺少参数 issue_id");
|
||||
}
|
||||
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在: " + issueId));
|
||||
|
||||
AiAnalysis analysis = new AiAnalysis();
|
||||
analysis.setIssue(issue);
|
||||
analysis.setStatus("processing");
|
||||
analysis.setPromptTemplateId(ANALYSIS_TEMPLATE);
|
||||
analysis.setStartedAt(LocalDateTime.now());
|
||||
analysis.setExtractedKeywords(keywordExtractor.extract(
|
||||
issue.getTitle(), issue.getDescription(), issue.getCategory(), issue.getPhase()));
|
||||
analysisRepository.save(analysis);
|
||||
|
||||
try {
|
||||
Map<String, Object> systemVars = new LinkedHashMap<>();
|
||||
systemVars.put("userGoal", "进行根因分析");
|
||||
systemVars.put("issueContext", buildIssueContext(issue));
|
||||
systemVars.put("availableTools", List.of());
|
||||
String systemPrompt = promptEngine.render(SYS_ROLE_TEMPLATE, systemVars);
|
||||
|
||||
Map<String, Object> analysisVars = buildAnalysisVariables(issue, analysis.getExtractedKeywords());
|
||||
String userPrompt = promptEngine.render(ANALYSIS_TEMPLATE, analysisVars);
|
||||
|
||||
ModelRoutingService.CallResult result = modelRoutingService.call(systemPrompt, userPrompt);
|
||||
|
||||
ObjectNode json = promptFormatter.extractJsonObject(result.text());
|
||||
if (json == null) {
|
||||
log.warn("AI分析输出解析失败,尝试修复 issueId={}", issueId);
|
||||
json = repairJsonOutput(result.text(), systemPrompt);
|
||||
}
|
||||
if (json == null) {
|
||||
throw new BusinessException("LLM输出无法解析为JSON");
|
||||
}
|
||||
|
||||
String category = text(json, "category");
|
||||
String rootCause = text(json, "rootCause");
|
||||
String suggestion = text(json, "suggestion");
|
||||
if ((category == null || category.isBlank())
|
||||
|| (rootCause == null || rootCause.isBlank())
|
||||
|| (suggestion == null || suggestion.isBlank())) {
|
||||
throw new BusinessException("LLM输出缺少分析内容(category/rootCause/suggestion)");
|
||||
}
|
||||
|
||||
analysis.setCategory(category);
|
||||
analysis.setKeywords(joinArray(json, "keywords"));
|
||||
analysis.setRootCause(rootCause);
|
||||
analysis.setSuggestion(suggestion);
|
||||
analysis.setStatus("completed");
|
||||
analysis.setPromptVersion(1);
|
||||
analysis.setModelProvider(result.provider());
|
||||
analysis.setModelName(result.provider());
|
||||
analysis.setCompletedAt(LocalDateTime.now());
|
||||
analysisRepository.save(analysis);
|
||||
|
||||
issue.setAiAnalysisId(analysis.getId());
|
||||
issueRepository.save(issue);
|
||||
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("analysisId", analysis.getId());
|
||||
map.put("category", category);
|
||||
map.put("keywords", analysis.getKeywords());
|
||||
map.put("rootCause", rootCause);
|
||||
map.put("suggestion", suggestion);
|
||||
map.put("status", "completed");
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
log.error("AI分析工具执行失败 issueId={}", issueId, e);
|
||||
analysis.setStatus("failed");
|
||||
analysis.setErrorMessage(e.getMessage());
|
||||
analysis.setCompletedAt(LocalDateTime.now());
|
||||
analysisRepository.save(analysis);
|
||||
throw new BusinessException("AI分析失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildIssueContext(Issue issue) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("指摘编号: ").append(issue.getIssueNo()).append("\n");
|
||||
sb.append("标题: ").append(issue.getTitle()).append("\n");
|
||||
if (issue.getDescription() != null) {
|
||||
sb.append("描述: ").append(issue.getDescription()).append("\n");
|
||||
}
|
||||
if (issue.getPhase() != null) {
|
||||
sb.append("工程阶段: ").append(issue.getPhase()).append("\n");
|
||||
}
|
||||
if (issue.getCategory() != null) {
|
||||
sb.append("问题分类: ").append(issue.getCategory()).append("\n");
|
||||
}
|
||||
if (issue.getImpactLevel() != null) {
|
||||
sb.append("影响度: ").append(issue.getImpactLevel()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildAnalysisVariables(Issue issue, String extractedKeywords) {
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("issueNo", issue.getIssueNo());
|
||||
vars.put("issueTitle", issue.getTitle());
|
||||
vars.put("issueDescription", issue.getDescription() == null ? "" : issue.getDescription());
|
||||
vars.put("issuePhase", issue.getPhase() == null ? "" : issue.getPhase());
|
||||
vars.put("issueCategory", issue.getCategory() == null ? "" : issue.getCategory());
|
||||
vars.put("issueImpactLevel", issue.getImpactLevel() == null ? "" : issue.getImpactLevel());
|
||||
vars.put("issueKeywords", extractedKeywords);
|
||||
vars.put("similarCases", buildKnowledgeContext(issue));
|
||||
return vars;
|
||||
}
|
||||
|
||||
private String buildKnowledgeContext(Issue issue) {
|
||||
try {
|
||||
StringBuilder query = new StringBuilder(issue.getTitle() == null ? "" : issue.getTitle());
|
||||
if (issue.getDescription() != null && !issue.getDescription().isBlank()) {
|
||||
if (query.length() > 0) {
|
||||
query.append(" ");
|
||||
}
|
||||
query.append(issue.getDescription());
|
||||
}
|
||||
if (query.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
List<?> results = (List<?>) knowledgeSearchTool.execute(
|
||||
Map.of("query", query.toString(), "top_k", 5));
|
||||
if (results == null || results.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
Object r = results.get(i);
|
||||
if (r instanceof Map) {
|
||||
Map<?, ?> m = (Map<?, ?>) r;
|
||||
sb.append(i + 1).append(". ")
|
||||
.append(m.get("doc_name")).append(": ")
|
||||
.append(m.get("content")).append("\n");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("知识库检索失败,降级为无相似案例: {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectNode repairJsonOutput(String broken, String systemPrompt) {
|
||||
try {
|
||||
String repairPrompt = "以下内容本应是严格的 JSON,但无法解析。"
|
||||
+ "请将修正后的完整 JSON 重新输出,禁止 Markdown 围栏、禁止解释性文字,"
|
||||
+ "直接输出 JSON:\n\n" + broken;
|
||||
ModelRoutingService.CallResult retry = modelRoutingService.call(systemPrompt, repairPrompt);
|
||||
return promptFormatter.extractJsonObject(retry.text());
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON 修复重试失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String text(ObjectNode json, String field) {
|
||||
JsonNode node = get(json, field);
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return node.isContainerNode() ? node.toString() : node.asText();
|
||||
}
|
||||
|
||||
private String joinArray(ObjectNode json, String field) {
|
||||
JsonNode node = get(json, field);
|
||||
if (node == null || !node.isArray()) {
|
||||
return "";
|
||||
}
|
||||
List<String> items = new ArrayList<>();
|
||||
for (JsonNode n : (com.fasterxml.jackson.databind.node.ArrayNode) node) {
|
||||
items.add(n.asText());
|
||||
}
|
||||
return String.join(",", items);
|
||||
}
|
||||
|
||||
private JsonNode get(ObjectNode json, String field) {
|
||||
JsonNode node = json.get(field);
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> entry : json.properties()) {
|
||||
if (entry.getKey().equalsIgnoreCase(field)) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.issue.IssueService;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class AssignPendingTool implements Tool {
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
private final IssueService issueService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public AssignPendingTool(IssueRepository issueRepository,
|
||||
IssueService issueService,
|
||||
UserRepository userRepository) {
|
||||
this.issueRepository = issueRepository;
|
||||
this.issueService = issueService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "assign_pending";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "为待处理指摘分配对应者(写操作,需审批),自动查找所有待处理(open)且未分配对应者的指摘并统一分配,参数:assignee_name(必填,对应者用户名)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
String assigneeName = parameters.get("assignee_name") == null ? "" : String.valueOf(parameters.get("assignee_name"));
|
||||
if (assigneeName.isBlank()) {
|
||||
throw new BusinessException("assign_pending 缺少参数 assignee_name");
|
||||
}
|
||||
User assignee = userRepository.findByUsername(assigneeName)
|
||||
.or(() -> userRepository.findByUserid(assigneeName))
|
||||
.orElse(null);
|
||||
if (assignee == null) {
|
||||
throw new BusinessException("对应者不存在: " + assigneeName);
|
||||
}
|
||||
List<Issue> pending = issueRepository.findByIsDeletedFalseAndStatusAndAssigneeIsNull("open");
|
||||
if (pending.isEmpty()) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("assigned", 0);
|
||||
map.put("message", "当前没有待处理且未分配对应者的指摘。");
|
||||
return map;
|
||||
}
|
||||
List<Long> ids = pending.stream().map(Issue::getId).toList();
|
||||
User operator = userRepository.findByUserid("admin")
|
||||
.or(() -> userRepository.findByUsername("admin")).orElse(null);
|
||||
int count = issueService.batchAssign(ids, assignee.getId(), operator);
|
||||
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("assigned", count);
|
||||
map.put("targetCount", ids.size());
|
||||
map.put("assignee", assigneeName);
|
||||
map.put("message", "已将 " + count + " 条待处理指摘分配给对应者 " + assigneeName);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.api.dto.issue.IssueCreateRequest;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.issue.IssueService;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class IssueCreateTool implements Tool {
|
||||
|
||||
private final IssueService issueService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public IssueCreateTool(IssueService issueService, UserRepository userRepository) {
|
||||
this.issueService = issueService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "create_issue";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "新建指摘(写操作,需审批),参数:title(必填), description, phase, priority, impact_level, category, assignee_name(对应者姓名), department_id(数字)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
String title = parameters.get("title") == null ? "" : String.valueOf(parameters.get("title"));
|
||||
if (title.isBlank()) {
|
||||
throw new BusinessException("create_issue 缺少参数 title");
|
||||
}
|
||||
|
||||
IssueCreateRequest req = new IssueCreateRequest();
|
||||
req.setTitle(title);
|
||||
req.setDescription(asString(parameters.get("description")));
|
||||
req.setPhase(asString(parameters.get("phase")));
|
||||
req.setPriority(asString(parameters.get("priority")));
|
||||
req.setCategory(asString(parameters.get("category")));
|
||||
req.setImpactLevel(asString(parameters.get("impact_level")));
|
||||
|
||||
String assigneeName = asString(parameters.get("assignee_name"));
|
||||
if (!assigneeName.isBlank()) {
|
||||
userRepository.findByUsername(assigneeName)
|
||||
.or(() -> userRepository.findByUserid(assigneeName))
|
||||
.ifPresent(u -> req.setAssigneeId(u.getId()));
|
||||
}
|
||||
Long departmentId = toLong(parameters.get("department_id"));
|
||||
if (departmentId != null) {
|
||||
req.setDepartmentId(departmentId);
|
||||
}
|
||||
|
||||
User creator = userRepository.findByUserid("admin")
|
||||
.or(() -> userRepository.findByUsername("admin")).orElse(null);
|
||||
var resp = issueService.create(req, creator);
|
||||
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", resp.getId());
|
||||
map.put("issue_no", resp.getIssueNo());
|
||||
map.put("title", resp.getTitle());
|
||||
map.put("status", resp.getStatus());
|
||||
map.put("created", true);
|
||||
return map;
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class IssueQueryTool implements Tool {
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
|
||||
public IssueQueryTool(IssueRepository issueRepository) {
|
||||
this.issueRepository = issueRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "query_issue";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "查询指摘详情,参数:issue_id(数字)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
Long issueId = toLong(parameters.get("issue_id"));
|
||||
if (issueId == null) {
|
||||
throw new BusinessException("query_issue 缺少参数 issue_id");
|
||||
}
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在: " + issueId));
|
||||
return toMap(issue);
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(Issue issue) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", issue.getId());
|
||||
map.put("issue_no", issue.getIssueNo());
|
||||
map.put("title", issue.getTitle());
|
||||
map.put("description", issue.getDescription());
|
||||
map.put("status", issue.getStatus());
|
||||
map.put("priority", issue.getPriority());
|
||||
map.put("phase", issue.getPhase());
|
||||
map.put("category", issue.getCategory());
|
||||
map.put("impact_level", issue.getImpactLevel());
|
||||
map.put("sub_project", issue.getSubProject());
|
||||
map.put("pgm_no", issue.getPgmNo());
|
||||
map.put("ng_reason", issue.getNgReason());
|
||||
map.put("response_content", issue.getResponseContent());
|
||||
map.put("agent_status", issue.getAgentStatus());
|
||||
map.put("created_at", issue.getCreatedAt() == null ? null : issue.getCreatedAt().toString());
|
||||
return map;
|
||||
}
|
||||
|
||||
static Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class IssueUpdateTool implements Tool {
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
|
||||
public IssueUpdateTool(IssueRepository issueRepository) {
|
||||
this.issueRepository = issueRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "update_issue";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "更新指摘字段(写操作,需审批),参数:issue_id(数字), field(字段名), value(值);支持字段:status/priority/phase/title/description/response_content/ng_reason/response_workload/review_workload";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
Long issueId = IssueQueryTool.toLong(parameters.get("issue_id"));
|
||||
if (issueId == null) {
|
||||
throw new BusinessException("update_issue 缺少参数 issue_id");
|
||||
}
|
||||
String field = parameters.get("field") == null ? "" : String.valueOf(parameters.get("field"));
|
||||
if (field.isBlank()) {
|
||||
throw new BusinessException("update_issue 缺少参数 field");
|
||||
}
|
||||
Object value = parameters.get("value");
|
||||
if (value == null) {
|
||||
throw new BusinessException("update_issue 缺少参数 value");
|
||||
}
|
||||
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在: " + issueId));
|
||||
applyField(issue, field, value);
|
||||
issueRepository.save(issue);
|
||||
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", issue.getId());
|
||||
map.put("issue_no", issue.getIssueNo());
|
||||
map.put("status", issue.getStatus());
|
||||
map.put("priority", issue.getPriority());
|
||||
map.put("phase", issue.getPhase());
|
||||
map.put("updated", true);
|
||||
return map;
|
||||
}
|
||||
|
||||
private void applyField(Issue issue, String field, Object value) {
|
||||
switch (field) {
|
||||
case "status" -> issue.setStatus(String.valueOf(value));
|
||||
case "priority" -> issue.setPriority(String.valueOf(value));
|
||||
case "phase" -> issue.setPhase(String.valueOf(value));
|
||||
case "title" -> issue.setTitle(String.valueOf(value));
|
||||
case "description" -> issue.setDescription(String.valueOf(value));
|
||||
case "response_content" -> issue.setResponseContent(String.valueOf(value));
|
||||
case "ng_reason" -> issue.setNgReason(String.valueOf(value));
|
||||
case "response_workload" -> issue.setResponseWorkload(toDecimal(value));
|
||||
case "review_workload" -> issue.setReviewWorkload(toDecimal(value));
|
||||
default -> throw new BusinessException("不支持的字段: " + field);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal toDecimal(Object value) {
|
||||
if (value instanceof Number) {
|
||||
return new BigDecimal(value.toString());
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException("数值字段格式错误: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.service.knowledge.SearchService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class KnowledgeSearchTool implements Tool {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
public KnowledgeSearchTool(SearchService searchService) {
|
||||
this.searchService = searchService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "search_knowledge";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "检索本地知识库,参数:query(字符串), top_k(可选数字,默认5)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
String query = parameters.get("query") == null ? "" : String.valueOf(parameters.get("query"));
|
||||
if (query.isBlank()) {
|
||||
throw new com.ims.common.exception.BusinessException("search_knowledge 缺少参数 query");
|
||||
}
|
||||
int topK = 5;
|
||||
if (parameters.get("top_k") != null) {
|
||||
topK = Math.max(1, IssueQueryTool.toLong(parameters.get("top_k")).intValue());
|
||||
}
|
||||
List<SearchService.SearchResult> results = searchService.search(query, topK);
|
||||
return results.stream().map(r -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("chunk_id", r.getChunkId());
|
||||
m.put("content", r.getContent());
|
||||
m.put("doc_name", r.getDocName());
|
||||
m.put("score", r.getScore());
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.issue.IssueService;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class NotifyOverdueTool implements Tool {
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
private final IssueService issueService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public NotifyOverdueTool(IssueRepository issueRepository,
|
||||
IssueService issueService,
|
||||
UserRepository userRepository) {
|
||||
this.issueRepository = issueRepository;
|
||||
this.issueService = issueService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "notify_overdue";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "催办逾期指摘(写操作,需审批),自动查找所有已逾期且未关闭的指摘并向对应者发送催办通知,参数:content(可选,催办内容)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
String content = parameters.get("content") == null ? "" : String.valueOf(parameters.get("content"));
|
||||
List<Issue> overdue = issueRepository.findByIsDeletedFalseAndStatusNotAndDeadlineBefore("closed", LocalDateTime.now());
|
||||
if (overdue.isEmpty()) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("notified", 0);
|
||||
map.put("message", "当前没有逾期未关闭的指摘,无需催办。");
|
||||
return map;
|
||||
}
|
||||
List<Long> ids = overdue.stream().map(Issue::getId).toList();
|
||||
User operator = userRepository.findByUserid("admin")
|
||||
.or(() -> userRepository.findByUsername("admin")).orElse(null);
|
||||
int count = issueService.batchNotify(ids, content, operator);
|
||||
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("notified", count);
|
||||
map.put("targetCount", ids.size());
|
||||
map.put("message", "已对 " + count + " 条逾期指摘执行催办(已向对应者发送通知)");
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface Tool {
|
||||
|
||||
String name();
|
||||
|
||||
String description();
|
||||
|
||||
boolean isWrite();
|
||||
|
||||
Object execute(Map<String, Object> parameters);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ToolRegistry {
|
||||
|
||||
private final Map<String, Tool> tools = new LinkedHashMap<>();
|
||||
|
||||
public ToolRegistry(List<Tool> toolList) {
|
||||
for (Tool tool : toolList) {
|
||||
tools.put(tool.name(), tool);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, String> descriptions() {
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
tools.forEach((name, tool) -> result.put(name, tool.description()));
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<String> names() {
|
||||
return List.copyOf(tools.keySet());
|
||||
}
|
||||
|
||||
public List<Tool> tools() {
|
||||
return List.copyOf(tools.values());
|
||||
}
|
||||
|
||||
public boolean contains(String name) {
|
||||
return tools.containsKey(name);
|
||||
}
|
||||
|
||||
public Tool get(String name) {
|
||||
Tool tool = tools.get(name);
|
||||
if (tool == null) {
|
||||
throw new BusinessException("未知工具: " + name);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
public boolean isWrite(String name) {
|
||||
Tool tool = tools.get(name);
|
||||
return tool != null && tool.isWrite();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ims.service.agent.tool;
|
||||
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.IssueLog;
|
||||
import com.ims.service.repository.IssueLogRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class WeeklyReportTool implements Tool {
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final IssueRepository issueRepository;
|
||||
private final IssueLogRepository issueLogRepository;
|
||||
|
||||
public WeeklyReportTool(EntityManager entityManager,
|
||||
IssueRepository issueRepository,
|
||||
IssueLogRepository issueLogRepository) {
|
||||
this.entityManager = entityManager;
|
||||
this.issueRepository = issueRepository;
|
||||
this.issueLogRepository = issueLogRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "weekly_report";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "生成本周指摘处理统计报告(只读),统计本周新增/关闭/各状态分布/逾期/高优先级数量,参数:无";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrite() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(Map<String, Object> parameters) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate monday = today.with(DayOfWeek.MONDAY);
|
||||
LocalDateTime weekStart = monday.atStartOfDay();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
long weekNew = countBySql("select count(*) from issues where is_deleted = false and created_at >= ?1", weekStart);
|
||||
long weekClosed = countBySql("select count(*) from issues where is_deleted = false and closed_at is not null and closed_at >= ?1", weekStart);
|
||||
long overdue = countBySql("select count(*) from issues where is_deleted = false and status <> 'closed' and deadline < ?1", now);
|
||||
long highRisk = countBySql("select count(*) from issues where is_deleted = false and status in ('open','in_progress') and priority in ('urgent','high')");
|
||||
|
||||
Map<String, Long> statusCounts = new LinkedHashMap<>();
|
||||
List<Object[]> rows = entityManager.createNativeQuery(
|
||||
"select status, count(*) from issues where is_deleted = false group by status").getResultList();
|
||||
for (Object[] row : rows) {
|
||||
statusCounts.put((String) row[0], ((Number) row[1]).longValue());
|
||||
}
|
||||
|
||||
List<Issue> recent = issueRepository
|
||||
.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "createdAt"))).getContent();
|
||||
List<IssueLog> recentLogs = issueLogRepository
|
||||
.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "createdAt"))).getContent();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("【本周指摘处理报告】").append(monday.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
|
||||
.append(" ~ ").append(today.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))).append("\n");
|
||||
sb.append("一、总量:本周新增 ").append(weekNew).append(" 条,本周关闭 ").append(weekClosed).append(" 条\n");
|
||||
sb.append("二、状态分布:");
|
||||
statusCounts.forEach((k, v) -> sb.append(k).append("=").append(v).append(" "));
|
||||
sb.append("\n");
|
||||
sb.append("三、风险提示:逾期未关闭 ").append(overdue).append(" 条,待处理/进行中 high 及以上优先级 ").append(highRisk).append(" 条\n");
|
||||
sb.append("四、最新新增:");
|
||||
if (recent.isEmpty()) {
|
||||
sb.append("无\n");
|
||||
} else {
|
||||
for (Issue i : recent) {
|
||||
sb.append("\n - ").append(i.getIssueNo()).append(" ").append(i.getTitle())
|
||||
.append(" (").append(i.getStatus()).append(")");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append("五、最近动态:");
|
||||
if (recentLogs.isEmpty()) {
|
||||
sb.append("无\n");
|
||||
} else {
|
||||
for (IssueLog l : recentLogs) {
|
||||
sb.append("\n - ").append(l.getAction());
|
||||
if (l.getIssue() != null) {
|
||||
sb.append(" ").append(l.getIssue().getIssueNo());
|
||||
}
|
||||
if (l.getUser() != null) {
|
||||
sb.append(" by ").append(l.getUser().getUsername());
|
||||
}
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append("报告生成时间:").append(now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private long countBySql(String sql, Object... params) {
|
||||
jakarta.persistence.Query q = entityManager.createNativeQuery(sql);
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
q.setParameter(i + 1, params[i]);
|
||||
}
|
||||
return ((Number) q.getSingleResult()).longValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.ims.api.dto.ai.AiAnalysisRequest;
|
||||
import com.ims.api.dto.ai.AiAnalysisResponse;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.agent.tool.KnowledgeSearchTool;
|
||||
import com.ims.service.entity.AiAnalysis;
|
||||
import com.ims.service.entity.AiFeedback;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.ToolExecution;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.AiAnalysisRepository;
|
||||
import com.ims.service.repository.AiFeedbackRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.RoleRepository;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import com.ims.service.repository.ToolExecutionRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import com.ims.service.repository.UserRoleRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@Service
|
||||
public class AiAnalysisService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AiAnalysisService.class);
|
||||
private static final String SYS_ROLE_TEMPLATE = "SYS_ROLE_001";
|
||||
private static final String ANALYSIS_TEMPLATE = "ANALYSIS_ROOT_CAUSE_001";
|
||||
private static final String ANALYSIS_TOOL_NAME = "ai_analysis";
|
||||
|
||||
private final AiAnalysisRepository analysisRepository;
|
||||
private final AiFeedbackRepository feedbackRepository;
|
||||
private final IssueRepository issueRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final ToolExecutionRepository toolExecutionRepository;
|
||||
private final PromptTemplateEngine promptEngine;
|
||||
private final ModelRoutingService modelRoutingService;
|
||||
private final PromptFormatter promptFormatter;
|
||||
private final AiConfigService aiConfigService;
|
||||
private final SystemContextBuilder systemContextBuilder;
|
||||
private final KeywordExtractor keywordExtractor;
|
||||
private final KnowledgeSearchTool knowledgeSearchTool;
|
||||
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(1, r -> {
|
||||
Thread t = new Thread(r, "ai-analysis-");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
public AiAnalysisService(AiAnalysisRepository analysisRepository,
|
||||
AiFeedbackRepository feedbackRepository,
|
||||
IssueRepository issueRepository,
|
||||
UserRepository userRepository,
|
||||
UserRoleRepository userRoleRepository,
|
||||
RoleRepository roleRepository,
|
||||
ToolExecutionRepository toolExecutionRepository,
|
||||
PromptTemplateEngine promptEngine,
|
||||
ModelRoutingService modelRoutingService,
|
||||
PromptFormatter promptFormatter,
|
||||
AiConfigService aiConfigService,
|
||||
SystemContextBuilder systemContextBuilder,
|
||||
KeywordExtractor keywordExtractor,
|
||||
KnowledgeSearchTool knowledgeSearchTool) {
|
||||
this.analysisRepository = analysisRepository;
|
||||
this.feedbackRepository = feedbackRepository;
|
||||
this.issueRepository = issueRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.toolExecutionRepository = toolExecutionRepository;
|
||||
this.promptEngine = promptEngine;
|
||||
this.modelRoutingService = modelRoutingService;
|
||||
this.promptFormatter = promptFormatter;
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.systemContextBuilder = systemContextBuilder;
|
||||
this.keywordExtractor = keywordExtractor;
|
||||
this.knowledgeSearchTool = knowledgeSearchTool;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void resetStaleAnalysis() {
|
||||
try {
|
||||
List<AiAnalysis> stale = analysisRepository.findByStatusIn(
|
||||
List.of("pending", "processing"), PageRequest.of(0, 1000));
|
||||
if (stale.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (AiAnalysis a : stale) {
|
||||
a.setStatus("failed");
|
||||
a.setErrorMessage("服务重启,分析被中断");
|
||||
a.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
analysisRepository.saveAll(stale);
|
||||
log.warn("启动时重置遗留分析记录 {} 条为 failed", stale.size());
|
||||
} catch (Exception e) {
|
||||
log.error("重置遗留分析记录失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public int batchGenerate(AiAnalysisRequest request, Long userId) {
|
||||
List<Long> issueIds = request.getIssueIds();
|
||||
if (issueIds == null || issueIds.isEmpty()) {
|
||||
throw new BusinessException("请选择需要分析的指摘");
|
||||
}
|
||||
for (Long issueId : issueIds) {
|
||||
executor.execute(() -> analyzeOne(issueId, userId));
|
||||
}
|
||||
return issueIds.size();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void analyzeOne(Long issueId, Long userId) {
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在: " + issueId));
|
||||
|
||||
AiAnalysis analysis = new AiAnalysis();
|
||||
analysis.setIssue(issue);
|
||||
analysis.setStatus("processing");
|
||||
analysis.setPromptTemplateId(ANALYSIS_TEMPLATE);
|
||||
analysis.setStartedAt(LocalDateTime.now());
|
||||
analysis.setExtractedKeywords(keywordExtractor.extract(
|
||||
issue.getTitle(), issue.getDescription(), issue.getCategory(), issue.getPhase()));
|
||||
analysisRepository.save(analysis);
|
||||
|
||||
ToolExecution execution = ToolExecution.builder()
|
||||
.toolName(ANALYSIS_TOOL_NAME)
|
||||
.inputParams("{\"issueId\":" + issueId + "}")
|
||||
.status("running")
|
||||
.build();
|
||||
toolExecutionRepository.save(execution);
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
User user = userRepository.findWithDepartmentById(userId).orElse(null);
|
||||
Map<String, Object> systemVars = systemContextBuilder.build(user, null);
|
||||
String systemPrompt = promptEngine.render(SYS_ROLE_TEMPLATE, systemVars);
|
||||
|
||||
Map<String, Object> analysisVars = buildAnalysisVariables(issue, analysis.getExtractedKeywords());
|
||||
String userPrompt = promptEngine.render(ANALYSIS_TEMPLATE, analysisVars);
|
||||
|
||||
String provider = modelRoutingService.currentProvider();
|
||||
String modelName = null;
|
||||
ModelRoutingService.CallResult result;
|
||||
try {
|
||||
result = modelRoutingService.call(systemPrompt, userPrompt);
|
||||
} catch (Exception e) {
|
||||
modelName = null;
|
||||
throw e;
|
||||
}
|
||||
modelName = result.provider().equals("deepseek")
|
||||
? aiConfigModelName("deepseek")
|
||||
: aiConfigModelName("ollama");
|
||||
|
||||
ObjectNode json = promptFormatter.extractJsonObject(result.text());
|
||||
if (json == null) {
|
||||
log.warn("AI输出解析失败,尝试修复重试 issueId={}", issueId);
|
||||
json = repairJsonOutput(result.text(), systemPrompt);
|
||||
}
|
||||
if (json == null) {
|
||||
throw new BusinessException("LLM输出无法解析为JSON: " + snippet(result.text()));
|
||||
}
|
||||
|
||||
String category = text(json, "category");
|
||||
String rootCause = text(json, "rootCause");
|
||||
String suggestion = text(json, "suggestion");
|
||||
if ((category == null || category.isBlank())
|
||||
|| (rootCause == null || rootCause.isBlank())
|
||||
|| (suggestion == null || suggestion.isBlank())) {
|
||||
throw new BusinessException("LLM输出缺少分析内容(category/rootCause/suggestion): " + snippet(result.text()));
|
||||
}
|
||||
|
||||
analysis.setCategory(category);
|
||||
analysis.setKeywords(joinArray(json, "keywords"));
|
||||
analysis.setRootCause(rootCause);
|
||||
analysis.setSuggestion(suggestion);
|
||||
analysis.setStatus("completed");
|
||||
analysis.setPromptVersion(1);
|
||||
analysis.setModelProvider(result.provider());
|
||||
analysis.setModelName(modelName);
|
||||
analysis.setCompletedAt(LocalDateTime.now());
|
||||
analysisRepository.save(analysis);
|
||||
|
||||
issue.setAiAnalysisId(analysis.getId());
|
||||
issueRepository.save(issue);
|
||||
|
||||
execution.setStatus("success");
|
||||
execution.setOutputResult("分类: " + (category == null ? "" : category)
|
||||
+ " | 关键词: " + (analysis.getKeywords() == null ? "" : analysis.getKeywords()));
|
||||
execution.setExecutionTimeMs(System.currentTimeMillis() - start);
|
||||
toolExecutionRepository.save(execution);
|
||||
} catch (Exception e) {
|
||||
log.error("AI分析失败 issueId={}", issueId, e);
|
||||
analysis.setStatus("failed");
|
||||
analysis.setErrorMessage(e.getMessage());
|
||||
analysis.setCompletedAt(LocalDateTime.now());
|
||||
analysisRepository.save(analysis);
|
||||
|
||||
execution.setStatus("failed");
|
||||
execution.setOutputResult(snippet(e.getMessage()));
|
||||
execution.setExecutionTimeMs(System.currentTimeMillis() - start);
|
||||
toolExecutionRepository.save(execution);
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectNode repairJsonOutput(String broken, String systemPrompt) {
|
||||
try {
|
||||
String repairPrompt = "以下内容本应是严格的 JSON,但无法解析。"
|
||||
+ "请将修正后的完整 JSON 重新输出,禁止 Markdown 围栏、禁止解释性文字,"
|
||||
+ "直接输出 JSON:\n\n" + broken;
|
||||
ModelRoutingService.CallResult retry = modelRoutingService.call(systemPrompt, repairPrompt);
|
||||
return promptFormatter.extractJsonObject(retry.text());
|
||||
} catch (Exception e) {
|
||||
log.warn("JSON 修复重试失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String aiConfigModelName(String provider) {
|
||||
try {
|
||||
com.ims.api.dto.ai.AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
if ("deepseek".equalsIgnoreCase(provider)) {
|
||||
return cfg.getDeepseekModel();
|
||||
}
|
||||
return cfg.getOllamaChatModel();
|
||||
} catch (Exception ignored) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
public Page<AiAnalysisResponse> records(Long id, int page, int pageSize, Long issueId, Long departmentId,
|
||||
String status, LocalDate startDate, LocalDate endDate) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), pageSize);
|
||||
LocalDateTime start = startDate == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : startDate.atStartOfDay();
|
||||
LocalDateTime end = endDate == null ? LocalDateTime.of(2999, 12, 31, 23, 59, 59) : endDate.plusDays(1).atStartOfDay();
|
||||
Page<AiAnalysis> result = analysisRepository.search(id, issueId, departmentId, status, start, end, pageable);
|
||||
return result.map(this::toResponse);
|
||||
}
|
||||
|
||||
public List<AiAnalysisResponse> running() {
|
||||
List<AiAnalysis> list = analysisRepository.findByStatusIn(
|
||||
List.of("pending", "processing"), PageRequest.of(0, 50));
|
||||
return list.stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void feedback(Long analysisId, Long userId, Boolean isHelpful, String comment) {
|
||||
AiAnalysis analysis = analysisRepository.findById(analysisId)
|
||||
.orElseThrow(() -> new BusinessException("分析记录不存在: " + analysisId));
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException("用户不存在: " + userId));
|
||||
|
||||
AiFeedback feedback = AiFeedback.builder()
|
||||
.aiAnalysis(analysis)
|
||||
.user(user)
|
||||
.isHelpful(isHelpful)
|
||||
.comment(comment)
|
||||
.build();
|
||||
feedbackRepository.save(feedback);
|
||||
|
||||
int delta = Boolean.TRUE.equals(isHelpful) ? 1 : -1;
|
||||
int current = analysis.getHelpfulCount() == null ? 0 : analysis.getHelpfulCount();
|
||||
analysis.setHelpfulCount(Math.max(current + delta, 0));
|
||||
analysisRepository.save(analysis);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildAnalysisVariables(Issue issue, String extractedKeywords) {
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
vars.put("issueNo", issue.getIssueNo());
|
||||
vars.put("issueTitle", issue.getTitle());
|
||||
vars.put("issueDescription", issue.getDescription() == null ? "" : issue.getDescription());
|
||||
vars.put("issuePhase", issue.getPhase() == null ? "" : issue.getPhase());
|
||||
vars.put("issueCategory", issue.getCategory() == null ? "" : issue.getCategory());
|
||||
vars.put("issueImpactLevel", issue.getImpactLevel() == null ? "" : issue.getImpactLevel());
|
||||
vars.put("issueKeywords", extractedKeywords);
|
||||
vars.put("similarCases", buildKnowledgeContext(issue));
|
||||
return vars;
|
||||
}
|
||||
|
||||
private String buildKnowledgeContext(Issue issue) {
|
||||
try {
|
||||
StringBuilder query = new StringBuilder(issue.getTitle() == null ? "" : issue.getTitle());
|
||||
if (issue.getDescription() != null && !issue.getDescription().isBlank()) {
|
||||
if (query.length() > 0) {
|
||||
query.append(" ");
|
||||
}
|
||||
query.append(issue.getDescription());
|
||||
}
|
||||
if (query.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
List<?> results = (List<?>) knowledgeSearchTool.execute(
|
||||
Map.of("query", query.toString(), "top_k", 5));
|
||||
if (results == null || results.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
Object r = results.get(i);
|
||||
if (r instanceof Map) {
|
||||
Map<?, ?> m = (Map<?, ?>) r;
|
||||
sb.append(i + 1).append(". ")
|
||||
.append(m.get("doc_name")).append(": ")
|
||||
.append(m.get("content")).append("\n");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("知识库检索失败,降级为无相似案例: {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private AiAnalysisResponse toResponse(AiAnalysis a) {
|
||||
AiAnalysisResponse resp = new AiAnalysisResponse();
|
||||
resp.setId(a.getId());
|
||||
resp.setCategory(a.getCategory());
|
||||
resp.setKeywords(a.getKeywords());
|
||||
resp.setExtractedKeywords(a.getExtractedKeywords());
|
||||
resp.setRootCause(a.getRootCause());
|
||||
resp.setSuggestion(a.getSuggestion());
|
||||
resp.setStatus(a.getStatus());
|
||||
resp.setHelpfulCount(a.getHelpfulCount());
|
||||
resp.setPromptTemplateId(a.getPromptTemplateId());
|
||||
resp.setPromptVersion(a.getPromptVersion());
|
||||
resp.setModelProvider(a.getModelProvider());
|
||||
resp.setModelName(a.getModelName());
|
||||
resp.setErrorMessage(a.getErrorMessage());
|
||||
resp.setStartedAt(a.getStartedAt());
|
||||
resp.setCompletedAt(a.getCompletedAt());
|
||||
resp.setCreatedAt(a.getCreatedAt());
|
||||
if (a.getIssue() != null) {
|
||||
resp.setIssueId(a.getIssue().getId());
|
||||
resp.setIssueNo(a.getIssue().getIssueNo());
|
||||
resp.setIssueTitle(a.getIssue().getTitle());
|
||||
if (a.getIssue().getDepartment() != null) {
|
||||
resp.setDepartmentName(a.getIssue().getDepartment().getName());
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private String text(ObjectNode json, String field) {
|
||||
JsonNode node = get(json, field);
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return node.isContainerNode() ? node.toString() : node.asText();
|
||||
}
|
||||
|
||||
private String joinArray(ObjectNode json, String field) {
|
||||
JsonNode node = get(json, field);
|
||||
if (node == null || !node.isArray()) {
|
||||
return "";
|
||||
}
|
||||
List<String> items = new ArrayList<>();
|
||||
for (JsonNode n : (ArrayNode) node) {
|
||||
items.add(n.asText());
|
||||
}
|
||||
return String.join(",", items);
|
||||
}
|
||||
|
||||
private JsonNode get(ObjectNode json, String field) {
|
||||
JsonNode node = json.get(field);
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> entry : json.properties()) {
|
||||
if (entry.getKey().equalsIgnoreCase(field)) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String snippet(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return text.length() > 300 ? text.substring(0, 300) + "..." : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.ims.api.dto.ai.AiConfigResponse;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.ollama.OllamaChatModel;
|
||||
import org.springframework.ai.ollama.api.OllamaApi;
|
||||
import org.springframework.ai.ollama.api.OllamaOptions;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Component
|
||||
public class AiProviderConfig {
|
||||
|
||||
private static final String DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
||||
private static final int CONNECT_TIMEOUT_MS = 15_000;
|
||||
private static final int READ_TIMEOUT_MS = 900_000;
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
|
||||
public AiProviderConfig(AiConfigService aiConfigService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
}
|
||||
|
||||
public ChatModel buildChatModel(String provider, AiConfigResponse cfg) {
|
||||
if ("deepseek".equalsIgnoreCase(provider)) {
|
||||
return buildDeepseekModel(cfg);
|
||||
}
|
||||
return buildOllamaModel(cfg);
|
||||
}
|
||||
|
||||
public String defaultModelName(String provider, AiConfigResponse cfg) {
|
||||
if ("deepseek".equalsIgnoreCase(provider)) {
|
||||
return cfg.getDeepseekModel();
|
||||
}
|
||||
return cfg.getOllamaChatModel();
|
||||
}
|
||||
|
||||
private ChatModel buildOllamaModel(AiConfigResponse cfg) {
|
||||
String baseUrl = cfg.getOllamaBaseUrl();
|
||||
String model = cfg.getOllamaChatModel();
|
||||
Double temperature = cfg.getOllamaTemperature() != null
|
||||
? cfg.getOllamaTemperature().doubleValue() : 0.3;
|
||||
Integer numPredict = cfg.getOllamaNumPredict() != null ? cfg.getOllamaNumPredict() : 8192;
|
||||
|
||||
OllamaOptions options = OllamaOptions.builder()
|
||||
.model(model)
|
||||
.temperature(temperature)
|
||||
.numPredict(numPredict)
|
||||
.build();
|
||||
return OllamaChatModel.builder()
|
||||
.ollamaApi(new OllamaApi(baseUrl, restClientBuilder(), WebClient.builder()))
|
||||
.defaultOptions(options)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ChatModel buildDeepseekModel(AiConfigResponse cfg) {
|
||||
String apiKey = aiConfigService.getEffectiveApiKey();
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
throw new BusinessException("未配置 DeepSeek API Key");
|
||||
}
|
||||
String model = cfg.getDeepseekModel();
|
||||
OpenAiApi api = new OpenAiApi(DEEPSEEK_BASE_URL, apiKey, restClientBuilder(), WebClient.builder());
|
||||
OpenAiChatOptions options = OpenAiChatOptions.builder()
|
||||
.model(model)
|
||||
.maxCompletionTokens(8192)
|
||||
.build();
|
||||
return new OpenAiChatModel(api, options);
|
||||
}
|
||||
|
||||
private RestClient.Builder restClientBuilder() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
factory.setReadTimeout(READ_TIMEOUT_MS);
|
||||
return RestClient.builder().requestFactory(factory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.api.service.ai.ChatService;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DeepSeekChatService implements ChatService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DeepSeekChatService.class);
|
||||
private static final String API_URL = "https://api.deepseek.com/v1/chat/completions";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiConfigService aiConfigService;
|
||||
private final RestClient restClient;
|
||||
|
||||
public DeepSeekChatService(ObjectMapper objectMapper, AiConfigService aiConfigService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.restClient = RestClient.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chat(String systemPrompt, String userPrompt) {
|
||||
String apiKey = aiConfigService.getEffectiveApiKey();
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
throw new IllegalStateException("DeepSeek API key is not configured");
|
||||
}
|
||||
String model = aiConfigService.getConfig().getDeepseekModel();
|
||||
|
||||
Map<String, Object> body = Map.of(
|
||||
"model", model,
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", systemPrompt),
|
||||
Map.of("role", "user", "content", userPrompt)
|
||||
),
|
||||
"stream", false,
|
||||
"temperature", 0.3
|
||||
);
|
||||
|
||||
try {
|
||||
String json = restClient.post()
|
||||
.uri(API_URL)
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode content = root.path("choices").path(0).path("message").get("content");
|
||||
if (content == null || content.isMissingNode()) {
|
||||
throw new IllegalStateException("DeepSeek chat empty response: " + json);
|
||||
}
|
||||
return content.asText();
|
||||
} catch (Exception e) {
|
||||
log.error("DeepSeek chat failed", e);
|
||||
throw new IllegalStateException("DeepSeek chat failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class KeywordExtractor {
|
||||
|
||||
private static final int MAX_KEYWORDS = 4;
|
||||
|
||||
private static final List<String> TERMS = Arrays.asList(
|
||||
"需求", "设计", "编码", "实现", "测试", "验证", "环境", "数据", "接口",
|
||||
"性能", "安全", "流程", "配置", "文档", "依赖", "版本", "部署", "运维",
|
||||
"报错", "崩溃", "超时", "内存", "数据库", "缓存", "并发", "权限",
|
||||
"兼容", "升级", "迁移", "备份", "日志", "异常", "边界", "加载",
|
||||
"响应", "延迟", "质量", "验收", "规范", "标准", "自动化"
|
||||
);
|
||||
|
||||
public String extract(String title, String description, String category, String phase) {
|
||||
String text = (value(title) + " " + value(description) + " " + value(category) + " " + value(phase)).trim();
|
||||
if (text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<String> found = new ArrayList<>();
|
||||
for (String term : TERMS) {
|
||||
if (text.contains(term)) {
|
||||
found.add(term);
|
||||
}
|
||||
}
|
||||
if (found.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
found.sort(Comparator.comparingInt(text::indexOf));
|
||||
if (found.size() > MAX_KEYWORDS) {
|
||||
found = new ArrayList<>(found.subList(0, MAX_KEYWORDS));
|
||||
}
|
||||
return String.join(",", found);
|
||||
}
|
||||
|
||||
private String value(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.ims.api.dto.ai.AiConfigResponse;
|
||||
import com.ims.service.agent.tool.AgentFunctionCallbackAdapter;
|
||||
import com.ims.service.agent.tool.ToolRegistry;
|
||||
import com.ims.service.entity.AiCallLog;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import com.ims.service.repository.AiCallLogRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.ollama.api.OllamaOptions;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ModelRoutingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ModelRoutingService.class);
|
||||
|
||||
private final AiProviderConfig aiProviderConfig;
|
||||
private final AiConfigService aiConfigService;
|
||||
private final AiCallLogRepository aiCallLogRepository;
|
||||
private final ToolRegistry toolRegistry;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile String cachedProvider;
|
||||
private volatile ChatModel cachedChatModel;
|
||||
private volatile String cachedFallbackProvider;
|
||||
private volatile ChatModel cachedFallbackChatModel;
|
||||
|
||||
public ModelRoutingService(AiProviderConfig aiProviderConfig, AiConfigService aiConfigService,
|
||||
AiCallLogRepository aiCallLogRepository,
|
||||
@Lazy ToolRegistry toolRegistry, ObjectMapper objectMapper) {
|
||||
this.aiProviderConfig = aiProviderConfig;
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.aiCallLogRepository = aiCallLogRepository;
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public String currentProvider() {
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
String provider = cfg.getProvider();
|
||||
return provider == null || provider.isBlank() ? "ollama" : provider;
|
||||
}
|
||||
|
||||
public ChatModel chatModel() {
|
||||
String provider = currentProvider();
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
if (cachedProvider == null || !cachedProvider.equals(provider)) {
|
||||
cachedProvider = provider;
|
||||
cachedChatModel = aiProviderConfig.buildChatModel(provider, cfg);
|
||||
log.info("路由切换 ChatModel -> {}", provider);
|
||||
}
|
||||
return cachedChatModel;
|
||||
}
|
||||
|
||||
private ChatModel fallbackChatModel() {
|
||||
String provider = currentProvider();
|
||||
String fallback = "deepseek".equalsIgnoreCase(provider) ? "ollama" : "deepseek";
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
if (cachedFallbackProvider == null || !cachedFallbackProvider.equals(fallback)) {
|
||||
cachedFallbackProvider = fallback;
|
||||
cachedFallbackChatModel = aiProviderConfig.buildChatModel(fallback, cfg);
|
||||
}
|
||||
return cachedFallbackChatModel;
|
||||
}
|
||||
|
||||
public CallResult call(String systemPrompt, String userPrompt) {
|
||||
String provider = currentProvider();
|
||||
try {
|
||||
String text = call(chatModel(), systemPrompt, userPrompt);
|
||||
return new CallResult(provider, text);
|
||||
} catch (Exception primaryError) {
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
boolean fallbackEnabled = cfg.getAutoFallbackEnabled() != null && cfg.getAutoFallbackEnabled();
|
||||
if (!fallbackEnabled) {
|
||||
throw primaryError;
|
||||
}
|
||||
String fallback = "deepseek".equalsIgnoreCase(provider) ? "ollama" : "deepseek";
|
||||
log.warn("主引擎 {} 调用失败,降级到 {}: {}", provider, fallback, primaryError.getMessage());
|
||||
String text = call(fallbackChatModel(), systemPrompt, userPrompt);
|
||||
return new CallResult(fallback, text);
|
||||
}
|
||||
}
|
||||
|
||||
private String call(ChatModel model, String systemPrompt, String userPrompt) {
|
||||
String provider = providerOf(model);
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
Prompt prompt = new Prompt(new SystemMessage(systemPrompt), new UserMessage(userPrompt));
|
||||
ChatResponse response = model.call(prompt);
|
||||
String text = response.getResult().getOutput().getText();
|
||||
saveLog(provider, modelNameOf(provider), "success",
|
||||
System.currentTimeMillis() - start, snippet(text), null);
|
||||
return text;
|
||||
} catch (Exception e) {
|
||||
saveLog(provider, modelNameOf(provider), "failed",
|
||||
System.currentTimeMillis() - start, null, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public ToolCallResult callWithTools(String systemPrompt, String userPrompt) {
|
||||
String provider = currentProvider();
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
if (!"ollama".equalsIgnoreCase(provider)) {
|
||||
String text = call(chatModel(), systemPrompt, userPrompt);
|
||||
return new ToolCallResult(provider, text, List.of());
|
||||
}
|
||||
List<FunctionCallback> callbacks = new ArrayList<>();
|
||||
for (var tool : toolRegistry.tools()) {
|
||||
callbacks.add(new AgentFunctionCallbackAdapter(tool, objectMapper));
|
||||
}
|
||||
Set<String> names = callbacks.stream().map(FunctionCallback::getName).collect(Collectors.toSet());
|
||||
OllamaOptions options = OllamaOptions.builder()
|
||||
.model(cfg.getOllamaChatModel())
|
||||
.temperature(cfg.getOllamaTemperature() != null ? cfg.getOllamaTemperature().doubleValue() : 0.3)
|
||||
.numPredict(cfg.getOllamaNumPredict() != null ? cfg.getOllamaNumPredict() : 4096)
|
||||
.toolCallbacks(callbacks)
|
||||
.toolNames(names)
|
||||
.internalToolExecutionEnabled(false)
|
||||
.build();
|
||||
Prompt prompt = new Prompt(List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)), options);
|
||||
ChatResponse response = chatModel().call(prompt);
|
||||
AssistantMessage message = response.getResult().getOutput();
|
||||
List<ToolCallInfo> infos = new ArrayList<>();
|
||||
if (message.getToolCalls() != null) {
|
||||
for (AssistantMessage.ToolCall tc : message.getToolCalls()) {
|
||||
infos.add(new ToolCallInfo(tc.name(), tc.arguments()));
|
||||
}
|
||||
}
|
||||
String text = message.getText();
|
||||
saveLog(provider, modelNameOf(provider), "success",
|
||||
System.currentTimeMillis() - start, snippet(text), null);
|
||||
return new ToolCallResult(provider, text, infos);
|
||||
} catch (Exception e) {
|
||||
saveLog(provider, modelNameOf(provider), "failed",
|
||||
System.currentTimeMillis() - start, null, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public record ToolCallInfo(String name, String arguments) {
|
||||
}
|
||||
|
||||
public record ToolCallResult(String provider, String text, List<ToolCallInfo> toolCalls) {
|
||||
}
|
||||
|
||||
private String providerOf(ChatModel model) {
|
||||
if (model instanceof OpenAiChatModel) {
|
||||
return "deepseek";
|
||||
}
|
||||
return "ollama";
|
||||
}
|
||||
|
||||
private String modelNameOf(String provider) {
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
return "deepseek".equalsIgnoreCase(provider) ? cfg.getDeepseekModel() : cfg.getOllamaChatModel();
|
||||
}
|
||||
|
||||
private String snippet(String text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return text.length() > 200 ? text.substring(0, 200) : text;
|
||||
}
|
||||
|
||||
private void saveLog(String provider, String model, String status, long latencyMs, String snippet, String error) {
|
||||
try {
|
||||
AiCallLog log = AiCallLog.builder()
|
||||
.provider(provider)
|
||||
.model(model)
|
||||
.status(status)
|
||||
.latencyMs(latencyMs)
|
||||
.responseSnippet(snippet)
|
||||
.errorMessage(error)
|
||||
.build();
|
||||
aiCallLogRepository.save(log);
|
||||
} catch (Exception e) {
|
||||
log.warn("保存 AI 调用日志失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public record CallResult(String provider, String text) {
|
||||
}
|
||||
|
||||
public Map<String, Object> test() {
|
||||
String provider = currentProvider();
|
||||
AiConfigResponse cfg = aiConfigService.getConfig();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
String text = call(chatModel(), "你是测试助手", "请仅回复两个字:正常");
|
||||
result.put("success", true);
|
||||
result.put("provider", provider);
|
||||
result.put("model", "deepseek".equalsIgnoreCase(provider) ? cfg.getDeepseekModel() : cfg.getOllamaChatModel());
|
||||
result.put("latencyMs", System.currentTimeMillis() - start);
|
||||
result.put("reply", text == null ? "" : (text.length() > 200 ? text.substring(0, 200) : text));
|
||||
} catch (Exception e) {
|
||||
result.put("success", false);
|
||||
result.put("provider", provider);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.api.service.ai.ChatService;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service("aiChatOllamaService")
|
||||
public class OllamaChatService implements ChatService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OllamaChatService.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiConfigService aiConfigService;
|
||||
private final int connectTimeoutMs;
|
||||
private final int readTimeoutMs;
|
||||
|
||||
public OllamaChatService(ObjectMapper objectMapper, AiConfigService aiConfigService,
|
||||
@org.springframework.beans.factory.annotation.Value("${ollama.timeout.connect:10000}") int connectTimeoutMs,
|
||||
@org.springframework.beans.factory.annotation.Value("${ollama.timeout.read:1800000}") int readTimeoutMs) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
this.readTimeoutMs = readTimeoutMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chat(String systemPrompt, String userPrompt) {
|
||||
String baseUrl = aiConfigService.getConfig().getOllamaBaseUrl();
|
||||
String model = aiConfigService.getConfig().getOllamaChatModel();
|
||||
if (model == null || model.isBlank()) {
|
||||
throw new IllegalStateException("Ollama chat model is not configured");
|
||||
}
|
||||
|
||||
Map<String, Object> body = Map.of(
|
||||
"model", model,
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", systemPrompt),
|
||||
Map.of("role", "user", "content", userPrompt)
|
||||
),
|
||||
"stream", false,
|
||||
"options", Map.of("temperature", 0.3, "num_predict", 1024)
|
||||
);
|
||||
|
||||
try {
|
||||
String requestBody = objectMapper.writeValueAsString(body);
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(baseUrl + "/api/chat").toURL().openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(connectTimeoutMs);
|
||||
conn.setReadTimeout(readTimeoutMs);
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(requestBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code != 200) {
|
||||
String err = new String(conn.getErrorStream() == null ? new byte[0] : conn.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
throw new IllegalStateException("Ollama chat HTTP " + code + ": " + err);
|
||||
}
|
||||
|
||||
String json = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode content = root.path("message").get("content");
|
||||
if (content == null || content.isMissingNode()) {
|
||||
throw new IllegalStateException("Ollama chat empty response: " + json);
|
||||
}
|
||||
return content.asText();
|
||||
} catch (Exception e) {
|
||||
log.error("Ollama chat failed", e);
|
||||
throw new IllegalStateException("Ollama chat failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PromptFormatter {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PromptFormatter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ObjectNode extractJsonObject(String output) {
|
||||
if (output == null || output.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String text = output.trim();
|
||||
if (text.contains("```")) {
|
||||
text = text.replaceAll("```(json)?", "").replaceAll("```", "").trim();
|
||||
}
|
||||
int start = text.indexOf('{');
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
String candidate = extractObject(text, start);
|
||||
if (candidate == null) {
|
||||
return null;
|
||||
}
|
||||
ObjectNode node = tryParse(candidate);
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
node = tryParse(escapeNewlinesInStrings(candidate));
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
node = tryParse(escapeNewlinesInStrings(candidate) + "}");
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ObjectNode tryParse(String json) {
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
if (node instanceof ObjectNode) {
|
||||
return (ObjectNode) node;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractObject(String text, int start) {
|
||||
boolean inString = false;
|
||||
boolean escaped = false;
|
||||
int depth = 0;
|
||||
for (int i = start; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (!inString) {
|
||||
if (c == '{') {
|
||||
depth++;
|
||||
} else if (c == '}' && --depth == 0) {
|
||||
return text.substring(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String escapeNewlinesInStrings(String json) {
|
||||
StringBuilder sb = new StringBuilder(json.length());
|
||||
boolean inString = false;
|
||||
boolean escaped = false;
|
||||
for (int i = 0; i < json.length(); i++) {
|
||||
char c = json.charAt(i);
|
||||
if (escaped) {
|
||||
sb.append(c);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\') {
|
||||
sb.append(c);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
inString = !inString;
|
||||
sb.append(c);
|
||||
continue;
|
||||
}
|
||||
if (inString && (c == '\n' || c == '\r' || c == '\t')) {
|
||||
sb.append("\\n");
|
||||
continue;
|
||||
}
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.PromptRenderLog;
|
||||
import com.ims.service.entity.PromptTemplate;
|
||||
import com.ims.service.repository.PromptRenderLogRepository;
|
||||
import com.ims.service.repository.PromptTemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class PromptTemplateEngine {
|
||||
|
||||
private static final Pattern VAR_PATTERN = Pattern.compile("\\{\\{(\\w+)\\}\\}");
|
||||
|
||||
private final PromptTemplateRepository templateRepository;
|
||||
private final PromptRenderLogRepository renderLogRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PromptTemplateEngine(PromptTemplateRepository templateRepository,
|
||||
PromptRenderLogRepository renderLogRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.templateRepository = templateRepository;
|
||||
this.renderLogRepository = renderLogRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public PromptTemplate loadActive(String templateId) {
|
||||
return templateRepository.findByTemplateIdAndIsActiveTrue(templateId)
|
||||
.orElseThrow(() -> new BusinessException("Prompt模板不存在或已停用: " + templateId));
|
||||
}
|
||||
|
||||
public String render(String templateId, Map<String, Object> variables) {
|
||||
PromptTemplate template = loadActive(templateId);
|
||||
return render(template, variables);
|
||||
}
|
||||
|
||||
public String render(PromptTemplate template, Map<String, Object> variables) {
|
||||
validateVariables(template, variables);
|
||||
String content = template.getContent();
|
||||
if (variables != null) {
|
||||
for (Map.Entry<String, Object> entry : variables.entrySet()) {
|
||||
String placeholder = "{{" + entry.getKey() + "}}";
|
||||
content = content.replace(placeholder, formatValue(entry.getValue()));
|
||||
}
|
||||
}
|
||||
Matcher matcher = VAR_PATTERN.matcher(content);
|
||||
return matcher.replaceAll("");
|
||||
}
|
||||
|
||||
public String renderAndLog(String templateId, Map<String, Object> variables,
|
||||
String modelProvider, String llmModel) {
|
||||
long start = System.currentTimeMillis();
|
||||
PromptTemplate template = loadActive(templateId);
|
||||
String rendered = render(template, variables);
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
|
||||
PromptRenderLog log = new PromptRenderLog();
|
||||
log.setRequestId(UUID.randomUUID().toString().replace("-", ""));
|
||||
log.setTemplateId(template.getTemplateId());
|
||||
log.setTemplateVersion(template.getVersion());
|
||||
log.setRenderedPrompt(rendered);
|
||||
log.setVariablesUsed(toJson(variables));
|
||||
log.setTokensInput(estimateTokens(rendered));
|
||||
log.setExecutionTimeMs((int) cost);
|
||||
log.setLlmModel(llmModel);
|
||||
log.setModelProvider(modelProvider);
|
||||
renderLogRepository.save(log);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private void validateVariables(PromptTemplate template, Map<String, Object> variables) {
|
||||
JsonNode requiredVars = parseVariables(template.getVariables());
|
||||
if (requiredVars == null || !requiredVars.isArray()) {
|
||||
return;
|
||||
}
|
||||
for (JsonNode node : requiredVars) {
|
||||
if (node.has("required") && node.get("required").asBoolean()) {
|
||||
String name = node.get("name").asText();
|
||||
Object value = variables == null ? null : variables.get(name);
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
throw new BusinessException("缺少必需变量: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode parseVariables(String variables) {
|
||||
if (variables == null || variables.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(variables);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String formatValue(Object value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
return toJson(value);
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
if (value == null) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private int estimateTokens(String text) {
|
||||
return text == null ? 0 : (int) Math.ceil(text.length() / 4.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.ims.api.service.ai.ChatService;
|
||||
import com.ims.service.knowledge.AiConfigService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class RoutingChatService implements ChatService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RoutingChatService.class);
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final OllamaChatService ollamaChatService;
|
||||
private final DeepSeekChatService deepSeekChatService;
|
||||
|
||||
public RoutingChatService(AiConfigService aiConfigService,
|
||||
OllamaChatService ollamaChatService,
|
||||
DeepSeekChatService deepSeekChatService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.ollamaChatService = ollamaChatService;
|
||||
this.deepSeekChatService = deepSeekChatService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chat(String systemPrompt, String userPrompt) {
|
||||
String provider = aiConfigService.getConfig().getProvider();
|
||||
boolean fallbackEnabled = Boolean.TRUE.equals(aiConfigService.getConfig().getAutoFallbackEnabled());
|
||||
try {
|
||||
if ("deepseek".equalsIgnoreCase(provider)) {
|
||||
return deepSeekChatService.chat(systemPrompt, userPrompt);
|
||||
}
|
||||
return ollamaChatService.chat(systemPrompt, userPrompt);
|
||||
} catch (Exception e) {
|
||||
if (fallbackEnabled) {
|
||||
try {
|
||||
if ("deepseek".equalsIgnoreCase(provider)) {
|
||||
log.warn("DeepSeek chat failed, fallback to Ollama: {}", e.getMessage());
|
||||
return ollamaChatService.chat(systemPrompt, userPrompt);
|
||||
}
|
||||
log.warn("Ollama chat failed, fallback to DeepSeek: {}", e.getMessage());
|
||||
return deepSeekChatService.chat(systemPrompt, userPrompt);
|
||||
} catch (Exception fe) {
|
||||
log.error("Fallback chat provider also failed", fe);
|
||||
throw new IllegalStateException("AI chat unavailable: " + fe.getMessage(), fe);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("AI chat unavailable: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ims.service.ai;
|
||||
|
||||
import com.ims.service.entity.Role;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.entity.UserRole;
|
||||
import com.ims.service.repository.RoleRepository;
|
||||
import com.ims.service.repository.UserRoleRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class SystemContextBuilder {
|
||||
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final ModelRoutingService modelRoutingService;
|
||||
|
||||
public SystemContextBuilder(UserRoleRepository userRoleRepository,
|
||||
RoleRepository roleRepository,
|
||||
ModelRoutingService modelRoutingService) {
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.modelRoutingService = modelRoutingService;
|
||||
}
|
||||
|
||||
public Map<String, Object> build(User user, List<String> availableTools) {
|
||||
Map<String, Object> vars = new HashMap<>();
|
||||
if (user != null) {
|
||||
vars.put("currentUserName", user.getUsername());
|
||||
vars.put("currentUserRole", resolveRoleName(user.getId()));
|
||||
vars.put("currentUserDepartment", user.getDepartment() != null ? user.getDepartment().getName() : "");
|
||||
vars.put("agentAutoExecuteEnabled", user.getAgentAutoExecute() != null && user.getAgentAutoExecute());
|
||||
} else {
|
||||
vars.put("currentUserName", "系统");
|
||||
vars.put("currentUserRole", "系统管理员");
|
||||
vars.put("currentUserDepartment", "");
|
||||
vars.put("agentAutoExecuteEnabled", false);
|
||||
}
|
||||
if (availableTools == null || availableTools.isEmpty()) {
|
||||
vars.put("availableTools", "(本任务无需工具)");
|
||||
} else {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String t : availableTools) {
|
||||
sb.append("- ").append(t).append("\n");
|
||||
}
|
||||
vars.put("availableTools", sb.toString());
|
||||
}
|
||||
vars.put("currentTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
vars.put("modelProvider", modelRoutingService.currentProvider());
|
||||
return vars;
|
||||
}
|
||||
|
||||
private String resolveRoleName(Long userId) {
|
||||
List<UserRole> userRoles = userRoleRepository.findByUserId(userId);
|
||||
if (userRoles.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (UserRole ur : userRoles) {
|
||||
roleRepository.findById(ur.getRoleId()).map(Role::getName).ifPresent(names::add);
|
||||
}
|
||||
return String.join(",", names);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ims.service.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ims.service.config;
|
||||
|
||||
import com.ims.common.util.JwtUtil;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class JwtConfig {
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.access-token-expiration}")
|
||||
private long accessTokenExpiration;
|
||||
|
||||
@Value("${jwt.refresh-token-expiration}")
|
||||
private long refreshTokenExpiration;
|
||||
|
||||
@Bean
|
||||
public JwtUtil jwtUtil() {
|
||||
return new JwtUtil(secret, accessTokenExpiration, refreshTokenExpiration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ims.service.config;
|
||||
|
||||
import io.minio.MinioClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MinioConfig {
|
||||
|
||||
@Value("${minio.endpoint}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${minio.access-key}")
|
||||
private String accessKey;
|
||||
|
||||
@Value("${minio.secret-key}")
|
||||
private String secretKey;
|
||||
|
||||
@Bean
|
||||
public MinioClient minioClient() {
|
||||
return MinioClient.builder()
|
||||
.endpoint(endpoint)
|
||||
.credentials(accessKey, secretKey)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ims.service.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
return new StringRedisTemplate(connectionFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ims.service.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
private final CorsConfigurationSource corsConfigurationSource;
|
||||
|
||||
public WebConfig(CorsConfigurationSource corsConfigurationSource) {
|
||||
this.corsConfigurationSource = corsConfigurationSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
return new CorsFilter(corsConfigurationSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.ims.service.dashboard;
|
||||
|
||||
import com.ims.api.dto.dashboard.DashboardStatsResponse;
|
||||
import com.ims.service.entity.IssueLog;
|
||||
import com.ims.service.repository.IssueLogRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DashboardService {
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final IssueLogRepository issueLogRepository;
|
||||
|
||||
public DashboardService(EntityManager entityManager, IssueLogRepository issueLogRepository) {
|
||||
this.entityManager = entityManager;
|
||||
this.issueLogRepository = issueLogRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DashboardStatsResponse stats() {
|
||||
DashboardStatsResponse resp = new DashboardStatsResponse();
|
||||
Map<String, Long> statusCounts = statusCounts();
|
||||
resp.setPendingCount(statusCounts.getOrDefault("open", 0L));
|
||||
resp.setInProgressCount(statusCounts.getOrDefault("in_progress", 0L));
|
||||
resp.setPendingConfirmCount(statusCounts.getOrDefault("pending_confirm", 0L));
|
||||
resp.setClosedCount(statusCounts.getOrDefault("closed", 0L));
|
||||
resp.setTodayNewCount(todayNewCount());
|
||||
resp.setMonthlyClosedCount(monthlyClosedCount());
|
||||
|
||||
List<DashboardStatsResponse.StatusCount> distribution = new ArrayList<>();
|
||||
statusCounts.forEach((k, v) -> distribution.add(new DashboardStatsResponse.StatusCount(k, v)));
|
||||
resp.setStatusDistribution(distribution);
|
||||
|
||||
resp.setTrend(trend7Days());
|
||||
resp.setRecentActivities(recentActivities());
|
||||
resp.setInsights(insights());
|
||||
resp.setCards(buildCards());
|
||||
return resp;
|
||||
}
|
||||
|
||||
private List<DashboardStatsResponse.CardInfo> buildCards() {
|
||||
Map<String, Long> counts = statusCounts();
|
||||
long pending = counts.getOrDefault("open", 0L);
|
||||
long inProgress = counts.getOrDefault("in_progress", 0L);
|
||||
long todayNew = todayNewCount();
|
||||
long monthlyClosed = monthlyClosedCount();
|
||||
|
||||
long overdue = countBySql(
|
||||
"select count(*) from issues where is_deleted = false and status <> 'closed' and deadline < now()");
|
||||
long highRisk = countBySql(
|
||||
"select count(*) from issues where is_deleted = false and status in ('open','in_progress') " +
|
||||
"and priority in ('urgent','high')");
|
||||
|
||||
long yesterdayNew = countCreatedOn(LocalDate.now().minusDays(1));
|
||||
long lastMonthClosed = countClosedInMonth(LocalDate.now().minusMonths(1));
|
||||
|
||||
List<DashboardStatsResponse.CardInfo> cards = new ArrayList<>();
|
||||
cards.add(new DashboardStatsResponse.CardInfo("pendingCount", pending,
|
||||
overdue > 0 ? "其中 " + overdue + " 条已逾期" : "无逾期",
|
||||
overdue > 0 ? "建议启动催办,避免影响工程进度。" : "待处理指摘无逾期,请保持跟进。"));
|
||||
cards.add(new DashboardStatsResponse.CardInfo("inProgressCount", inProgress,
|
||||
highRisk > 0 ? "其中 " + highRisk + " 条 urgent/high" : "暂无高风险",
|
||||
highRisk > 0 ? "高优先级指摘建议优先分配处理。" : "进行中指摘均在正常推进。"));
|
||||
cards.add(new DashboardStatsResponse.CardInfo("monthlyClosedCount", monthlyClosed,
|
||||
lastMonthClosed > 0 ? "较上月" + changeText(monthlyClosed, lastMonthClosed) : "上月无关闭",
|
||||
"本月已关闭 " + monthlyClosed + " 条,处理效率" + (monthlyClosed >= lastMonthClosed ? "较上月提升。" : "较上月下降,建议跟进。")));
|
||||
cards.add(new DashboardStatsResponse.CardInfo("todayNewCount", todayNew,
|
||||
"较昨日" + changeText(todayNew, yesterdayNew),
|
||||
"今日新增 " + todayNew + " 条指摘,请及时确认并分配。"));
|
||||
return cards;
|
||||
}
|
||||
|
||||
private String changeText(long current, long base) {
|
||||
if (base <= 0) {
|
||||
return current > 0 ? " +" + current : " 持平";
|
||||
}
|
||||
long diff = current - base;
|
||||
long pct = Math.round(diff * 100.0 / base);
|
||||
return (diff >= 0 ? " +" : " ") + pct + "%";
|
||||
}
|
||||
|
||||
private long countCreatedOn(LocalDate date) {
|
||||
Object r = entityManager.createQuery(
|
||||
"select count(i) from Issue i where i.createdAt >= :from and i.createdAt < :to and i.isDeleted = false")
|
||||
.setParameter("from", date.atStartOfDay())
|
||||
.setParameter("to", date.plusDays(1).atStartOfDay())
|
||||
.getSingleResult();
|
||||
return ((Number) r).longValue();
|
||||
}
|
||||
|
||||
private long countClosedInMonth(LocalDate month) {
|
||||
Object r = entityManager.createNativeQuery(
|
||||
"select count(*) from issues where is_deleted = false and closed_at is not null " +
|
||||
"and CAST(closed_at AS DATE) >= :from and CAST(closed_at AS DATE) <= :to")
|
||||
.setParameter("from", month.withDayOfMonth(1))
|
||||
.setParameter("to", month.withDayOfMonth(month.lengthOfMonth()))
|
||||
.getSingleResult();
|
||||
return ((Number) r).longValue();
|
||||
}
|
||||
|
||||
private long monthlyClosedCount() {
|
||||
LocalDate today = LocalDate.now();
|
||||
Object r = entityManager.createNativeQuery(
|
||||
"select count(*) from issues where is_deleted = false and closed_at is not null " +
|
||||
"and CAST(closed_at AS DATE) >= :from and CAST(closed_at AS DATE) <= :to")
|
||||
.setParameter("from", today.withDayOfMonth(1))
|
||||
.setParameter("to", today)
|
||||
.getSingleResult();
|
||||
return ((Number) r).longValue();
|
||||
}
|
||||
|
||||
private List<DashboardStatsResponse.Insight> insights() {
|
||||
List<DashboardStatsResponse.Insight> list = new ArrayList<>();
|
||||
long highRisk = countBySql(
|
||||
"select count(*) from issues where is_deleted = false and status in ('open','in_progress') " +
|
||||
"and priority in ('urgent','high')");
|
||||
long overdue = countBySql(
|
||||
"select count(*) from issues where is_deleted = false and status <> 'closed' and deadline < now()");
|
||||
long kbDocs = countBySql(
|
||||
"select count(*) from knowledge_documents where status = 'completed'");
|
||||
list.add(new DashboardStatsResponse.Insight("high-risk", "高风险警报",
|
||||
"进行中有 " + highRisk + " 条 urgent/high 优先级的指摘,建议优先跟进。"));
|
||||
list.add(new DashboardStatsResponse.Insight("reminder", "任务提醒",
|
||||
"有 " + overdue + " 条指摘已逾期未关闭,建议启动自动催办。"));
|
||||
list.add(new DashboardStatsResponse.Insight("suggestion", "自动对应建议",
|
||||
"知识库已收录 " + kbDocs + " 篇案例文档,Agent 可检索相似方案辅助对应。"));
|
||||
return list;
|
||||
}
|
||||
|
||||
private long countBySql(String sql) {
|
||||
Object r = entityManager.createNativeQuery(sql).getSingleResult();
|
||||
return ((Number) r).longValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Long> statusCounts() {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
List<Object[]> rows = entityManager.createNativeQuery(
|
||||
"select status, count(*) from issues where is_deleted = false group by status").getResultList();
|
||||
for (Object[] row : rows) {
|
||||
map.put((String) row[0], ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private long todayNewCount() {
|
||||
LocalDateTime start = LocalDate.now().atStartOfDay();
|
||||
Object r = entityManager.createQuery(
|
||||
"select count(i) from Issue i where i.createdAt >= :start and i.isDeleted = false")
|
||||
.setParameter("start", start).getSingleResult();
|
||||
return ((Number) r).longValue();
|
||||
}
|
||||
|
||||
private List<DashboardStatsResponse.DailyTrend> trend7Days() {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate from = today.minusDays(6);
|
||||
Map<LocalDate, Long> created = countGroupedByDay("created_at", from);
|
||||
Map<LocalDate, Long> closed = countGroupedByDay("closed_at", from);
|
||||
List<DashboardStatsResponse.DailyTrend> trend = new ArrayList<>();
|
||||
for (int i = 0; i < 7; i++) {
|
||||
LocalDate d = from.plusDays(i);
|
||||
trend.add(new DashboardStatsResponse.DailyTrend(
|
||||
d.toString(), created.getOrDefault(d, 0L), closed.getOrDefault(d, 0L)));
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<LocalDate, Long> countGroupedByDay(String column, LocalDate from) {
|
||||
Map<LocalDate, Long> map = new HashMap<>();
|
||||
List<Object[]> rows = entityManager.createNativeQuery(
|
||||
"select CAST(" + column + " AS DATE), count(*) from issues where " + column +
|
||||
" >= :from and is_deleted = false group by 1")
|
||||
.setParameter("from", from.atStartOfDay()).getResultList();
|
||||
for (Object[] row : rows) {
|
||||
map.put(((java.sql.Date) row[0]).toLocalDate(), ((Number) row[1]).longValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private List<DashboardStatsResponse.Activity> recentActivities() {
|
||||
List<IssueLog> logs = issueLogRepository
|
||||
.findAll(PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))).getContent();
|
||||
List<DashboardStatsResponse.Activity> list = new ArrayList<>();
|
||||
for (IssueLog l : logs) {
|
||||
list.add(new DashboardStatsResponse.Activity(
|
||||
l.getUser() != null ? l.getUser().getUsername() : "",
|
||||
l.getAction(),
|
||||
l.getIssue() != null && l.getIssue().getIssueNo() != null ? l.getIssue().getIssueNo() : "",
|
||||
l.getIssue() != null && l.getIssue().getTitle() != null ? l.getIssue().getTitle() : "",
|
||||
l.getCreatedAt()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "agent_memories")
|
||||
public class AgentMemory {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "issue_summary", nullable = false, columnDefinition = "TEXT")
|
||||
private String issueSummary;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "solution_steps", nullable = false, columnDefinition = "jsonb")
|
||||
private String solutionSteps;
|
||||
|
||||
@Column(name = "effectiveness_score", precision = 3, scale = 2)
|
||||
private BigDecimal effectivenessScore;
|
||||
|
||||
@Column(columnDefinition = "vector(1536)")
|
||||
private String embedding;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (effectivenessScore == null) effectivenessScore = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "agent_plans")
|
||||
public class AgentPlan {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issue_id", nullable = true)
|
||||
private Issue issue;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String goal;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "plan_steps", nullable = false, columnDefinition = "jsonb")
|
||||
private String planSteps;
|
||||
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "requires_approval")
|
||||
private Boolean requiresApproval;
|
||||
|
||||
@Column(name = "approval_status", length = 20)
|
||||
private String approvalStatus;
|
||||
|
||||
@Column(name = "approval_comment", length = 500)
|
||||
private String approvalComment;
|
||||
|
||||
@Column(name = "agent_message", columnDefinition = "TEXT")
|
||||
private String agentMessage;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by")
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "model_provider", length = 20)
|
||||
private String modelProvider;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (status == null) status = "pending";
|
||||
if (requiresApproval == null) requiresApproval = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "ai_analysis")
|
||||
public class AiAnalysis {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issue_id", nullable = false)
|
||||
private Issue issue;
|
||||
|
||||
@Column(length = 100)
|
||||
private String category;
|
||||
|
||||
@Column(length = 500)
|
||||
private String keywords;
|
||||
|
||||
@Column(name = "extracted_keywords", length = 500)
|
||||
private String extractedKeywords;
|
||||
|
||||
@Column(name = "root_cause", length = 1000)
|
||||
private String rootCause;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String suggestion;
|
||||
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "helpful_count")
|
||||
private Integer helpfulCount;
|
||||
|
||||
@Column(name = "prompt_template_id", length = 100)
|
||||
private String promptTemplateId;
|
||||
|
||||
@Column(name = "prompt_version")
|
||||
private Integer promptVersion;
|
||||
|
||||
@Column(name = "model_provider", length = 20)
|
||||
private String modelProvider;
|
||||
|
||||
@Column(name = "model_name", length = 50)
|
||||
private String modelName;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@Column(name = "started_at")
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (status == null) status = "pending";
|
||||
if (helpfulCount == null) helpfulCount = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "ai_call_logs")
|
||||
public class AiCallLog {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(length = 20)
|
||||
private String provider;
|
||||
|
||||
@Column(length = 100)
|
||||
private String model;
|
||||
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "latency_ms")
|
||||
private Long latencyMs;
|
||||
|
||||
@Column(name = "response_snippet", columnDefinition = "TEXT")
|
||||
private String responseSnippet;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "ai_feedback")
|
||||
public class AiFeedback {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "ai_analysis_id", nullable = false)
|
||||
private AiAnalysis aiAnalysis;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "is_helpful", nullable = false)
|
||||
private Boolean isHelpful;
|
||||
|
||||
@Column(length = 500)
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "attachments")
|
||||
public class Attachment {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issue_id", nullable = false)
|
||||
private Issue issue;
|
||||
|
||||
@Column(name = "file_name", nullable = false, length = 200)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "file_path", nullable = false, length = 500)
|
||||
private String filePath;
|
||||
|
||||
@Column(name = "file_size", nullable = false)
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "mime_type", length = 100)
|
||||
private String mimeType;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "uploaded_by", nullable = false)
|
||||
private User uploadedBy;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "departments")
|
||||
public class Department {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "parent_id")
|
||||
private Department parent;
|
||||
|
||||
@Column(name = "sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "import_records")
|
||||
public class ImportRecord {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "file_name", nullable = false, length = 255)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "total_count", nullable = false)
|
||||
private Integer totalCount;
|
||||
|
||||
@Column(name = "success_count", nullable = false)
|
||||
private Integer successCount;
|
||||
|
||||
@Column(name = "fail_count", nullable = false)
|
||||
private Integer failCount;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "error_log", columnDefinition = "TEXT")
|
||||
private String errorLog;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "operator_id")
|
||||
private User operator;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (status == null) status = "success";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "issues")
|
||||
public class Issue {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "issue_no", nullable = false, unique = true, length = 20)
|
||||
private String issueNo;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String status;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String priority;
|
||||
|
||||
private LocalDateTime deadline;
|
||||
|
||||
@Column(name = "review_date")
|
||||
private LocalDateTime reviewDate;
|
||||
|
||||
@Column(length = 50)
|
||||
private String phase;
|
||||
|
||||
@Column(name = "sub_project", length = 100)
|
||||
private String subProject;
|
||||
|
||||
@Column(length = 50)
|
||||
private String category;
|
||||
|
||||
@Column(name = "impact_level", length = 10)
|
||||
private String impactLevel;
|
||||
|
||||
@Column(name = "impact_scope", length = 200)
|
||||
private String impactScope;
|
||||
|
||||
@Column(length = 100)
|
||||
private String deployment;
|
||||
|
||||
@Column(name = "pgm_no", length = 50)
|
||||
private String pgmNo;
|
||||
|
||||
@Column(name = "review_workload", precision = 5, scale = 1)
|
||||
private BigDecimal reviewWorkload;
|
||||
|
||||
@Column(name = "response_workload", precision = 5, scale = 1)
|
||||
private BigDecimal responseWorkload;
|
||||
|
||||
@Column(name = "response_content", columnDefinition = "TEXT")
|
||||
private String responseContent;
|
||||
|
||||
@Column(name = "ng_reason", length = 200)
|
||||
private String ngReason;
|
||||
|
||||
@Column(name = "response_completed_at")
|
||||
private LocalDateTime responseCompletedAt;
|
||||
|
||||
@Column(name = "confirm_at")
|
||||
private LocalDateTime confirmAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "creator_id", nullable = false)
|
||||
private User creator;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "assignee_id")
|
||||
private User assignee;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "department_id", nullable = false)
|
||||
private Department department;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "reviewer_id")
|
||||
private User reviewer;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "validator_id")
|
||||
private User validator;
|
||||
|
||||
@Column(name = "ai_analysis_id")
|
||||
private Long aiAnalysisId;
|
||||
|
||||
@Column(name = "agent_last_plan_id")
|
||||
private Long agentLastPlanId;
|
||||
|
||||
@Column(name = "agent_status", length = 20)
|
||||
private String agentStatus;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "closed_at")
|
||||
private LocalDateTime closedAt;
|
||||
|
||||
@Column(name = "is_deleted")
|
||||
private Boolean isDeleted;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (status == null) status = "draft";
|
||||
if (priority == null) priority = "medium";
|
||||
if (agentStatus == null) agentStatus = "human_driven";
|
||||
if (isDeleted == null) isDeleted = false;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "issue_logs")
|
||||
public class IssueLog {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issue_id", nullable = false)
|
||||
private Issue issue;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String action;
|
||||
|
||||
@Column(name = "from_status", length = 30)
|
||||
private String fromStatus;
|
||||
|
||||
@Column(name = "to_status", length = 30)
|
||||
private String toStatus;
|
||||
|
||||
@Column(length = 500)
|
||||
private String remark;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "knowledge_chunks")
|
||||
public class KnowledgeChunk {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "doc_id", nullable = false)
|
||||
private KnowledgeDocument doc;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "vector(768)")
|
||||
private String embedding;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb")
|
||||
private String metadata;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "knowledge_documents")
|
||||
public class KnowledgeDocument {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String name;
|
||||
|
||||
@Column(name = "file_path", nullable = false, length = 500)
|
||||
private String filePath;
|
||||
|
||||
@Column(name = "file_size", nullable = false)
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "file_type", nullable = false, length = 20)
|
||||
private String fileType;
|
||||
|
||||
@Column(name = "chunk_count")
|
||||
private Integer chunkCount;
|
||||
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "uploaded_by", nullable = false)
|
||||
private User uploadedBy;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (status == null) status = "pending";
|
||||
if (chunkCount == null) chunkCount = 0;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "knowledge_search_logs")
|
||||
public class KnowledgeSearchLog {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issue_id")
|
||||
private Issue issue;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String query;
|
||||
|
||||
@Column(name = "rewritten_query", columnDefinition = "TEXT")
|
||||
private String rewrittenQuery;
|
||||
|
||||
@Column(name = "top_k")
|
||||
private Integer topK;
|
||||
|
||||
@Column(name = "total_matches")
|
||||
private Integer totalMatches;
|
||||
|
||||
@Column(name = "duration_ms")
|
||||
private Integer durationMs;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (topK == null) topK = 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "notifications")
|
||||
public class Notification {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, length = 1000)
|
||||
private String content;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String type;
|
||||
|
||||
@Column(length = 500)
|
||||
private String link;
|
||||
|
||||
@Column(name = "is_read")
|
||||
private Boolean isRead;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issue_id")
|
||||
private Issue issue;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (isRead == null) isRead = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "permissions")
|
||||
public class Permission {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String resource;
|
||||
|
||||
@Column(length = 255)
|
||||
private String description;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "phase_rules")
|
||||
public class PhaseRule {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String keyword;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String phase;
|
||||
|
||||
@Column(name = "match_mode", nullable = false, length = 20)
|
||||
private String matchMode;
|
||||
|
||||
@Column(length = 50)
|
||||
private String source;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean active;
|
||||
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (matchMode == null) matchMode = "contains";
|
||||
if (active == null) active = true;
|
||||
if (sortOrder == null) sortOrder = 0;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "prompt_render_logs")
|
||||
public class PromptRenderLog {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_id", nullable = false, length = 64)
|
||||
private String requestId;
|
||||
|
||||
@Column(name = "template_id", nullable = false, length = 100)
|
||||
private String templateId;
|
||||
|
||||
@Column(name = "template_version", nullable = false)
|
||||
private Integer templateVersion;
|
||||
|
||||
@Column(name = "rendered_prompt", nullable = false, columnDefinition = "TEXT")
|
||||
private String renderedPrompt;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "variables_used", columnDefinition = "jsonb")
|
||||
private String variablesUsed;
|
||||
|
||||
@Column(name = "tokens_input")
|
||||
private Integer tokensInput;
|
||||
|
||||
@Column(name = "tokens_output")
|
||||
private Integer tokensOutput;
|
||||
|
||||
@Column(name = "execution_time_ms")
|
||||
private Integer executionTimeMs;
|
||||
|
||||
@Column(name = "llm_model", length = 50)
|
||||
private String llmModel;
|
||||
|
||||
@Column(name = "model_provider", length = 20)
|
||||
private String modelProvider;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "prompt_templates")
|
||||
public class PromptTemplate {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "template_id", nullable = false, unique = true, length = 100)
|
||||
private String templateId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String category;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer version;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb")
|
||||
private String variables;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "output_schema", columnDefinition = "jsonb")
|
||||
private String outputSchema;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "is_default")
|
||||
private Boolean isDefault;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by")
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (version == null) version = 1;
|
||||
if (isActive == null) isActive = true;
|
||||
if (isDefault == null) isDefault = false;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "prompt_template_versions")
|
||||
public class PromptTemplateVersion {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "template_id", nullable = false, length = 100)
|
||||
private String templateId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer version;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@Column(name = "change_log", length = 500)
|
||||
private String changeLog;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by")
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "roles")
|
||||
public class Role {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String name;
|
||||
|
||||
@Column(length = 255)
|
||||
private String description;
|
||||
|
||||
@Column(name = "agent_auto_execute")
|
||||
private Boolean agentAutoExecute;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (agentAutoExecute == null) agentAutoExecute = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.io.Serializable;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "role_permissions")
|
||||
@IdClass(RolePermission.RolePermissionId.class)
|
||||
public class RolePermission {
|
||||
@Id
|
||||
@Column(name = "role_id")
|
||||
private Long roleId;
|
||||
|
||||
@Id
|
||||
@Column(name = "permission_id")
|
||||
private Long permissionId;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class RolePermissionId implements Serializable {
|
||||
private Long roleId;
|
||||
private Long permissionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "task_executions")
|
||||
public class TaskExecution {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "task_id", nullable = false, unique = true, length = 64)
|
||||
private String taskId;
|
||||
|
||||
@Column(name = "task_type", nullable = false, length = 30)
|
||||
private String taskType;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "result_url", length = 500)
|
||||
private String resultUrl;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by", nullable = false)
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "started_at")
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Column(name = "retry_count")
|
||||
private Integer retryCount;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (status == null) status = "pending";
|
||||
if (retryCount == null) retryCount = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "tool_executions")
|
||||
public class ToolExecution {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "plan_id")
|
||||
private AgentPlan plan;
|
||||
|
||||
@Column(name = "tool_name", nullable = false, length = 50)
|
||||
private String toolName;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "input_params", nullable = false, columnDefinition = "jsonb")
|
||||
private String inputParams;
|
||||
|
||||
@Column(name = "output_result", columnDefinition = "TEXT")
|
||||
private String outputResult;
|
||||
|
||||
@Column(length = 20)
|
||||
private String status;
|
||||
|
||||
@Column(name = "execution_time_ms")
|
||||
private Long executionTimeMs;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
if (status == null) status = "success";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String userid;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(length = 100)
|
||||
private String email;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "department_id", nullable = false)
|
||||
private Department department;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "agent_auto_execute")
|
||||
private Boolean agentAutoExecute;
|
||||
|
||||
@Column(name = "last_login_at")
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
if (isActive == null) isActive = true;
|
||||
if (agentAutoExecute == null) agentAutoExecute = false;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ims.service.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.io.Serializable;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "user_roles")
|
||||
@IdClass(UserRole.UserRoleId.class)
|
||||
public class UserRole {
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "role_id")
|
||||
private Long roleId;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class UserRoleId implements Serializable {
|
||||
private Long userId;
|
||||
private Long roleId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ims.service.issue;
|
||||
|
||||
import com.ims.api.dto.issue.AttachmentResponse;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Attachment;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.AttachmentRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AttachmentService {
|
||||
|
||||
private final AttachmentRepository attachmentRepository;
|
||||
private final IssueRepository issueRepository;
|
||||
private final MinioClient minioClient;
|
||||
|
||||
@Value("${minio.bucket}")
|
||||
private String bucket;
|
||||
|
||||
public AttachmentService(AttachmentRepository attachmentRepository,
|
||||
IssueRepository issueRepository,
|
||||
MinioClient minioClient) {
|
||||
this.attachmentRepository = attachmentRepository;
|
||||
this.issueRepository = issueRepository;
|
||||
this.minioClient = minioClient;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AttachmentResponse> listByIssue(Long issueId) {
|
||||
return attachmentRepository.findByIssueId(issueId).stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AttachmentResponse upload(Long issueId, MultipartFile file, User user) {
|
||||
Issue issue = issueRepository.findById(issueId)
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在"));
|
||||
String objectKey = "attachments/" + issueId + "/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
|
||||
try (var is = file.getInputStream()) {
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectKey)
|
||||
.stream(is, file.getSize(), -1)
|
||||
.contentType(file.getContentType())
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("附件上传失败: " + e.getMessage());
|
||||
}
|
||||
Attachment att = Attachment.builder()
|
||||
.issue(issue)
|
||||
.fileName(file.getOriginalFilename())
|
||||
.filePath(objectKey)
|
||||
.fileSize(file.getSize())
|
||||
.mimeType(file.getContentType())
|
||||
.uploadedBy(user)
|
||||
.build();
|
||||
return toResponse(attachmentRepository.save(att));
|
||||
}
|
||||
|
||||
public void download(Long attachmentId, HttpServletResponse response) {
|
||||
Attachment att = attachmentRepository.findById(attachmentId)
|
||||
.orElseThrow(() -> new BusinessException("附件不存在"));
|
||||
try (var obj = minioClient.getObject(GetObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(att.getFilePath())
|
||||
.build())) {
|
||||
String mime = att.getMimeType() != null ? att.getMimeType() : "application/octet-stream";
|
||||
String encoded = URLEncoder.encode(att.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType(mime);
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + encoded + "\"");
|
||||
obj.transferTo(response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("附件下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long attachmentId) {
|
||||
Attachment att = attachmentRepository.findById(attachmentId)
|
||||
.orElseThrow(() -> new BusinessException("附件不存在"));
|
||||
try {
|
||||
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(att.getFilePath())
|
||||
.build());
|
||||
} catch (Exception ignored) {}
|
||||
attachmentRepository.deleteById(attachmentId);
|
||||
}
|
||||
|
||||
private AttachmentResponse toResponse(Attachment a) {
|
||||
AttachmentResponse resp = new AttachmentResponse();
|
||||
resp.setId(a.getId());
|
||||
resp.setFileName(a.getFileName());
|
||||
resp.setFileSize(a.getFileSize());
|
||||
resp.setMimeType(a.getMimeType());
|
||||
resp.setFilePath(a.getFilePath());
|
||||
resp.setUploadedByName(a.getUploadedBy() != null ? a.getUploadedBy().getUsername() : null);
|
||||
resp.setCreatedAt(a.getCreatedAt());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package com.ims.service.issue;
|
||||
|
||||
import com.ims.api.dto.issue.IssueCreateRequest;
|
||||
import com.ims.api.dto.issue.IssueListRequest;
|
||||
import com.ims.api.dto.issue.IssueResponse;
|
||||
import com.ims.api.dto.issue.IssueUpdateRequest;
|
||||
import com.ims.common.dto.PageResult;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Department;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.IssueLog;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.notification.NotificationService;
|
||||
import com.ims.service.repository.DepartmentRepository;
|
||||
import com.ims.service.repository.IssueLogRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import com.ims.service.security.DataScope;
|
||||
import com.ims.service.security.DataScopeContext;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Year;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class IssueService {
|
||||
|
||||
public static final String STATUS_DRAFT = "draft";
|
||||
public static final String STATUS_OPEN = "open";
|
||||
public static final String STATUS_IN_PROGRESS = "in_progress";
|
||||
public static final String STATUS_RESOLVED = "resolved";
|
||||
public static final String STATUS_VERIFIED = "verified";
|
||||
public static final String STATUS_CLOSED = "closed";
|
||||
public static final String STATUS_REJECTED = "rejected";
|
||||
|
||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||
STATUS_DRAFT, Set.of(STATUS_OPEN),
|
||||
STATUS_OPEN, Set.of(STATUS_DRAFT, STATUS_IN_PROGRESS, STATUS_REJECTED),
|
||||
STATUS_IN_PROGRESS, Set.of(STATUS_OPEN, STATUS_RESOLVED, STATUS_REJECTED),
|
||||
STATUS_RESOLVED, Set.of(STATUS_IN_PROGRESS, STATUS_VERIFIED, STATUS_REJECTED),
|
||||
STATUS_VERIFIED, Set.of(STATUS_RESOLVED, STATUS_IN_PROGRESS, STATUS_CLOSED, STATUS_REJECTED),
|
||||
STATUS_CLOSED, Set.of(),
|
||||
STATUS_REJECTED, Set.of(STATUS_OPEN, STATUS_CLOSED));
|
||||
|
||||
private static final Map<String, String> STATUS_LABELS = Map.of(
|
||||
STATUS_DRAFT, "草稿",
|
||||
STATUS_OPEN, "待处理",
|
||||
STATUS_IN_PROGRESS, "进行中",
|
||||
STATUS_RESOLVED, "已解决",
|
||||
STATUS_VERIFIED, "已验证",
|
||||
STATUS_CLOSED, "已关闭",
|
||||
STATUS_REJECTED, "已驳回");
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
private final IssueLogRepository issueLogRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public IssueService(IssueRepository issueRepository,
|
||||
IssueLogRepository issueLogRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
NotificationService notificationService,
|
||||
EntityManager entityManager) {
|
||||
this.issueRepository = issueRepository;
|
||||
this.issueLogRepository = issueLogRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IssueResponse create(IssueCreateRequest req, User creator) {
|
||||
Department department = departmentRepository.findById(
|
||||
req.getDepartmentId() != null ? req.getDepartmentId() : creator.getDepartment().getId())
|
||||
.orElseThrow(() -> new BusinessException("部门不存在"));
|
||||
|
||||
String createStatus = req.getStatus() != null ? req.getStatus() : STATUS_DRAFT;
|
||||
if (!STATUS_DRAFT.equals(createStatus)) {
|
||||
throw new BusinessException("新建指摘仅支持草稿状态");
|
||||
}
|
||||
|
||||
Issue issue = Issue.builder()
|
||||
.issueNo(generateIssueNo())
|
||||
.title(req.getTitle())
|
||||
.description(req.getDescription())
|
||||
.status(createStatus)
|
||||
.priority(req.getPriority() != null ? req.getPriority() : "medium")
|
||||
.deadline(req.getDeadline())
|
||||
.phase(req.getPhase())
|
||||
.subProject(req.getSubProject())
|
||||
.category(req.getCategory())
|
||||
.impactLevel(req.getImpactLevel())
|
||||
.impactScope(req.getImpactScope())
|
||||
.deployment(req.getDeployment())
|
||||
.pgmNo(req.getPgmNo())
|
||||
.reviewWorkload(req.getReviewWorkload())
|
||||
.responseWorkload(req.getResponseWorkload())
|
||||
.responseContent(req.getResponseContent())
|
||||
.ngReason(req.getNgReason())
|
||||
.responseCompletedAt(req.getResponseCompletedAt())
|
||||
.confirmAt(req.getConfirmAt())
|
||||
.creator(creator)
|
||||
.assignee(resolveUser(req.getAssigneeId()))
|
||||
.department(department)
|
||||
.reviewer(resolveUser(req.getReviewerId()))
|
||||
.validator(resolveUser(req.getValidatorId()))
|
||||
.isDeleted(false)
|
||||
.build();
|
||||
issue = issueRepository.save(issue);
|
||||
log(issue, creator, "CREATE", null, issue.getStatus(), "创建指摘");
|
||||
return toResponse(issue);
|
||||
}
|
||||
|
||||
@DataScope
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<IssueResponse> list(IssueListRequest req) {
|
||||
StringBuilder jpql = new StringBuilder("select i from Issue i where i.isDeleted = false");
|
||||
StringBuilder countJpql = new StringBuilder("select count(i) from Issue i where i.isDeleted = false");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
|
||||
appendIfPresent(jpql, countJpql, params, "i.status = :status", "status", req.getStatus());
|
||||
appendIfPresent(jpql, countJpql, params, "i.phase = :phase", "phase", req.getPhase());
|
||||
appendIfPresent(jpql, countJpql, params, "i.subProject = :subProject", "subProject", req.getSubProject());
|
||||
appendIfPresent(jpql, countJpql, params, "i.priority = :priority", "priority", req.getPriority());
|
||||
appendIfPresent(jpql, countJpql, params, "i.impactLevel = :impactLevel", "impactLevel", req.getImpactLevel());
|
||||
if (req.getAssigneeId() != null) {
|
||||
jpql.append(" and i.assignee.id = :assigneeId");
|
||||
countJpql.append(" and i.assignee.id = :assigneeId");
|
||||
params.put("assigneeId", req.getAssigneeId());
|
||||
}
|
||||
if (req.getDepartmentId() != null) {
|
||||
jpql.append(" and i.department.id = :departmentId");
|
||||
countJpql.append(" and i.department.id = :departmentId");
|
||||
params.put("departmentId", req.getDepartmentId());
|
||||
}
|
||||
if (req.getStartDate() != null) {
|
||||
jpql.append(" and i.createdAt >= :startDate");
|
||||
countJpql.append(" and i.createdAt >= :startDate");
|
||||
params.put("startDate", req.getStartDate());
|
||||
}
|
||||
if (req.getEndDate() != null) {
|
||||
jpql.append(" and i.createdAt <= :endDate");
|
||||
countJpql.append(" and i.createdAt <= :endDate");
|
||||
params.put("endDate", req.getEndDate());
|
||||
}
|
||||
if (req.getKeyword() != null && !req.getKeyword().isBlank()) {
|
||||
String kw = "%" + req.getKeyword() + "%";
|
||||
jpql.append(" and (i.title like :kw or i.issueNo like :kw or i.description like :kw)");
|
||||
countJpql.append(" and (i.title like :kw or i.issueNo like :kw or i.description like :kw)");
|
||||
params.put("kw", kw);
|
||||
}
|
||||
Set<Long> scopeDeptIds = DataScopeContext.get();
|
||||
if (scopeDeptIds != null && !scopeDeptIds.isEmpty()) {
|
||||
jpql.append(" and i.department.id in :scopeDeptIds");
|
||||
countJpql.append(" and i.department.id in :scopeDeptIds");
|
||||
params.put("scopeDeptIds", scopeDeptIds);
|
||||
}
|
||||
jpql.append(" order by i.createdAt desc");
|
||||
|
||||
int p = Math.max(1, req.getPage());
|
||||
int ps = req.getPageSize() > 0 ? req.getPageSize() : 20;
|
||||
|
||||
TypedQuery<Issue> query = entityManager.createQuery(jpql.toString(), Issue.class);
|
||||
TypedQuery<Long> countQuery = entityManager.createQuery(countJpql.toString(), Long.class);
|
||||
params.forEach((k, v) -> {
|
||||
query.setParameter(k, v);
|
||||
countQuery.setParameter(k, v);
|
||||
});
|
||||
query.setFirstResult((p - 1) * ps).setMaxResults(ps);
|
||||
|
||||
long total = countQuery.getSingleResult();
|
||||
List<IssueResponse> items = query.getResultList().stream().map(this::toResponse).toList();
|
||||
return new PageResult<>(items, total, p, ps);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public IssueResponse detail(Long id) {
|
||||
return toResponse(getIssue(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IssueResponse update(Long id, IssueUpdateRequest req, User user) {
|
||||
Issue issue = getIssue(id);
|
||||
if (req.getTitle() != null) issue.setTitle(req.getTitle());
|
||||
if (req.getDescription() != null) issue.setDescription(req.getDescription());
|
||||
if (req.getPriority() != null) issue.setPriority(req.getPriority());
|
||||
if (req.getDeadline() != null) issue.setDeadline(req.getDeadline());
|
||||
if (req.getPhase() != null) issue.setPhase(req.getPhase());
|
||||
if (req.getSubProject() != null) issue.setSubProject(req.getSubProject());
|
||||
if (req.getCategory() != null) issue.setCategory(req.getCategory());
|
||||
if (req.getImpactLevel() != null) issue.setImpactLevel(req.getImpactLevel());
|
||||
if (req.getImpactScope() != null) issue.setImpactScope(req.getImpactScope());
|
||||
if (req.getDeployment() != null) issue.setDeployment(req.getDeployment());
|
||||
if (req.getPgmNo() != null) issue.setPgmNo(req.getPgmNo());
|
||||
if (req.getReviewWorkload() != null) issue.setReviewWorkload(req.getReviewWorkload());
|
||||
if (req.getResponseWorkload() != null) issue.setResponseWorkload(req.getResponseWorkload());
|
||||
if (req.getResponseContent() != null) issue.setResponseContent(req.getResponseContent());
|
||||
if (req.getNgReason() != null) issue.setNgReason(req.getNgReason());
|
||||
if (req.getResponseCompletedAt() != null) issue.setResponseCompletedAt(req.getResponseCompletedAt());
|
||||
if (req.getConfirmAt() != null) issue.setConfirmAt(req.getConfirmAt());
|
||||
if (req.getAssigneeId() != null) issue.setAssignee(resolveUser(req.getAssigneeId()));
|
||||
if (req.getDepartmentId() != null) {
|
||||
issue.setDepartment(departmentRepository.findById(req.getDepartmentId())
|
||||
.orElseThrow(() -> new BusinessException("部门不存在")));
|
||||
}
|
||||
if (req.getReviewerId() != null) issue.setReviewer(resolveUser(req.getReviewerId()));
|
||||
if (req.getValidatorId() != null) issue.setValidator(resolveUser(req.getValidatorId()));
|
||||
issueRepository.save(issue);
|
||||
log(issue, user, "UPDATE", null, null, "更新指摘");
|
||||
return toResponse(issue);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, User user) {
|
||||
Issue issue = getIssue(id);
|
||||
issue.setIsDeleted(true);
|
||||
issueRepository.save(issue);
|
||||
log(issue, user, "DELETE", null, null, "删除指摘");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IssueResponse changeStatus(Long id, String newStatus, String remark, User user) {
|
||||
Issue issue = getIssue(id);
|
||||
String from = issue.getStatus();
|
||||
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(newStatus)) {
|
||||
throw new BusinessException("非法的状态流转: " + from + " -> " + newStatus);
|
||||
}
|
||||
issue.setStatus(newStatus);
|
||||
if (STATUS_CLOSED.equals(newStatus)) {
|
||||
issue.setClosedAt(LocalDateTime.now());
|
||||
}
|
||||
issueRepository.save(issue);
|
||||
log(issue, user, "STATUS_CHANGE", from, newStatus, remark != null ? remark : "状态流转");
|
||||
sendStatusNotification(issue, from, newStatus);
|
||||
return toResponse(issue);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changeAgentMode(Long id, String mode, User user) {
|
||||
if (!"human_driven".equals(mode) && !"agent_driven".equals(mode)) {
|
||||
throw new BusinessException("非法模式: " + mode);
|
||||
}
|
||||
Issue issue = getIssue(id);
|
||||
issue.setAgentStatus(mode);
|
||||
issueRepository.save(issue);
|
||||
log(issue, user, "AGENT_MODE", null, null, "切换Agent驱动模式: " + mode);
|
||||
}
|
||||
|
||||
@DataScope
|
||||
@Transactional(readOnly = true)
|
||||
public List<Issue> findAllFiltered(IssueListRequest req) {
|
||||
StringBuilder jpql = new StringBuilder("select i from Issue i where i.isDeleted = false");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
appendIfPresent(jpql, jpql, params, "i.status = :status", "status", req.getStatus());
|
||||
appendIfPresent(jpql, jpql, params, "i.phase = :phase", "phase", req.getPhase());
|
||||
appendIfPresent(jpql, jpql, params, "i.subProject = :subProject", "subProject", req.getSubProject());
|
||||
appendIfPresent(jpql, jpql, params, "i.priority = :priority", "priority", req.getPriority());
|
||||
appendIfPresent(jpql, jpql, params, "i.impactLevel = :impactLevel", "impactLevel", req.getImpactLevel());
|
||||
if (req.getAssigneeId() != null) {
|
||||
jpql.append(" and i.assignee.id = :assigneeId");
|
||||
params.put("assigneeId", req.getAssigneeId());
|
||||
}
|
||||
if (req.getDepartmentId() != null) {
|
||||
jpql.append(" and i.department.id = :departmentId");
|
||||
params.put("departmentId", req.getDepartmentId());
|
||||
}
|
||||
if (req.getStartDate() != null) {
|
||||
jpql.append(" and i.createdAt >= :startDate");
|
||||
params.put("startDate", req.getStartDate());
|
||||
}
|
||||
if (req.getEndDate() != null) {
|
||||
jpql.append(" and i.createdAt <= :endDate");
|
||||
params.put("endDate", req.getEndDate());
|
||||
}
|
||||
if (req.getKeyword() != null && !req.getKeyword().isBlank()) {
|
||||
String kw = "%" + req.getKeyword() + "%";
|
||||
jpql.append(" and (i.title like :kw or i.issueNo like :kw or i.description like :kw)");
|
||||
params.put("kw", kw);
|
||||
}
|
||||
Set<Long> scopeDeptIds = DataScopeContext.get();
|
||||
if (scopeDeptIds != null && !scopeDeptIds.isEmpty()) {
|
||||
jpql.append(" and i.department.id in :scopeDeptIds");
|
||||
params.put("scopeDeptIds", scopeDeptIds);
|
||||
}
|
||||
jpql.append(" order by i.createdAt desc");
|
||||
TypedQuery<Issue> query = entityManager.createQuery(jpql.toString(), Issue.class);
|
||||
params.forEach(query::setParameter);
|
||||
return query.getResultList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int batchAssign(List<Long> issueIds, Long assigneeId, User user) {
|
||||
User assignee = resolveUser(assigneeId);
|
||||
if (assignee == null) {
|
||||
throw new BusinessException("担当者不存在");
|
||||
}
|
||||
int count = 0;
|
||||
for (Long id : issueIds) {
|
||||
Issue issue = getIssue(id);
|
||||
issue.setAssignee(assignee);
|
||||
issueRepository.save(issue);
|
||||
log(issue, user, "BATCH_ASSIGN", null, null, "批量统一分配担当者");
|
||||
notificationService.notify(assignee, "指摘分配: " + issue.getIssueNo(),
|
||||
"您被分配为指摘《" + issue.getTitle() + "》的担当者,请及时处理。",
|
||||
"assign", "/issues/" + issue.getId(), issue);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int batchNotify(List<Long> issueIds, String content, User user) {
|
||||
int count = 0;
|
||||
String msg = (content != null && !content.isBlank()) ? content : "您的指摘已逾期或即将到期,请尽快处理。";
|
||||
for (Long id : issueIds) {
|
||||
Issue issue = getIssue(id);
|
||||
if (issue.getAssignee() != null) {
|
||||
notificationService.notify(issue.getAssignee(), "指摘催办: " + issue.getIssueNo(),
|
||||
"指摘《" + issue.getTitle() + "》" + msg,
|
||||
"reminder", "/issues/" + issue.getId(), issue);
|
||||
}
|
||||
log(issue, user, "BATCH_NOTIFY", null, null, "批量催办");
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Map<String, Object>> logs(Long issueId) {
|
||||
return issueLogRepository.findByIssueIdOrderByCreatedAtDesc(issueId).stream().map(l -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("action", l.getAction());
|
||||
m.put("fromStatus", l.getFromStatus());
|
||||
m.put("toStatus", l.getToStatus());
|
||||
m.put("remark", l.getRemark());
|
||||
m.put("userName", l.getUser() != null ? l.getUser().getUsername() : "");
|
||||
m.put("createdAt", l.getCreatedAt());
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private void appendIfPresent(StringBuilder jpql, StringBuilder countJpql, Map<String, Object> params,
|
||||
String clause, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
jpql.append(" and ").append(clause);
|
||||
countJpql.append(" and ").append(clause);
|
||||
params.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateIssueNo() {
|
||||
String prefix = "ISSUE-" + Year.now().getValue() + "-";
|
||||
TypedQuery<String> q = entityManager.createQuery(
|
||||
"select max(i.issueNo) from Issue i where i.issueNo like :prefix", String.class)
|
||||
.setParameter("prefix", prefix + "%");
|
||||
String max = q.getSingleResult();
|
||||
int next = 1;
|
||||
if (max != null && max.length() > prefix.length()) {
|
||||
try {
|
||||
next = Integer.parseInt(max.substring(prefix.length())) + 1;
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return prefix + String.format("%03d", next);
|
||||
}
|
||||
|
||||
private User resolveUser(Long userId) {
|
||||
return userId != null ? userRepository.findById(userId).orElse(null) : null;
|
||||
}
|
||||
|
||||
private Issue getIssue(Long id) {
|
||||
return issueRepository.findById(id)
|
||||
.filter(i -> !Boolean.TRUE.equals(i.getIsDeleted()))
|
||||
.orElseThrow(() -> new BusinessException("指摘不存在"));
|
||||
}
|
||||
|
||||
private void log(Issue issue, User user, String action, String from, String to, String remark) {
|
||||
IssueLog l = IssueLog.builder()
|
||||
.issue(issue)
|
||||
.user(user)
|
||||
.action(action)
|
||||
.fromStatus(from)
|
||||
.toStatus(to)
|
||||
.remark(remark)
|
||||
.build();
|
||||
issueLogRepository.save(l);
|
||||
}
|
||||
|
||||
private void sendStatusNotification(Issue issue, String from, String to) {
|
||||
String title = "指摘状态更新: " + issue.getIssueNo();
|
||||
String content = "指摘《" + issue.getTitle() + "》状态从 " + STATUS_LABELS.getOrDefault(from, from)
|
||||
+ " 变更为 " + STATUS_LABELS.getOrDefault(to, to);
|
||||
String link = "/issues/" + issue.getId();
|
||||
if (issue.getAssignee() != null) {
|
||||
notificationService.notify(issue.getAssignee(), title, content, "status", link, issue);
|
||||
}
|
||||
notificationService.notify(issue.getCreator(), title, content, "status", link, issue);
|
||||
}
|
||||
|
||||
private IssueResponse toResponse(Issue i) {
|
||||
IssueResponse r = new IssueResponse();
|
||||
r.setId(i.getId());
|
||||
r.setIssueNo(i.getIssueNo());
|
||||
r.setTitle(i.getTitle());
|
||||
r.setDescription(i.getDescription());
|
||||
r.setStatus(i.getStatus());
|
||||
r.setPriority(i.getPriority());
|
||||
r.setDeadline(i.getDeadline());
|
||||
r.setPhase(i.getPhase());
|
||||
r.setSubProject(i.getSubProject());
|
||||
r.setCategory(i.getCategory());
|
||||
r.setImpactLevel(i.getImpactLevel());
|
||||
r.setImpactScope(i.getImpactScope());
|
||||
r.setDeployment(i.getDeployment());
|
||||
r.setPgmNo(i.getPgmNo());
|
||||
r.setReviewWorkload(i.getReviewWorkload());
|
||||
r.setResponseWorkload(i.getResponseWorkload());
|
||||
r.setResponseContent(i.getResponseContent());
|
||||
r.setNgReason(i.getNgReason());
|
||||
r.setResponseCompletedAt(i.getResponseCompletedAt());
|
||||
r.setConfirmAt(i.getConfirmAt());
|
||||
if (i.getCreator() != null) {
|
||||
r.setCreatorId(i.getCreator().getId());
|
||||
r.setCreatorName(i.getCreator().getUsername());
|
||||
}
|
||||
if (i.getAssignee() != null) {
|
||||
r.setAssigneeId(i.getAssignee().getId());
|
||||
r.setAssigneeName(i.getAssignee().getUsername());
|
||||
}
|
||||
if (i.getDepartment() != null) {
|
||||
r.setDepartmentId(i.getDepartment().getId());
|
||||
r.setDepartmentName(i.getDepartment().getName());
|
||||
}
|
||||
if (i.getReviewer() != null) {
|
||||
r.setReviewerId(i.getReviewer().getId());
|
||||
r.setReviewerName(i.getReviewer().getUsername());
|
||||
}
|
||||
if (i.getValidator() != null) {
|
||||
r.setValidatorId(i.getValidator().getId());
|
||||
r.setValidatorName(i.getValidator().getUsername());
|
||||
}
|
||||
r.setAgentStatus(i.getAgentStatus());
|
||||
r.setAgentLastPlanId(i.getAgentLastPlanId());
|
||||
r.setCreatedAt(i.getCreatedAt());
|
||||
r.setUpdatedAt(i.getUpdatedAt());
|
||||
r.setClosedAt(i.getClosedAt());
|
||||
return r;
|
||||
}
|
||||
|
||||
public static String escapeCsv(String s) {
|
||||
if (s == null) return "";
|
||||
if (s.contains(",") || s.contains("\"") || s.contains("\n")) {
|
||||
return "\"" + s.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.api.dto.ai.AiConfigRequest;
|
||||
import com.ims.api.dto.ai.AiConfigResponse;
|
||||
import com.ims.service.notification.NotificationService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class AiConfigService {
|
||||
|
||||
private static final String REDIS_KEY = "ai:config";
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
@Value("${ai.provider}")
|
||||
private String defaultProvider;
|
||||
|
||||
@Value("${ollama.base-url:http://localhost:11434}")
|
||||
private String defaultOllamaUrl;
|
||||
|
||||
@Value("${ollama.embedding.model}")
|
||||
private String defaultOllamaModel;
|
||||
|
||||
@Value("${ollama.chat.model}")
|
||||
private String defaultOllamaChatModel;
|
||||
|
||||
@Value("${ollama.chat.options.temperature:0.3}")
|
||||
private Float defaultTemperature;
|
||||
|
||||
@Value("${ollama.chat.options.num-predict:4096}")
|
||||
private Integer defaultNumPredict;
|
||||
|
||||
@Value("${deepseek.api.key}")
|
||||
private String defaultDeepseekKey;
|
||||
|
||||
@Value("${deepseek.model}")
|
||||
private String defaultDeepseekModel;
|
||||
|
||||
@Value("${deepseek.embedding-model}")
|
||||
private String defaultDeepseekEmbeddingModel;
|
||||
|
||||
@Value("${ai.auto-fallback-enabled}")
|
||||
private Boolean defaultAutoFallback;
|
||||
|
||||
@Value("${agent.max-steps:10}")
|
||||
private Integer defaultAgentMaxSteps;
|
||||
|
||||
@Value("${agent.auto-execute-high-risk:false}")
|
||||
private Boolean defaultAutoExecuteHighRisk;
|
||||
|
||||
@Value("${agent.user-rate-limit:10}")
|
||||
private Integer defaultUserRateLimit;
|
||||
|
||||
@Value("${knowledge.chunk-size:200}")
|
||||
private Integer defaultChunkSize;
|
||||
|
||||
@Value("${knowledge.chunk-overlap:40}")
|
||||
private Integer defaultChunkOverlap;
|
||||
|
||||
@Value("${knowledge.max-upload-size:52428800}")
|
||||
private Long defaultMaxUploadSize;
|
||||
|
||||
public AiConfigService(StringRedisTemplate redisTemplate, ObjectMapper objectMapper,
|
||||
NotificationService notificationService) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
public AiConfigResponse getConfig() {
|
||||
String json = redisTemplate.opsForValue().get(REDIS_KEY);
|
||||
if (json != null) {
|
||||
try {
|
||||
AiConfigRequest request = objectMapper.readValue(json, AiConfigRequest.class);
|
||||
return toResponse(request);
|
||||
} catch (JsonProcessingException ignored) {}
|
||||
}
|
||||
return defaultConfig();
|
||||
}
|
||||
|
||||
public void updateConfig(AiConfigRequest request) {
|
||||
AiConfigRequest merged = readStored();
|
||||
if (merged == null) {
|
||||
merged = new AiConfigRequest();
|
||||
copyResponseToRequest(defaultConfig(), merged);
|
||||
}
|
||||
String before = merged.getProvider();
|
||||
mergeNonNull(merged, request);
|
||||
saveConfig(merged);
|
||||
String after = merged.getProvider();
|
||||
if (after != null && !after.equals(before)) {
|
||||
notificationService.notifyAdmins("AI引擎切换",
|
||||
"AI 推理引擎由 " + (before == null ? "默认" : before) + " 切换为 " + after);
|
||||
}
|
||||
}
|
||||
|
||||
private AiConfigRequest readStored() {
|
||||
String json = redisTemplate.opsForValue().get(REDIS_KEY);
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, AiConfigRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void saveConfig(AiConfigRequest request) {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(request);
|
||||
redisTemplate.opsForValue().set(REDIS_KEY, json, 24, TimeUnit.HOURS);
|
||||
} catch (JsonProcessingException ignored) {}
|
||||
}
|
||||
|
||||
private void mergeNonNull(AiConfigRequest target, AiConfigRequest source) {
|
||||
if (source.getProvider() != null) target.setProvider(source.getProvider());
|
||||
if (source.getOllamaBaseUrl() != null) target.setOllamaBaseUrl(source.getOllamaBaseUrl());
|
||||
if (source.getOllamaChatModel() != null) target.setOllamaChatModel(source.getOllamaChatModel());
|
||||
if (source.getOllamaEmbeddingModel() != null) target.setOllamaEmbeddingModel(source.getOllamaEmbeddingModel());
|
||||
if (source.getOllamaTemperature() != null) target.setOllamaTemperature(source.getOllamaTemperature());
|
||||
if (source.getOllamaNumPredict() != null) target.setOllamaNumPredict(source.getOllamaNumPredict());
|
||||
if (source.getDeepseekApiKey() != null) target.setDeepseekApiKey(source.getDeepseekApiKey());
|
||||
if (source.getDeepseekModel() != null) target.setDeepseekModel(source.getDeepseekModel());
|
||||
if (source.getDeepseekEmbeddingModel() != null) target.setDeepseekEmbeddingModel(source.getDeepseekEmbeddingModel());
|
||||
if (source.getAutoFallbackEnabled() != null) target.setAutoFallbackEnabled(source.getAutoFallbackEnabled());
|
||||
if (source.getAgentMaxSteps() != null) target.setAgentMaxSteps(source.getAgentMaxSteps());
|
||||
if (source.getAutoExecuteHighRisk() != null) target.setAutoExecuteHighRisk(source.getAutoExecuteHighRisk());
|
||||
if (source.getUserRateLimit() != null) target.setUserRateLimit(source.getUserRateLimit());
|
||||
if (source.getChunkSize() != null) target.setChunkSize(source.getChunkSize());
|
||||
if (source.getChunkOverlap() != null) target.setChunkOverlap(source.getChunkOverlap());
|
||||
if (source.getMaxUploadSize() != null) target.setMaxUploadSize(source.getMaxUploadSize());
|
||||
}
|
||||
|
||||
private void copyResponseToRequest(AiConfigResponse resp, AiConfigRequest req) {
|
||||
req.setProvider(resp.getProvider());
|
||||
req.setOllamaBaseUrl(resp.getOllamaBaseUrl());
|
||||
req.setOllamaChatModel(resp.getOllamaChatModel());
|
||||
req.setOllamaEmbeddingModel(resp.getOllamaEmbeddingModel());
|
||||
req.setOllamaTemperature(resp.getOllamaTemperature());
|
||||
req.setOllamaNumPredict(resp.getOllamaNumPredict());
|
||||
req.setDeepseekModel(resp.getDeepseekModel());
|
||||
req.setDeepseekEmbeddingModel(resp.getDeepseekEmbeddingModel());
|
||||
req.setAutoFallbackEnabled(resp.getAutoFallbackEnabled());
|
||||
req.setAgentMaxSteps(resp.getAgentMaxSteps());
|
||||
req.setAutoExecuteHighRisk(resp.getAutoExecuteHighRisk());
|
||||
req.setUserRateLimit(resp.getUserRateLimit());
|
||||
req.setChunkSize(resp.getChunkSize());
|
||||
req.setChunkOverlap(resp.getChunkOverlap());
|
||||
req.setMaxUploadSize(resp.getMaxUploadSize());
|
||||
}
|
||||
|
||||
public String getEffectiveApiKey() {
|
||||
String json = redisTemplate.opsForValue().get(REDIS_KEY);
|
||||
if (json != null) {
|
||||
try {
|
||||
AiConfigRequest request = objectMapper.readValue(json, AiConfigRequest.class);
|
||||
if (request.getDeepseekApiKey() != null && !request.getDeepseekApiKey().isEmpty()) {
|
||||
return request.getDeepseekApiKey();
|
||||
}
|
||||
} catch (JsonProcessingException ignored) {}
|
||||
}
|
||||
return defaultDeepseekKey;
|
||||
}
|
||||
|
||||
private AiConfigResponse toResponse(AiConfigRequest request) {
|
||||
AiConfigResponse resp = new AiConfigResponse();
|
||||
resp.setProvider(request.getProvider());
|
||||
resp.setOllamaBaseUrl(request.getOllamaBaseUrl());
|
||||
resp.setOllamaChatModel(request.getOllamaChatModel());
|
||||
resp.setOllamaEmbeddingModel(request.getOllamaEmbeddingModel());
|
||||
resp.setOllamaTemperature(request.getOllamaTemperature());
|
||||
resp.setOllamaNumPredict(request.getOllamaNumPredict());
|
||||
resp.setDeepseekModel(request.getDeepseekModel());
|
||||
resp.setDeepseekEmbeddingModel(request.getDeepseekEmbeddingModel());
|
||||
resp.setAutoFallbackEnabled(request.getAutoFallbackEnabled());
|
||||
resp.setAgentMaxSteps(request.getAgentMaxSteps());
|
||||
resp.setAutoExecuteHighRisk(request.getAutoExecuteHighRisk());
|
||||
resp.setUserRateLimit(request.getUserRateLimit());
|
||||
resp.setChunkSize(request.getChunkSize());
|
||||
resp.setChunkOverlap(request.getChunkOverlap());
|
||||
resp.setMaxUploadSize(request.getMaxUploadSize());
|
||||
return resp;
|
||||
}
|
||||
|
||||
private AiConfigResponse defaultConfig() {
|
||||
AiConfigResponse config = new AiConfigResponse();
|
||||
config.setProvider(defaultProvider);
|
||||
config.setOllamaBaseUrl(defaultOllamaUrl);
|
||||
config.setOllamaChatModel(defaultOllamaChatModel);
|
||||
config.setOllamaEmbeddingModel(defaultOllamaModel);
|
||||
config.setOllamaTemperature(defaultTemperature);
|
||||
config.setOllamaNumPredict(defaultNumPredict);
|
||||
config.setDeepseekModel(defaultDeepseekModel);
|
||||
config.setDeepseekEmbeddingModel(defaultDeepseekEmbeddingModel);
|
||||
config.setAutoFallbackEnabled(defaultAutoFallback);
|
||||
config.setAgentMaxSteps(defaultAgentMaxSteps);
|
||||
config.setAutoExecuteHighRisk(defaultAutoExecuteHighRisk);
|
||||
config.setUserRateLimit(defaultUserRateLimit);
|
||||
config.setChunkSize(defaultChunkSize);
|
||||
config.setChunkOverlap(defaultChunkOverlap);
|
||||
config.setMaxUploadSize(defaultMaxUploadSize);
|
||||
return config;
|
||||
}
|
||||
|
||||
public Float getEffectiveTemperature() {
|
||||
Float t = getConfig().getOllamaTemperature();
|
||||
return t != null ? t : defaultTemperature;
|
||||
}
|
||||
|
||||
public Integer getEffectiveNumPredict() {
|
||||
Integer n = getConfig().getOllamaNumPredict();
|
||||
return n != null ? n : defaultNumPredict;
|
||||
}
|
||||
|
||||
public Integer getEffectiveMaxSteps() {
|
||||
Integer m = getConfig().getAgentMaxSteps();
|
||||
return m != null ? m : defaultAgentMaxSteps;
|
||||
}
|
||||
|
||||
public Boolean getEffectiveAutoExecuteHighRisk() {
|
||||
Boolean b = getConfig().getAutoExecuteHighRisk();
|
||||
return b != null ? b : defaultAutoExecuteHighRisk;
|
||||
}
|
||||
|
||||
public Integer getEffectiveUserRateLimit() {
|
||||
Integer l = getConfig().getUserRateLimit();
|
||||
return l != null ? l : defaultUserRateLimit;
|
||||
}
|
||||
|
||||
public Integer getEffectiveChunkSize() {
|
||||
Integer c = getConfig().getChunkSize();
|
||||
return c != null && c > 0 ? c : defaultChunkSize;
|
||||
}
|
||||
|
||||
public Integer getEffectiveChunkOverlap() {
|
||||
Integer o = getConfig().getChunkOverlap();
|
||||
return o != null && o >= 0 ? o : defaultChunkOverlap;
|
||||
}
|
||||
|
||||
public Long getEffectiveMaxUploadSize() {
|
||||
Long m = getConfig().getMaxUploadSize();
|
||||
return m != null && m > 0 ? m : defaultMaxUploadSize;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DeepSeekEmbeddingService {
|
||||
|
||||
private static final String API_URL = "https://api.deepseek.com/v1/embeddings";
|
||||
|
||||
private final RestClient restClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiConfigService aiConfigService;
|
||||
|
||||
public DeepSeekEmbeddingService(ObjectMapper objectMapper, AiConfigService aiConfigService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.restClient = RestClient.builder().build();
|
||||
}
|
||||
|
||||
public List<Float> embed(String text) {
|
||||
String apiKey = aiConfigService.getEffectiveApiKey();
|
||||
String model = aiConfigService.getConfig().getDeepseekEmbeddingModel();
|
||||
|
||||
Map<String, Object> body = Map.of(
|
||||
"model", model,
|
||||
"input", text
|
||||
);
|
||||
|
||||
try {
|
||||
String json = restClient.post()
|
||||
.uri(API_URL)
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode data = root.get("data");
|
||||
if (data == null || !data.isArray() || data.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
JsonNode embedding = data.get(0).get("embedding");
|
||||
List<Float> result = new ArrayList<>();
|
||||
for (JsonNode n : embedding) {
|
||||
result.add(n.floatValue());
|
||||
}
|
||||
return result;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("DeepSeek embedding failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
|
||||
import org.apache.tika.Tika;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class DocumentParserService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DocumentParserService.class);
|
||||
|
||||
private static final Pattern TOKEN_PATTERN = Pattern.compile(
|
||||
"[\\u3040-\\u30ff\\u31f0-\\u31ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\u3001-\\u303f\\uff00-\\uffef]|[A-Za-z0-9]+");
|
||||
|
||||
private final Tika tika = new Tika();
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
|
||||
public DocumentParserService(AiConfigService aiConfigService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
}
|
||||
|
||||
public String extractText(InputStream inputStream, String fileName) {
|
||||
String lower = fileName.toLowerCase();
|
||||
if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) {
|
||||
try {
|
||||
return extractTextFromExcel(inputStream, fileName);
|
||||
} catch (Throwable t) {
|
||||
log.warn("POI parsing failed for {}, falling back to raw text", fileName, t);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
return tika.parseToString(inputStream);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse document: " + fileName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String extractTextFromExcel(InputStream inputStream, String fileName) {
|
||||
try (Workbook workbook = createWorkbook(inputStream, fileName)) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
|
||||
Sheet sheet = workbook.getSheetAt(i);
|
||||
if (sheet.getPhysicalNumberOfRows() == 0) continue;
|
||||
for (Row row : sheet) {
|
||||
for (Cell cell : row) {
|
||||
String value = formatter.formatCellValue(cell);
|
||||
if (value != null && !value.isEmpty()) {
|
||||
sb.append(value).append(" ");
|
||||
}
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
return sb.toString().trim();
|
||||
} catch (Throwable e) {
|
||||
log.warn("Failed to parse Excel file: {}", fileName, e);
|
||||
throw new RuntimeException("Failed to parse Excel: " + fileName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Workbook createWorkbook(InputStream inputStream, String fileName) {
|
||||
try {
|
||||
if (fileName.toLowerCase().endsWith(".xlsx")) {
|
||||
return new XSSFWorkbook(inputStream);
|
||||
}
|
||||
return new HSSFWorkbook(inputStream);
|
||||
} catch (Throwable e) {
|
||||
log.warn("Failed to create workbook for: {}", fileName, e);
|
||||
throw new RuntimeException("Failed to create workbook: " + fileName, e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> splitText(String text) {
|
||||
int chunkSize = aiConfigService.getEffectiveChunkSize();
|
||||
int chunkOverlap = aiConfigService.getEffectiveChunkOverlap();
|
||||
List<String> chunks = new ArrayList<>();
|
||||
List<String> tokens = new ArrayList<>();
|
||||
Matcher matcher = TOKEN_PATTERN.matcher(text);
|
||||
while (matcher.find()) {
|
||||
tokens.add(matcher.group());
|
||||
}
|
||||
int tokenCount = tokens.size();
|
||||
|
||||
if (tokenCount == 0) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
if (tokenCount <= chunkSize) {
|
||||
chunks.add(text);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
int start = 0;
|
||||
while (start < tokenCount) {
|
||||
int end = Math.min(start + chunkSize, tokenCount);
|
||||
StringBuilder chunk = new StringBuilder();
|
||||
for (int i = start; i < end; i++) {
|
||||
if (i > start) chunk.append(" ");
|
||||
chunk.append(tokens.get(i));
|
||||
}
|
||||
chunks.add(chunk.toString());
|
||||
|
||||
if (end >= tokenCount) break;
|
||||
start = end - chunkOverlap;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface EmbeddingService {
|
||||
List<Float> embed(String text);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import com.ims.api.dto.knowledge.KnowledgeDocResponse;
|
||||
import com.ims.service.entity.KnowledgeDocument;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.KnowledgeChunkRepository;
|
||||
import com.ims.service.repository.KnowledgeDocumentRepository;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class KnowledgeService {
|
||||
|
||||
private final KnowledgeDocumentRepository documentRepository;
|
||||
private final KnowledgeChunkRepository chunkRepository;
|
||||
private final MinioClient minioClient;
|
||||
private final DocumentParserService documentParserService;
|
||||
private final OllamaEmbeddingService embeddingService;
|
||||
private final EntityManager entityManager;
|
||||
private final AiConfigService aiConfigService;
|
||||
|
||||
@Value("${minio.bucket}")
|
||||
private String bucket;
|
||||
|
||||
public KnowledgeService(KnowledgeDocumentRepository documentRepository,
|
||||
KnowledgeChunkRepository chunkRepository,
|
||||
MinioClient minioClient,
|
||||
DocumentParserService documentParserService,
|
||||
OllamaEmbeddingService embeddingService,
|
||||
EntityManager entityManager,
|
||||
AiConfigService aiConfigService) {
|
||||
this.documentRepository = documentRepository;
|
||||
this.chunkRepository = chunkRepository;
|
||||
this.minioClient = minioClient;
|
||||
this.documentParserService = documentParserService;
|
||||
this.embeddingService = embeddingService;
|
||||
this.entityManager = entityManager;
|
||||
this.aiConfigService = aiConfigService;
|
||||
}
|
||||
|
||||
public Page<KnowledgeDocResponse> list(int page, int pageSize) {
|
||||
return documentRepository.findAll(PageRequest.of(page - 1, pageSize))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public KnowledgeDocResponse upload(MultipartFile file, User user) {
|
||||
if (file.isEmpty()) {
|
||||
throw new RuntimeException("File is empty");
|
||||
}
|
||||
long maxUploadSize = aiConfigService.getEffectiveMaxUploadSize();
|
||||
if (file.getSize() > maxUploadSize) {
|
||||
throw new RuntimeException("File size exceeds the maximum allowed size of " + (maxUploadSize / (1024 * 1024)) + "MB");
|
||||
}
|
||||
String fileName = file.getOriginalFilename();
|
||||
String objectKey = UUID.randomUUID() + "_" + fileName;
|
||||
String fileType = getFileType(fileName);
|
||||
|
||||
KnowledgeDocument doc = KnowledgeDocument.builder()
|
||||
.name(fileName)
|
||||
.filePath(objectKey)
|
||||
.fileSize(file.getSize())
|
||||
.fileType(fileType)
|
||||
.status("processing")
|
||||
.uploadedBy(user)
|
||||
.build();
|
||||
doc = documentRepository.save(doc);
|
||||
|
||||
byte[] fileBytes;
|
||||
try {
|
||||
fileBytes = file.getBytes();
|
||||
} catch (Exception e) {
|
||||
doc.setStatus("failed");
|
||||
doc.setErrorMessage("Failed to read file");
|
||||
documentRepository.save(doc);
|
||||
return toResponse(doc);
|
||||
}
|
||||
|
||||
try {
|
||||
try (var is = new java.io.ByteArrayInputStream(fileBytes)) {
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectKey)
|
||||
.stream(is, fileBytes.length, -1)
|
||||
.contentType(file.getContentType())
|
||||
.build());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
String text;
|
||||
try (var is = new java.io.ByteArrayInputStream(fileBytes)) {
|
||||
text = documentParserService.extractText(is, fileName);
|
||||
} catch (Throwable e) {
|
||||
text = new String(fileBytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
text = new String(fileBytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
List<String> chunks = documentParserService.splitText(text);
|
||||
|
||||
int chunkCount = 0;
|
||||
for (String chunkText : chunks) {
|
||||
List<Float> embedding = embeddingService.embed(chunkText);
|
||||
String vectorStr = SearchService.vectorToPgvectorString(embedding);
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO knowledge_chunks (doc_id, content, embedding, created_at) " +
|
||||
"VALUES (:docId, :content, CAST(:embedding AS vector), NOW())")
|
||||
.setParameter("docId", doc.getId())
|
||||
.setParameter("content", chunkText)
|
||||
.setParameter("embedding", vectorStr)
|
||||
.executeUpdate();
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
doc.setChunkCount(chunkCount);
|
||||
doc.setStatus("completed");
|
||||
documentRepository.save(doc);
|
||||
} catch (Throwable e) {
|
||||
doc.setStatus("failed");
|
||||
doc.setErrorMessage(e.getMessage());
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
|
||||
return toResponse(doc);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
KnowledgeDocument doc = documentRepository.findById(id).orElse(null);
|
||||
if (doc == null) return;
|
||||
|
||||
try {
|
||||
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(doc.getFilePath())
|
||||
.build());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
chunkRepository.deleteByDocId(id);
|
||||
documentRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reindex(Long id) {
|
||||
KnowledgeDocument doc = documentRepository.findById(id).orElse(null);
|
||||
if (doc == null) return;
|
||||
|
||||
chunkRepository.deleteByDocId(id);
|
||||
doc.setStatus("processing");
|
||||
doc.setChunkCount(0);
|
||||
documentRepository.save(doc);
|
||||
|
||||
try {
|
||||
String text = documentParserService.extractText(null, doc.getName());
|
||||
List<String> chunks = documentParserService.splitText(text);
|
||||
|
||||
int chunkCount = 0;
|
||||
for (String chunkText : chunks) {
|
||||
List<Float> embedding = embeddingService.embed(chunkText);
|
||||
String vectorStr = SearchService.vectorToPgvectorString(embedding);
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO knowledge_chunks (doc_id, content, embedding, created_at) " +
|
||||
"VALUES (:docId, :content, CAST(:embedding AS vector), NOW())")
|
||||
.setParameter("docId", doc.getId())
|
||||
.setParameter("content", chunkText)
|
||||
.setParameter("embedding", vectorStr)
|
||||
.executeUpdate();
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
doc.setChunkCount(chunkCount);
|
||||
doc.setStatus("completed");
|
||||
doc.setErrorMessage(null);
|
||||
documentRepository.save(doc);
|
||||
} catch (Throwable e) {
|
||||
doc.setStatus("failed");
|
||||
doc.setErrorMessage(e.getMessage());
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private String getFileType(String fileName) {
|
||||
if (fileName == null) return "unknown";
|
||||
String lower = fileName.toLowerCase();
|
||||
if (lower.endsWith(".pdf")) return "pdf";
|
||||
if (lower.endsWith(".docx")) return "docx";
|
||||
if (lower.endsWith(".doc")) return "doc";
|
||||
if (lower.endsWith(".txt")) return "txt";
|
||||
if (lower.endsWith(".md")) return "md";
|
||||
if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) return "xlsx";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private KnowledgeDocResponse toResponse(KnowledgeDocument doc) {
|
||||
KnowledgeDocResponse resp = new KnowledgeDocResponse();
|
||||
resp.setId(doc.getId());
|
||||
resp.setName(doc.getName());
|
||||
resp.setFileSize(doc.getFileSize());
|
||||
resp.setFileType(doc.getFileType());
|
||||
resp.setChunkCount(doc.getChunkCount());
|
||||
resp.setStatus(doc.getStatus());
|
||||
resp.setErrorMessage(doc.getErrorMessage());
|
||||
resp.setUploadedByName(doc.getUploadedBy().getUsername());
|
||||
resp.setCreatedAt(doc.getCreatedAt());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class OllamaEmbeddingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OllamaEmbeddingService.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiConfigService aiConfigService;
|
||||
|
||||
public OllamaEmbeddingService(ObjectMapper objectMapper, AiConfigService aiConfigService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiConfigService = aiConfigService;
|
||||
}
|
||||
|
||||
public List<Float> embed(String text) {
|
||||
String baseUrl = aiConfigService.getConfig().getOllamaBaseUrl();
|
||||
String model = aiConfigService.getConfig().getOllamaEmbeddingModel();
|
||||
|
||||
String requestBody = "{\"model\":\"" + model + "\",\"prompt\":\"" + escapeJson(text) + "\"}";
|
||||
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(baseUrl + "/api/embeddings").toURL().openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(30000);
|
||||
conn.setReadTimeout(60000);
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
byte[] input = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(input, 0, input.length);
|
||||
}
|
||||
|
||||
String json = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
JsonNode embedding = root.get("embedding");
|
||||
if (embedding == null || !embedding.isArray()) {
|
||||
throw new RuntimeException("No embedding in response: " + json);
|
||||
}
|
||||
|
||||
List<Float> result = new ArrayList<>();
|
||||
for (JsonNode n : embedding) {
|
||||
result.add(n.floatValue());
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("Ollama embedding failed", e);
|
||||
throw new RuntimeException("Ollama embedding failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String escapeJson(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class RoutingEmbeddingService implements EmbeddingService {
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final OllamaEmbeddingService ollamaEmbeddingService;
|
||||
private final DeepSeekEmbeddingService deepSeekEmbeddingService;
|
||||
|
||||
public RoutingEmbeddingService(AiConfigService aiConfigService,
|
||||
OllamaEmbeddingService ollamaEmbeddingService,
|
||||
DeepSeekEmbeddingService deepSeekEmbeddingService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.ollamaEmbeddingService = ollamaEmbeddingService;
|
||||
this.deepSeekEmbeddingService = deepSeekEmbeddingService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Float> embed(String text) {
|
||||
String provider = aiConfigService.getConfig().getProvider();
|
||||
System.out.println("=== ROUTING to provider: " + provider);
|
||||
if ("deepseek".equalsIgnoreCase(provider)) {
|
||||
return deepSeekEmbeddingService.embed(text);
|
||||
}
|
||||
System.out.println("=== CALLING OllamaEmbeddingService.embed()");
|
||||
List<Float> result = ollamaEmbeddingService.embed(text);
|
||||
System.out.println("=== Ollama result size: " + result.size());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import com.ims.service.entity.KnowledgeSearchLog;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.KnowledgeSearchLogRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SearchLogService {
|
||||
|
||||
private final KnowledgeSearchLogRepository repository;
|
||||
|
||||
public SearchLogService(KnowledgeSearchLogRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public void log(String query, String rewrittenQuery, Integer topK, Integer totalMatches,
|
||||
Integer durationMs, User user) {
|
||||
KnowledgeSearchLog log = KnowledgeSearchLog.builder()
|
||||
.query(query)
|
||||
.rewrittenQuery(rewrittenQuery)
|
||||
.topK(topK)
|
||||
.totalMatches(totalMatches)
|
||||
.durationMs(durationMs)
|
||||
.user(user)
|
||||
.build();
|
||||
repository.save(log);
|
||||
}
|
||||
|
||||
public Page<KnowledgeSearchLog> list(int page, int pageSize) {
|
||||
return repository.findAll(PageRequest.of(page - 1, pageSize));
|
||||
}
|
||||
|
||||
public long todayCount() {
|
||||
return repository.count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ims.service.knowledge;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class SearchService {
|
||||
|
||||
private final RoutingEmbeddingService embeddingService;
|
||||
private final SearchLogService searchLogService;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public SearchService(RoutingEmbeddingService embeddingService,
|
||||
SearchLogService searchLogService,
|
||||
EntityManager entityManager) {
|
||||
this.embeddingService = embeddingService;
|
||||
this.searchLogService = searchLogService;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
public List<SearchResult> search(String query, int topK) {
|
||||
long start = System.currentTimeMillis();
|
||||
List<Float> queryVector = embeddingService.embed(query);
|
||||
|
||||
String vectorStr = vectorToPgvectorString(queryVector);
|
||||
String sql = "SELECT kc.id, kc.content, kd.name AS doc_name, " +
|
||||
"1 - (kc.embedding <=> '" + vectorStr + "'::vector) AS score " +
|
||||
"FROM knowledge_chunks kc " +
|
||||
"JOIN knowledge_documents kd ON kd.id = kc.doc_id " +
|
||||
"WHERE kd.status = 'completed' " +
|
||||
"ORDER BY score DESC LIMIT :topK";
|
||||
|
||||
Query nativeQuery = entityManager.createNativeQuery(sql);
|
||||
nativeQuery.setParameter("topK", topK);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = nativeQuery.getResultList();
|
||||
|
||||
List<SearchResult> results = new ArrayList<>();
|
||||
for (Object[] row : rows) {
|
||||
SearchResult r = new SearchResult();
|
||||
r.setChunkId(((Number) row[0]).longValue());
|
||||
r.setContent((String) row[1]);
|
||||
r.setDocName((String) row[2]);
|
||||
r.setScore(row[3] != null ? ((Number) row[3]).doubleValue() : 0.0);
|
||||
results.add(r);
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
searchLogService.log(query, null, topK, results.size(), (int) duration, null);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static String vectorToPgvectorString(List<Float> vector) {
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < vector.size(); i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append(vector.get(i));
|
||||
}
|
||||
sb.append("]");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static class SearchResult {
|
||||
private Long chunkId;
|
||||
private String content;
|
||||
private String docName;
|
||||
private Double score;
|
||||
|
||||
public Long getChunkId() { return chunkId; }
|
||||
public void setChunkId(Long chunkId) { this.chunkId = chunkId; }
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
public String getDocName() { return docName; }
|
||||
public void setDocName(String docName) { this.docName = docName; }
|
||||
public Double getScore() { return score; }
|
||||
public void setScore(Double score) { this.score = score; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ims.service.notification;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
|
||||
|
||||
public void send(String to, String subject, String content) {
|
||||
if (to == null || to.isBlank()) {
|
||||
log.warn("[FAKE-MAIL] 收件人邮箱为空,跳过发送 subject={}", subject);
|
||||
return;
|
||||
}
|
||||
log.info("[FAKE-MAIL] to={} | subject={} | content={}", to, subject, content);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.ims.service.notification;
|
||||
|
||||
import com.ims.service.entity.AgentPlan;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.NotificationRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class NotificationScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NotificationScheduler.class);
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final NotificationService notificationService;
|
||||
private final NotificationRepository notificationRepository;
|
||||
|
||||
public NotificationScheduler(EntityManager entityManager,
|
||||
NotificationService notificationService,
|
||||
NotificationRepository notificationRepository) {
|
||||
this.entityManager = entityManager;
|
||||
this.notificationService = notificationService;
|
||||
this.notificationRepository = notificationRepository;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 */30 * * * *")
|
||||
@Transactional
|
||||
public void escalateApprovalTimeouts() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(48);
|
||||
List<AgentPlan> plans = entityManager.createQuery(
|
||||
"select p from AgentPlan p where p.approvalStatus = 'requested' and p.createdAt < :cutoff", AgentPlan.class)
|
||||
.setParameter("cutoff", cutoff)
|
||||
.getResultList();
|
||||
for (AgentPlan plan : plans) {
|
||||
User target = plan.getCreatedBy();
|
||||
Issue issue = plan.getIssue();
|
||||
if (target == null || issue == null) continue;
|
||||
if (notificationRepository.existsByTypeAndIssueIdAndCreatedAtAfter(
|
||||
"approval_timeout", issue.getId(), LocalDateTime.now().minusHours(24))) continue;
|
||||
notificationService.notify(target, "审批超时提醒: " + issue.getIssueNo(),
|
||||
"Agent 审批请求已超过 48 小时未响应,请及时处理或升级。",
|
||||
"approval_timeout", "/issues/" + issue.getId(), issue);
|
||||
log.info("审批超时升级 planId={}, issueId={}", plan.getId(), issue.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 */6 * * *")
|
||||
@Transactional
|
||||
public void remindUpcomingDeadlines() {
|
||||
LocalDateTime from = LocalDateTime.now();
|
||||
LocalDateTime to = from.plusDays(3);
|
||||
List<Issue> issues = entityManager.createQuery(
|
||||
"select i from Issue i where i.isDeleted = false and i.status <> 'closed' " +
|
||||
"and i.deadline is not null and i.deadline between :from and :to", Issue.class)
|
||||
.setParameter("from", from)
|
||||
.setParameter("to", to)
|
||||
.getResultList();
|
||||
for (Issue issue : issues) {
|
||||
if (issue.getAssignee() == null) continue;
|
||||
if (notificationRepository.existsByTypeAndIssueIdAndCreatedAtAfter(
|
||||
"deadline_reminder", issue.getId(), LocalDateTime.now().minusDays(1))) continue;
|
||||
notificationService.notify(issue.getAssignee(), "整改即将到期: " + issue.getIssueNo(),
|
||||
"指摘《" + issue.getTitle() + "》整改截止日期 " + issue.getDeadline() + " 临近,请尽快处理。",
|
||||
"deadline_reminder", "/issues/" + issue.getId(), issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.ims.service.notification;
|
||||
|
||||
import com.ims.api.dto.notification.NotificationResponse;
|
||||
import com.ims.common.dto.PageResult;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.Notification;
|
||||
import com.ims.service.entity.Role;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.entity.UserRole;
|
||||
import com.ims.service.repository.NotificationRepository;
|
||||
import com.ims.service.repository.RoleRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import com.ims.service.repository.UserRoleRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class NotificationService {
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final EntityManager entityManager;
|
||||
private final EmailService emailService;
|
||||
private final RoleRepository roleRepository;
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public NotificationService(NotificationRepository notificationRepository, EntityManager entityManager,
|
||||
EmailService emailService, RoleRepository roleRepository,
|
||||
UserRoleRepository userRoleRepository, UserRepository userRepository) {
|
||||
this.notificationRepository = notificationRepository;
|
||||
this.entityManager = entityManager;
|
||||
this.emailService = emailService;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<NotificationResponse> list(Long userId, int page, int pageSize) {
|
||||
int p = Math.max(1, page);
|
||||
int ps = pageSize > 0 ? pageSize : 20;
|
||||
TypedQuery<Notification> query = entityManager.createQuery(
|
||||
"select n from Notification n where n.user.id = :userId order by n.createdAt desc", Notification.class)
|
||||
.setParameter("userId", userId)
|
||||
.setFirstResult((p - 1) * ps)
|
||||
.setMaxResults(ps);
|
||||
long total = ((Number) entityManager.createQuery(
|
||||
"select count(n) from Notification n where n.user.id = :userId")
|
||||
.setParameter("userId", userId).getSingleResult()).longValue();
|
||||
List<NotificationResponse> items = query.getResultList().stream().map(this::toResponse).toList();
|
||||
return new PageResult<>(items, total, p, ps);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long unreadCount(Long userId) {
|
||||
return notificationRepository.countByUserIdAndIsRead(userId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markRead(Long id, Long userId) {
|
||||
Notification n = notificationRepository.findById(id).orElse(null);
|
||||
if (n == null || !n.getUser().getId().equals(userId)) return;
|
||||
n.setIsRead(true);
|
||||
notificationRepository.save(n);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int markAllRead(Long userId) {
|
||||
return notificationRepository.markAllRead(userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void notify(User user, String title, String content, String type, String link, Issue issue) {
|
||||
if (user == null) return;
|
||||
Notification n = Notification.builder()
|
||||
.user(user)
|
||||
.title(title)
|
||||
.content(content)
|
||||
.type(type)
|
||||
.link(link)
|
||||
.isRead(false)
|
||||
.issue(issue)
|
||||
.build();
|
||||
notificationRepository.save(n);
|
||||
emailService.send(user.getEmail(), title, content);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void notifyAdmins(String title, String content) {
|
||||
Set<Long> adminRoleIds = roleRepository.findByNameIn(List.of("超级管理员", "部门管理员")).stream()
|
||||
.map(Role::getId)
|
||||
.collect(Collectors.toSet());
|
||||
if (adminRoleIds.isEmpty()) return;
|
||||
Set<Long> adminUserIds = userRoleRepository.findByRoleIdIn(adminRoleIds).stream()
|
||||
.map(UserRole::getUserId)
|
||||
.collect(Collectors.toSet());
|
||||
for (Long userId : adminUserIds) {
|
||||
userRepository.findById(userId).ifPresent(user -> notify(user, title, content, "admin", null, null));
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationResponse toResponse(Notification n) {
|
||||
NotificationResponse resp = new NotificationResponse();
|
||||
resp.setId(n.getId());
|
||||
resp.setTitle(n.getTitle());
|
||||
resp.setContent(n.getContent());
|
||||
resp.setType(n.getType());
|
||||
resp.setLink(n.getLink());
|
||||
resp.setIsRead(n.getIsRead());
|
||||
resp.setCreatedAt(n.getCreatedAt());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.ims.service.prompt;
|
||||
|
||||
import com.ims.api.dto.prompt.PromptRenderLogResponse;
|
||||
import com.ims.api.dto.prompt.PromptStatsResponse;
|
||||
import com.ims.api.dto.prompt.PromptTemplateRequest;
|
||||
import com.ims.api.dto.prompt.PromptTemplateResponse;
|
||||
import com.ims.api.dto.prompt.PromptTestRequest;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.ai.PromptTemplateEngine;
|
||||
import com.ims.service.entity.PromptRenderLog;
|
||||
import com.ims.service.entity.PromptTemplate;
|
||||
import com.ims.service.entity.PromptTemplateVersion;
|
||||
import com.ims.service.notification.NotificationService;
|
||||
import com.ims.service.repository.PromptRenderLogRepository;
|
||||
import com.ims.service.repository.PromptTemplateRepository;
|
||||
import com.ims.service.repository.PromptTemplateVersionRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class PromptService {
|
||||
|
||||
private final PromptTemplateRepository templateRepository;
|
||||
private final PromptTemplateVersionRepository versionRepository;
|
||||
private final PromptRenderLogRepository renderLogRepository;
|
||||
private final PromptTemplateEngine promptEngine;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public PromptService(PromptTemplateRepository templateRepository,
|
||||
PromptTemplateVersionRepository versionRepository,
|
||||
PromptRenderLogRepository renderLogRepository,
|
||||
PromptTemplateEngine promptEngine,
|
||||
NotificationService notificationService) {
|
||||
this.templateRepository = templateRepository;
|
||||
this.versionRepository = versionRepository;
|
||||
this.renderLogRepository = renderLogRepository;
|
||||
this.promptEngine = promptEngine;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
public Page<PromptTemplateResponse> list(int page, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), pageSize);
|
||||
return templateRepository.findAllByIsActiveTrue(pageable).map(this::toResponse);
|
||||
}
|
||||
|
||||
public PromptTemplateResponse detail(String templateId) {
|
||||
PromptTemplate template = templateRepository.findByTemplateIdAndIsActiveTrue(templateId)
|
||||
.orElseThrow(() -> new BusinessException("模板不存在: " + templateId));
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
public PromptTemplateResponse create(PromptTemplateRequest request) {
|
||||
if (templateRepository.findByTemplateIdAndIsActiveTrue(request.getTemplateId()).isPresent()) {
|
||||
throw new BusinessException("模板ID已存在: " + request.getTemplateId());
|
||||
}
|
||||
PromptTemplate template = new PromptTemplate();
|
||||
template.setTemplateId(request.getTemplateId());
|
||||
template.setName(request.getName());
|
||||
template.setCategory(request.getCategory());
|
||||
template.setVersion(1);
|
||||
template.setContent(request.getContent());
|
||||
template.setVariables(request.getVariables());
|
||||
template.setOutputSchema(request.getOutputSchema());
|
||||
template.setIsActive(true);
|
||||
templateRepository.save(template);
|
||||
|
||||
saveVersion(template, "初始版本");
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
public PromptTemplateResponse update(String templateId, PromptTemplateRequest request) {
|
||||
PromptTemplate template = templateRepository.findByTemplateIdAndIsActiveTrue(templateId)
|
||||
.orElseThrow(() -> new BusinessException("模板不存在: " + templateId));
|
||||
|
||||
int nextVersion = template.getVersion() + 1;
|
||||
template.setVersion(nextVersion);
|
||||
template.setName(request.getName());
|
||||
template.setContent(request.getContent());
|
||||
template.setVariables(request.getVariables());
|
||||
template.setOutputSchema(request.getOutputSchema());
|
||||
templateRepository.save(template);
|
||||
|
||||
saveVersion(template, "更新至版本 " + nextVersion);
|
||||
notificationService.notifyAdmins("Prompt模板更新: " + templateId,
|
||||
"模板 " + templateId + " 已更新至版本 v" + nextVersion);
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
public PromptTemplateResponse rollback(String templateId, int version) {
|
||||
PromptTemplate template = templateRepository.findByTemplateIdAndIsActiveTrue(templateId)
|
||||
.orElseThrow(() -> new BusinessException("模板不存在: " + templateId));
|
||||
PromptTemplateVersion target = versionRepository.findByTemplateIdOrderByVersionDesc(templateId).stream()
|
||||
.filter(v -> v.getVersion() == version)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException("版本不存在: " + version));
|
||||
|
||||
int nextVersion = template.getVersion() + 1;
|
||||
template.setVersion(nextVersion);
|
||||
template.setContent(target.getContent());
|
||||
templateRepository.save(template);
|
||||
|
||||
saveVersion(template, "回滚至版本 " + version);
|
||||
notificationService.notifyAdmins("Prompt模板更新: " + templateId,
|
||||
"模板 " + templateId + " 已回滚至版本 v" + version);
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
public Map<String, Object> test(PromptTestRequest request) {
|
||||
PromptTemplate template = templateRepository.findByTemplateIdAndIsActiveTrue(request.getTemplateId())
|
||||
.orElseThrow(() -> new BusinessException("模板不存在: " + request.getTemplateId()));
|
||||
Map<String, Object> vars = request.getVariables() == null ? Map.of() : request.getVariables();
|
||||
String rendered = promptEngine.render(template, vars);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("templateId", template.getTemplateId());
|
||||
result.put("version", template.getVersion());
|
||||
result.put("rendered", rendered);
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> versions(String templateId) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (PromptTemplateVersion v : versionRepository.findByTemplateIdOrderByVersionDesc(templateId)) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", v.getId());
|
||||
m.put("templateId", v.getTemplateId());
|
||||
m.put("version", v.getVersion());
|
||||
m.put("content", v.getContent());
|
||||
m.put("changeLog", v.getChangeLog());
|
||||
m.put("createdAt", v.getCreatedAt() == null ? null : v.getCreatedAt().toString());
|
||||
result.add(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Page<PromptRenderLogResponse> logs(int page, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(Math.max(page - 1, 0), pageSize);
|
||||
return renderLogRepository.findAllByOrderByCreatedAtDesc(pageable).map(this::toLogResponse);
|
||||
}
|
||||
|
||||
public List<PromptStatsResponse> stats() {
|
||||
Map<String, long[]> agg = new HashMap<>();
|
||||
for (Object[] row : renderLogRepository.aggregateByTemplate()) {
|
||||
String templateId = (String) row[0];
|
||||
long count = ((Number) row[1]).longValue();
|
||||
double avgMs = row[2] == null ? 0 : ((Number) row[2]).doubleValue();
|
||||
agg.put(templateId, new long[]{count, Double.doubleToLongBits(avgMs)});
|
||||
}
|
||||
|
||||
List<PromptStatsResponse> result = new ArrayList<>();
|
||||
for (PromptTemplate template : templateRepository.findAllByIsActiveTrue()) {
|
||||
PromptStatsResponse stat = new PromptStatsResponse();
|
||||
stat.setTemplateId(template.getTemplateId());
|
||||
stat.setName(template.getName());
|
||||
stat.setCategory(template.getCategory());
|
||||
stat.setLatestVersion(template.getVersion());
|
||||
long[] data = agg.get(template.getTemplateId());
|
||||
if (data != null) {
|
||||
stat.setUseCount(data[0]);
|
||||
stat.setAvgExecutionTimeMs(Double.longBitsToDouble(data[1]));
|
||||
} else {
|
||||
stat.setUseCount(0L);
|
||||
stat.setAvgExecutionTimeMs(0.0);
|
||||
}
|
||||
result.add(stat);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void saveVersion(PromptTemplate template, String changeLog) {
|
||||
PromptTemplateVersion version = new PromptTemplateVersion();
|
||||
version.setTemplateId(template.getTemplateId());
|
||||
version.setVersion(template.getVersion());
|
||||
version.setContent(template.getContent());
|
||||
version.setChangeLog(changeLog);
|
||||
versionRepository.save(version);
|
||||
}
|
||||
|
||||
private PromptTemplateResponse toResponse(PromptTemplate t) {
|
||||
PromptTemplateResponse resp = new PromptTemplateResponse();
|
||||
resp.setId(t.getId());
|
||||
resp.setTemplateId(t.getTemplateId());
|
||||
resp.setName(t.getName());
|
||||
resp.setCategory(t.getCategory());
|
||||
resp.setVersion(t.getVersion());
|
||||
resp.setContent(t.getContent());
|
||||
resp.setVariables(t.getVariables());
|
||||
resp.setOutputSchema(t.getOutputSchema());
|
||||
resp.setIsActive(t.getIsActive());
|
||||
resp.setIsDefault(t.getIsDefault());
|
||||
resp.setCreatedAt(t.getCreatedAt());
|
||||
resp.setUpdatedAt(t.getUpdatedAt());
|
||||
return resp;
|
||||
}
|
||||
|
||||
private PromptRenderLogResponse toLogResponse(PromptRenderLog log) {
|
||||
PromptRenderLogResponse resp = new PromptRenderLogResponse();
|
||||
resp.setId(log.getId());
|
||||
resp.setRequestId(log.getRequestId());
|
||||
resp.setTemplateId(log.getTemplateId());
|
||||
resp.setTemplateVersion(log.getTemplateVersion());
|
||||
resp.setRenderedPrompt(log.getRenderedPrompt());
|
||||
resp.setVariablesUsed(log.getVariablesUsed());
|
||||
resp.setTokensInput(log.getTokensInput());
|
||||
resp.setTokensOutput(log.getTokensOutput());
|
||||
resp.setExecutionTimeMs(log.getExecutionTimeMs());
|
||||
resp.setLlmModel(log.getLlmModel());
|
||||
resp.setModelProvider(log.getModelProvider());
|
||||
resp.setCreatedAt(log.getCreatedAt());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.ims.service.prompt;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.api.dto.prompt.PromptLogResponse;
|
||||
import com.ims.api.dto.prompt.PromptStatsResponse;
|
||||
import com.ims.api.dto.prompt.PromptTemplateRequest;
|
||||
import com.ims.api.dto.prompt.PromptTemplateResponse;
|
||||
import com.ims.api.dto.prompt.PromptTestRequest;
|
||||
import com.ims.api.dto.prompt.PromptTestResponse;
|
||||
import com.ims.api.dto.prompt.PromptVersionResponse;
|
||||
import com.ims.api.service.prompt.PromptService;
|
||||
import com.ims.common.constant.ResultCode;
|
||||
import com.ims.common.dto.PageResult;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.PromptRenderLog;
|
||||
import com.ims.service.entity.PromptTemplate;
|
||||
import com.ims.service.entity.PromptTemplateVersion;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.PromptRenderLogRepository;
|
||||
import com.ims.service.repository.PromptTemplateRepository;
|
||||
import com.ims.service.repository.PromptTemplateVersionRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class PromptServiceImpl implements PromptService {
|
||||
|
||||
private static final Pattern VAR_PATTERN = Pattern.compile("\\{\\{\\s*(\\w+)\\s*}}");
|
||||
|
||||
private final PromptTemplateRepository templateRepository;
|
||||
private final PromptTemplateVersionRepository versionRepository;
|
||||
private final PromptRenderLogRepository renderLogRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PromptServiceImpl(PromptTemplateRepository templateRepository,
|
||||
PromptTemplateVersionRepository versionRepository,
|
||||
PromptRenderLogRepository renderLogRepository,
|
||||
UserRepository userRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.templateRepository = templateRepository;
|
||||
this.versionRepository = versionRepository;
|
||||
this.renderLogRepository = renderLogRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<PromptTemplateResponse> list(int page, int pageSize, String category, String keyword) {
|
||||
String cat = category == null ? null : category.toLowerCase(Locale.ROOT);
|
||||
String kw = keyword == null ? null : keyword.toLowerCase(Locale.ROOT);
|
||||
List<PromptTemplate> filtered = templateRepository.findAll().stream()
|
||||
.filter(t -> cat == null || cat.isBlank()
|
||||
|| (t.getCategory() != null && t.getCategory().toLowerCase(Locale.ROOT).contains(cat)))
|
||||
.filter(t -> kw == null || kw.isBlank()
|
||||
|| (t.getName() != null && t.getName().toLowerCase(Locale.ROOT).contains(kw))
|
||||
|| (t.getTemplateId() != null && t.getTemplateId().toLowerCase(Locale.ROOT).contains(kw)))
|
||||
.sorted(Comparator.comparing(PromptTemplate::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.toList();
|
||||
int total = filtered.size();
|
||||
int from = Math.min((page - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
List<PromptTemplateResponse> items = filtered.subList(from, to).stream().map(this::toResponse).toList();
|
||||
return new PageResult<>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PromptTemplateResponse detail(String templateId) {
|
||||
PromptTemplate template = findByTemplateId(templateId);
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public PromptTemplateResponse create(PromptTemplateRequest request, String operatorUsername) {
|
||||
if (templateRepository.findAll().stream().anyMatch(t -> t.getTemplateId().equals(request.getTemplateId()))) {
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "模板ID已存在");
|
||||
}
|
||||
PromptTemplate template = PromptTemplate.builder()
|
||||
.templateId(request.getTemplateId())
|
||||
.name(request.getName())
|
||||
.category(request.getCategory())
|
||||
.content(request.getContent())
|
||||
.variables(request.getVariables())
|
||||
.outputSchema(request.getOutputSchema())
|
||||
.version(1)
|
||||
.isActive(true)
|
||||
.isDefault(false)
|
||||
.createdBy(resolveUser(operatorUsername))
|
||||
.build();
|
||||
template = templateRepository.save(template);
|
||||
saveVersion(template.getTemplateId(), template.getVersion(), template.getContent(), "初始版本", operatorUsername);
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public PromptTemplateResponse update(String templateId, PromptTemplateRequest request, String operatorUsername) {
|
||||
PromptTemplate template = findByTemplateId(templateId);
|
||||
int nextVersion = template.getVersion() + 1;
|
||||
if (request.getName() != null && !request.getName().isBlank()) {
|
||||
template.setName(request.getName());
|
||||
}
|
||||
if (request.getCategory() != null && !request.getCategory().isBlank()) {
|
||||
template.setCategory(request.getCategory());
|
||||
}
|
||||
if (request.getContent() != null && !request.getContent().isBlank()) {
|
||||
template.setContent(request.getContent());
|
||||
}
|
||||
if (request.getVariables() != null) {
|
||||
template.setVariables(request.getVariables());
|
||||
}
|
||||
if (request.getOutputSchema() != null) {
|
||||
template.setOutputSchema(request.getOutputSchema());
|
||||
}
|
||||
template.setVersion(nextVersion);
|
||||
template = templateRepository.save(template);
|
||||
saveVersion(templateId, nextVersion, template.getContent(), "更新到 v" + nextVersion, operatorUsername);
|
||||
return toResponse(template);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void rollback(String templateId, int version, String operatorUsername) {
|
||||
PromptTemplate template = findByTemplateId(templateId);
|
||||
PromptTemplateVersion target = versionRepository.findByTemplateIdOrderByVersionDesc(templateId).stream()
|
||||
.filter(v -> v.getVersion() == version)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "版本不存在"));
|
||||
int nextVersion = template.getVersion() + 1;
|
||||
template.setContent(target.getContent());
|
||||
template.setVersion(nextVersion);
|
||||
templateRepository.save(template);
|
||||
saveVersion(templateId, nextVersion, target.getContent(), "回滚到 v" + version, operatorUsername);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public PromptTestResponse test(PromptTestRequest request, String operatorUsername) {
|
||||
PromptTemplate template = findByTemplateId(request.getTemplateId());
|
||||
long start = System.currentTimeMillis();
|
||||
String rendered = render(template.getContent(), request.getVariables());
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
PromptRenderLog log = PromptRenderLog.builder()
|
||||
.requestId(UUID.randomUUID().toString().replace("-", ""))
|
||||
.templateId(template.getTemplateId())
|
||||
.templateVersion(template.getVersion())
|
||||
.renderedPrompt(rendered)
|
||||
.variablesUsed(toJson(request.getVariables()))
|
||||
.executionTimeMs((int) cost)
|
||||
.build();
|
||||
renderLogRepository.save(log);
|
||||
PromptTestResponse response = new PromptTestResponse();
|
||||
response.setTemplateId(template.getTemplateId());
|
||||
response.setTemplateVersion(template.getVersion());
|
||||
response.setRenderedPrompt(rendered);
|
||||
response.setExecutionTimeMs((int) cost);
|
||||
response.setTokenEstimate(rendered.length() / 2);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<PromptVersionResponse> versions(String templateId) {
|
||||
return versionRepository.findByTemplateIdOrderByVersionDesc(templateId).stream()
|
||||
.map(v -> {
|
||||
PromptVersionResponse r = new PromptVersionResponse();
|
||||
r.setId(v.getId());
|
||||
r.setTemplateId(v.getTemplateId());
|
||||
r.setVersion(v.getVersion());
|
||||
r.setContent(v.getContent());
|
||||
r.setChangeLog(v.getChangeLog());
|
||||
r.setCreatedBy(v.getCreatedBy() == null ? "" : v.getCreatedBy().getUsername());
|
||||
r.setCreatedAt(v.getCreatedAt());
|
||||
return r;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<PromptLogResponse> logs(int page, int pageSize) {
|
||||
List<PromptLogResponse> all = renderLogRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(PromptRenderLog::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(l -> {
|
||||
PromptLogResponse r = new PromptLogResponse();
|
||||
r.setId(l.getId());
|
||||
r.setRequestId(l.getRequestId());
|
||||
r.setTemplateId(l.getTemplateId());
|
||||
r.setTemplateVersion(l.getTemplateVersion());
|
||||
r.setRenderedPrompt(l.getRenderedPrompt());
|
||||
r.setExecutionTimeMs(l.getExecutionTimeMs());
|
||||
r.setLlmModel(l.getLlmModel());
|
||||
r.setModelProvider(l.getModelProvider());
|
||||
r.setCreatedAt(l.getCreatedAt());
|
||||
return r;
|
||||
}).toList();
|
||||
int total = all.size();
|
||||
int from = Math.min((page - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
return new PageResult<>(all.subList(from, to), total, page, pageSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PromptStatsResponse stats() {
|
||||
PromptStatsResponse stats = new PromptStatsResponse();
|
||||
stats.setTotalTemplates(templateRepository.count());
|
||||
stats.setActiveTemplates(templateRepository.findAll().stream().filter(t -> Boolean.TRUE.equals(t.getIsActive())).count());
|
||||
stats.setTotalVersions(versionRepository.count());
|
||||
stats.setTotalRenders(renderLogRepository.count());
|
||||
return stats;
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String render(String content, Map<String, Object> variables) {
|
||||
if (content == null || variables == null) {
|
||||
return content;
|
||||
}
|
||||
Matcher matcher = VAR_PATTERN.matcher(content);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
Object value = variables.get(matcher.group(1));
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(value == null ? "" : String.valueOf(value)));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private PromptTemplate findByTemplateId(String templateId) {
|
||||
return templateRepository.findAll().stream()
|
||||
.filter(t -> t.getTemplateId().equals(templateId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.NOT_FOUND.getCode(), "模板不存在"));
|
||||
}
|
||||
|
||||
private void saveVersion(String templateId, int version, String content, String changeLog, String operatorUsername) {
|
||||
PromptTemplateVersion history = PromptTemplateVersion.builder()
|
||||
.templateId(templateId)
|
||||
.version(version)
|
||||
.content(content)
|
||||
.changeLog(changeLog)
|
||||
.createdBy(resolveUser(operatorUsername))
|
||||
.build();
|
||||
versionRepository.save(history);
|
||||
}
|
||||
|
||||
private User resolveUser(String operatorUsername) {
|
||||
if (operatorUsername == null) {
|
||||
return null;
|
||||
}
|
||||
return userRepository.findByUsername(operatorUsername)
|
||||
.or(() -> userRepository.findByUserid(operatorUsername))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private PromptTemplateResponse toResponse(PromptTemplate template) {
|
||||
PromptTemplateResponse r = new PromptTemplateResponse();
|
||||
r.setId(template.getId());
|
||||
r.setTemplateId(template.getTemplateId());
|
||||
r.setName(template.getName());
|
||||
r.setCategory(template.getCategory());
|
||||
r.setVersion(template.getVersion());
|
||||
r.setContent(template.getContent());
|
||||
r.setVariables(template.getVariables());
|
||||
r.setOutputSchema(template.getOutputSchema());
|
||||
r.setIsActive(template.getIsActive());
|
||||
r.setIsDefault(template.getIsDefault());
|
||||
r.setCreatedAt(template.getCreatedAt());
|
||||
r.setUpdatedAt(template.getUpdatedAt());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.AgentMemory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface AgentMemoryRepository extends JpaRepository<AgentMemory, Long> {
|
||||
|
||||
@Query(value = "SELECT * FROM agent_memories ORDER BY embedding <=> CAST(:vector AS vector) LIMIT :limit",
|
||||
nativeQuery = true)
|
||||
List<AgentMemory> findSimilar(@Param("vector") String vector, @Param("limit") int limit);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.AgentPlan;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface AgentPlanRepository extends JpaRepository<AgentPlan, Long> {
|
||||
List<AgentPlan> findByIssueIdOrderByCreatedAtDesc(Long issueId);
|
||||
|
||||
Page<AgentPlan> findByApprovalStatusOrderByCreatedAtDesc(String approvalStatus, Pageable pageable);
|
||||
|
||||
long countByApprovalStatus(String approvalStatus);
|
||||
|
||||
long countByCreatedAtAfter(LocalDateTime time);
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.AiAnalysis;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface AiAnalysisRepository extends JpaRepository<AiAnalysis, Long>, JpaSpecificationExecutor<AiAnalysis> {
|
||||
List<AiAnalysis> findByIssueIdOrderByCreatedAtDesc(Long issueId);
|
||||
Page<AiAnalysis> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
@Query("SELECT a FROM AiAnalysis a WHERE a.status IN :statuses ORDER BY a.createdAt DESC")
|
||||
List<AiAnalysis> findByStatusIn(@Param("statuses") Collection<String> statuses, Pageable pageable);
|
||||
|
||||
@Query("SELECT COUNT(DISTINCT a.issue.id) FROM AiAnalysis a WHERE a.status = 'completed'")
|
||||
long countAnalyzedIssues();
|
||||
|
||||
@Query("SELECT a.status, COUNT(a) FROM AiAnalysis a GROUP BY a.status")
|
||||
List<Object[]> countByStatusGroup();
|
||||
|
||||
@Query("SELECT a.category, COUNT(a) FROM AiAnalysis a WHERE a.status = 'completed' AND a.category IS NOT NULL GROUP BY a.category")
|
||||
List<Object[]> countByCategoryGroup();
|
||||
|
||||
@Query("SELECT a.issue.department.name, COUNT(a) FROM AiAnalysis a WHERE a.status = 'completed' AND a.issue.department IS NOT NULL GROUP BY a.issue.department.name")
|
||||
List<Object[]> countByDepartmentGroup();
|
||||
|
||||
@Query("SELECT FUNCTION('DATE', a.createdAt) as d, a.status, COUNT(a) FROM AiAnalysis a WHERE a.createdAt >= :since GROUP BY FUNCTION('DATE', a.createdAt), a.status ORDER BY d")
|
||||
List<Object[]> dailyTrendGroup(@Param("since") LocalDateTime since);
|
||||
|
||||
default Page<AiAnalysis> search(Long id, Long issueId, Long departmentId, String status,
|
||||
LocalDateTime start, LocalDateTime end, Pageable pageable) {
|
||||
Specification<AiAnalysis> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (id != null) {
|
||||
predicates.add(cb.equal(root.get("id"), id));
|
||||
}
|
||||
if (issueId != null) {
|
||||
predicates.add(cb.equal(root.get("issue").get("id"), issueId));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(cb.equal(root.get("issue").get("department").get("id"), departmentId));
|
||||
}
|
||||
if (status != null) {
|
||||
predicates.add(cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (start != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), start));
|
||||
}
|
||||
if (end != null) {
|
||||
predicates.add(cb.lessThan(root.get("createdAt"), end));
|
||||
}
|
||||
query.orderBy(cb.desc(root.get("createdAt")));
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
return findAll(spec, pageable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.AiCallLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AiCallLogRepository extends JpaRepository<AiCallLog, Long> {
|
||||
List<AiCallLog> findTop20ByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.AiFeedback;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface AiFeedbackRepository extends JpaRepository<AiFeedback, Long> {
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.Attachment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface AttachmentRepository extends JpaRepository<Attachment, Long> {
|
||||
List<Attachment> findByIssueId(Long issueId);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.Department;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface DepartmentRepository extends JpaRepository<Department, Long> {
|
||||
List<Department> findByParentIdOrderBySortOrder(Long parentId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.ImportRecord;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface ImportRecordRepository extends JpaRepository<ImportRecord, Long> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.IssueLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface IssueLogRepository extends JpaRepository<IssueLog, Long> {
|
||||
List<IssueLog> findByIssueIdOrderByCreatedAtDesc(Long issueId);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.Issue;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface IssueRepository extends JpaRepository<Issue, Long>, JpaSpecificationExecutor<Issue> {
|
||||
Optional<Issue> findByIssueNo(String issueNo);
|
||||
|
||||
List<Issue> findByIsDeletedFalseAndStatusNotAndDeadlineBefore(String status, LocalDateTime now);
|
||||
|
||||
List<Issue> findByIsDeletedFalseAndStatusAndAssigneeIsNull(String status);
|
||||
|
||||
default Page<Issue> search(String status, String phase, Long departmentId, String keyword,
|
||||
LocalDateTime start, LocalDateTime end, Pageable pageable) {
|
||||
Specification<Issue> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.isFalse(root.get("isDeleted")));
|
||||
if (status != null) {
|
||||
predicates.add(cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (phase != null) {
|
||||
predicates.add(cb.equal(root.get("phase"), phase));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(cb.equal(root.get("department").get("id"), departmentId));
|
||||
}
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
String like = "%" + keyword + "%";
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("issueNo"), like),
|
||||
cb.like(root.get("title"), like)));
|
||||
}
|
||||
if (start != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), start));
|
||||
}
|
||||
if (end != null) {
|
||||
predicates.add(cb.lessThan(root.get("createdAt"), end));
|
||||
}
|
||||
query.orderBy(cb.desc(root.get("createdAt")));
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
return findAll(spec, pageable);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.KnowledgeChunk;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface KnowledgeChunkRepository extends JpaRepository<KnowledgeChunk, Long> {
|
||||
List<KnowledgeChunk> findByDocId(Long docId);
|
||||
void deleteByDocId(Long docId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.KnowledgeDocument;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface KnowledgeDocumentRepository extends JpaRepository<KnowledgeDocument, Long> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.KnowledgeSearchLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface KnowledgeSearchLogRepository extends JpaRepository<KnowledgeSearchLog, Long> {
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.Notification;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface NotificationRepository extends JpaRepository<Notification, Long> {
|
||||
List<Notification> findByUserIdAndIsReadOrderByCreatedAtDesc(Long userId, Boolean isRead);
|
||||
long countByUserIdAndIsRead(Long userId, Boolean isRead);
|
||||
|
||||
boolean existsByTypeAndIssueIdAndCreatedAtAfter(String type, Long issueId, LocalDateTime after);
|
||||
|
||||
@Modifying
|
||||
@Query("update Notification n set n.isRead = true where n.user.id = :userId and n.isRead = false")
|
||||
int markAllRead(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.Permission;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface PermissionRepository extends JpaRepository<Permission, Long> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.PhaseRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PhaseRuleRepository extends JpaRepository<PhaseRule, Long> {
|
||||
List<PhaseRule> findByActiveTrueOrderBySortOrderAsc();
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.PromptRenderLog;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PromptRenderLogRepository extends JpaRepository<PromptRenderLog, Long> {
|
||||
Page<PromptRenderLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
@Query(value = "SELECT template_id, COUNT(*), AVG(execution_time_ms) " +
|
||||
"FROM prompt_render_logs GROUP BY template_id ORDER BY COUNT(*) DESC",
|
||||
nativeQuery = true)
|
||||
List<Object[]> aggregateByTemplate();
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.PromptTemplate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface PromptTemplateRepository extends JpaRepository<PromptTemplate, Long> {
|
||||
Optional<PromptTemplate> findByTemplateIdAndIsActiveTrue(String templateId);
|
||||
List<PromptTemplate> findByCategoryAndIsActiveTrue(String category);
|
||||
List<PromptTemplate> findAllByIsActiveTrue();
|
||||
Page<PromptTemplate> findAllByIsActiveTrue(Pageable pageable);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.PromptTemplateVersion;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PromptTemplateVersionRepository extends JpaRepository<PromptTemplateVersion, Long> {
|
||||
List<PromptTemplateVersion> findByTemplateIdOrderByVersionDesc(String templateId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.RolePermission;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface RolePermissionRepository extends JpaRepository<RolePermission, RolePermission.RolePermissionId> {
|
||||
List<RolePermission> findByRoleId(Long roleId);
|
||||
|
||||
List<RolePermission> findByRoleIdIn(Collection<Long> roleIds);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.Role;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface RoleRepository extends JpaRepository<Role, Long> {
|
||||
List<Role> findByNameIn(Collection<String> names);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.TaskExecution;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface TaskExecutionRepository extends JpaRepository<TaskExecution, Long> {
|
||||
Optional<TaskExecution> findByTaskId(String taskId);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.ToolExecution;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface ToolExecutionRepository extends JpaRepository<ToolExecution, Long> {
|
||||
List<ToolExecution> findByPlanId(Long planId);
|
||||
|
||||
long countByStatus(String status);
|
||||
|
||||
long countByCreatedAtAfter(LocalDateTime time);
|
||||
|
||||
List<ToolExecution> findTop15ByOrderByCreatedAtDesc();
|
||||
|
||||
Page<ToolExecution> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
|
||||
|
||||
@Query(value = "SELECT COUNT(*) FROM tool_executions WHERE created_at >= :start AND created_at < :end", nativeQuery = true)
|
||||
long countBetween(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.User;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUserid(String userid);
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
@EntityGraph(attributePaths = "department")
|
||||
Optional<User> findWithDepartmentById(Long id);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ims.service.repository;
|
||||
|
||||
import com.ims.service.entity.UserRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface UserRoleRepository extends JpaRepository<UserRole, UserRole.UserRoleId> {
|
||||
List<UserRole> findByUserId(Long userId);
|
||||
List<UserRole> findByRoleIdIn(Collection<Long> roleIds);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ims.service.security;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface DataScope {
|
||||
String departmentAlias() default "d";
|
||||
String userAlias() default "";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ims.service.security;
|
||||
|
||||
import com.ims.service.entity.Department;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.DepartmentRepository;
|
||||
import com.ims.service.repository.RoleRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import com.ims.service.repository.UserRoleRepository;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class DataScopeAspect {
|
||||
|
||||
private static final String SUPER_ADMIN_ROLE = "超级管理员";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
public DataScopeAspect(UserRepository userRepository, DepartmentRepository departmentRepository,
|
||||
UserRoleRepository userRoleRepository, RoleRepository roleRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
}
|
||||
|
||||
@Around("@annotation(dataScope)")
|
||||
public Object doAround(ProceedingJoinPoint joinPoint, DataScope dataScope) throws Throwable {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
String name = authentication.getName();
|
||||
userRepository.findByUsername(name)
|
||||
.or(() -> userRepository.findByUserid(name))
|
||||
.ifPresent(user -> {
|
||||
if (!isSuperAdmin(user) && user.getDepartment() != null) {
|
||||
Set<Long> scope = new HashSet<>();
|
||||
collectDepartments(user.getDepartment().getId(), scope);
|
||||
DataScopeContext.set(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
DataScopeContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(User user) {
|
||||
return userRoleRepository.findByUserId(user.getId()).stream()
|
||||
.map(ur -> roleRepository.findById(ur.getRoleId()).map(r -> r.getName()).orElse(""))
|
||||
.anyMatch(SUPER_ADMIN_ROLE::equals);
|
||||
}
|
||||
|
||||
private void collectDepartments(Long parentId, Set<Long> acc) {
|
||||
if (parentId == null) {
|
||||
return;
|
||||
}
|
||||
acc.add(parentId);
|
||||
List<Department> children = departmentRepository.findByParentIdOrderBySortOrder(parentId);
|
||||
for (Department child : children) {
|
||||
collectDepartments(child.getId(), acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ims.service.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public final class DataScopeContext {
|
||||
|
||||
private static final ThreadLocal<Set<Long>> DEPT_IDS = new ThreadLocal<>();
|
||||
|
||||
private DataScopeContext() {
|
||||
}
|
||||
|
||||
public static void set(Set<Long> deptIds) {
|
||||
DEPT_IDS.set(deptIds);
|
||||
}
|
||||
|
||||
public static Set<Long> get() {
|
||||
return DEPT_IDS.get();
|
||||
}
|
||||
|
||||
public static boolean isScoped() {
|
||||
return DEPT_IDS.get() != null && !DEPT_IDS.get().isEmpty();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
DEPT_IDS.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ims.service.security;
|
||||
|
||||
import com.ims.common.util.JwtUtil;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JwtAuthFilter.class);
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final UserDetailsServiceImpl userDetailsService;
|
||||
|
||||
public JwtAuthFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String token = extractToken(request);
|
||||
if (token == null) {
|
||||
log.debug("No Bearer token found for {} {}", request.getMethod(), request.getRequestURI());
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
if (!jwtUtil.validateToken(token)) {
|
||||
log.debug("Invalid or expired token for {} {}: prefix={}", request.getMethod(), request.getRequestURI(),
|
||||
token.substring(0, Math.min(20, token.length())));
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"Token无效或已过期\"}");
|
||||
return;
|
||||
}
|
||||
Claims claims = jwtUtil.parseToken(token);
|
||||
String username = claims.getSubject();
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
log.debug("Authenticated user: {}", username);
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
String queryToken = request.getParameter("token");
|
||||
if (StringUtils.hasText(queryToken)) {
|
||||
return queryToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ims.service.security;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"Token已过期或未登录,请重新登录\"}");
|
||||
})
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.addAllowedOriginPattern("*");
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.ims.service.security;
|
||||
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@Service
|
||||
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserDetailsServiceImpl(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByUserid(username)
|
||||
.or(() -> userRepository.findByUsername(username))
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
return new org.springframework.security.core.userdetails.User(
|
||||
user.getUsername(),
|
||||
user.getPasswordHash(),
|
||||
user.getIsActive() != null && user.getIsActive(),
|
||||
true, true, true,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ims.service.system;
|
||||
|
||||
import com.ims.api.dto.system.DepartmentResponse;
|
||||
import com.ims.api.service.system.DepartmentService;
|
||||
import com.ims.service.entity.Department;
|
||||
import com.ims.service.repository.DepartmentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DepartmentServiceImpl implements DepartmentService {
|
||||
|
||||
private final DepartmentRepository departmentRepository;
|
||||
|
||||
public DepartmentServiceImpl(DepartmentRepository departmentRepository) {
|
||||
this.departmentRepository = departmentRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DepartmentResponse> tree() {
|
||||
List<Department> all = departmentRepository.findAll();
|
||||
Map<Long, DepartmentResponse> map = new LinkedHashMap<>();
|
||||
for (Department d : all) {
|
||||
DepartmentResponse r = new DepartmentResponse();
|
||||
r.setId(d.getId());
|
||||
r.setName(d.getName());
|
||||
r.setParentId(d.getParent() == null ? null : d.getParent().getId());
|
||||
r.setSortOrder(d.getSortOrder());
|
||||
r.setChildren(new ArrayList<>());
|
||||
map.put(d.getId(), r);
|
||||
}
|
||||
List<DepartmentResponse> roots = new ArrayList<>();
|
||||
for (DepartmentResponse r : map.values()) {
|
||||
if (r.getParentId() == null) {
|
||||
roots.add(r);
|
||||
} else if (map.containsKey(r.getParentId())) {
|
||||
map.get(r.getParentId()).getChildren().add(r);
|
||||
}
|
||||
}
|
||||
sort(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
private void sort(List<DepartmentResponse> nodes) {
|
||||
nodes.sort(Comparator.comparing(DepartmentResponse::getSortOrder, Comparator.nullsLast(Integer::compareTo)));
|
||||
for (DepartmentResponse n : nodes) {
|
||||
sort(n.getChildren());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package com.ims.service.system;
|
||||
|
||||
import com.ims.api.dto.imports.AgentValidateResponse;
|
||||
import com.ims.api.dto.imports.ImportConfirmRequest;
|
||||
import com.ims.api.dto.imports.ImportPreviewResponse;
|
||||
import com.ims.api.dto.imports.ImportRecordResponse;
|
||||
import com.ims.api.dto.imports.ImportRow;
|
||||
import com.ims.api.service.system.ImportService;
|
||||
import com.ims.common.constant.ResultCode;
|
||||
import com.ims.common.dto.PageResult;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Department;
|
||||
import com.ims.service.entity.ImportRecord;
|
||||
import com.ims.service.entity.Issue;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.repository.DepartmentRepository;
|
||||
import com.ims.service.repository.ImportRecordRepository;
|
||||
import com.ims.service.repository.IssueRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import org.apache.poi.openxml4j.opc.OPCPackage;
|
||||
import org.apache.poi.ss.usermodel.DateUtil;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.eventusermodel.XSSFReader;
|
||||
import org.apache.poi.xssf.model.SharedStrings;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.xml.sax.Attributes;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
import org.xml.sax.helpers.DefaultHandler;
|
||||
|
||||
import javax.xml.parsers.SAXParserFactory;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
public class ImportServiceImpl implements ImportService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImportServiceImpl.class);
|
||||
|
||||
private static final String[] HEADERS = {"标题", "工程阶段", "优先级", "期限", "子项目", "类别", "影响级别", "描述", "担当者工号", "部门名称"};
|
||||
private static final Set<String> PRIORITIES = Set.of("high", "medium", "low");
|
||||
private static final int TEMPLATE_DATA_START_ROW = 5;
|
||||
private static final int TEMPLATE_DATA_END_ROW = 272;
|
||||
private static final int TEMPLATE_HEADER_ROW = 4;
|
||||
private static final Map<String, String> TEMPLATE_HEADERS = Map.ofEntries(
|
||||
Map.entry("A", "項番"), Map.entry("B", "レビュー日"), Map.entry("C", "ドキュメント分類"),
|
||||
Map.entry("D", "対象PP"), Map.entry("E", "レビュー対象"), Map.entry("F", "プロジェクト名"),
|
||||
Map.entry("G", "ブランチ名"), Map.entry("H", "バージョン"), Map.entry("I", "レビュー者"),
|
||||
Map.entry("J", "指摘事項"), Map.entry("K", "対応内容"), Map.entry("L", "指摘取込バージョン"),
|
||||
Map.entry("M", "担当者"), Map.entry("N", "対応期限"), Map.entry("O", "完了日"),
|
||||
Map.entry("P", "承認日"), Map.entry("Q", "承認者"), Map.entry("R", "備考"));
|
||||
private static final DateTimeFormatter[] DEADLINE_FORMATS = {
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd")
|
||||
};
|
||||
private static final AtomicInteger SEQ = new AtomicInteger(0);
|
||||
|
||||
private final IssueRepository issueRepository;
|
||||
private final ImportRecordRepository importRecordRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ImportSuggestService importSuggestService;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public ImportServiceImpl(IssueRepository issueRepository,
|
||||
ImportRecordRepository importRecordRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
UserRepository userRepository,
|
||||
ImportSuggestService importSuggestService,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.issueRepository = issueRepository;
|
||||
this.importRecordRepository = importRecordRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.importSuggestService = importSuggestService;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] generateTemplate() {
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource("templates/レビュー記録表.xlsx");
|
||||
if (resource.exists()) {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
}
|
||||
return generateFallbackTemplate();
|
||||
} catch (IOException e) {
|
||||
log.warn("Template file unavailable, fallback to generated template: {}", e.getMessage());
|
||||
return generateFallbackTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] generateFallbackTemplate() {
|
||||
try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = workbook.createSheet("指摘导入");
|
||||
Row header = sheet.createRow(0);
|
||||
for (int i = 0; i < HEADERS.length; i++) {
|
||||
header.createCell(i).setCellValue(HEADERS[i]);
|
||||
sheet.setColumnWidth(i, 18 * 256);
|
||||
}
|
||||
sheet.createRow(1).createCell(2).setCellValue("high/medium/low");
|
||||
workbook.write(out);
|
||||
return out.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "模板生成失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImportPreviewResponse preview(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "文件不能为空");
|
||||
}
|
||||
ParseResult parsed = parseRows(file);
|
||||
List<ImportRow> rows = parsed.rows;
|
||||
int valid = 0;
|
||||
int error = 0;
|
||||
for (ImportRow row : rows) {
|
||||
row.setErrors(validate(row));
|
||||
if (row.getErrors().isEmpty()) {
|
||||
row.setStatus("ok");
|
||||
valid++;
|
||||
} else {
|
||||
row.setStatus("error");
|
||||
error++;
|
||||
}
|
||||
}
|
||||
ImportPreviewResponse response = new ImportPreviewResponse();
|
||||
response.setTotal(rows.size());
|
||||
response.setValidCount(valid);
|
||||
response.setErrorCount(error);
|
||||
response.setHeaderValid(parsed.headerValid);
|
||||
response.setRows(rows);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentValidateResponse aiValidate(List<ImportRow> rows) {
|
||||
return importSuggestService.suggest(rows == null ? List.of() : rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImportRecordResponse confirm(ImportConfirmRequest request, String operatorUsername) {
|
||||
User operator = userRepository.findByUsername(operatorUsername)
|
||||
.or(() -> userRepository.findByUserid(operatorUsername))
|
||||
.orElse(null);
|
||||
List<ImportRow> rows = request.getRows() == null ? List.of() : request.getRows();
|
||||
int success = 0;
|
||||
int fail = 0;
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (ImportRow row : rows) {
|
||||
List<String> errs = validate(row);
|
||||
if (!errs.isEmpty()) {
|
||||
fail++;
|
||||
errors.add("第" + row.getRowNo() + "行: " + String.join("; ", errs));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
transactionTemplate.execute(status -> {
|
||||
importSuggestService.enrich(row);
|
||||
createIssue(row, operator);
|
||||
return null;
|
||||
});
|
||||
success++;
|
||||
} catch (Exception e) {
|
||||
fail++;
|
||||
errors.add("第" + row.getRowNo() + "行: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
String status = fail == 0 ? "success" : (success == 0 ? "failed" : "partial");
|
||||
ImportRecord record = ImportRecord.builder()
|
||||
.fileName(nz(request.getFileName()))
|
||||
.totalCount(rows.size())
|
||||
.successCount(success)
|
||||
.failCount(fail)
|
||||
.status(status)
|
||||
.errorLog(errors.isEmpty() ? null : String.join("\n", errors))
|
||||
.operator(operator)
|
||||
.build();
|
||||
record = importRecordRepository.save(record);
|
||||
return toResponse(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<ImportRecordResponse> records(int page, int pageSize) {
|
||||
List<ImportRecord> all = importRecordRepository.findAll();
|
||||
all.sort(Comparator.comparing(ImportRecord::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
int total = all.size();
|
||||
int from = Math.min((page - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
List<ImportRecordResponse> items = all.subList(from, to).stream().map(this::toResponse).toList();
|
||||
return new PageResult<>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
private ParseResult parseRows(MultipartFile file) {
|
||||
List<ImportRow> rows = new ArrayList<>();
|
||||
boolean headerValid = true;
|
||||
java.io.File tmp = null;
|
||||
try {
|
||||
tmp = java.io.File.createTempFile("ims_import_", ".xlsx");
|
||||
file.transferTo(tmp);
|
||||
try (OPCPackage pkg = OPCPackage.open(tmp)) {
|
||||
XSSFReader reader = new XSSFReader(pkg);
|
||||
SharedStrings sst = reader.getSharedStringsTable();
|
||||
SheetDataHandler handler = new SheetDataHandler(sst);
|
||||
SAXParserFactory factory = SAXParserFactory.newInstance();
|
||||
factory.setNamespaceAware(false);
|
||||
java.util.Iterator<InputStream> sheets = reader.getSheetsData();
|
||||
while (sheets.hasNext()) {
|
||||
try (InputStream sheetIn = sheets.next()) {
|
||||
factory.newSAXParser().parse(sheetIn, handler);
|
||||
} catch (Exception e) {
|
||||
log.warn("Sheet parse failed: {}", e.getMessage());
|
||||
}
|
||||
if (!handler.rows.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
headerValid = handler.isValidHeader();
|
||||
rows = handler.rows;
|
||||
}
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Excel parse failed", e);
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "Excel 解析失败,请使用标准模板");
|
||||
} finally {
|
||||
if (tmp != null && tmp.exists()) {
|
||||
tmp.delete();
|
||||
}
|
||||
}
|
||||
return new ParseResult(rows, headerValid);
|
||||
}
|
||||
|
||||
private static class SheetDataHandler extends DefaultHandler {
|
||||
private final SharedStrings sst;
|
||||
private final List<ImportRow> rows = new ArrayList<>();
|
||||
private final Map<String, String> headerValues = new LinkedHashMap<>();
|
||||
private int currentRowNum = -1;
|
||||
private String currentCol;
|
||||
private String currentCellType;
|
||||
private final StringBuilder cellValue = new StringBuilder();
|
||||
private final Map<String, String> rowValues = new LinkedHashMap<>();
|
||||
private boolean inCellValue = false;
|
||||
|
||||
SheetDataHandler(SharedStrings sst) {
|
||||
this.sst = sst;
|
||||
}
|
||||
|
||||
boolean isValidHeader() {
|
||||
if (headerValues.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Map.Entry<String, String> e : TEMPLATE_HEADERS.entrySet()) {
|
||||
String actual = headerValues.get(e.getKey());
|
||||
if (actual == null || !actual.trim().equals(e.getValue())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
|
||||
switch (qName) {
|
||||
case "row" -> {
|
||||
currentRowNum = Integer.parseInt(attributes.getValue("r"));
|
||||
rowValues.clear();
|
||||
}
|
||||
case "c" -> {
|
||||
String ref = attributes.getValue("r");
|
||||
currentCol = ref == null ? "" : ref.replaceAll("\\d+", "");
|
||||
currentCellType = attributes.getValue("t");
|
||||
cellValue.setLength(0);
|
||||
inCellValue = true;
|
||||
}
|
||||
case "v" -> {
|
||||
cellValue.setLength(0);
|
||||
inCellValue = true;
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void characters(char[] ch, int start, int length) {
|
||||
if (inCellValue) {
|
||||
cellValue.append(ch, start, length);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endElement(String uri, String localName, String qName) {
|
||||
if ("v".equals(qName)) {
|
||||
String raw = cellValue.toString();
|
||||
String value = resolveCell(raw, currentCellType);
|
||||
if (!currentCol.isBlank() && value != null && !value.isBlank()) {
|
||||
rowValues.put(currentCol, value);
|
||||
}
|
||||
inCellValue = false;
|
||||
} else if ("row".equals(qName)) {
|
||||
if (currentRowNum == TEMPLATE_HEADER_ROW) {
|
||||
for (Map.Entry<String, String> e : TEMPLATE_HEADERS.entrySet()) {
|
||||
String v = rowValues.get(e.getKey());
|
||||
if (v != null) {
|
||||
headerValues.put(e.getKey(), v);
|
||||
}
|
||||
}
|
||||
} else if (currentRowNum >= TEMPLATE_DATA_START_ROW && currentRowNum <= TEMPLATE_DATA_END_ROW) {
|
||||
ImportRow item = toImportRow(currentRowNum, rowValues);
|
||||
if (item != null) {
|
||||
rows.add(item);
|
||||
}
|
||||
}
|
||||
currentRowNum = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveCell(String raw, String type) {
|
||||
if ("s".equals(type)) {
|
||||
try {
|
||||
int idx = Integer.parseInt(raw.trim());
|
||||
return sst.getItemAt(idx).getString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if ("inlineStr".equals(type)) {
|
||||
return raw.trim();
|
||||
}
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static ImportRow toImportRow(int rowNum, Map<String, String> values) {
|
||||
String title = values.get("J");
|
||||
if (title == null || title.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
ImportRow item = new ImportRow();
|
||||
item.setRowNo(rowNum);
|
||||
item.setTitle(title.trim());
|
||||
item.setDocType(values.get("E"));
|
||||
item.setCategory(values.get("C"));
|
||||
item.setSubProject(values.get("F"));
|
||||
item.setAssigneeUserid(values.get("M"));
|
||||
item.setReviewerUserid(values.get("I"));
|
||||
item.setValidatorUserid(values.get("Q"));
|
||||
String desc = buildDescription(values);
|
||||
item.setDescription(desc.isEmpty() ? null : desc);
|
||||
String dateB = values.get("B");
|
||||
String dateN = values.get("N");
|
||||
item.setReviewDate(excelDateToText(dateB));
|
||||
String deadline = excelDateToText(dateN);
|
||||
if (deadline == null) {
|
||||
deadline = excelDateToText(values.get("O"));
|
||||
}
|
||||
if (deadline == null) {
|
||||
deadline = excelDateToText(values.get("P"));
|
||||
}
|
||||
item.setDeadline(deadline);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static String buildDescription(Map<String, String> values) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
String description = values.get("K");
|
||||
if (description != null && !description.isBlank()) {
|
||||
parts.add(description.trim());
|
||||
}
|
||||
String note = values.get("G");
|
||||
if (note != null && !note.isBlank()) {
|
||||
String n = note.trim();
|
||||
if (!n.equals("528") && !n.equals("529") && !n.equals("531")
|
||||
&& !n.contains("対応内容") && !n.contains("カンリョウ")) {
|
||||
parts.add("【補足】" + n);
|
||||
}
|
||||
}
|
||||
appendInfoPart(parts, "対象PP", values.get("D"));
|
||||
appendInfoPart(parts, "バージョン", values.get("H"));
|
||||
appendInfoPart(parts, "指摘取込バージョン", values.get("L"));
|
||||
appendInfoPart(parts, "備考", values.get("R"));
|
||||
return String.join("\n", parts);
|
||||
}
|
||||
|
||||
private static void appendInfoPart(List<String> parts, String label, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
String v = value.trim();
|
||||
if (!v.equals("528") && !v.equals("529") && !v.equals("531")
|
||||
&& !v.contains("対応内容") && !v.contains("カンリョウ")) {
|
||||
parts.add("【" + label + "】" + v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String excelDateToText(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String v = value.trim();
|
||||
try {
|
||||
double serial = Double.parseDouble(v);
|
||||
if (DateUtil.isValidExcelDate(serial)) {
|
||||
return DateUtil.getJavaDate(serial).toInstant()
|
||||
.atZone(java.time.ZoneId.systemDefault())
|
||||
.toLocalDate().toString();
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private List<String> validate(ImportRow row) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
if (row.getTitle() == null || row.getTitle().isBlank()) {
|
||||
errors.add("标题必填");
|
||||
}
|
||||
if (row.getPriority() != null && !row.getPriority().isBlank()
|
||||
&& !PRIORITIES.contains(row.getPriority().trim().toLowerCase())) {
|
||||
errors.add("优先级必须是 high/medium/low");
|
||||
}
|
||||
if (row.getDeadline() != null && !row.getDeadline().isBlank() && parseDeadline(row.getDeadline()) == null) {
|
||||
errors.add("期限格式不正确");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void createIssue(ImportRow row, User operator) {
|
||||
Issue issue = new Issue();
|
||||
issue.setIssueNo(genIssueNo());
|
||||
issue.setTitle(truncate(row.getTitle().trim(), 200));
|
||||
issue.setStatus("draft");
|
||||
issue.setPriority(row.getPriority() == null || row.getPriority().isBlank()
|
||||
? "medium" : row.getPriority().trim().toLowerCase());
|
||||
issue.setPhase(row.getPhase());
|
||||
issue.setSubProject(row.getSubProject());
|
||||
issue.setCategory(row.getCategory());
|
||||
issue.setImpactLevel(row.getImpactLevel());
|
||||
issue.setDescription(row.getDescription());
|
||||
issue.setDeadline(parseDeadline(row.getDeadline()));
|
||||
issue.setReviewDate(parseDeadline(row.getReviewDate()));
|
||||
issue.setCreator(operator);
|
||||
issue.setDepartment(resolveDepartment(row.getDepartmentName(), operator));
|
||||
issue.setAssignee(resolveAssignee(row.getAssigneeUserid()));
|
||||
issue.setReviewer(resolveAssignee(row.getReviewerUserid()));
|
||||
issue.setValidator(resolveAssignee(row.getValidatorUserid()));
|
||||
issue.setAgentStatus("human_driven");
|
||||
issue.setIsDeleted(false);
|
||||
issueRepository.save(issue);
|
||||
}
|
||||
|
||||
private Department resolveDepartment(String departmentName, User operator) {
|
||||
if (departmentName != null && !departmentName.isBlank()) {
|
||||
Department matched = departmentRepository.findAll().stream()
|
||||
.filter(d -> departmentName.trim().equals(d.getName()))
|
||||
.findFirst().orElse(null);
|
||||
if (matched != null) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
if (operator != null && operator.getDepartment() != null) {
|
||||
return operator.getDepartment();
|
||||
}
|
||||
return departmentRepository.findAll().stream().findFirst().orElseThrow(
|
||||
() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "系统无部门数据"));
|
||||
}
|
||||
|
||||
private User resolveAssignee(String userid) {
|
||||
if (userid == null || userid.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return userRepository.findByUserid(userid.trim())
|
||||
.or(() -> userRepository.findByUsername(userid.trim()))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String genIssueNo() {
|
||||
int seq = SEQ.incrementAndGet() % 1000;
|
||||
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
return "ISS" + time + String.format("%03d", seq);
|
||||
}
|
||||
|
||||
private LocalDateTime parseDeadline(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String v = value.trim();
|
||||
for (DateTimeFormatter f : DEADLINE_FORMATS) {
|
||||
try {
|
||||
return LocalDateTime.parse(v, f);
|
||||
} catch (DateTimeParseException ignored) {}
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(v, DateTimeFormatter.ofPattern("yyyy/MM/dd")).atStartOfDay();
|
||||
} catch (DateTimeParseException ignored) {}
|
||||
try {
|
||||
return LocalDate.parse(v, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay();
|
||||
} catch (DateTimeParseException ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String truncate(String value, int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max);
|
||||
}
|
||||
|
||||
private ImportRecordResponse toResponse(ImportRecord record) {
|
||||
ImportRecordResponse r = new ImportRecordResponse();
|
||||
r.setId(record.getId());
|
||||
r.setFileName(record.getFileName());
|
||||
r.setTotalCount(record.getTotalCount());
|
||||
r.setSuccessCount(record.getSuccessCount());
|
||||
r.setFailCount(record.getFailCount());
|
||||
r.setStatus(record.getStatus());
|
||||
r.setErrorLog(record.getErrorLog());
|
||||
r.setOperator(record.getOperator() == null ? "" : record.getOperator().getUsername());
|
||||
r.setCreatedAt(record.getCreatedAt());
|
||||
return r;
|
||||
}
|
||||
|
||||
private String nz(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
private static class ParseResult {
|
||||
private final List<ImportRow> rows;
|
||||
private final boolean headerValid;
|
||||
|
||||
ParseResult(List<ImportRow> rows, boolean headerValid) {
|
||||
this.rows = rows;
|
||||
this.headerValid = headerValid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
package com.ims.service.system;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ims.api.dto.imports.AgentValidateResponse;
|
||||
import com.ims.api.dto.imports.ImportRow;
|
||||
import com.ims.api.dto.imports.ImportSuggestion;
|
||||
import com.ims.service.ai.RoutingChatService;
|
||||
import com.ims.service.entity.PhaseRule;
|
||||
import com.ims.service.repository.PhaseRuleRepository;
|
||||
import com.ims.service.repository.PromptTemplateRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class ImportSuggestService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImportSuggestService.class);
|
||||
|
||||
private static final int LLM_BATCH_SIZE = 3;
|
||||
|
||||
private static final String[] PHASE_DICT = {"基本设计", "详细设计", "单体测试", "结合测试", "系统测试", "验收", "开发", "测试"};
|
||||
private static final Set<String> PRIORITIES = Set.of("high", "medium", "low");
|
||||
private static final List<PhaseRule> BUILTIN_PHASE_RULES = List.of(
|
||||
rule("詳細設計", "详细设计", "exact"),
|
||||
rule("開発", "开发", "exact"),
|
||||
rule("画面設計書", "详细设计", "contains"),
|
||||
rule("共通部品設計書", "详细设计", "contains"),
|
||||
rule("Impl", "开发", "impl"),
|
||||
rule("単体テスト", "单体测试", "contains"),
|
||||
rule("結合テスト", "结合测试", "contains"),
|
||||
rule("詳細設計", "详细设计", "contains"),
|
||||
rule("構築手順書", "开发", "contains"),
|
||||
rule("設定条件書", "基本设计", "contains"),
|
||||
rule("パラメータシート", "详细设计", "contains"),
|
||||
rule("運用受入", "验收", "contains"),
|
||||
rule("運用", "验收", "contains"),
|
||||
rule("受入", "验收", "contains"));
|
||||
private static final DateTimeFormatter[] DEADLINE_FORMATS = {
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd")
|
||||
};
|
||||
private static final Map<String, String> PRIORITY_ALIASES = Map.of(
|
||||
"紧急", "high", "高", "high", "中", "medium", "低", "low");
|
||||
private static final Map<String, String> HIGH_KEYWORDS = Map.ofEntries(
|
||||
Map.entry("崩溃", "high"), Map.entry("宕机", "high"), Map.entry("无法启动", "high"), Map.entry("停机", "high"),
|
||||
Map.entry("故障", "high"), Map.entry("异常", "high"), Map.entry("损坏", "high"), Map.entry("严重", "high"),
|
||||
Map.entry("障害", "high"), Map.entry("異常", "high"), Map.entry("エラー", "high"), Map.entry("不整合", "high"),
|
||||
Map.entry("論理エラー", "high"), Map.entry("ロジックエラー", "high"), Map.entry("動作しない", "high"),
|
||||
Map.entry("失敗する", "high"), Map.entry("セキュリティ", "high"), Map.entry("データ不整合", "high"),
|
||||
Map.entry("优化", "low"), Map.entry("性能", "low"), Map.entry("建议", "low"), Map.entry("改进", "low"),
|
||||
Map.entry("コメント", "low"), Map.entry("注釈", "low"), Map.entry("表記", "low"), Map.entry("見直し", "low"),
|
||||
Map.entry("フォーマット", "low"), Map.entry("ドキュメント", "low"));
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"你是「指摘管理系统」的数据校验专家 Agent,专精于制造业与软件工程领域的质量问题数据校验。"
|
||||
+ "对批量导入的指摘数据做智能校验,判断该文件是否可作为指摘表导入,并找出可修正的字段问题。"
|
||||
+ "只输出一个 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。";
|
||||
|
||||
private static final String VALIDATE_BATCH_001 =
|
||||
"请对以下批量导入的指摘数据行进行智能校验。\n\n"
|
||||
+ "字段说明:title=指摘事項、description=対応内容、deadline=対応期限、reviewDate=レビュー実施日、phase=工程阶段、priority=优先级、category=ドキュメント分類、subProject=プロジェクト名、docType=対象ドキュメント\n"
|
||||
+ "已知规则:\n"
|
||||
+ "- 优先级取值必须为 high(紧急)、medium(中)、low(低)。若遇到 \"紧急\"、\"高\"、\"中\"、\"低\"、\"HIGH\" 等非规范写法,请给出归一化建议\n"
|
||||
+ "- 工程阶段应是规范阶段名称(如:基本设计、详细设计、单体测试、结合测试、系统测试、验收、开发)\n"
|
||||
+ "- 期限应为日期格式(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss)\n"
|
||||
+ "- 标题必填,不允许为空\n"
|
||||
+ "- 若没有有效数据行、所有行缺少标题或内容明显与指摘无关,则该文件不能作为指摘表导入\n\n"
|
||||
+ "智能推断要求(仅当字段缺失或为空时):\n"
|
||||
+ "- 若 phase 为空,根据 docType(対象ドキュメント文件名,如含 \"単体テスト\"、\"結合テスト\"、\"画面設計書\"、\"共通部品設計書\"、\"構築手順書\"、\"設定条件書\"、\"パラメータシート\"、\"運用\")或指摘内容上下文推断工程阶段\n"
|
||||
+ "- 若 priority 为空,根据指摘内容的严重程度推断:功能异常/安全/数据不一致/无法运行→high;逻辑需修正/规格不一致→medium;表示/注释/格式/文档→low\n"
|
||||
+ "- 推断结果作为 suggestions 输出(field 为 phase 或 priority),并附简短 reason 说明推断依据\n\n"
|
||||
+ "数据行(JSON):\n%s\n\n"
|
||||
+ "输出格式:单个 JSON 对象,结构为 {\"usable\":true或false,\"usableReason\":\"文件级不可用原因(可用时为空字符串)\","
|
||||
+ "\"suggestions\":[{\"rowNo\":行号,\"field\":\"字段英文名\",\"fieldName\":\"字段中文名\","
|
||||
+ "\"original\":\"原始值\",\"suggested\":\"建议值\",\"reason\":\"修正理由\",\"level\":\"fix或error\"}]}\n"
|
||||
+ "- usable=false 表示整个文件不能作为指摘表导入(如无有效数据行、无标题、内容与指摘无关)\n"
|
||||
+ "- 行内原值已规范或无法推断正确值,则不要输出该项。无任何建议时 suggestions 输出 []。\n"
|
||||
+ "- level=error 表示该字段值非法无法自动修正,level=fix 表示可一键应用的修正建议。\n"
|
||||
+ "- reason 必须简短(不超过20字),直接说明修正内容与原因,禁止出现\"参照\"、\"参考\"、\"参见\"等表述。";
|
||||
|
||||
private final RoutingChatService chatService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectMapper rawJsonMapper;
|
||||
private final PromptTemplateRepository promptTemplateRepository;
|
||||
private final PhaseRuleRepository phaseRuleRepository;
|
||||
|
||||
private volatile List<PhaseRule> phaseRuleCache;
|
||||
private volatile long phaseRuleCacheAt;
|
||||
|
||||
private static final long PHASE_RULE_CACHE_TTL_MS = 60_000;
|
||||
|
||||
private static PhaseRule rule(String keyword, String phase, String matchMode) {
|
||||
return PhaseRule.builder().keyword(keyword).phase(phase).matchMode(matchMode).active(true).sortOrder(0).build();
|
||||
}
|
||||
|
||||
public ImportSuggestService(RoutingChatService chatService, ObjectMapper objectMapper,
|
||||
PromptTemplateRepository promptTemplateRepository,
|
||||
PhaseRuleRepository phaseRuleRepository) {
|
||||
this.chatService = chatService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.rawJsonMapper = new ObjectMapper().configure(JsonGenerator.Feature.ESCAPE_NON_ASCII, false);
|
||||
this.promptTemplateRepository = promptTemplateRepository;
|
||||
this.phaseRuleRepository = phaseRuleRepository;
|
||||
}
|
||||
|
||||
public AgentValidateResponse suggest(List<ImportRow> rows) {
|
||||
long startNanos = System.nanoTime();
|
||||
List<ImportRow> list = rows == null ? List.of() : rows;
|
||||
|
||||
FileUsability ruleUsable = ruleUsability(list);
|
||||
List<ImportSuggestion> rules = ruleSuggest(list);
|
||||
|
||||
LlmResult llm = null;
|
||||
try {
|
||||
llm = llmSuggest(list);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI validate unavailable, fallback to rule engine: {}", e.getMessage());
|
||||
}
|
||||
boolean usable;
|
||||
String usableReason;
|
||||
if (llm != null) {
|
||||
usable = ruleUsable.usable;
|
||||
usableReason = ruleUsable.usable
|
||||
? (llm.usable != null && !llm.usable ? "AI提示:" + nz(llm.usableReason) : "")
|
||||
: ruleUsable.reason;
|
||||
} else {
|
||||
usable = ruleUsable.usable;
|
||||
usableReason = ruleUsable.reason;
|
||||
}
|
||||
|
||||
List<ImportSuggestion> merged = rules;
|
||||
if (llm != null && llm.suggestions != null && !llm.suggestions.isEmpty()) {
|
||||
merged = merge(rules, llm.suggestions);
|
||||
}
|
||||
|
||||
long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000;
|
||||
log.info("Agent validate finished: rows={} engine={} suggestions={} elapsedMs={}",
|
||||
list.size(), llm != null ? "ai" : "rule", merged.size(), elapsedMs);
|
||||
|
||||
AgentValidateResponse response = new AgentValidateResponse();
|
||||
response.setUsable(usable);
|
||||
response.setUsableReason(usableReason);
|
||||
response.setSuggestions(merged);
|
||||
response.setEngine(llm != null ? "ai" : "rule");
|
||||
response.setElapsedMs(elapsedMs);
|
||||
return response;
|
||||
}
|
||||
|
||||
public void enrich(ImportRow row) {
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
if (row.getPriority() == null || row.getPriority().isBlank()) {
|
||||
String inferred = inferPriority(row);
|
||||
if (inferred != null) {
|
||||
row.setPriority(inferred);
|
||||
}
|
||||
}
|
||||
if (row.getPhase() == null || row.getPhase().isBlank()) {
|
||||
String inferred = inferPhase(row);
|
||||
if (inferred != null) {
|
||||
row.setPhase(inferred);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private FileUsability ruleUsability(List<ImportRow> rows) {
|
||||
if (rows.isEmpty()) {
|
||||
return new FileUsability(false, "文件中未检测到有效的指摘数据行,无法作为指摘表导入。");
|
||||
}
|
||||
boolean anyTitle = rows.stream().anyMatch(r -> !nz(r.getTitle()).isBlank());
|
||||
if (!anyTitle) {
|
||||
return new FileUsability(false, "文件中缺少指摘标题,无法识别为有效指摘数据。");
|
||||
}
|
||||
return new FileUsability(true, "");
|
||||
}
|
||||
|
||||
private List<ImportSuggestion> ruleSuggest(List<ImportRow> rows) {
|
||||
List<ImportSuggestion> result = new ArrayList<>();
|
||||
for (ImportRow row : rows) {
|
||||
if (row.getRowNo() == null) {
|
||||
continue;
|
||||
}
|
||||
result.addAll(rowLevelErrors(row));
|
||||
String phase = nz(row.getPhase());
|
||||
if (!phase.isBlank() && !matchesDict(phase)) {
|
||||
String match = nearestPhase(phase);
|
||||
if (match != null) {
|
||||
result.add(build(row.getRowNo(), "phase", "工程阶段", phase, match,
|
||||
"「" + phase + "」非规范阶段名,应改为「" + match + "」",
|
||||
"fix"));
|
||||
}
|
||||
} else if (phase.isBlank()) {
|
||||
String inferred = inferPhase(row);
|
||||
if (inferred != null) {
|
||||
result.add(build(row.getRowNo(), "phase", "工程阶段", "", inferred,
|
||||
"未填写,按対象ドキュメント推断为「" + inferred + "」",
|
||||
"fix"));
|
||||
}
|
||||
}
|
||||
String priority = nz(row.getPriority()).trim();
|
||||
if (priority.isBlank()) {
|
||||
String suggested = inferPriority(row);
|
||||
if (suggested != null) {
|
||||
result.add(build(row.getRowNo(), "priority", "优先级", "", suggested,
|
||||
"未填写,按内容严重程度推断为「" + suggested + "」",
|
||||
"fix"));
|
||||
}
|
||||
} else {
|
||||
String normalized = normalizePriority(priority);
|
||||
if (normalized != null && !normalized.equals(priority.toLowerCase())) {
|
||||
result.add(build(row.getRowNo(), "priority", "优先级", priority, normalized,
|
||||
"「" + priority + "」非规范取值,应改为 " + normalized,
|
||||
"fix"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ImportSuggestion> rowLevelErrors(ImportRow row) {
|
||||
List<ImportSuggestion> list = new ArrayList<>();
|
||||
if (nz(row.getTitle()).isBlank()) {
|
||||
list.add(build(row.getRowNo(), "title", "标题", null, null, "标题必填", "error"));
|
||||
}
|
||||
String priority = nz(row.getPriority()).trim();
|
||||
if (!priority.isBlank() && normalizePriority(priority) == null) {
|
||||
list.add(build(row.getRowNo(), "priority", "优先级", priority, null, "优先级必须是 high/medium/low", "error"));
|
||||
}
|
||||
String deadline = nz(row.getDeadline()).trim();
|
||||
if (!deadline.isBlank() && !isValidDeadline(deadline)) {
|
||||
list.add(build(row.getRowNo(), "deadline", "期限", deadline, null,
|
||||
"期限格式不正确(应为 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss)", "error"));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private LlmResult llmSuggest(List<ImportRow> rows) {
|
||||
try {
|
||||
if (rows.isEmpty()) {
|
||||
return new LlmResult(true, "", List.of());
|
||||
}
|
||||
List<ImportSuggestion> allSuggestions = new ArrayList<>();
|
||||
Boolean usable = null;
|
||||
String usableReason = "";
|
||||
for (int i = 0; i < rows.size(); i += LLM_BATCH_SIZE) {
|
||||
List<ImportRow> batch = rows.subList(i, Math.min(i + LLM_BATCH_SIZE, rows.size()));
|
||||
LlmResult partial = llmSuggestBatch(batch);
|
||||
if (partial == null) {
|
||||
return null;
|
||||
}
|
||||
if (partial.suggestions != null) {
|
||||
allSuggestions.addAll(partial.suggestions);
|
||||
}
|
||||
if (Boolean.FALSE.equals(partial.usable)) {
|
||||
usable = false;
|
||||
usableReason = nz(partial.usableReason);
|
||||
} else if (usable == null) {
|
||||
usable = true;
|
||||
}
|
||||
}
|
||||
return new LlmResult(usable, usableReason, allSuggestions);
|
||||
} catch (Exception e) {
|
||||
log.warn("LLM suggest failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LlmResult llmSuggestBatch(List<ImportRow> batch) {
|
||||
try {
|
||||
String rowsJson = rawJsonMapper.writeValueAsString(batch.stream().map(r -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("rowNo", r.getRowNo());
|
||||
m.put("title", r.getTitle());
|
||||
m.put("docType", r.getDocType());
|
||||
m.put("phase", r.getPhase());
|
||||
m.put("priority", r.getPriority());
|
||||
m.put("deadline", r.getDeadline());
|
||||
m.put("reviewDate", r.getReviewDate());
|
||||
m.put("category", r.getCategory());
|
||||
m.put("impactLevel", r.getImpactLevel());
|
||||
m.put("description", r.getDescription());
|
||||
m.put("subProject", r.getSubProject());
|
||||
return m;
|
||||
}).toList());
|
||||
String userPrompt = String.format(loadPromptTemplate(), rowsJson);
|
||||
long llmStart = System.nanoTime();
|
||||
String output = chatService.chat(SYSTEM_PROMPT, userPrompt);
|
||||
long llmElapsedMs = (System.nanoTime() - llmStart) / 1_000_000;
|
||||
log.info("LLM batch suggest: rows={} elapsedMs={}", batch.size(), llmElapsedMs);
|
||||
return parseLlmResult(output);
|
||||
} catch (Exception e) {
|
||||
log.warn("LLM batch suggest failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String loadPromptTemplate() {
|
||||
try {
|
||||
return promptTemplateRepository.findByTemplateIdAndIsActiveTrue("VALIDATE_BATCH_001")
|
||||
.map(t -> t.getContent() == null || t.getContent().isBlank()
|
||||
? VALIDATE_BATCH_001 : t.getContent().replace("{{rows}}", "%s"))
|
||||
.orElse(VALIDATE_BATCH_001);
|
||||
} catch (Exception e) {
|
||||
log.warn("Load prompt template from DB failed, use builtin: {}", e.getMessage());
|
||||
return VALIDATE_BATCH_001;
|
||||
}
|
||||
}
|
||||
|
||||
private LlmResult parseLlmResult(String output) {
|
||||
String text = output == null ? "" : output.trim();
|
||||
if (text.startsWith("```")) {
|
||||
text = text.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", "");
|
||||
}
|
||||
if (text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node;
|
||||
try {
|
||||
node = objectMapper.readTree(text);
|
||||
} catch (Exception e) {
|
||||
log.warn("LLM output is not valid JSON: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
return new LlmResult(null, "", parseSuggestionArray(node));
|
||||
}
|
||||
if (!node.isObject()) {
|
||||
return null;
|
||||
}
|
||||
Boolean usable = node.path("usable").isMissingNode() || node.path("usable").isNull()
|
||||
? null : node.path("usable").asBoolean();
|
||||
String usableReason = node.path("usableReason").asText();
|
||||
List<ImportSuggestion> suggestions = parseSuggestionArray(node.path("suggestions"));
|
||||
return new LlmResult(usable, usableReason, suggestions);
|
||||
}
|
||||
|
||||
private List<ImportSuggestion> parseSuggestionArray(JsonNode array) {
|
||||
List<ImportSuggestion> list = new ArrayList<>();
|
||||
if (array == null || !array.isArray()) {
|
||||
return list;
|
||||
}
|
||||
for (JsonNode n : array) {
|
||||
ImportSuggestion s = parseSuggestion(n);
|
||||
if (s != null) {
|
||||
list.add(s);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private ImportSuggestion parseSuggestion(JsonNode n) {
|
||||
ImportSuggestion s = new ImportSuggestion();
|
||||
s.setRowNo(n.path("rowNo").isMissingNode() || n.path("rowNo").isNull() ? null : n.path("rowNo").asInt());
|
||||
s.setField(n.path("field").asText());
|
||||
s.setFieldName(n.path("fieldName").asText());
|
||||
s.setOriginal(n.path("original").asText());
|
||||
s.setSuggested(n.path("suggested").asText());
|
||||
s.setReason(n.path("reason").asText());
|
||||
s.setLevel(n.path("level").isMissingNode() || n.path("level").asText().isBlank() ? "fix" : n.path("level").asText());
|
||||
if (s.getRowNo() != null && s.getSuggested() != null && !s.getSuggested().isBlank()) {
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<ImportSuggestion> merge(List<ImportSuggestion> rules, List<ImportSuggestion> ai) {
|
||||
Map<String, ImportSuggestion> map = new LinkedHashMap<>();
|
||||
for (ImportSuggestion s : ai) {
|
||||
map.put(key(s), s);
|
||||
}
|
||||
for (ImportSuggestion s : rules) {
|
||||
map.putIfAbsent(key(s), s);
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private String key(ImportSuggestion s) {
|
||||
return s.getRowNo() + ":" + s.getField();
|
||||
}
|
||||
|
||||
private boolean matchesDict(String value) {
|
||||
String v = value.trim();
|
||||
for (String d : PHASE_DICT) {
|
||||
if (v.equals(d)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String nearestPhase(String value) {
|
||||
String v = value.trim();
|
||||
String best = null;
|
||||
int bestDist = Integer.MAX_VALUE;
|
||||
for (String d : PHASE_DICT) {
|
||||
int dist = levenshtein(v, d);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
return bestDist <= 2 ? best : null;
|
||||
}
|
||||
|
||||
private int levenshtein(String a, String b) {
|
||||
int[][] dp = new int[a.length() + 1][b.length() + 1];
|
||||
for (int i = 0; i <= a.length(); i++) {
|
||||
dp[i][0] = i;
|
||||
}
|
||||
for (int j = 0; j <= b.length(); j++) {
|
||||
dp[0][j] = j;
|
||||
}
|
||||
for (int i = 1; i <= a.length(); i++) {
|
||||
for (int j = 1; j <= b.length(); j++) {
|
||||
int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
|
||||
dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
|
||||
}
|
||||
}
|
||||
return dp[a.length()][b.length()];
|
||||
}
|
||||
|
||||
private String inferPriority(ImportRow row) {
|
||||
String text = nz(row.getTitle()) + " " + nz(row.getDescription());
|
||||
String lower = text.toLowerCase();
|
||||
for (Map.Entry<String, String> e : HIGH_KEYWORDS.entrySet()) {
|
||||
if (lower.contains(e.getKey())) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
private String inferPhase(ImportRow row) {
|
||||
List<PhaseRule> rules = loadPhaseRules();
|
||||
String subProject = nz(row.getSubProject()).trim();
|
||||
if (!subProject.isBlank()) {
|
||||
String phase = matchExact(rules, subProject);
|
||||
if (phase != null) {
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
String docType = nz(row.getDocType()).trim();
|
||||
if (!docType.isBlank()) {
|
||||
String phase = matchContains(rules, docType);
|
||||
if (phase != null) {
|
||||
return phase;
|
||||
}
|
||||
String implPhase = matchImpl(rules, docType);
|
||||
if (implPhase != null) {
|
||||
return implPhase;
|
||||
}
|
||||
}
|
||||
String text = nz(row.getTitle()) + " " + nz(row.getDescription()) + " " + nz(row.getCategory());
|
||||
return matchContains(rules, text);
|
||||
}
|
||||
|
||||
private List<PhaseRule> loadPhaseRules() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (phaseRuleCache != null && now - phaseRuleCacheAt < PHASE_RULE_CACHE_TTL_MS) {
|
||||
return phaseRuleCache;
|
||||
}
|
||||
List<PhaseRule> rules = BUILTIN_PHASE_RULES;
|
||||
try {
|
||||
List<PhaseRule> fromDb = phaseRuleRepository.findByActiveTrueOrderBySortOrderAsc();
|
||||
if (fromDb != null && !fromDb.isEmpty()) {
|
||||
rules = fromDb;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Load phase rules from DB failed, fallback to builtin: {}", e.getMessage());
|
||||
}
|
||||
phaseRuleCache = rules;
|
||||
phaseRuleCacheAt = now;
|
||||
return rules;
|
||||
}
|
||||
|
||||
private String matchExact(List<PhaseRule> rules, String value) {
|
||||
for (PhaseRule r : rules) {
|
||||
if ("exact".equalsIgnoreCase(r.getMatchMode()) && value.equals(r.getKeyword())) {
|
||||
return r.getPhase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String matchContains(List<PhaseRule> rules, String text) {
|
||||
for (PhaseRule r : rules) {
|
||||
if ("contains".equalsIgnoreCase(r.getMatchMode()) && text.contains(r.getKeyword())) {
|
||||
return r.getPhase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String matchImpl(List<PhaseRule> rules, String value) {
|
||||
for (PhaseRule r : rules) {
|
||||
if ("impl".equalsIgnoreCase(r.getMatchMode())
|
||||
&& value.endsWith(r.getKeyword())) {
|
||||
return r.getPhase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizePriority(String value) {
|
||||
String v = value.trim();
|
||||
for (Map.Entry<String, String> e : PRIORITY_ALIASES.entrySet()) {
|
||||
if (e.getKey().equalsIgnoreCase(v)) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
if (PRIORITIES.contains(v.toLowerCase())) {
|
||||
return v.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ImportSuggestion build(Integer rowNo, String field, String fieldName,
|
||||
String original, String suggested, String reason, String level) {
|
||||
ImportSuggestion s = new ImportSuggestion();
|
||||
s.setRowNo(rowNo);
|
||||
s.setField(field);
|
||||
s.setFieldName(fieldName);
|
||||
s.setOriginal(original);
|
||||
s.setSuggested(suggested);
|
||||
s.setReason(reason);
|
||||
s.setLevel(level);
|
||||
return s;
|
||||
}
|
||||
|
||||
private boolean isValidDeadline(String value) {
|
||||
String v = value.trim();
|
||||
for (DateTimeFormatter f : DEADLINE_FORMATS) {
|
||||
try {
|
||||
LocalDateTime.parse(v, f);
|
||||
return true;
|
||||
} catch (DateTimeParseException ignored) {}
|
||||
}
|
||||
try {
|
||||
LocalDate.parse(v, DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
return true;
|
||||
} catch (DateTimeParseException ignored) {}
|
||||
try {
|
||||
LocalDate.parse(v, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
return true;
|
||||
} catch (DateTimeParseException ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String nz(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
private static class LlmResult {
|
||||
private final Boolean usable;
|
||||
private final String usableReason;
|
||||
private final List<ImportSuggestion> suggestions;
|
||||
|
||||
LlmResult(Boolean usable, String usableReason, List<ImportSuggestion> suggestions) {
|
||||
this.usable = usable;
|
||||
this.usableReason = usableReason == null ? "" : usableReason;
|
||||
this.suggestions = suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FileUsability {
|
||||
private final boolean usable;
|
||||
private final String reason;
|
||||
|
||||
FileUsability(boolean usable, String reason) {
|
||||
this.usable = usable;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ims.service.system;
|
||||
|
||||
import com.ims.api.dto.system.LogQueryRequest;
|
||||
import com.ims.api.dto.system.LogResponse;
|
||||
import com.ims.api.service.system.LogService;
|
||||
import com.ims.common.dto.PageResult;
|
||||
import com.ims.service.entity.IssueLog;
|
||||
import com.ims.service.entity.TaskExecution;
|
||||
import com.ims.service.repository.IssueLogRepository;
|
||||
import com.ims.service.repository.TaskExecutionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class LogServiceImpl implements LogService {
|
||||
|
||||
private final IssueLogRepository issueLogRepository;
|
||||
private final TaskExecutionRepository taskExecutionRepository;
|
||||
|
||||
public LogServiceImpl(IssueLogRepository issueLogRepository,
|
||||
TaskExecutionRepository taskExecutionRepository) {
|
||||
this.issueLogRepository = issueLogRepository;
|
||||
this.taskExecutionRepository = taskExecutionRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<LogResponse> list(LogQueryRequest request) {
|
||||
List<LogResponse> all = new ArrayList<>();
|
||||
for (IssueLog log : issueLogRepository.findAll()) {
|
||||
LogResponse r = new LogResponse();
|
||||
r.setId(log.getId());
|
||||
r.setOperator(log.getUser() == null ? "" : log.getUser().getUsername());
|
||||
r.setAction(log.getAction());
|
||||
r.setResource("issue");
|
||||
String detail = "指摘 " + (log.getIssue() == null ? "" : log.getIssue().getIssueNo());
|
||||
String from = nz(log.getFromStatus());
|
||||
String to = nz(log.getToStatus());
|
||||
if (!from.isEmpty() || !to.isEmpty()) {
|
||||
detail += " 状态: " + from + " -> " + to;
|
||||
}
|
||||
if (log.getRemark() != null && !log.getRemark().isBlank()) {
|
||||
detail += " " + log.getRemark();
|
||||
}
|
||||
r.setDetail(detail);
|
||||
r.setCreatedAt(log.getCreatedAt());
|
||||
all.add(r);
|
||||
}
|
||||
for (TaskExecution t : taskExecutionRepository.findAll()) {
|
||||
LogResponse r = new LogResponse();
|
||||
r.setId(t.getId());
|
||||
r.setOperator(t.getCreatedBy() == null ? "" : t.getCreatedBy().getUsername());
|
||||
r.setAction("task." + t.getTaskType());
|
||||
r.setResource("task");
|
||||
String detail = "任务 " + t.getTaskId() + " 状态: " + t.getStatus();
|
||||
if (t.getErrorMessage() != null && !t.getErrorMessage().isBlank()) {
|
||||
detail += " 错误: " + t.getErrorMessage();
|
||||
}
|
||||
r.setDetail(detail);
|
||||
r.setCreatedAt(t.getCreatedAt());
|
||||
all.add(r);
|
||||
}
|
||||
String keyword = request.getKeyword() == null ? null : request.getKeyword().toLowerCase(Locale.ROOT);
|
||||
String operator = request.getOperator() == null ? null : request.getOperator().toLowerCase(Locale.ROOT);
|
||||
String actionType = request.getActionType();
|
||||
LocalDateTime start = request.getStartTime();
|
||||
LocalDateTime end = request.getEndTime();
|
||||
List<LogResponse> filtered = all.stream()
|
||||
.filter(r -> operator == null || r.getOperator() != null
|
||||
&& r.getOperator().toLowerCase(Locale.ROOT).contains(operator))
|
||||
.filter(r -> actionType == null || actionType.isBlank() || r.getAction().contains(actionType))
|
||||
.filter(r -> keyword == null || keyword.isBlank()
|
||||
|| (r.getOperator() != null && r.getOperator().toLowerCase(Locale.ROOT).contains(keyword))
|
||||
|| (r.getDetail() != null && r.getDetail().toLowerCase(Locale.ROOT).contains(keyword)))
|
||||
.filter(r -> start == null || (r.getCreatedAt() != null && !r.getCreatedAt().isBefore(start)))
|
||||
.filter(r -> end == null || (r.getCreatedAt() != null && !r.getCreatedAt().isAfter(end)))
|
||||
.sorted(Comparator.comparing(LogResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.toList();
|
||||
int total = filtered.size();
|
||||
int from = Math.min((request.getPage() - 1) * request.getPageSize(), total);
|
||||
int to = Math.min(from + request.getPageSize(), total);
|
||||
return new PageResult<>(filtered.subList(from, to), total, request.getPage(), request.getPageSize());
|
||||
}
|
||||
|
||||
private String nz(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.ims.service.system;
|
||||
|
||||
import com.ims.api.dto.system.PermissionResponse;
|
||||
import com.ims.api.dto.system.RoleRequest;
|
||||
import com.ims.api.dto.system.RoleResponse;
|
||||
import com.ims.api.service.system.RoleService;
|
||||
import com.ims.common.constant.ResultCode;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Permission;
|
||||
import com.ims.service.entity.Role;
|
||||
import com.ims.service.entity.RolePermission;
|
||||
import com.ims.service.repository.PermissionRepository;
|
||||
import com.ims.service.repository.RolePermissionRepository;
|
||||
import com.ims.service.repository.RoleRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class RoleServiceImpl implements RoleService {
|
||||
|
||||
private static final String DEFAULT_DATA_SCOPE = "all";
|
||||
|
||||
private final RoleRepository roleRepository;
|
||||
private final RolePermissionRepository rolePermissionRepository;
|
||||
private final PermissionRepository permissionRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public RoleServiceImpl(RoleRepository roleRepository,
|
||||
RolePermissionRepository rolePermissionRepository,
|
||||
PermissionRepository permissionRepository,
|
||||
JdbcTemplate jdbcTemplate) {
|
||||
this.roleRepository = roleRepository;
|
||||
this.rolePermissionRepository = rolePermissionRepository;
|
||||
this.permissionRepository = permissionRepository;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RoleResponse> list() {
|
||||
return roleRepository.findAll().stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<PermissionResponse> permissions() {
|
||||
return permissionRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(Permission::getId))
|
||||
.map(p -> {
|
||||
PermissionResponse r = new PermissionResponse();
|
||||
r.setId(p.getId());
|
||||
r.setCode(p.getCode());
|
||||
r.setName(p.getName());
|
||||
r.setResource(p.getResource());
|
||||
return r;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RoleResponse create(RoleRequest request) {
|
||||
if (roleRepository.findAll().stream().anyMatch(r -> r.getName().equals(request.getName()))) {
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "角色名称已存在");
|
||||
}
|
||||
Role role = Role.builder()
|
||||
.name(request.getName())
|
||||
.description(request.getDescription())
|
||||
.agentAutoExecute(Boolean.TRUE.equals(request.getAgentAutoExecute()))
|
||||
.build();
|
||||
role = roleRepository.save(role);
|
||||
String scope = request.getDataScope() == null || request.getDataScope().isBlank()
|
||||
? DEFAULT_DATA_SCOPE : request.getDataScope();
|
||||
jdbcTemplate.update("UPDATE roles SET data_scope = ? WHERE id = ?", scope, role.getId());
|
||||
savePermissions(role.getId(), request.getPermissionIds());
|
||||
return toResponse(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RoleResponse update(Long id, RoleRequest request) {
|
||||
Role role = roleRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "角色不存在"));
|
||||
if (request.getName() != null && !request.getName().isBlank()) {
|
||||
role.setName(request.getName());
|
||||
}
|
||||
if (request.getDescription() != null) {
|
||||
role.setDescription(request.getDescription());
|
||||
}
|
||||
if (request.getAgentAutoExecute() != null) {
|
||||
role.setAgentAutoExecute(request.getAgentAutoExecute());
|
||||
}
|
||||
roleRepository.save(role);
|
||||
if (request.getDataScope() != null) {
|
||||
jdbcTemplate.update("UPDATE roles SET data_scope = ? WHERE id = ?", request.getDataScope(), id);
|
||||
}
|
||||
savePermissions(id, request.getPermissionIds());
|
||||
return toResponse(role);
|
||||
}
|
||||
|
||||
private void savePermissions(Long roleId, List<Long> permissionIds) {
|
||||
List<RolePermission> existing = rolePermissionRepository.findByRoleId(roleId);
|
||||
if (!existing.isEmpty()) {
|
||||
rolePermissionRepository.deleteAll(existing);
|
||||
}
|
||||
if (permissionIds == null || permissionIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<RolePermission> permissions = permissionIds.stream()
|
||||
.filter(permissionRepository::existsById)
|
||||
.map(permissionId -> RolePermission.builder().roleId(roleId).permissionId(permissionId).build())
|
||||
.toList();
|
||||
if (!permissions.isEmpty()) {
|
||||
rolePermissionRepository.saveAll(permissions);
|
||||
}
|
||||
}
|
||||
|
||||
private RoleResponse toResponse(Role role) {
|
||||
RoleResponse r = new RoleResponse();
|
||||
r.setId(role.getId());
|
||||
r.setName(role.getName());
|
||||
r.setDescription(role.getDescription());
|
||||
r.setAgentAutoExecute(role.getAgentAutoExecute());
|
||||
r.setDataScope(readDataScope(role.getId()));
|
||||
r.setCreatedAt(role.getCreatedAt());
|
||||
r.setPermissionIds(rolePermissionRepository.findByRoleId(role.getId())
|
||||
.stream().map(RolePermission::getPermissionId).toList());
|
||||
return r;
|
||||
}
|
||||
|
||||
private String readDataScope(Long roleId) {
|
||||
List<String> result = jdbcTemplate.queryForList("SELECT data_scope FROM roles WHERE id = ?", String.class, roleId);
|
||||
return result.isEmpty() || result.get(0) == null ? DEFAULT_DATA_SCOPE : result.get(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.ims.service.system;
|
||||
|
||||
import com.ims.api.dto.system.UserRequest;
|
||||
import com.ims.api.dto.system.UserResponse;
|
||||
import com.ims.api.service.system.UserService;
|
||||
import com.ims.common.constant.ResultCode;
|
||||
import com.ims.common.dto.PageResult;
|
||||
import com.ims.common.exception.BusinessException;
|
||||
import com.ims.service.entity.Department;
|
||||
import com.ims.service.entity.Role;
|
||||
import com.ims.service.entity.User;
|
||||
import com.ims.service.entity.UserRole;
|
||||
import com.ims.service.repository.DepartmentRepository;
|
||||
import com.ims.service.repository.RoleRepository;
|
||||
import com.ims.service.repository.UserRepository;
|
||||
import com.ims.service.repository.UserRoleRepository;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private static final String DEFAULT_PASSWORD = "Admin@123456";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserServiceImpl(UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
RoleRepository roleRepository,
|
||||
UserRoleRepository userRoleRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRoleRepository = userRoleRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<UserResponse> list(int page, int pageSize, String keyword, Long departmentId, Boolean isActive) {
|
||||
List<User> filtered = userRepository.findAll().stream()
|
||||
.filter(u -> departmentId == null || (u.getDepartment() != null && departmentId.equals(u.getDepartment().getId())))
|
||||
.filter(u -> isActive == null || isActive.equals(u.getIsActive()))
|
||||
.filter(u -> keyword == null || keyword.isBlank() || matches(u, keyword))
|
||||
.sorted(Comparator.comparing(User::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.toList();
|
||||
int total = filtered.size();
|
||||
int from = Math.min((page - 1) * pageSize, total);
|
||||
int to = Math.min(from + pageSize, total);
|
||||
List<UserResponse> items = filtered.subList(from, to).stream().map(this::toResponse).toList();
|
||||
return new PageResult<>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserResponse create(UserRequest request) {
|
||||
if (userRepository.findByUserid(request.getUserid()).isPresent()) {
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "账号已存在");
|
||||
}
|
||||
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||
throw new BusinessException(ResultCode.BAD_REQUEST.getCode(), "用户名已存在");
|
||||
}
|
||||
Department department = departmentRepository.findById(request.getDepartmentId())
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "部门不存在"));
|
||||
String password = request.getPassword() == null || request.getPassword().isBlank()
|
||||
? DEFAULT_PASSWORD : request.getPassword();
|
||||
User user = User.builder()
|
||||
.userid(request.getUserid())
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.passwordHash(passwordEncoder.encode(password))
|
||||
.department(department)
|
||||
.isActive(request.getIsActive() == null || request.getIsActive())
|
||||
.agentAutoExecute(Boolean.TRUE.equals(request.getAgentAutoExecute()))
|
||||
.build();
|
||||
user = userRepository.save(user);
|
||||
saveRoles(user.getId(), request.getRoleIds());
|
||||
return toResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserResponse update(Long id, UserRequest request) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "用户不存在"));
|
||||
if (request.getUsername() != null && !request.getUsername().isBlank()) {
|
||||
user.setUsername(request.getUsername());
|
||||
}
|
||||
if (request.getEmail() != null) {
|
||||
user.setEmail(request.getEmail());
|
||||
}
|
||||
if (request.getPassword() != null && !request.getPassword().isBlank()) {
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
}
|
||||
if (request.getDepartmentId() != null) {
|
||||
user.setDepartment(departmentRepository.findById(request.getDepartmentId())
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "部门不存在")));
|
||||
}
|
||||
if (request.getIsActive() != null) {
|
||||
user.setIsActive(request.getIsActive());
|
||||
}
|
||||
if (request.getAgentAutoExecute() != null) {
|
||||
user.setAgentAutoExecute(request.getAgentAutoExecute());
|
||||
}
|
||||
user = userRepository.save(user);
|
||||
saveRoles(id, request.getRoleIds());
|
||||
return toResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateStatus(Long id, Boolean isActive) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new BusinessException(ResultCode.BAD_REQUEST.getCode(), "用户不存在"));
|
||||
user.setIsActive(isActive);
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
private boolean matches(User u, String keyword) {
|
||||
String kw = keyword.toLowerCase(Locale.ROOT);
|
||||
return (u.getUserid() != null && u.getUserid().toLowerCase(Locale.ROOT).contains(kw))
|
||||
|| (u.getUsername() != null && u.getUsername().toLowerCase(Locale.ROOT).contains(kw))
|
||||
|| (u.getEmail() != null && u.getEmail().toLowerCase(Locale.ROOT).contains(kw));
|
||||
}
|
||||
|
||||
private void saveRoles(Long userId, List<Long> roleIds) {
|
||||
List<UserRole> existing = userRoleRepository.findByUserId(userId);
|
||||
if (!existing.isEmpty()) {
|
||||
userRoleRepository.deleteAll(existing);
|
||||
}
|
||||
if (roleIds == null || roleIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<UserRole> roles = roleIds.stream()
|
||||
.filter(roleRepository::existsById)
|
||||
.map(roleId -> UserRole.builder().userId(userId).roleId(roleId).build())
|
||||
.toList();
|
||||
if (!roles.isEmpty()) {
|
||||
userRoleRepository.saveAll(roles);
|
||||
}
|
||||
}
|
||||
|
||||
private UserResponse toResponse(User u) {
|
||||
UserResponse r = new UserResponse();
|
||||
r.setId(u.getId());
|
||||
r.setUserid(u.getUserid());
|
||||
r.setUsername(u.getUsername());
|
||||
r.setEmail(u.getEmail());
|
||||
r.setDepartmentId(u.getDepartment() == null ? null : u.getDepartment().getId());
|
||||
r.setDepartmentName(u.getDepartment() == null ? null : u.getDepartment().getName());
|
||||
r.setIsActive(u.getIsActive());
|
||||
r.setAgentAutoExecute(u.getAgentAutoExecute());
|
||||
r.setLastLoginAt(u.getLastLoginAt());
|
||||
r.setCreatedAt(u.getCreatedAt());
|
||||
List<Long> roleIds = userRoleRepository.findByUserId(u.getId()).stream().map(UserRole::getRoleId).toList();
|
||||
r.setRoleIds(roleIds);
|
||||
r.setRoles(roleIds.isEmpty() ? List.of()
|
||||
: roleRepository.findAllById(roleIds).stream().map(Role::getName).toList());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
-- departments 部门表
|
||||
CREATE TABLE departments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
parent_id BIGINT REFERENCES departments(id),
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- users 用户表
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
userid VARCHAR(50) NOT NULL UNIQUE,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100),
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
agent_auto_execute BOOLEAN DEFAULT FALSE,
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- roles 角色表
|
||||
CREATE TABLE roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
description VARCHAR(255),
|
||||
agent_auto_execute BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- permissions 权限表
|
||||
CREATE TABLE permissions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(50) NOT NULL,
|
||||
description VARCHAR(255)
|
||||
);
|
||||
|
||||
-- user_roles 用户角色关联
|
||||
CREATE TABLE user_roles (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
role_id BIGINT NOT NULL REFERENCES roles(id),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
-- role_permissions 角色权限关联
|
||||
CREATE TABLE role_permissions (
|
||||
role_id BIGINT NOT NULL REFERENCES roles(id),
|
||||
permission_id BIGINT NOT NULL REFERENCES permissions(id),
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
-- issues 指摘主表
|
||||
CREATE TABLE issues (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
issue_no VARCHAR(20) NOT NULL UNIQUE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'draft',
|
||||
priority VARCHAR(10) NOT NULL DEFAULT 'medium',
|
||||
deadline TIMESTAMP,
|
||||
phase VARCHAR(50),
|
||||
sub_project VARCHAR(100),
|
||||
category VARCHAR(50),
|
||||
impact_level VARCHAR(10),
|
||||
impact_scope VARCHAR(200),
|
||||
deployment VARCHAR(100),
|
||||
pgm_no VARCHAR(50),
|
||||
review_workload DECIMAL(5,1),
|
||||
response_workload DECIMAL(5,1),
|
||||
response_content TEXT,
|
||||
ng_reason VARCHAR(200),
|
||||
response_completed_at TIMESTAMP,
|
||||
confirm_at TIMESTAMP,
|
||||
creator_id BIGINT NOT NULL REFERENCES users(id),
|
||||
assignee_id BIGINT REFERENCES users(id),
|
||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||
reviewer_id BIGINT REFERENCES users(id),
|
||||
validator_id BIGINT REFERENCES users(id),
|
||||
ai_analysis_id BIGINT,
|
||||
agent_last_plan_id BIGINT,
|
||||
agent_status VARCHAR(20) DEFAULT 'human_driven',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- issue_logs 指摘操作日志
|
||||
CREATE TABLE issue_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
issue_id BIGINT NOT NULL REFERENCES issues(id),
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
action VARCHAR(30) NOT NULL,
|
||||
from_status VARCHAR(30),
|
||||
to_status VARCHAR(30),
|
||||
remark VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- attachments 附件表
|
||||
CREATE TABLE attachments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
issue_id BIGINT NOT NULL REFERENCES issues(id),
|
||||
file_name VARCHAR(200) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
mime_type VARCHAR(100),
|
||||
uploaded_by BIGINT NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- notifications 通知表
|
||||
CREATE TABLE notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content VARCHAR(1000) NOT NULL,
|
||||
type VARCHAR(30) NOT NULL,
|
||||
link VARCHAR(500),
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
issue_id BIGINT REFERENCES issues(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- task_executions 异步任务表
|
||||
CREATE TABLE task_executions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
task_type VARCHAR(30) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
result_url VARCHAR(500),
|
||||
error_message TEXT,
|
||||
created_by BIGINT NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
retry_count INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_issues_dept_status ON issues(department_id, status, created_at DESC);
|
||||
CREATE INDEX idx_issues_assignee ON issues(assignee_id, status);
|
||||
CREATE INDEX idx_issues_deadline ON issues(deadline);
|
||||
CREATE INDEX idx_issues_no ON issues(issue_no);
|
||||
CREATE INDEX idx_issue_logs_issue ON issue_logs(issue_id, created_at DESC);
|
||||
CREATE INDEX idx_attachments_issue ON attachments(issue_id);
|
||||
CREATE INDEX idx_notifications_user ON notifications(user_id, is_read, created_at DESC);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- ai_analysis AI分析结果表
|
||||
CREATE TABLE ai_analysis (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
issue_id BIGINT NOT NULL REFERENCES issues(id),
|
||||
category VARCHAR(100),
|
||||
keywords VARCHAR(500),
|
||||
root_cause VARCHAR(1000),
|
||||
suggestion TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
helpful_count INT DEFAULT 0,
|
||||
prompt_template_id VARCHAR(100),
|
||||
prompt_version INT,
|
||||
model_provider VARCHAR(20),
|
||||
model_name VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ai_feedback AI反馈表
|
||||
CREATE TABLE ai_feedback (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ai_analysis_id BIGINT NOT NULL REFERENCES ai_analysis(id),
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
is_helpful BOOLEAN NOT NULL,
|
||||
comment VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ai_analysis_issue ON ai_analysis(issue_id, created_at DESC);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- agent_plans Agent规划表
|
||||
CREATE TABLE agent_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
issue_id BIGINT NOT NULL REFERENCES issues(id),
|
||||
goal TEXT NOT NULL,
|
||||
plan_steps JSONB NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
requires_approval BOOLEAN DEFAULT FALSE,
|
||||
approval_status VARCHAR(20),
|
||||
approval_comment VARCHAR(500),
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
model_provider VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- agent_memories Agent长期记忆表
|
||||
CREATE TABLE agent_memories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
issue_summary TEXT NOT NULL,
|
||||
solution_steps JSONB NOT NULL,
|
||||
effectiveness_score DECIMAL(3,2) DEFAULT 0.00,
|
||||
embedding VECTOR(1536),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- tool_executions 工具执行明细表
|
||||
CREATE TABLE tool_executions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plan_id BIGINT REFERENCES agent_plans(id),
|
||||
tool_name VARCHAR(50) NOT NULL,
|
||||
input_params JSONB NOT NULL,
|
||||
output_result TEXT,
|
||||
status VARCHAR(20) DEFAULT 'success',
|
||||
execution_time_ms BIGINT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_plans_issue ON agent_plans(issue_id, created_at DESC);
|
||||
CREATE INDEX idx_tool_executions_plan ON tool_executions(plan_id);
|
||||
-- agent_memories 向量索引
|
||||
CREATE INDEX idx_agent_memories_embedding ON agent_memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- knowledge_documents 知识库文档表
|
||||
CREATE TABLE knowledge_documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
file_type VARCHAR(20) NOT NULL,
|
||||
chunk_count INT DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
uploaded_by BIGINT NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- knowledge_chunks 知识库向量切片表
|
||||
CREATE TABLE knowledge_chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
doc_id BIGINT NOT NULL REFERENCES knowledge_documents(id),
|
||||
content TEXT NOT NULL,
|
||||
embedding VECTOR(1536) NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- knowledge_search_logs 知识库检索审计表
|
||||
CREATE TABLE knowledge_search_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
issue_id BIGINT REFERENCES issues(id),
|
||||
query TEXT NOT NULL,
|
||||
rewritten_query TEXT,
|
||||
top_k INT DEFAULT 5,
|
||||
total_matches INT,
|
||||
duration_ms INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_knowledge_chunks_embedding ON knowledge_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
CREATE INDEX idx_knowledge_search_logs_user ON knowledge_search_logs(user_id, created_at DESC);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- prompt_templates Prompt模板主表
|
||||
CREATE TABLE prompt_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
template_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
content TEXT NOT NULL,
|
||||
variables JSONB,
|
||||
output_schema JSONB,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- prompt_template_versions Prompt模板版本历史表
|
||||
CREATE TABLE prompt_template_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
template_id VARCHAR(100) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
change_log VARCHAR(500),
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- prompt_render_logs Prompt渲染审计表
|
||||
CREATE TABLE prompt_render_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id VARCHAR(64) NOT NULL,
|
||||
template_id VARCHAR(100) NOT NULL,
|
||||
template_version INT NOT NULL,
|
||||
rendered_prompt TEXT NOT NULL,
|
||||
variables_used JSONB,
|
||||
tokens_input INT,
|
||||
tokens_output INT,
|
||||
execution_time_ms INT,
|
||||
llm_model VARCHAR(50),
|
||||
model_provider VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prompt_templates_id ON prompt_templates(template_id, is_active);
|
||||
CREATE INDEX idx_prompt_templates_cat ON prompt_templates(category, is_active);
|
||||
CREATE INDEX idx_prompt_render_logs_req ON prompt_render_logs(request_id);
|
||||
CREATE INDEX idx_prompt_render_logs_tpl ON prompt_render_logs(template_id, created_at DESC);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 部门数据
|
||||
INSERT INTO departments (id, name, parent_id, sort_order) VALUES
|
||||
(1, '总公司', NULL, 1),
|
||||
(2, '技术部', 1, 1),
|
||||
(3, '质量部', 1, 2),
|
||||
(4, '产品部', 1, 3);
|
||||
|
||||
-- 角色数据
|
||||
INSERT INTO roles (id, name, description, agent_auto_execute) VALUES
|
||||
(1, '超级管理员', '系统最高权限角色', TRUE),
|
||||
(2, '部门管理员', '管理部门内用户和业务', FALSE),
|
||||
(3, '指摘录入员', '负责录入指摘', FALSE),
|
||||
(4, '整改担当', '负责对应整改', FALSE),
|
||||
(5, '验证人员', '负责验证整改结果', FALSE),
|
||||
(6, '只读用户', '仅有查看权限', FALSE);
|
||||
|
||||
-- 用户数据(密码均为 Admin@2026,bcrypt 哈希)
|
||||
INSERT INTO users (id, userid, username, email, password_hash, department_id, is_active, agent_auto_execute) VALUES
|
||||
(1, 'admin', '系统管理员', '[email protected]', '$2b$10$RgDKGUTF75aTu/8apLJjQOkDnOQWSd0/bBu4XBj6pXK4XvZcmQ6e6', 1, TRUE, TRUE),
|
||||
(2, 'zhangsan', '张三', '[email protected]', '$2b$10$RgDKGUTF75aTu/8apLJjQOkDnOQWSd0/bBu4XBj6pXK4XvZcmQ6e6', 2, TRUE, FALSE),
|
||||
(3, 'lisi', '李四', '[email protected]', '$2b$10$RgDKGUTF75aTu/8apLJjQOkDnOQWSd0/bBu4XBj6pXK4XvZcmQ6e6', 3, TRUE, FALSE);
|
||||
|
||||
-- 用户角色关联
|
||||
INSERT INTO user_roles (user_id, role_id) VALUES
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 5);
|
||||
|
||||
-- 权限数据(菜单权限)
|
||||
INSERT INTO permissions (id, code, name, resource, description) VALUES
|
||||
(1, 'MENU_DASHBOARD', '工作台', 'menu', '工作台页面'),
|
||||
(2, 'MENU_ISSUES', '指摘列表', 'menu', '指摘列表页面'),
|
||||
(3, 'MENU_BATCH_INPUT', '批量录入', 'menu', '批量录入页面'),
|
||||
(4, 'MENU_AI_ANALYSIS', 'AI智能分析', 'menu', 'AI分析管理页面'),
|
||||
(5, 'MENU_KNOWLEDGE_BASE', '知识库管理', 'menu', '知识库管理页面'),
|
||||
(6, 'MENU_SYSTEM_USERS', '用户管理', 'menu', '用户管理页面'),
|
||||
(7, 'MENU_SYSTEM_ROLES', '角色权限', 'menu', '角色权限页面'),
|
||||
(8, 'MENU_SYSTEM_LOGS', '系统日志', 'menu', '系统日志页面'),
|
||||
(9, 'MENU_SYSTEM_AGENT', 'Agent管理', 'menu', 'Agent管理页面');
|
||||
|
||||
-- 超级管理员赋权
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT 1, id FROM permissions;
|
||||
@@ -0,0 +1,2 @@
|
||||
UPDATE users SET password_hash = '$2b$10$RgDKGUTF75aTu/8apLJjQOkDnOQWSd0/bBu4XBj6pXK4XvZcmQ6e6'
|
||||
WHERE userid IN ('admin', 'zhangsan', 'lisi');
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 修正向量维度:nomic-embed-text 输出 768 维,而非 1536
|
||||
DROP TABLE IF EXISTS knowledge_chunks CASCADE;
|
||||
|
||||
CREATE TABLE knowledge_chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
doc_id BIGINT NOT NULL REFERENCES knowledge_documents(id),
|
||||
content TEXT NOT NULL,
|
||||
embedding VECTOR(768) NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS idx_knowledge_chunks_embedding;
|
||||
CREATE INDEX idx_knowledge_chunks_embedding ON knowledge_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Prompt模板种子数据(内容取自概要设计 3.4 节,其余模板与 V4.2 一致)
|
||||
-- 模板ID:SYS_ROLE_001 / PLAN_001 / ANALYSIS_ROOT_CAUSE_001 / QUERY_REWRITE_001 / MEMORY_RERANK_001 / FORMAT_JSON_001
|
||||
|
||||
INSERT INTO prompt_templates (template_id, name, category, version, content, variables, output_schema, is_active, is_default, created_by) VALUES
|
||||
|
||||
('SYS_ROLE_001', '系统角色Prompt模板', 'system', 1,
|
||||
E'# 角色定义\n你是「指摘管理专家Agent」,专精于制造业与软件工程领域的质量问题跟踪与整改管理。\n你的知识边界严格限定于以下范围:\n- 指摘全生命周期管理(创建 → 分配 → 整改 → 验证 → 关闭)\n- 工程质量管理标准(ISO 9001、CMMI、企业内部QA规范)\n- 历史成功案例与失败教训(来自本地知识库检索)\n- 项目工程阶段管理(需求/设计/编码/测试/部署/运维)\n\n# 身份标识\n- 系统名称:指摘管理系统(Issue Tracking System)\n- 你的名称:指摘助手(Issue Assistant)\n- 当前版本:V6.0\n\n# 行为约束(强制遵守,违反将导致操作被拒绝)\n1. 【术语强制】所有输入输出必须使用标准指摘管理术语:\n - 使用「对应者」而非「负责人」「处理人」\n - 使用「PGM」指代项目编号,格式为 PGM-XXXX\n - 使用「指摘」而非「问题」「缺陷」「bug」\n - 使用「对应内容」而非「解决方案」「修复方案」\n - 使用「确认者」而非「审核人」「验收人」\n - 使用「对应完了日」而非「完成日期」\n - 使用「review工数」和「对应工数」计量工作量\n - 状态术语:draft(草稿)/ open(待处理)/ in_progress(进行中)/ resolved(已解决)/ verified(已验证)/ closed(已关闭)/ rejected(已驳回)\n\n2. 【审批强制】涉及以下高风险操作必须调用 request_human_approval 工具:\n - 关闭指摘(close_issue)\n - 删除指摘(delete_issue)\n - 跨部门分配指摘(assignee部门 ≠ 当前指摘部门)\n - 修改已验证状态的指摘\n - 批量操作超过10条指摘\n - 任何涉及数据删除或不可逆变更的操作\n\n3. 【知识优先】生成整改建议时,必须遵循以下优先级:\n - 第一优先:引用本地知识库中的历史成功案例(需标注案例ID)\n - 第二优先:引用企业内部QA规范\n - 第三优先:基于通用工程管理最佳实践\n - 禁止:提供与指摘管理无关的建议(如财务、人事、市场等)\n\n4. 【上下文依赖】当用户意图模糊时,必须优先询问以下信息以明确上下文:\n - 指摘ID(issue_id)\n - 工程阶段(phase)\n - 归属部门(department_id)\n - 当前状态(status)\n 禁止在上下文不明的情况下执行操作。\n\n5. 【输出结构化】所有输出必须严格符合预定义JSON Schema或Markdown模板,禁止自由发挥。\n - 分析类输出 → JSON格式\n - 对话类输出 → Markdown格式,含结构化标题\n - 工具调用 → 严格参数格式\n\n6. 【安全边界】禁止执行以下行为:\n - 生成、传播或协助创建恶意代码\n - 泄露其他用户的敏感信息(密码、个人联系方式等)\n - 执行超出指摘管理范畴的系统操作\n - 伪造或篡改审计日志\n\n# 当前环境信息\n- 当前用户:{{currentUserName}}({{currentUserRole}})\n- 所属部门:{{currentUserDepartment}}\n- Agent自动执行权限:{{agentAutoExecuteEnabled}}\n- 可用工具列表(格式:工具名 - 描述):\n{{availableTools}}\n- 当前时间:{{currentTime}}\n\n# 工具调用规则(适用于当前AI引擎:{{modelProvider}})\n- 当你需要调用工具时,请在你的回复中输出一个 JSON 对象,格式为:\n{\n "tool": "工具名",\n "parameters": { ... }\n}\n- 不要用自然语言额外解释,直接输出该 JSON。\n- 如果目标已完成,输出 { "tool": "goal_completed", "parameters": {} }。',
|
||||
'[{"name":"currentUserName","type":"string","required":true,"description":"当前登录用户名"},{"name":"currentUserRole","type":"string","required":true,"description":"当前用户角色名"},{"name":"currentUserDepartment","type":"string","required":true,"description":"当前用户所属部门"},{"name":"agentAutoExecuteEnabled","type":"boolean","required":true,"description":"Agent自动执行权限"},{"name":"availableTools","type":"string","required":true,"description":"可用工具列表(已格式化文本)"},{"name":"currentTime","type":"string","required":true,"description":"当前时间"},{"name":"modelProvider","type":"string","required":true,"description":"当前AI引擎"}]',
|
||||
'{"type":"object","description":"系统角色约束,用于引导模型行为","properties":{"tool":{"type":"string","description":"工具名"},"parameters":{"type":"object","description":"工具参数"}}}',
|
||||
TRUE, TRUE, NULL),
|
||||
|
||||
('PLAN_001', 'ReAct规划Prompt模板', 'agent', 1,
|
||||
E'# 任务\n基于用户目标和当前上下文,生成结构化的执行计划(ReAct循环)。\n\n# 输入\n用户目标:{{userGoal}}\n当前指摘上下文:{{issueContext}}\n历史相似案例:{{similarCases}}\n可用工具列表:{{availableTools}}\n已执行步骤:{{executedSteps}}\n当前步数:{{currentStep}} / 最大步数:{{maxSteps}}\n\n# 思考格式(必须严格遵循)\n你必须按以下结构输出思考过程,**且最终必须输出一个 JSON 对象**,其中 `action` 字段包含下一步的工具调用。\n\n[思考]\n1. 用户意图解析:将自然语言目标转化为明确的业务操作\n2. 上下文评估:当前已掌握的信息是否足够执行\n3. 工具选择:从可用工具中选择最合适的工具(仅限1个)\n4. 参数生成:为选定工具生成所需参数\n5. 风险评估:判断是否需要人工审批\n6. 输出格式:确认输出符合JSON Schema\n\n[行动]\n```json\n{\n "action": {\n "tool_name": "工具名称(必须从可用工具列表中选择)",\n "parameters": {\n "param1": "值1",\n "param2": "值2"\n },\n "reasoning": "选择此工具的理由(一句话)",\n "requires_approval": true/false,\n "approval_reason": "如需要审批,写明原因"\n }\n}\n```\n\n# 约束\n- 每步只能调用一个工具。\n- 如需审批,必须调用 request_human_approval 工具,不得直接执行目标操作。\n- 参数中的ID类字段必须为数字,禁止传递字符串ID。\n- 如用户目标已完成,输出 tool_name 为 "goal_completed"。\n- 如达到最大步数仍未完成,输出 tool_name 为 "max_steps_reached" 并说明原因。',
|
||||
'[{"name":"userGoal","type":"string","required":true,"description":"用户目标"},{"name":"issueContext","type":"string","required":true,"description":"当前指摘上下文"},{"name":"similarCases","type":"string","required":false,"description":"历史相似案例"},{"name":"availableTools","type":"string","required":true,"description":"可用工具列表"},{"name":"executedSteps","type":"string","required":false,"description":"已执行步骤"},{"name":"currentStep","type":"integer","required":true,"description":"当前步数"},{"name":"maxSteps","type":"integer","required":true,"description":"最大步数"}]',
|
||||
'{"type":"object","properties":{"action":{"type":"object","required":["tool_name","parameters","reasoning","requires_approval"],"properties":{"tool_name":{"type":"string"},"parameters":{"type":"object"},"reasoning":{"type":"string"},"requires_approval":{"type":"boolean"},"approval_reason":{"type":"string"}}}}}',
|
||||
TRUE, TRUE, NULL),
|
||||
|
||||
('ANALYSIS_ROOT_CAUSE_001', 'AI分析Prompt模板', 'analysis', 1,
|
||||
E'# 任务\n你是指摘管理专家,请对给定的指摘进行深度根因分析,并输出结构化 JSON。\n\n# 输入\n指摘编号:{{issueNo}}\n指摘标题:{{issueTitle}}\n指摘描述:{{issueDescription}}\n工程阶段:{{issuePhase}}\n指摘分类:{{issueCategory}}\n影响级别:{{issueImpactLevel}}\n历史相似案例:{{similarCases}}\n\n# 分析要求\n1. 从需求理解偏差、设计缺陷、编码实现、测试遗漏、环境/数据、流程管理等维度判断根因。\n2. 关键词提取3-5个,覆盖核心业务概念与原因。\n3. 整改建议必须可执行、分优先级,并优先引用相似案例。\n\n# 输出格式(严格JSON,禁止Markdown围栏)\n{\n "category": "根因分类(如:需求理解偏差/设计缺陷/编码实现/测试遗漏/环境数据/流程管理)",\n "keywords": ["关键词1", "关键词2", "关键词3"],\n "rootCause": "根因分析,100-200字",\n "suggestion": "整改建议,含优先级与具体措施" \n}\n\n# 约束\n- 输出必须是可以直接 JSON.parse 的纯JSON。\n- 不要输出解释性文字或 Markdown 代码块。',
|
||||
'[{"name":"issueNo","type":"string","required":true,"description":"指摘编号"},{"name":"issueTitle","type":"string","required":true,"description":"指摘标题"},{"name":"issueDescription","type":"string","required":true,"description":"指摘描述"},{"name":"issuePhase","type":"string","required":false,"description":"工程阶段"},{"name":"issueCategory","type":"string","required":false,"description":"指摘分类"},{"name":"issueImpactLevel","type":"string","required":false,"description":"影响级别"},{"name":"similarCases","type":"string","required":false,"description":"历史相似案例"}]',
|
||||
'{"type":"object","required":["category","keywords","rootCause","suggestion"],"properties":{"category":{"type":"string"},"keywords":{"type":"array","items":{"type":"string"}},"rootCause":{"type":"string"},"suggestion":{"type":"string"}}}',
|
||||
TRUE, TRUE, NULL),
|
||||
|
||||
('QUERY_REWRITE_001', 'Query改写Prompt模板', 'knowledge', 1,
|
||||
E'# 任务\n将用户的自然语言查询改写为适合向量检索的领域专业查询。\n\n# 输入\n用户原始查询:{{query}}\n领域上下文:{{domainContext}}\n\n# 改写要求\n1. 使用标准指摘管理术语(对应者、PGM、指摘、对应内容等)。\n2. 去除口语化、语气词与冗余信息。\n3. 必要时拆分多意图为多个子查询(最多3个)。\n\n# 输出格式(严格JSON)\n{\n "rewritten_query": "改写后的主查询",\n "queries": ["子查询1", "子查询2"],\n "keywords": ["检索关键词1", "检索关键词2"]\n}\n\n# 约束\n- 输出必须是纯JSON。\n- 不得添加与查询无关的内容。',
|
||||
'[{"name":"query","type":"string","required":true,"description":"用户原始查询"},{"name":"domainContext","type":"string","required":false,"description":"领域上下文"}]',
|
||||
'{"type":"object","required":["rewritten_query","queries","keywords"],"properties":{"rewritten_query":{"type":"string"},"queries":{"type":"array","items":{"type":"string"}},"keywords":{"type":"array","items":{"type":"string"}}}}',
|
||||
TRUE, TRUE, NULL),
|
||||
|
||||
('MEMORY_RERANK_001', '记忆重排序Prompt模板', 'knowledge', 1,
|
||||
E'# 任务\n对检索到的历史案例记忆进行相关性评估并重新排序。\n\n# 输入\n用户查询:{{query}}\n候选记忆列表:{{candidates}}\n\n# 排序要求\n1. 按与查询的相关性从高到低排序,同一相关度时按 effectivenessScore 高的优先。\n2. 只输出与查询明确相关的记忆,过滤无关项。\n\n# 输出格式(严格JSON)\n{\n "ranked_ids": [数字ID列表,按相关度从高到低],\n "reasoning": "排序依据简述"\n}\n\n# 约束\n- ranked_ids 必须来自候选记忆的 id 字段。\n- 输出必须是纯JSON。',
|
||||
'[{"name":"query","type":"string","required":true,"description":"用户查询"},{"name":"candidates","type":"string","required":true,"description":"候选记忆列表(含id、issueSummary、effectivenessScore)"}]',
|
||||
'{"type":"object","required":["ranked_ids","reasoning"],"properties":{"ranked_ids":{"type":"array","items":{"type":"integer"}},"reasoning":{"type":"string"}}}',
|
||||
TRUE, TRUE, NULL),
|
||||
|
||||
('FORMAT_JSON_001', '输出格式化模板', 'system', 1,
|
||||
E'# 输出格式约束(强制)\n所有输出必须严格遵循以下规则:\n\n1. 当要求 JSON 输出时:\n - 必须输出纯JSON,不得包含 Markdown 围栏(```json ```)。\n - 字段名必须与要求完全一致,不得增删。\n - 字符串必须使用双引号。\n\n2. 目标 JSON Schema:\n{{outputSchema}}\n\n3. 当要求 Markdown 输出时:\n - 使用结构化标题(## / ###)组织内容。\n - 列表使用 - 前缀。\n\n# 约束\n- 任何情况下都不得输出解释性前缀或后缀文字。',
|
||||
'[{"name":"outputSchema","type":"string","required":true,"description":"目标JSON Schema"}]',
|
||||
'{"type":"object","description":"通用输出格式约束"}',
|
||||
TRUE, TRUE, NULL);
|
||||
|
||||
-- 版本历史
|
||||
INSERT INTO prompt_template_versions (template_id, version, content, change_log, created_by)
|
||||
SELECT template_id, version, content, '初始版本', NULL FROM prompt_templates;
|
||||
@@ -0,0 +1,273 @@
|
||||
-- ============================================================
|
||||
-- V2.0 全模块演示数据(Agent 管理 / AI 分析 / 知识库 / 指摘)
|
||||
-- 依赖 V1.6 种子用户(admin=1, 张三=2, 李四=3)、部门(2技术部 3质量部 4产品部)
|
||||
-- ============================================================
|
||||
|
||||
-- ---------- issues 指摘 ----------
|
||||
INSERT INTO issues (id, issue_no, title, description, status, priority, deadline, phase, sub_project, category,
|
||||
impact_level, impact_scope, deployment, pgm_no, review_workload, response_workload,
|
||||
response_content, ng_reason, response_completed_at, confirm_at,
|
||||
creator_id, assignee_id, department_id, reviewer_id, validator_id,
|
||||
agent_last_plan_id, agent_status, created_at, updated_at, closed_at, is_deleted) VALUES
|
||||
(1, 'ISS-2026-001', '指摘列表批量导出 Excel 列顺序与标准模板不一致',
|
||||
'用户在指摘列表页勾选多条记录后批量导出 Excel,导出的列顺序为「创建时间/标题/状态」,与标准模板要求的「指摘编号/标题/对应者/状态」不一致,需人工二次调整,影响月度报告生成效率。',
|
||||
'draft', 'medium', NOW() + interval '7 days', '编码', '指摘管理', '功能缺陷', 'C', '影响月度报告数据整理', 'V6.0', 'PGM-1001', 1.0, 2.0, NULL, NULL, NULL, NULL,
|
||||
1, 2, 2, NULL, NULL, NULL, 'human_driven', NOW() - interval '2 hours', NOW() - interval '2 hours', NULL, FALSE),
|
||||
(2, 'ISS-2026-002', '知识库检索接口在无匹配结果时返回 500',
|
||||
'当查询词在知识库中无任何匹配时,GET /knowledge/search 返回 500 而非空结果集,前端无法给出友好提示,用户误以为系统故障。',
|
||||
'open', 'high', NOW() + interval '5 days', '测试', '知识库', '功能缺陷', 'B', '影响知识库检索可用性', 'V6.0', 'PGM-1002', 0.5, 1.5, NULL, NULL, NULL, NULL,
|
||||
2, 2, 2, NULL, NULL, NULL, 'rejected', NOW() - interval '3 hours', NOW() - interval '3 hours', NULL, FALSE),
|
||||
(3, 'ISS-2026-003', '已关闭的指摘可被重复打开',
|
||||
'已验证并关闭的指摘,在详情页仍可点击「重新打开」,导致已关闭记录重新进入整改流程,破坏关闭状态的一致性。',
|
||||
'open', 'high', NOW() + interval '4 days', '编码', '指摘管理', '流程管理', 'A', '影响关闭状态一致性', 'V6.0', 'PGM-1003', 1.5, 3.0, NULL, NULL, NULL, NULL,
|
||||
3, 2, 3, NULL, NULL, NULL, 'failed', NOW() - interval '1 day', NOW() - interval '1 day', NULL, FALSE),
|
||||
(4, 'ISS-2026-004', '日期范围筛选按日粒度统计不准确',
|
||||
'AI 分析页按「对应完了日」筛选时,当日创建的新指摘未被统计进结果集,与 SQL 中 NOW() 精度差异导致边界数据丢失。',
|
||||
'in_progress', 'medium', NOW() + interval '10 days', '测试', 'AI分析', '功能缺陷', 'C', '影响分析统计准确性', 'V6.0', 'PGM-1004', 2.0, 2.5, NULL, NULL, NULL, NULL,
|
||||
1, 3, 2, 2, NULL, NULL, 'human_driven', NOW() - interval '2 days', NOW() - interval '1 day', NULL, FALSE),
|
||||
(5, 'ISS-2026-005', 'Agent 跨部门分配指摘未触发人工审批',
|
||||
'Agent 将指摘分配给其他部门的对应者时,未调用 request_human_approval,直接执行了跨部门分配,绕过审批策略。',
|
||||
'in_progress', 'high', NOW() + interval '6 days', '编码', 'Agent', '功能缺陷', 'A', '影响审批合规', 'V6.0', 'PGM-1005', 2.5, 4.0, NULL, NULL, NULL, NULL,
|
||||
2, 3, 2, 3, NULL, NULL, 'human_driven', NOW() - interval '2 days', NOW() - interval '2 days', NULL, FALSE),
|
||||
(6, 'ISS-2026-006', '上传附件大于 10MB 时前端无任何提示',
|
||||
'附件上传时若文件超过后端限制大小,前端不提示且无 loading 结束回调,用户认为上传成功但实际未生效。',
|
||||
'resolved', 'high', NOW() - interval '1 day', '部署', '附件管理', '功能缺陷', 'B', '影响大附件上传体验', 'V6.0', 'PGM-1006', 1.0, 2.0,
|
||||
'已在上传组件中加入文件大小预校验,超过 10MB 时直接拦截并提示;同时补充超时与失败的回调处理。',
|
||||
'前端未做大小预校验', NOW() - interval '3 days', NOW() - interval '2 days',
|
||||
3, 2, 3, 2, 2, NULL, 'human_driven', NOW() - interval '6 days', NOW() - interval '3 days', NULL, FALSE),
|
||||
(7, 'ISS-2026-007', '系统通知未读计数在不同浏览器不一致',
|
||||
'同一账号在 Chrome 与 Edge 中未读通知计数不同,疑似本地缓存与后端计数存在时间差,影响用户对通知状态的判断。',
|
||||
'resolved', 'medium', NOW() - interval '2 days', '部署', '通知中心', '功能缺陷', 'C', '影响通知计数一致性', 'V6.0', 'PGM-1007', 0.5, 1.0,
|
||||
'统一改为登录后从后端拉取未读计数,并添加 60 秒轮询,消除浏览器间差异。',
|
||||
'计数来源不一致', NOW() - interval '5 days', NOW() - interval '4 days',
|
||||
1, 3, 4, 2, 3, NULL, 'human_driven', NOW() - interval '8 days', NOW() - interval '5 days', NULL, FALSE),
|
||||
(8, 'ISS-2026-008', '批量录入重复点击提交导致指摘重复创建',
|
||||
'批量录入页在提交响应较慢时,用户连续点击「提交」按钮,会创建多条内容相同的指摘记录,缺少幂等保护。',
|
||||
'verified', 'medium', NOW() - interval '3 days', '编码', '批量录入', '功能缺陷', 'B', '影响数据准确性', 'V6.0', 'PGM-1008', 1.5, 2.5,
|
||||
'提交按钮提交期间禁用,后端按内容 hash 加唯一约束防重。',
|
||||
'缺少幂等处理', NOW() - interval '7 days', NOW() - interval '6 days',
|
||||
2, 3, 4, 2, 3, 1, 'awaiting_approval', NOW() - interval '9 days', NOW() - interval '7 days', NULL, FALSE),
|
||||
(9, 'ISS-2026-009', '知识库向量检索中文分词召回率偏低',
|
||||
'使用字符级切分后,长句查询召回率仍偏低,无法命中包含「指摘对应完了日」等复合术语的历史案例,影响知识复用。',
|
||||
'verified', 'high', NOW() - interval '1 day', '测试', '知识库', '性能问题', 'B', '影响知识检索质量', 'V6.0', 'PGM-1009', 2.0, 3.5,
|
||||
'优化查询改写提示词,对复合术语进行扩展检索,增加 top_k 重排。',
|
||||
'复合术语切分不理想', NOW() - interval '4 days', NOW() - interval '3 days',
|
||||
1, 2, 2, 2, 3, 2, 'awaiting_approval', NOW() - interval '10 days', NOW() - interval '8 days', NULL, FALSE),
|
||||
(10, 'ISS-2026-010', 'AI 分析建议引用了不存在的知识库案例',
|
||||
'AI 分析结果中的整改建议标注了历史案例编号,但该案例在知识库中已被删除,点击跳转 404,破坏建议的可追溯性。',
|
||||
'open', 'medium', NOW() + interval '3 days', '测试', 'AI分析', '功能缺陷', 'C', '影响建议可信度', 'V6.0', 'PGM-1010', 1.0, 1.5, NULL, NULL, NULL, NULL,
|
||||
2, 3, 3, 2, NULL, 3, 'awaiting_approval', NOW() - interval '12 hours', NOW() - interval '12 hours', NULL, FALSE),
|
||||
(11, 'ISS-2026-011', '角色权限修改后未即时生效',
|
||||
'管理员修改角色权限后,已登录用户仍需重新登录才能看到新权限,权限缓存未按角色失效。',
|
||||
'open', 'medium', NOW() + interval '8 days', '运维', '权限管理', '流程管理', 'C', '影响权限即时性', 'V6.0', 'PGM-1011', 1.0, 1.5, NULL, NULL, NULL, NULL,
|
||||
3, 2, 3, 3, NULL, 4, 'awaiting_approval', NOW() - interval '5 hours', NOW() - interval '5 hours', NULL, FALSE),
|
||||
(12, 'ISS-2026-012', '指摘对应完了日逾期未自动提醒',
|
||||
'已超过对应完了日的指摘,系统未自动向对应者与部门管理员发送提醒,导致整改逾期无人跟进。',
|
||||
'closed', 'high', NOW() - interval '2 days', '运维', '提醒中心', '功能缺陷', 'B', '影响整改时效', 'V6.0', 'PGM-1012', 1.5, 3.0,
|
||||
'新增定时任务,每日扫描逾期指摘并推送通知至对应者与部门管理员。',
|
||||
'缺少逾期扫描定时任务', NOW() - interval '6 days', NOW() - interval '5 days',
|
||||
1, 3, 4, 2, 3, 5, 'completed', NOW() - interval '12 days', NOW() - interval '6 days', NOW() - interval '5 days', FALSE);
|
||||
|
||||
SELECT setval('issues_id_seq', (SELECT MAX(id) FROM issues));
|
||||
|
||||
-- ---------- issue_logs 指摘操作日志 ----------
|
||||
INSERT INTO issue_logs (issue_id, user_id, action, from_status, to_status, remark, created_at) VALUES
|
||||
(1, 1, 'CREATE', NULL, 'draft', '录入新指摘,等待确认', NOW() - interval '2 hours'),
|
||||
(2, 2, 'CREATE', NULL, 'open', '录入新指摘', NOW() - interval '3 hours'),
|
||||
(3, 3, 'CREATE', NULL, 'open', '录入新指摘', NOW() - interval '1 day'),
|
||||
(4, 1, 'CREATE', NULL, 'in_progress', '录入并立即进入整改', NOW() - interval '2 days'),
|
||||
(5, 2, 'CREATE', NULL, 'in_progress', 'Agent 触达', NOW() - interval '2 days'),
|
||||
(6, 3, 'UPDATE', 'in_progress', 'resolved', '对应内容已提交', NOW() - interval '3 days'),
|
||||
(6, 2, 'REVIEW', 'resolved', 'resolved', 'review 通过', NOW() - interval '2 days'),
|
||||
(6, 2, 'VERIFY', 'resolved', 'resolved', '验证通过', NOW() - interval '2 days'),
|
||||
(8, 2, 'UPDATE', 'open', 'verified', '整改完成并通过验证', NOW() - interval '7 days'),
|
||||
(8, 1, 'UPDATE', 'verified', 'verified', 'Agent 请求更新状态(待审批)', NOW() - interval '2 hours'),
|
||||
(9, 1, 'UPDATE', 'open', 'verified', '整改完成并通过验证', NOW() - interval '4 days'),
|
||||
(9, 2, 'UPDATE', 'verified', 'verified', 'Agent 请求调整优先级(待审批)', NOW() - interval '1 hour'),
|
||||
(10, 2, 'UPDATE', 'open', 'open', 'Agent 请求补充对应内容(待审批)', NOW() - interval '30 minutes'),
|
||||
(11, 3, 'UPDATE', 'open', 'open', 'Agent 请求关闭指摘(待审批)', NOW() - interval '20 minutes'),
|
||||
(12, 1, 'CLOSE', 'verified', 'closed', '整改完成并关闭', NOW() - interval '5 days');
|
||||
|
||||
-- ---------- notifications 通知 ----------
|
||||
INSERT INTO notifications (user_id, title, content, type, link, is_read, issue_id, created_at) VALUES
|
||||
(2, '有新的指摘分配', '指摘 ISS-2026-004 已分配给您,请及时对应', 'ASSIGNMENT', '/issues/detail/4', FALSE, 4, NOW() - interval '1 day'),
|
||||
(3, '整改对应提醒', '指摘 ISS-2026-006 的对应完了日已临近,请尽快完成对应', 'REMINDER', '/issues/detail/6', FALSE, 6, NOW() - interval '1 day'),
|
||||
(2, 'Agent 操作待审批', 'Agent 请求将 ISS-2026-009 的优先级调整为 high,请审批', 'APPROVAL', '/system/agent-admin', FALSE, 9, NOW() - interval '1 hour'),
|
||||
(3, 'Agent 操作待审批', 'Agent 请求关闭指摘 ISS-2026-011,请审批', 'APPROVAL', '/system/agent-admin', FALSE, 11, NOW() - interval '20 minutes'),
|
||||
(1, '知识库上传完成', '文档「指摘管理操作规范.txt」已完成解析与向量化', 'SYSTEM', '/knowledge-base', TRUE, NULL, NOW() - interval '3 days'),
|
||||
(2, 'AI 分析完成', '指摘 ISS-2026-002 的 AI 根因分析已生成', 'SYSTEM', '/ai-analysis', TRUE, 2, NOW() - interval '2 days');
|
||||
|
||||
-- ---------- ai_analysis AI 分析 ----------
|
||||
INSERT INTO ai_analysis (id, issue_id, category, keywords, root_cause, suggestion, status, helpful_count,
|
||||
prompt_template_id, prompt_version, model_provider, model_name, created_at) VALUES
|
||||
(1, 1, '编码实现', '批量导出,Excel,列顺序,模板',
|
||||
'导出功能使用固定列定义而未读取标准模板配置,列顺序写死在代码中,模板升级后未同步。',
|
||||
'将列顺序改为从模板配置读取,并提供模板与导出结果的自动比对;补充列顺序单元测试。',
|
||||
'completed', 4, 'ANALYSIS_ROOT_CAUSE_001', 1, 'ollama', 'llama3.1:8b', NOW() - interval '2 hours'),
|
||||
(2, 2, '编码实现', '知识库,检索,空结果,500',
|
||||
'检索服务未处理空结果分支,直接访问第一个元素导致 NPE 抛给全局异常处理。',
|
||||
'空结果时返回空列表;对检索链路增加 try-catch,保证任何异常均返回友好提示。',
|
||||
'completed', 5, 'ANALYSIS_ROOT_CAUSE_001', 1, 'ollama', 'llama3.1:8b', NOW() - interval '3 hours'),
|
||||
(3, 3, '流程管理', '关闭,重新打开,状态一致性',
|
||||
'关闭操作缺少状态机约束,任意用户均可对 closed 状态执行 reopen,未校验当前状态。',
|
||||
'引入状态机校验,仅 verified 状态可关闭、仅 closed 状态可驳回重开,并增加操作审计。',
|
||||
'completed', 3, 'ANALYSIS_ROOT_CAUSE_001', 1, 'ollama', 'llama3.1:8b', NOW() - interval '1 day'),
|
||||
(4, 4, '测试遗漏', '日期筛选,边界,当日数据',
|
||||
'日期比较使用 startOfDay 与 SQL NOW() 精度不一致,当日零点创建的数据被上一区间截断。',
|
||||
'统一使用闭区间 [start, end) 比较,并补充当日零点边界测试用例。',
|
||||
'completed', 2, 'ANALYSIS_ROOT_CAUSE_001', 1, 'deepseek', 'deepseek-v4-pro', NOW() - interval '2 days'),
|
||||
(5, 5, '编码实现', 'Agent,跨部门,审批',
|
||||
'Agent 工具调用时仅按 isWrite 判断是否审批,未校验 assignee 部门与指摘部门是否一致。',
|
||||
'在 assign_issue 工具内增加部门一致性校验,跨部门时强制返回审批请求。',
|
||||
'completed', 4, 'ANALYSIS_ROOT_CAUSE_001', 1, 'deepseek', 'deepseek-v4-pro', NOW() - interval '2 days'),
|
||||
(6, 6, '界面问题', '附件,上传,大小限制',
|
||||
'前端上传组件未配置 maxSize 校验与错误回调,后端拒绝后前端无感知。',
|
||||
'上传前预校验文件大小,失败时展示明确错误信息并恢复按钮状态。',
|
||||
'failed', 0, 'ANALYSIS_ROOT_CAUSE_001', 1, 'ollama', 'llama3.1:8b', NOW() - interval '5 days');
|
||||
|
||||
SELECT setval('ai_analysis_id_seq', (SELECT MAX(id) FROM ai_analysis));
|
||||
|
||||
-- ---------- ai_feedback AI 反馈 ----------
|
||||
INSERT INTO ai_feedback (ai_analysis_id, user_id, is_helpful, comment, created_at) VALUES
|
||||
(1, 2, TRUE, '列顺序问题定位准确,建议可直接落地', NOW() - interval '1 day'),
|
||||
(2, 1, TRUE, '根因分析到位', NOW() - interval '1 day'),
|
||||
(5, 3, FALSE, '建议中应补充跨部门审批的具体工具名', NOW() - interval '1 day');
|
||||
|
||||
-- ---------- knowledge_documents 知识库文档(序列自增,避免与已有文档 id 冲突) ----------
|
||||
INSERT INTO knowledge_documents (name, file_path, file_size, file_type, chunk_count, status, uploaded_by, created_at, updated_at) VALUES
|
||||
('指摘管理操作规范.txt', '/tmp/ims/demo/指摘管理操作规范.txt', 24576, 'txt', 5, 'completed', 1, NOW() - interval '3 days', NOW() - interval '3 days'),
|
||||
('历史整改案例汇总.xlsx', '/tmp/ims/demo/历史整改案例汇总.xlsx', 187392, 'xlsx', 4, 'completed', 2, NOW() - interval '6 days', NOW() - interval '6 days'),
|
||||
('质量保证手册.pdf', '/tmp/ims/demo/质量保证手册.pdf', 524288, 'pdf', 3, 'completed', 1, NOW() - interval '9 days', NOW() - interval '9 days');
|
||||
|
||||
SELECT setval('knowledge_documents_id_seq', (SELECT MAX(id) FROM knowledge_documents));
|
||||
|
||||
-- ---------- knowledge_chunks 知识库切片(768 维向量,doc_id 按文档名关联) ----------
|
||||
INSERT INTO knowledge_chunks (doc_id, content, embedding, metadata, created_at) VALUES
|
||||
((SELECT id FROM knowledge_documents WHERE name='指摘管理操作规范.txt'), '指摘录入规范:录入指摘时必须填写标题、归属部门、工程阶段与影响级别;对应者在收到分配通知后 3 个工作日内完成对应。',
|
||||
array_cat(ARRAY[0.92,0.31,0.08]::real[], array_fill(0.001::real, ARRAY[765]))::vector, '{"page":1,"docName":"指摘管理操作规范.txt"}'::jsonb, NOW() - interval '3 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='指摘管理操作规范.txt'), '状态流转规则:draft 草稿 → open 待处理 → in_progress 进行中 → resolved 已解决 → verified 已验证 → closed 已关闭;rejected 状态仅允许已关闭指摘驳回重开。',
|
||||
array_cat(ARRAY[0.85,0.22,0.15]::real[], array_fill(0.002::real, ARRAY[765]))::vector, '{"page":2,"docName":"指摘管理操作规范.txt"}'::jsonb, NOW() - interval '3 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='指摘管理操作规范.txt'), '对应者定义:指摘的整改担当人,负责提交对应内容与对应完了日;对应完成后由确认者进行验证,验证通过后关闭指摘。',
|
||||
array_cat(ARRAY[0.78,0.45,0.33]::real[], array_fill(0.001::real, ARRAY[765]))::vector, '{"page":3,"docName":"指摘管理操作规范.txt"}'::jsonb, NOW() - interval '3 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='指摘管理操作规范.txt'), '工数记录:review 工数由确认者填写,对应工数由对应者填写,均以人日为单位,保留 1 位小数。',
|
||||
array_cat(ARRAY[0.70,0.50,0.28]::real[], array_fill(0.003::real, ARRAY[765]))::vector, '{"page":4,"docName":"指摘管理操作规范.txt"}'::jsonb, NOW() - interval '3 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='指摘管理操作规范.txt'), '逾期管理:超过对应完了日未完成的指摘,系统自动通知对应者及其部门管理员,逾期原因需在指摘中说明。',
|
||||
array_cat(ARRAY[0.64,0.18,0.42]::real[], array_fill(0.002::real, ARRAY[765]))::vector, '{"page":5,"docName":"指摘管理操作规范.txt"}'::jsonb, NOW() - interval '3 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='历史整改案例汇总.xlsx'), '案例:批量导出列顺序与模板不一致。根因:列定义硬编码。对策:列顺序改为从模板配置读取并增加比对测试,整改效果良好。',
|
||||
array_cat(ARRAY[0.88,0.27,0.11]::real[], array_fill(0.001::real, ARRAY[765]))::vector, '{"page":1,"docName":"历史整改案例汇总.xlsx"}'::jsonb, NOW() - interval '6 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='历史整改案例汇总.xlsx'), '案例:上传大附件失败无提示。根因:前端缺少大小预校验。对策:上传前校验文件大小并处理失败回调,用户反馈明显改善。',
|
||||
array_cat(ARRAY[0.81,0.39,0.19]::real[], array_fill(0.002::real, ARRAY[765]))::vector, '{"page":2,"docName":"历史整改案例汇总.xlsx"}'::jsonb, NOW() - interval '6 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='历史整改案例汇总.xlsx'), '案例:知识库检索中文召回率低。根因:复合术语切分不理想。对策:优化查询改写提示词、增加 top_k 重排,召回率提升约 30%。',
|
||||
array_cat(ARRAY[0.74,0.13,0.51]::real[], array_fill(0.001::real, ARRAY[765]))::vector, '{"page":3,"docName":"历史整改案例汇总.xlsx"}'::jsonb, NOW() - interval '6 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='历史整改案例汇总.xlsx'), '案例:权限修改未即时生效。根因:权限缓存未按角色失效。对策:角色变更时主动清除相关缓存,登录用户权限即时更新。',
|
||||
array_cat(ARRAY[0.67,0.55,0.24]::real[], array_fill(0.003::real, ARRAY[765]))::vector, '{"page":4,"docName":"历史整改案例汇总.xlsx"}'::jsonb, NOW() - interval '6 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='质量保证手册.pdf'), '质量方针:坚持缺陷预防、过程改进与持续验证,确保指摘全生命周期可追溯,整改闭环率达到 95% 以上。',
|
||||
array_cat(ARRAY[0.90,0.29,0.07]::real[], array_fill(0.001::real, ARRAY[765]))::vector, '{"page":1,"docName":"质量保证手册.pdf"}'::jsonb, NOW() - interval '9 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='质量保证手册.pdf'), '评审制度:设计评审与代码评审必须形成评审记录,评审发现的问题进入指摘管理流程跟踪至闭环。',
|
||||
array_cat(ARRAY[0.83,0.36,0.26]::real[], array_fill(0.002::real, ARRAY[765]))::vector, '{"page":2,"docName":"质量保证手册.pdf"}'::jsonb, NOW() - interval '9 days'),
|
||||
((SELECT id FROM knowledge_documents WHERE name='质量保证手册.pdf'), '验证要求:整改完成后由独立验证人员验证,验证不合格的指摘退回对应者并记录驳回原因。',
|
||||
array_cat(ARRAY[0.76,0.47,0.31]::real[], array_fill(0.001::real, ARRAY[765]))::vector, '{"page":3,"docName":"质量保证手册.pdf"}'::jsonb, NOW() - interval '9 days');
|
||||
|
||||
SELECT setval('knowledge_chunks_id_seq', (SELECT MAX(id) FROM knowledge_chunks));
|
||||
|
||||
-- ---------- agent_plans Agent 规划(含 4 条待审批) ----------
|
||||
INSERT INTO agent_plans (id, issue_id, goal, plan_steps, status, requires_approval, approval_status, approval_comment,
|
||||
created_by, model_provider, created_at, completed_at) VALUES
|
||||
(1, 8, '将指摘 ISS-2026-008 标记为已关闭',
|
||||
'["1. query_issue 查询指摘 8 当前状态", "2. update_issue 将状态更新为 closed"]',
|
||||
'awaiting_approval', TRUE, 'requested', NULL, 1, 'ollama', NOW() - interval '2 hours', NULL),
|
||||
(2, 9, '将指摘 ISS-2026-009 的优先级调整为 high',
|
||||
'["1. query_issue 查询指摘 9 详情", "2. update_issue 将优先级更新为 high"]',
|
||||
'awaiting_approval', TRUE, 'requested', NULL, 1, 'ollama', NOW() - interval '1 hour', NULL),
|
||||
(3, 10, '为指摘 ISS-2026-010 补充对应内容',
|
||||
'["1. query_issue 查询指摘 10 详情", "2. update_issue 写入对应内容"]',
|
||||
'awaiting_approval', TRUE, 'requested', NULL, 2, 'ollama', NOW() - interval '30 minutes', NULL),
|
||||
(4, 11, '关闭指摘 ISS-2026-011',
|
||||
'["1. query_issue 查询指摘 11 当前状态", "2. update_issue 将状态更新为 closed"]',
|
||||
'awaiting_approval', TRUE, 'requested', NULL, 2, 'ollama', NOW() - interval '20 minutes', NULL),
|
||||
(5, 12, '整理 ISS-2026-012 的整改完成情况并关闭',
|
||||
'["1. query_issue 查询指摘 12 详情", "2. search_knowledge 检索相似整改案例", "3. update_issue 更新状态为 closed"]',
|
||||
'completed', TRUE, 'approved', '同意关闭', 1, 'ollama', NOW() - interval '6 days', NOW() - interval '6 days'),
|
||||
(6, 1, '分析 ISS-2026-001 批量导出问题并更新备注',
|
||||
'["1. query_issue 查询指摘 1 详情", "2. update_issue 更新对应内容"]',
|
||||
'completed', TRUE, 'approved', '同意', 3, 'ollama', NOW() - interval '1 day', NOW() - interval '1 day'),
|
||||
(7, 2, '将 ISS-2026-002 关闭',
|
||||
'["1. query_issue 查询指摘 2 详情", "2. update_issue 将状态更新为 closed"]',
|
||||
'rejected', TRUE, 'rejected', '问题未整改完成,拒绝关闭', 1, 'ollama', NOW() - interval '3 hours', NOW() - interval '3 hours'),
|
||||
(8, 3, '将 ISS-2026-003 状态更新为 in_progress',
|
||||
'["1. query_issue 查询指摘 3 详情", "2. update_issue 将状态更新为 in_progress"]',
|
||||
'completed', TRUE, 'approved', '同意', 2, 'deepseek', NOW() - interval '1 day', NOW() - interval '1 day');
|
||||
|
||||
SELECT setval('agent_plans_id_seq', (SELECT MAX(id) FROM agent_plans));
|
||||
|
||||
-- ---------- tool_executions 工具执行记录 ----------
|
||||
-- 待审批计划关联的 pending 记录(output_result 作为审批原因展示)
|
||||
INSERT INTO tool_executions (plan_id, tool_name, input_params, output_result, status, execution_time_ms, created_at) VALUES
|
||||
(1, 'update_issue', '{"issue_id":8,"field":"status","value":"closed"}',
|
||||
'等待人工审批: 关闭指摘属于高风险操作,需人工确认', 'pending', NULL, NOW() - interval '2 hours'),
|
||||
(2, 'update_issue', '{"issue_id":9,"field":"priority","value":"high"}',
|
||||
'等待人工审批: 修改已验证指摘的优先级,需人工确认', 'pending', NULL, NOW() - interval '1 hour'),
|
||||
(3, 'update_issue', '{"issue_id":10,"field":"response_content","value":"补充历史案例引用说明"}',
|
||||
'等待人工审批: 写入对应内容属于写操作,需人工确认', 'pending', NULL, NOW() - interval '30 minutes'),
|
||||
(4, 'update_issue', '{"issue_id":11,"field":"status","value":"closed"}',
|
||||
'等待人工审批: 关闭指摘属于高风险操作,需人工确认', 'pending', NULL, NOW() - interval '20 minutes');
|
||||
|
||||
-- 已完成/已拒绝计划的历史执行记录
|
||||
INSERT INTO tool_executions (plan_id, tool_name, input_params, output_result, status, execution_time_ms, created_at) VALUES
|
||||
(5, 'query_issue', '{"issue_id":12}', '{"id":12,"issue_no":"ISS-2026-012","status":"open"}', 'success', 120, NOW() - interval '6 days'),
|
||||
(5, 'search_knowledge', '{"query":"整改完成情况汇总","top_k":3}', '{"results":[{"chunk_id":6,"score":0.87}]}', 'success', 680, NOW() - interval '6 days'),
|
||||
(5, 'update_issue', '{"issue_id":12,"field":"status","value":"closed"}', '{"id":12,"updated":true,"status":"closed"}', 'success', 210, NOW() - interval '6 days'),
|
||||
(6, 'query_issue', '{"issue_id":1}', '{"id":1,"issue_no":"ISS-2026-001","status":"draft"}', 'success', 98, NOW() - interval '1 day'),
|
||||
(6, 'update_issue', '{"issue_id":1,"field":"response_content","value":"批量导出列顺序模板化"}', '{"id":1,"updated":true}', 'success', 180, NOW() - interval '1 day'),
|
||||
(7, 'query_issue', '{"issue_id":2}', '{"id":2,"issue_no":"ISS-2026-002","status":"open"}', 'success', 105, NOW() - interval '3 hours'),
|
||||
(7, 'update_issue', '{"issue_id":2,"field":"status","value":"closed"}', '等待人工审批: 关闭指摘属于高风险操作', 'rejected', NULL, NOW() - interval '3 hours'),
|
||||
(8, 'query_issue', '{"issue_id":3}', '{"id":3,"issue_no":"ISS-2026-003","status":"open"}', 'success', 88, NOW() - interval '1 day'),
|
||||
(8, 'search_knowledge', '{"query":"已关闭指摘重复打开的处理"}', '模型服务不可用', 'failed', 2500, NOW() - interval '1 day'),
|
||||
(8, 'update_issue', '{"issue_id":3,"field":"status","value":"in_progress"}', '{"id":3,"updated":true}', 'success', 195, NOW() - interval '1 day');
|
||||
|
||||
-- 批量历史成功执行记录(填充运行概览执行次数与成功率)
|
||||
INSERT INTO tool_executions (plan_id, tool_name, input_params, output_result, status, execution_time_ms, created_at)
|
||||
SELECT 5, 'query_issue', '{"issue_id":12}', '{"id":12,"issue_no":"ISS-2026-012"}', 'success',
|
||||
90 + (g * 37) % 300,
|
||||
NOW() - ((g * 9) || ' hours')::interval
|
||||
FROM generate_series(1, 40) AS g;
|
||||
|
||||
SELECT setval('tool_executions_id_seq', (SELECT MAX(id) FROM tool_executions));
|
||||
|
||||
-- ---------- agent_memories Agent 长期记忆 ----------
|
||||
INSERT INTO agent_memories (issue_summary, solution_steps, effectiveness_score, embedding, created_at, updated_at) VALUES
|
||||
('跨部门指摘分配需人工审批,避免误分配',
|
||||
'["1. 检测 assignee 部门是否与指摘归属部门一致", "2. 不一致时调用 request_human_approval", "3. 人工批准后继续执行分配"]',
|
||||
4.80, NULL, NOW() - interval '5 days', NOW() - interval '5 days'),
|
||||
('批量导出 Excel 列顺序需与标准模板对齐',
|
||||
'["1. 列顺序改为从模板配置读取", "2. 导出前与模板比对", "3. 不一致时提示并拦截"]',
|
||||
4.50, NULL, NOW() - interval '4 days', NOW() - interval '4 days'),
|
||||
('知识库检索空结果应返回友好提示而非 500',
|
||||
'["1. 空结果分支返回空列表", "2. 检索链路整体 try-catch", "3. 补充空结果测试用例"]',
|
||||
4.20, NULL, NOW() - interval '4 days', NOW() - interval '4 days'),
|
||||
('中文指摘分词需按字符级处理提升召回率',
|
||||
'["1. 使用 CJK 字符级正则切分", "2. 复合术语扩展检索", "3. 增加 top_k 重排"]',
|
||||
3.80, NULL, NOW() - interval '3 days', NOW() - interval '3 days'),
|
||||
('逾期提醒应在对应完了日前 1 天自动发送',
|
||||
'["1. 每日扫描临近完成日指摘", "2. 向对应者与部门管理员推送通知", "3. 逾期后追加提醒"]',
|
||||
3.50, NULL, NOW() - interval '2 days', NOW() - interval '2 days'),
|
||||
('修改角色权限后需强制刷新权限缓存',
|
||||
'["1. 角色变更时清除相关缓存", "2. 已登录用户下次请求重新加载权限", "3. 补充权限刷新测试"]',
|
||||
2.90, NULL, NOW() - interval '1 day', NOW() - interval '1 day');
|
||||
|
||||
-- ---------- prompt_render_logs Prompt 渲染日志(使用统计) ----------
|
||||
INSERT INTO prompt_render_logs (request_id, template_id, template_version, rendered_prompt, variables_used,
|
||||
tokens_input, tokens_output, execution_time_ms, llm_model, model_provider, created_at)
|
||||
SELECT
|
||||
'REQ-' || to_char(NOW(), 'YYYYMMDD') || '-' || lpad(g::text, 4, '0'),
|
||||
(ARRAY['SYS_ROLE_001', 'PLAN_001', 'ANALYSIS_ROOT_CAUSE_001', 'QUERY_REWRITE_001', 'MEMORY_RERANK_001', 'FORMAT_JSON_001'])[1 + (g % 6)],
|
||||
1,
|
||||
'[' || (ARRAY['系统角色约束 Prompt', 'ReAct 规划 Prompt', '根因分析 Prompt', '查询改写 Prompt', '记忆重排序 Prompt', '输出格式化 Prompt'])[1 + (g % 6)] || '] 渲染于 ' || NOW(),
|
||||
('{"requestId":"REQ-' || to_char(NOW(), 'YYYYMMDD') || '-' || lpad(g::text, 4, '0') || '","templateVersion":1}')::jsonb,
|
||||
1000 + (g * 137) % 8000,
|
||||
100 + (g * 53) % 1500,
|
||||
500 + (g * 977) % 12000,
|
||||
CASE WHEN g % 3 = 0 THEN 'deepseek-v4-pro' ELSE 'llama3.1:8b' END,
|
||||
CASE WHEN g % 3 = 0 THEN 'deepseek' ELSE 'ollama' END,
|
||||
NOW() - ((g % 7) || ' hours')::interval
|
||||
FROM generate_series(1, 40) AS g;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS data_scope VARCHAR(20) DEFAULT 'all';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- ai_analysis 增加失败原因字段
|
||||
ALTER TABLE ai_analysis ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- ai_analysis 增加分析耗时字段(秒)
|
||||
ALTER TABLE ai_analysis ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- ai_analysis 移除耗时字段,增加开始时间与成功/失败时间
|
||||
ALTER TABLE ai_analysis DROP COLUMN IF EXISTS duration_seconds;
|
||||
ALTER TABLE ai_analysis ADD COLUMN IF NOT EXISTS started_at TIMESTAMP;
|
||||
ALTER TABLE ai_analysis ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS ai_call_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider VARCHAR(20),
|
||||
model VARCHAR(100),
|
||||
status VARCHAR(20),
|
||||
latency_ms BIGINT,
|
||||
response_snippet TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_call_logs_created ON ai_call_logs(created_at DESC);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- 更新 ANALYSIS_ROOT_CAUSE_001 模板:新增预提取关键词输入变量,version 1 -> 2
|
||||
UPDATE prompt_templates
|
||||
SET content = E'# 任务\n你是指摘管理专家,请对给定的指摘进行深度根因分析,并输出结构化 JSON。\n\n# 输入\n指摘编号:{{issueNo}}\n指摘标题:{{issueTitle}}\n指摘描述:{{issueDescription}}\n工程阶段:{{issuePhase}}\n指摘分类:{{issueCategory}}\n影响级别:{{issueImpactLevel}}\n指摘关键词:{{issueKeywords}}\n历史相似案例:{{similarCases}}\n\n# 分析要求\n1. 从需求理解偏差、设计缺陷、编码实现、测试遗漏、环境/数据、流程管理等维度判断根因。\n2. 关键词提取3-5个,覆盖核心业务概念与原因。\n3. 整改建议必须可执行、分优先级,并优先引用相似案例。\n4. 参考输入中的"指摘关键词"辅助判断分析重点。\n\n# 输出格式(严格JSON,禁止Markdown围栏)\n{\n "category": "根因分类(如:需求理解偏差/设计缺陷/编码实现/测试遗漏/环境数据/流程管理)",\n "keywords": ["关键词1", "关键词2", "关键词3"],\n "rootCause": "根因分析,100-200字",\n "suggestion": "整改建议,含优先级与具体措施" \n}\n\n# 约束\n- 输出必须是可以直接 JSON.parse 的纯JSON。\n- 不要输出解释性文字或 Markdown 代码块。',
|
||||
variables = '[{"name":"issueNo","type":"string","required":true,"description":"指摘编号"},{"name":"issueTitle","type":"string","required":true,"description":"指摘标题"},{"name":"issueDescription","type":"string","required":true,"description":"指摘描述"},{"name":"issuePhase","type":"string","required":false,"description":"工程阶段"},{"name":"issueCategory","type":"string","required":false,"description":"指摘分类"},{"name":"issueImpactLevel","type":"string","required":false,"description":"影响级别"},{"name":"issueKeywords","type":"string","required":false,"description":"预提取的指摘关键词"},{"name":"similarCases","type":"string","required":false,"description":"历史相似案例"}]',
|
||||
version = version + 1
|
||||
WHERE template_id = 'ANALYSIS_ROOT_CAUSE_001';
|
||||
|
||||
-- 记录新版本历史(沿用 PromptService.update 的版本机制)
|
||||
INSERT INTO prompt_template_versions (template_id, version, content, change_log, created_by)
|
||||
SELECT template_id, version, content, '增加预提取关键词输入变量', NULL
|
||||
FROM prompt_templates
|
||||
WHERE template_id = 'ANALYSIS_ROOT_CAUSE_001';
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE ai_analysis ADD COLUMN IF NOT EXISTS extracted_keywords TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- 支持 Agent 创建型指令:无目标指摘时创建的计划可不关联指摘
|
||||
ALTER TABLE agent_plans ALTER COLUMN issue_id DROP NOT NULL;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Agent 增强:plan 增加 agent_message 字段,模板切换到 Ollama 原生工具调用模式
|
||||
ALTER TABLE agent_plans ADD COLUMN IF NOT EXISTS agent_message TEXT;
|
||||
|
||||
-- PLAN_001:重写为原生工具调用决策模板(不再要求手写 JSON)
|
||||
UPDATE prompt_templates
|
||||
SET content = $tpl$# 任务
|
||||
基于用户目标和当前上下文,选择合适的工具并生成调用参数。一次调用一个工具。
|
||||
|
||||
# 输入
|
||||
用户目标:{{userGoal}}
|
||||
当前指摘上下文:{{issueContext}}
|
||||
历史相似案例:{{similarCases}}
|
||||
可用工具列表:{{availableTools}}
|
||||
已执行步骤:{{executedSteps}}
|
||||
|
||||
# 决策规则
|
||||
1. 根据用户目标选择最合适的工具(只能从可用工具列表中选择)。
|
||||
2. 生成的参数必须完整,必填参数必须提供;不确定的信息从用户目标或上下文中提取。
|
||||
3. 创建指摘时,标题必须简明准确,描述需包含关键背景;对应者可从用户目标中提取。
|
||||
4. 写操作(新建/更新指摘)执行前会进入人工审批,请如实说明操作理由。
|
||||
5. 若用户目标已完成且无需进一步操作,直接用一句话总结处理结果,不要调用任何工具。$tpl$
|
||||
WHERE template_id = 'PLAN_001';
|
||||
|
||||
-- SYS_ROLE_001:替换工具调用规则段落(改为原生工具调用)
|
||||
UPDATE prompt_templates
|
||||
SET content = REPLACE(content,
|
||||
$old$# 工具调用规则(适用于当前AI引擎:{{modelProvider}})
|
||||
- 当你需要调用工具时,请在你的回复中输出一个 JSON 对象,格式为:
|
||||
{
|
||||
"tool": "工具名",
|
||||
"parameters": { ... }
|
||||
}
|
||||
- 不要用自然语言额外解释,直接输出该 JSON。
|
||||
- 如果目标已完成,输出 { "tool": "goal_completed", "parameters": {} }。$old$,
|
||||
$new$# 工具调用
|
||||
- 当需要执行操作时,直接调用可用的工具,系统会自动结构化你的调用并处理审批。
|
||||
- 写操作(create_issue / update_issue)执行前必须经过人工审批。
|
||||
- 如果用户目标已达成,直接用一句话总结结果,不要调用任何工具。$new$)
|
||||
WHERE template_id = 'SYS_ROLE_001';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 修复 ims-master-test 合并前缺失的表/列:
|
||||
-- 1. import_records(批量导入记录)
|
||||
-- 2. phase_rules(导入阶段规则)
|
||||
-- 3. issues.review_date(评审日期列,批量导入写入)
|
||||
-- 这些 DDL 与实体定义保持一致,用于通过 JPA ddl-auto=validate 校验。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
success_count INTEGER NOT NULL,
|
||||
fail_count INTEGER NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_log TEXT,
|
||||
operator_id BIGINT REFERENCES users (id),
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS phase_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
keyword VARCHAR(100) NOT NULL,
|
||||
phase VARCHAR(50) NOT NULL,
|
||||
match_mode VARCHAR(20) NOT NULL,
|
||||
source VARCHAR(50),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE issues ADD COLUMN IF NOT EXISTS review_date TIMESTAMP;
|
||||
Reference in New Issue
Block a user