feat(project): 实现AI项目初始化功能

- 新增项目初始化控制器,支持文件上传生成项目结构化数据
- 定义项目初始化结果DTO,包含项目、里程碑、任务、成员、资源、风险和时间节点等信息
- 实现项目初始化服务接口及其实现类,集成Spring AI结构化输出能力
- 支持根据内容或文件生成项目初始化数据,并保存到数据库
- 增加项目、里程碑、任务、成员、资源、风险及时间节点实体及对应Mapper
- 实现文件上传到OSS及项目初始化记录功能,记录解析状态及结果
- 添加PostgreSQL JSONB类型处理器,支持JSON对象字段存储
- 修改开发环境数据库配置,更新连接的数据库名称为aiprojectmanager
This commit is contained in:
2026-03-26 20:05:55 +08:00
parent 0bf41c5353
commit 729af44585
22 changed files with 1623 additions and 15 deletions

View File

@@ -1,6 +1,9 @@
package cn.yinlihupo.service.project;
import cn.yinlihupo.domain.dto.ProjectInitResult;
import cn.yinlihupo.domain.vo.ProjectInitResult;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* AI项目初始化服务接口
@@ -24,4 +27,21 @@ public interface ProjectService {
* @return 项目初始化结果
*/
ProjectInitResult generateProjectFromFile(String fileUrl, String fileType);
/**
* 上传文件并生成项目初始化数据,同时保存到数据库
*
* @param file 项目资料文件
* @return 项目初始化结果
*/
ProjectInitResult generateAndSaveProject(MultipartFile file);
/**
* 根据项目资料内容生成并保存项目初始化数据
*
* @param content 项目资料文本内容
* @param inputFiles 输入文件列表信息
* @return 项目初始化结果
*/
ProjectInitResult generateAndSaveProjectFromContent(String content, List<cn.yinlihupo.domain.entity.ProjectInitRecord.InputFile> inputFiles);
}

View File

@@ -1,13 +1,28 @@
package cn.yinlihupo.service.project.impl;
import cn.yinlihupo.domain.dto.ProjectInitResult;
import cn.hutool.core.util.IdUtil;
import cn.yinlihupo.domain.entity.*;
import cn.yinlihupo.domain.vo.ProjectInitResult;
import cn.yinlihupo.mapper.*;
import cn.yinlihupo.service.oss.OssService;
import cn.yinlihupo.service.project.ProjectService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AI项目初始化服务实现类
@@ -21,6 +36,16 @@ public class ProjectServiceImpl implements ProjectService {
private final ChatClient chatClient;
private final OssService ossService;
// Mapper依赖
private final ProjectMapper projectMapper;
private final ProjectMilestoneMapper projectMilestoneMapper;
private final TaskMapper taskMapper;
private final ProjectMemberMapper projectMemberMapper;
private final ResourceMapper resourceMapper;
private final RiskMapper riskMapper;
private final ProjectTimelineMapper projectTimelineMapper;
private final ProjectInitRecordMapper projectInitRecordMapper;
/**
* 项目初始化系统提示词模板
*/
@@ -172,4 +197,384 @@ public class ProjectServiceImpl implements ProjectService {
return generateProjectFromContent(content);
}
@Override
@Transactional(rollbackFor = Exception.class)
public ProjectInitResult generateAndSaveProject(MultipartFile file) {
log.info("开始上传文件并生成项目初始化数据, 文件名: {}", file.getOriginalFilename());
try {
// 1. 上传文件到OSS
String fileUrl = ossService.uploadFile(file, file.getOriginalFilename());
log.info("文件上传成功, URL: {}", fileUrl);
// 2. 读取文件内容
String content = ossService.readFileAsString(fileUrl);
if (content == null || content.isEmpty()) {
throw new RuntimeException("无法读取文件内容: " + fileUrl);
}
// 3. 构建输入文件信息
List<ProjectInitRecord.InputFile> inputFiles = new ArrayList<>();
ProjectInitRecord.InputFile inputFile = new ProjectInitRecord.InputFile();
inputFile.setName(file.getOriginalFilename());
inputFile.setPath(fileUrl);
inputFile.setType(file.getContentType());
inputFile.setSize(file.getSize());
inputFiles.add(inputFile);
// 4. 生成并保存项目数据
return generateAndSaveProjectFromContent(content, inputFiles);
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
throw new RuntimeException("项目初始化失败: " + e.getMessage(), e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public ProjectInitResult generateAndSaveProjectFromContent(String content,
List<ProjectInitRecord.InputFile> inputFiles) {
log.info("开始生成并保存项目初始化数据");
// 1. 先创建初始化记录(状态为处理中)
ProjectInitRecord initRecord = new ProjectInitRecord();
initRecord.setInputFiles(inputFiles);
initRecord.setInputText(content.length() > 5000 ? content.substring(0, 5000) + "..." : content);
initRecord.setParseStatus("processing");
projectInitRecordMapper.insert(initRecord);
ProjectInitResult result = null;
Usage usage = null;
try {
// 2. 调用AI生成项目数据
String userPrompt = "请根据以下项目资料,生成完整的项目初始化结构化数据:\n\n" +
content + "\n\n" +
"请严格按照系统提示词中的JSON格式输出确保所有字段都包含合理的值。";
// 创建 BeanOutputConverter 用于转换响应
BeanOutputConverter<ProjectInitResult> outputConverter = new BeanOutputConverter<>(ProjectInitResult.class);
var chatResponse = chatClient.prompt()
.system(PROJECT_INIT_SYSTEM_PROMPT)
.user(userPrompt)
.call()
.chatResponse();
// 使用 BeanOutputConverter 手动转换响应内容
String responseContent = chatResponse.getResult().getOutput().getText();
result = outputConverter.convert(responseContent);
usage = chatResponse.getMetadata().getUsage();
// 3. 保存项目数据到数据库
Long projectId = saveProjectData(result);
// 4. 更新初始化记录为成功状态
initRecord.setProjectId(projectId);
initRecord.setParseStatus("completed");
initRecord.setParseResult(result);
initRecord.setGeneratedMilestones(result.getMilestones() != null ? result.getMilestones().size() : 0);
initRecord.setGeneratedTasks(result.getTasks() != null ? result.getTasks().size() : 0);
initRecord.setGeneratedMembers(result.getMembers() != null ? result.getMembers().size() : 0);
initRecord.setGeneratedResources(result.getResources() != null ? result.getResources().size() : 0);
if (usage != null) {
initRecord.setTokensUsed(usage.getTotalTokens());
}
projectInitRecordMapper.updateById(initRecord);
log.info("项目初始化数据保存成功, projectId: {}", projectId);
return result;
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
// 更新初始化记录为失败状态
initRecord.setParseStatus("failed");
initRecord.setErrorMessage(e.getMessage());
if (usage != null) {
initRecord.setTokensUsed(usage.getTotalTokens());
}
projectInitRecordMapper.updateById(initRecord);
throw new RuntimeException("项目初始化失败: " + e.getMessage(), e);
}
}
/**
* 保存项目数据到数据库
*
* @param result AI生成的项目初始化结果
* @return 项目ID
*/
private Long saveProjectData(ProjectInitResult result) {
if (result == null || result.getProject() == null) {
throw new RuntimeException("项目数据为空");
}
// 1. 保存项目基本信息
Project project = convertToProject(result.getProject());
project.setProjectCode(generateProjectCode());
project.setStatus("planning"); // 初始化后状态为规划中
project.setProgress(0);
project.setVisibility(1);
projectMapper.insert(project);
Long projectId = project.getId();
log.info("项目基本信息保存成功, projectId: {}", projectId);
// 2. 保存里程碑
Map<Integer, Long> milestoneIndexToId = new HashMap<>();
if (result.getMilestones() != null && !result.getMilestones().isEmpty()) {
for (int i = 0; i < result.getMilestones().size(); i++) {
ProjectInitResult.MilestoneInfo milestoneInfo = result.getMilestones().get(i);
ProjectMilestone milestone = convertToMilestone(milestoneInfo, projectId);
milestone.setSortOrder(i);
projectMilestoneMapper.insert(milestone);
milestoneIndexToId.put(i, milestone.getId());
}
log.info("保存了 {} 个里程碑", result.getMilestones().size());
}
// 3. 保存任务(支持层级关系)
Map<String, Long> taskTempIdToId = new HashMap<>();
if (result.getTasks() != null && !result.getTasks().isEmpty()) {
// 第一轮保存所有任务建立临时ID到真实ID的映射
for (int i = 0; i < result.getTasks().size(); i++) {
ProjectInitResult.TaskInfo taskInfo = result.getTasks().get(i);
Task task = convertToTask(taskInfo, projectId);
// 设置里程碑关联(按索引匹配)
// 这里简化处理,可以根据任务时间范围匹配里程碑
if (!milestoneIndexToId.isEmpty()) {
// 简单策略:将任务平均分配到各个里程碑
int milestoneIndex = i % milestoneIndexToId.size();
task.setMilestoneId(milestoneIndexToId.get(milestoneIndex));
}
task.setSortOrder(i);
taskMapper.insert(task);
// 使用任务名称作为临时ID进行映射
String tempId = taskInfo.getTaskName();
taskTempIdToId.put(tempId, task.getId());
}
// 第二轮:更新任务依赖关系
for (int i = 0; i < result.getTasks().size(); i++) {
ProjectInitResult.TaskInfo taskInfo = result.getTasks().get(i);
if (taskInfo.getParentTaskId() != null && !taskInfo.getParentTaskId().isEmpty()) {
Task task = new Task();
task.setId(taskTempIdToId.get(taskInfo.getTaskName()));
// 查找父任务ID
Long parentId = taskTempIdToId.get(taskInfo.getParentTaskId());
if (parentId != null) {
task.setParentId(parentId);
taskMapper.updateById(task);
}
}
}
log.info("保存了 {} 个任务", result.getTasks().size());
}
// 4. 保存项目成员
if (result.getMembers() != null && !result.getMembers().isEmpty()) {
for (ProjectInitResult.MemberInfo memberInfo : result.getMembers()) {
ProjectMember member = convertToMember(memberInfo, projectId);
projectMemberMapper.insert(member);
}
log.info("保存了 {} 个成员", result.getMembers().size());
}
// 5. 保存资源
if (result.getResources() != null && !result.getResources().isEmpty()) {
for (ProjectInitResult.ResourceInfo resourceInfo : result.getResources()) {
Resource resource = convertToResource(resourceInfo, projectId);
resourceMapper.insert(resource);
}
log.info("保存了 {} 个资源", result.getResources().size());
}
// 6. 保存风险
if (result.getRisks() != null && !result.getRisks().isEmpty()) {
for (ProjectInitResult.RiskInfo riskInfo : result.getRisks()) {
Risk risk = convertToRisk(riskInfo, projectId);
riskMapper.insert(risk);
}
log.info("保存了 {} 个风险", result.getRisks().size());
}
// 7. 保存时间节点
if (result.getTimelineNodes() != null && !result.getTimelineNodes().isEmpty()) {
for (int i = 0; i < result.getTimelineNodes().size(); i++) {
ProjectInitResult.TimelineNodeInfo nodeInfo = result.getTimelineNodes().get(i);
ProjectTimeline timeline = convertToTimeline(nodeInfo, projectId);
timeline.setSortOrder(i);
projectTimelineMapper.insert(timeline);
}
log.info("保存了 {} 个时间节点", result.getTimelineNodes().size());
}
return projectId;
}
/**
* 生成项目编号
*/
private String generateProjectCode() {
return "PRJ" + IdUtil.fastSimpleUUID().substring(0, 12).toUpperCase();
}
/**
* 转换项目信息
*/
private Project convertToProject(ProjectInitResult.ProjectInfo info) {
Project project = new Project();
project.setProjectName(info.getProjectName());
project.setProjectType(info.getProjectType());
project.setDescription(info.getDescription());
project.setObjectives(info.getObjectives());
project.setPlanStartDate(info.getPlanStartDate());
project.setPlanEndDate(info.getPlanEndDate());
project.setBudget(info.getBudget());
project.setCurrency(info.getCurrency());
project.setPriority(info.getPriority());
project.setTags(info.getTags());
return project;
}
/**
* 转换里程碑信息
*/
private ProjectMilestone convertToMilestone(ProjectInitResult.MilestoneInfo info, Long projectId) {
ProjectMilestone milestone = new ProjectMilestone();
milestone.setProjectId(projectId);
milestone.setMilestoneName(info.getMilestoneName());
milestone.setDescription(info.getDescription());
milestone.setPlanDate(info.getPlanDate());
milestone.setStatus("pending");
milestone.setProgress(0);
milestone.setIsKey(0);
if (info.getDeliverables() != null && !info.getDeliverables().isEmpty()) {
milestone.setDeliverables(Arrays.asList(info.getDeliverables().split(",")));
}
return milestone;
}
/**
* 转换任务信息
*/
private Task convertToTask(ProjectInitResult.TaskInfo info, Long projectId) {
Task task = new Task();
task.setProjectId(projectId);
task.setTaskName(info.getTaskName());
task.setDescription(info.getDescription());
task.setPlanStartDate(info.getPlanStartDate());
task.setPlanEndDate(info.getPlanEndDate());
if (info.getEstimatedHours() != null) {
task.setPlanHours(BigDecimal.valueOf(info.getEstimatedHours()));
}
task.setPriority(info.getPriority());
task.setStatus("pending");
task.setProgress(0);
return task;
}
/**
* 转换成员信息
*/
private ProjectMember convertToMember(ProjectInitResult.MemberInfo info, Long projectId) {
ProjectMember member = new ProjectMember();
member.setProjectId(projectId);
member.setRoleCode(info.getRoleCode());
member.setResponsibility(info.getResponsibility());
if (info.getWeeklyHours() != null) {
member.setWeeklyHours(BigDecimal.valueOf(info.getWeeklyHours()));
}
member.setStatus(1);
// TODO: 需要根据name查找或创建用户暂时留空
return member;
}
/**
* 转换资源信息
*/
private Resource convertToResource(ProjectInitResult.ResourceInfo info, Long projectId) {
Resource resource = new Resource();
resource.setProjectId(projectId);
resource.setResourceName(info.getResourceName());
resource.setResourceType(info.getResourceType());
resource.setPlanQuantity(info.getQuantity());
resource.setUnit(info.getUnit());
resource.setUnitPrice(info.getUnitPrice());
resource.setSupplier(info.getSupplier());
resource.setStatus("planned");
resource.setCurrency("CNY");
return resource;
}
/**
* 转换风险信息
*/
private Risk convertToRisk(ProjectInitResult.RiskInfo info, Long projectId) {
Risk risk = new Risk();
risk.setProjectId(projectId);
risk.setRiskName(info.getRiskName());
risk.setCategory(info.getCategory());
risk.setDescription(info.getDescription());
risk.setRiskSource("ai_detection");
if (info.getProbability() != null) {
risk.setProbability(BigDecimal.valueOf(info.getProbability()));
}
if (info.getImpact() != null) {
risk.setImpact(BigDecimal.valueOf(info.getImpact()));
}
// 计算风险得分
if (risk.getProbability() != null && risk.getImpact() != null) {
BigDecimal score = risk.getProbability()
.multiply(risk.getImpact())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
risk.setRiskScore(score);
}
// 根据概率和影响确定风险等级
risk.setRiskLevel(calculateRiskLevel(info.getProbability(), info.getImpact()));
risk.setStatus("identified");
risk.setMitigationPlan(info.getMitigationPlan());
risk.setDiscoverTime(LocalDateTime.now());
return risk;
}
/**
* 计算风险等级
*/
private String calculateRiskLevel(Integer probability, Integer impact) {
if (probability == null || impact == null) {
return "low";
}
int score = probability * impact;
if (score >= 300) {
return "critical";
} else if (score >= 200) {
return "high";
} else if (score >= 100) {
return "medium";
} else {
return "low";
}
}
/**
* 转换时间节点信息
*/
private ProjectTimeline convertToTimeline(ProjectInitResult.TimelineNodeInfo info, Long projectId) {
ProjectTimeline timeline = new ProjectTimeline();
timeline.setProjectId(projectId);
timeline.setNodeName(info.getNodeName());
timeline.setNodeType(info.getNodeType());
timeline.setPlanDate(info.getPlanDate());
timeline.setDescription(info.getDescription());
timeline.setStatus("pending");
timeline.setKbScope(info.getKbScope());
return timeline;
}
}