init: 2026Technology-Competition initial commit

This commit is contained in:
xunhe
2026-08-27 17:35:01 +08:00
commit e2a4d40c9e
311 changed files with 37037 additions and 0 deletions
+89
View File
@@ -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;
}
}
@@ -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("\"", "\\\"");
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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> {
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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> {
}
@@ -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> {
}
@@ -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();
}
@@ -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();
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
}
@@ -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@2026bcrypt 哈希)
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 一致)
-- 模板IDSYS_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';
@@ -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);
@@ -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';
@@ -0,0 +1 @@
ALTER TABLE ai_analysis ADD COLUMN IF NOT EXISTS extracted_keywords TEXT;
@@ -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;