- 新增通用返回类BaseResponse用于统一接口响应格式 - 新增业务异常BusinessException及全局异常处理GlobalExceptionHandler - 新增OSS文件上传控制器支持文件上传与删除接口 - 添加项目核心实体类Project、ProjectMember、ProjectMilestone、ProjectTimeline和Resource - 实现ProjectService接口及其实现类,使用AI能力从项目文档生成结构化项目数据 - 在ProjectServiceImpl中实现项目数据解析、保存及业务逻辑,包括项目、里程碑、任务、成员、资源、风险等 - 项目初始化控制器ProjectController提供文件上传触发项目初始化功能 - 设计了详细的系统提示词和用户提示词,用于AI模型指导生成严格格式的结构化项目数据 - 设计项目数据持久化流程,确保生成的数据正确保存至数据库,支持事务回滚 - 增强日志记录,便于追踪项目初始化全过程及错误调试
539 lines
20 KiB
Java
539 lines
20 KiB
Java
package cn.yinlihupo.service.project.impl;
|
||
|
||
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.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项目初始化服务实现类
|
||
* 使用Spring AI结构化输出能力解析项目文档
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
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 static final String PROJECT_INIT_SYSTEM_PROMPT = """
|
||
# 角色
|
||
|
||
你是一个专业的项目管理助手,擅长从项目文档中提取结构化信息,自动生成项目计划。
|
||
|
||
# 任务
|
||
|
||
根据用户提供的项目资料,解析并生成以下结构化数据:
|
||
|
||
1. 项目基本信息(名称、周期、预算、目标)
|
||
2. 项目里程碑(关键节点)
|
||
3. 任务清单(WBS工作分解结构)
|
||
4. 项目成员及角色
|
||
5. 资源需求
|
||
6. 风险识别
|
||
|
||
# 输出格式
|
||
|
||
请严格按照以下JSON格式输出:
|
||
|
||
```json
|
||
{
|
||
"project": {
|
||
"project_name": "项目名称",
|
||
"project_type": "工程项目/研发项目/运营项目",
|
||
"description": "项目描述",
|
||
"objectives": "项目目标",
|
||
"plan_start_date": "2024-01-01",
|
||
"plan_end_date": "2024-12-31",
|
||
"budget": 1000000,
|
||
"currency": "CNY",
|
||
"priority": "high/medium/low",
|
||
"tags": ["标签1", "标签2"]
|
||
},
|
||
"milestones": [
|
||
{
|
||
"milestone_name": "里程碑名称",
|
||
"description": "描述",
|
||
"plan_date": "2024-03-01",
|
||
"deliverables": "交付物",
|
||
"owner_role": "负责人角色"
|
||
}
|
||
],
|
||
"tasks": [
|
||
{
|
||
"task_name": "任务名称",
|
||
"parent_task_id": null,
|
||
"description": "任务描述",
|
||
"plan_start_date": "2024-01-15",
|
||
"plan_end_date": "2024-02-15",
|
||
"estimated_hours": 80,
|
||
"priority": "high",
|
||
"assignee_role": "执行者角色",
|
||
"dependencies": ["前置任务ID"],
|
||
"deliverables": "交付物"
|
||
}
|
||
],
|
||
"members": [
|
||
{
|
||
"name": "姓名",
|
||
"role_code": "manager/leader/member",
|
||
"responsibility": "职责描述",
|
||
"department": "部门",
|
||
"weekly_hours": 40
|
||
}
|
||
],
|
||
"resources": [
|
||
{
|
||
"resource_name": "资源名称",
|
||
"resource_type": "human/material/equipment/software/finance",
|
||
"quantity": 1,
|
||
"unit": "单位",
|
||
"unit_price": 1000,
|
||
"supplier": "供应商"
|
||
}
|
||
],
|
||
"risks": [
|
||
{
|
||
"risk_name": "风险名称",
|
||
"category": "technical/schedule/cost/quality/resource/external",
|
||
"description": "风险描述",
|
||
"probability": 60,
|
||
"impact": 4,
|
||
"mitigation_plan": "缓解措施"
|
||
}
|
||
],
|
||
"timeline_nodes": [
|
||
{
|
||
"node_name": "时间节点名称",
|
||
"node_type": "milestone/phase/event",
|
||
"plan_date": "2024-06-01",
|
||
"description": "描述",
|
||
"kb_scope": ["report", "file", "risk", "ticket"]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
# 注意事项
|
||
|
||
1. 日期格式统一使用 YYYY-MM-DD
|
||
2. 任务之间要建立合理的依赖关系
|
||
3. 里程碑应该是关键节点,不宜过多
|
||
4. 资源要考虑人力、物料、设备等
|
||
5. 风险识别要基于项目特点
|
||
6. 如果文档信息不完整,根据项目类型合理推断
|
||
""";
|
||
|
||
/**
|
||
* 用户提示词模板
|
||
*/
|
||
private static final String USER_PROMPT_TEMPLATE = """
|
||
请根据以下项目资料,生成完整的项目初始化结构化数据:
|
||
|
||
{content}
|
||
|
||
请严格按照系统提示词中的JSON格式输出,确保所有字段都包含合理的值。
|
||
""";
|
||
|
||
@Override
|
||
public ProjectInitResult generateProjectFromContent(String content) {
|
||
log.info("开始根据内容生成项目初始化数据");
|
||
|
||
// 构建用户提示词,直接将内容嵌入
|
||
String userPrompt = "请根据以下项目资料,生成完整的项目初始化结构化数据:\n\n" +
|
||
content + "\n\n" +
|
||
"请严格按照系统提示词中的JSON格式输出,确保所有字段都包含合理的值。";
|
||
|
||
return chatClient.prompt()
|
||
.system(PROJECT_INIT_SYSTEM_PROMPT)
|
||
.user(userPrompt)
|
||
.call()
|
||
.entity(ProjectInitResult.class);
|
||
}
|
||
|
||
@Override
|
||
public ProjectInitResult generateProjectFromFile(String fileUrl, String fileType) {
|
||
log.info("开始根据文件生成项目初始化数据, fileUrl: {}, fileType: {}", fileUrl, fileType);
|
||
|
||
// 从OSS下载文件内容
|
||
String content = ossService.readFileAsString(fileUrl);
|
||
|
||
if (content == null || content.isEmpty()) {
|
||
throw new RuntimeException("无法读取文件内容: " + fileUrl);
|
||
}
|
||
|
||
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. 生成并保存项目数据
|
||
return generateAndSaveProjectFromContent(content);
|
||
|
||
} catch (Exception e) {
|
||
log.error("项目初始化失败: {}", e.getMessage(), e);
|
||
throw new RuntimeException("项目初始化失败: " + e.getMessage(), e);
|
||
}
|
||
}
|
||
|
||
@Override
|
||
@Transactional(rollbackFor = Exception.class)
|
||
public ProjectInitResult generateAndSaveProjectFromContent(String content) {
|
||
log.info("开始生成并保存项目初始化数据");
|
||
|
||
ProjectInitResult result;
|
||
|
||
try {
|
||
// 1. 调用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);
|
||
|
||
// 2. 保存项目数据到数据库
|
||
Long projectId = saveProjectData(result);
|
||
|
||
log.info("项目初始化数据保存成功, projectId: {}", projectId);
|
||
return result;
|
||
|
||
} catch (Exception e) {
|
||
log.error("项目初始化失败: {}", e.getMessage(), e);
|
||
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;
|
||
}
|
||
}
|