feat(project): 实现AI项目初始化及相关实体管理

- 新增通用返回类BaseResponse用于统一接口响应格式
- 新增业务异常BusinessException及全局异常处理GlobalExceptionHandler
- 新增OSS文件上传控制器支持文件上传与删除接口
- 添加项目核心实体类Project、ProjectMember、ProjectMilestone、ProjectTimeline和Resource
- 实现ProjectService接口及其实现类,使用AI能力从项目文档生成结构化项目数据
- 在ProjectServiceImpl中实现项目数据解析、保存及业务逻辑,包括项目、里程碑、任务、成员、资源、风险等
- 项目初始化控制器ProjectController提供文件上传触发项目初始化功能
- 设计了详细的系统提示词和用户提示词,用于AI模型指导生成严格格式的结构化项目数据
- 设计项目数据持久化流程,确保生成的数据正确保存至数据库,支持事务回滚
- 增强日志记录,便于追踪项目初始化全过程及错误调试
This commit is contained in:
2026-03-27 10:25:13 +08:00
parent 729af44585
commit 294ef21d50
19 changed files with 237 additions and 287 deletions

View File

@@ -0,0 +1,35 @@
package cn.yinlihupo.common.core;
import cn.yinlihupo.common.enums.ErrorCode;
import lombok.Data;
import java.io.Serializable;
/**
* 通用返回类
*
* @param <T>
*/
@Data
public class BaseResponse<T> implements Serializable {
private int code;
private T data;
private String message;
public BaseResponse(int code, T data, String message) {
this.code = code;
this.data = data;
this.message = message;
}
public BaseResponse(int code, T data) {
this(code, data, "");
}
public BaseResponse(ErrorCode errorCode) {
this(errorCode.getCode(), null, errorCode.getMessage());
}
}

View File

@@ -0,0 +1,29 @@
package cn.yinlihupo.common.exception;
import cn.yinlihupo.common.enums.ErrorCode;
/**
* 自定义异常类
*/
public class BusinessException extends RuntimeException {
/**
* 错误码
*/
private final int code;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,63 @@
package cn.yinlihupo.common.exception;
import cn.hutool.core.exceptions.UtilException;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.enums.ErrorCode;
import cn.yinlihupo.common.util.ResultUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*
*/
@RestControllerAdvice
@Slf4j
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("BusinessException", e);
return ResultUtils.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(UtilException.class)
public BaseResponse<?> utilExceptionHandler(UtilException e) {
log.error("UtilException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse<?> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
log.error("MethodArgumentNotValidException", e);
String errorMsg = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return ResultUtils.error(ErrorCode.PARAMS_ERROR, errorMsg);
}
@ExceptionHandler(BindException.class)
public BaseResponse<?> bindExceptionHandler(BindException e) {
log.error("BindException", e);
String errorMsg = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return ResultUtils.error(ErrorCode.PARAMS_ERROR, errorMsg);
}
@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("RuntimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误");
}
}

View File

@@ -1,89 +0,0 @@
package cn.yinlihupo.common.result;
import lombok.Data;
import java.io.Serializable;
/**
* 统一响应结果封装
*
* @param <T> 数据类型
*/
@Data
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 状态码
*/
private Integer code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 时间戳
*/
private Long timestamp;
public Result() {
this.timestamp = System.currentTimeMillis();
}
public Result(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
/**
* 成功响应
*/
public static <T> Result<T> success() {
return new Result<>(200, "操作成功", null);
}
/**
* 成功响应(带数据)
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
}
/**
* 成功响应(带消息和数据)
*/
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data);
}
/**
* 失败响应
*/
public static <T> Result<T> error() {
return new Result<>(500, "操作失败", null);
}
/**
* 失败响应(带消息)
*/
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null);
}
/**
* 失败响应(带状态码和消息)
*/
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}
}

View File

@@ -1,5 +0,0 @@
/**
* 统一响应模块
* 包含统一响应结果封装
*/
package cn.yinlihupo.ylhpaiprojectmanager.common.result;

View File

@@ -0,0 +1,77 @@
package cn.yinlihupo.common.util;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.enums.ErrorCode;
/**
* 返回工具类
*
*/
public class ResultUtils {
/**
* 成功
*
* @param data
* @param <T>
* @return
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(200, data, "ok");
}
/**
* 成功
*
* @param message 成功消息
* @param data 数据
* @param <T>
* @return
*/
public static <T> BaseResponse<T> success(String message, T data) {
return new BaseResponse<>(200, data, message);
}
/**
* 失败
*
* @param message 错误消息
* @return
*/
public static BaseResponse error(String message) {
return new BaseResponse(500, null, message);
}
/**
* 失败
*
* @param errorCode
* @return
*/
public static BaseResponse error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}
/**
* 失败
*
* @param code
* @param message
* @return
*/
public static BaseResponse error(int code, String message) {
return new BaseResponse(code, null, message);
}
/**
* 失败
*
* @param errorCode
* @param message
* @return
*/
public static BaseResponse error(ErrorCode errorCode, String message) {
return new BaseResponse(errorCode.getCode(), null, message);
}
}

View File

@@ -1,6 +1,7 @@
package cn.yinlihupo.controller.oss;
import cn.yinlihupo.common.result.Result;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.util.ResultUtils;
import cn.yinlihupo.service.oss.OssService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -26,19 +27,19 @@ public class OssController {
* @return 文件URL
*/
@PostMapping("/upload")
public Result<String> uploadFile(@RequestParam("file") MultipartFile file) {
public BaseResponse<String> uploadFile(@RequestParam("file") MultipartFile file) {
log.info("收到文件上传请求, 文件名: {}", file.getOriginalFilename());
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
return ResultUtils.error("上传文件不能为空");
}
try {
String fileUrl = ossService.uploadFile(file, file.getOriginalFilename());
return Result.success("文件上传成功", fileUrl);
return ResultUtils.success("文件上传成功", fileUrl);
} catch (Exception e) {
log.error("文件上传失败: {}", e.getMessage(), e);
return Result.error("文件上传失败: " + e.getMessage());
return ResultUtils.error("文件上传失败: " + e.getMessage());
}
}
@@ -50,21 +51,21 @@ public class OssController {
* @return 文件URL
*/
@PostMapping("/upload/{bucketName}")
public Result<String> uploadFileToBucket(
public BaseResponse<String> uploadFileToBucket(
@RequestParam("file") MultipartFile file,
@PathVariable String bucketName) {
log.info("收到文件上传请求, 文件名: {}, 存储桶: {}", file.getOriginalFilename(), bucketName);
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
return ResultUtils.error("上传文件不能为空");
}
try {
String fileUrl = ossService.uploadFile(file, file.getOriginalFilename(), bucketName);
return Result.success("文件上传成功", fileUrl);
return ResultUtils.success("文件上传成功", fileUrl);
} catch (Exception e) {
log.error("文件上传失败: {}", e.getMessage(), e);
return Result.error("文件上传失败: " + e.getMessage());
return ResultUtils.error("文件上传失败: " + e.getMessage());
}
}
@@ -75,15 +76,15 @@ public class OssController {
* @return 操作结果
*/
@DeleteMapping("/delete")
public Result<Void> deleteFile(@RequestParam("fileUrl") String fileUrl) {
public BaseResponse<Void> deleteFile(@RequestParam("fileUrl") String fileUrl) {
log.info("收到文件删除请求, fileUrl: {}", fileUrl);
try {
ossService.deleteFile(fileUrl);
return Result.success("文件删除成功", null);
return ResultUtils.success("文件删除成功", null);
} catch (Exception e) {
log.error("文件删除失败: {}", e.getMessage(), e);
return Result.error("文件删除失败: " + e.getMessage());
return ResultUtils.error("文件删除失败: " + e.getMessage());
}
}
}

View File

@@ -1,6 +1,7 @@
package cn.yinlihupo.controller.project;
import cn.yinlihupo.common.result.Result;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.util.ResultUtils;
import cn.yinlihupo.domain.vo.ProjectInitResult;
import cn.yinlihupo.service.oss.OssService;
import cn.yinlihupo.service.project.ProjectService;
@@ -29,20 +30,20 @@ public class ProjectController {
* @return 项目初始化结构化数据
*/
@PostMapping("/from-file")
public Result<ProjectInitResult> generateFromFile(@RequestParam("file") MultipartFile file) {
public BaseResponse<ProjectInitResult> generateFromFile(@RequestParam("file") MultipartFile file) {
log.info("收到项目初始化请求(文件上传), 文件名: {}", file.getOriginalFilename());
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
return ResultUtils.error("上传文件不能为空");
}
try {
// 上传文件、生成项目初始化数据并保存到数据库
ProjectInitResult result = projectService.generateAndSaveProject(file);
return Result.success("项目初始化成功", result);
return ResultUtils.success("项目初始化成功", result);
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
return Result.error("项目初始化失败: " + e.getMessage());
return ResultUtils.error("项目初始化失败: " + e.getMessage());
}
}

View File

@@ -17,7 +17,7 @@ import java.util.List;
@TableName("project")
public class Project {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -1,105 +0,0 @@
package cn.yinlihupo.domain.entity;
import cn.yinlihupo.common.handler.JsonbTypeHandler;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 项目初始化记录实体类
* 对应数据库表: project_init_record
*/
@Data
@TableName("project_init_record")
public class ProjectInitRecord {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 项目ID
*/
private Long projectId;
/**
* 上传的文件列表[{name, path, type, size}]
*/
@TableField(typeHandler = JsonbTypeHandler.class)
private List<InputFile> inputFiles;
/**
* 用户输入的项目描述
*/
private String inputText;
/**
* 状态: pending-待解析, processing-解析中, completed-已完成, failed-失败
*/
private String parseStatus;
/**
* 解析结果(结构化数据)
*/
@TableField(typeHandler = JsonbTypeHandler.class)
private Object parseResult;
/**
* 生成的里程碑数量
*/
private Integer generatedMilestones;
/**
* 生成的任务数量
*/
private Integer generatedTasks;
/**
* 生成的成员数量
*/
private Integer generatedMembers;
/**
* 生成的资源数量
*/
private Integer generatedResources;
/**
* 使用的AI模型
*/
private String model;
/**
* 消耗的Token数
*/
private Integer tokensUsed;
/**
* 错误信息
*/
private String errorMessage;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 输入文件信息内部类
*/
@Data
public static class InputFile {
private String name;
private String path;
private String type;
private Long size;
}
}

View File

@@ -15,7 +15,7 @@ import java.time.LocalDateTime;
@TableName("project_member")
public class ProjectMember {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -16,7 +16,7 @@ import java.util.List;
@TableName("project_milestone")
public class ProjectMilestone {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -16,7 +16,7 @@ import java.util.List;
@TableName("project_timeline")
public class ProjectTimeline {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -17,7 +17,7 @@ import java.util.List;
@TableName("resource")
public class Resource {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -17,7 +17,7 @@ import java.util.List;
@TableName("risk")
public class Risk {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -17,7 +17,7 @@ import java.util.List;
@TableName("task")
public class Task {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**

View File

@@ -1,12 +0,0 @@
package cn.yinlihupo.mapper;
import cn.yinlihupo.domain.entity.ProjectInitRecord;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 项目初始化记录Mapper接口
*/
@Mapper
public interface ProjectInitRecordMapper extends BaseMapper<ProjectInitRecord> {
}

View File

@@ -3,8 +3,6 @@ package cn.yinlihupo.service.project;
import cn.yinlihupo.domain.vo.ProjectInitResult;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* AI项目初始化服务接口
* 使用Spring AI结构化输出能力从项目文档中提取结构化信息
@@ -40,8 +38,7 @@ public interface ProjectService {
* 根据项目资料内容生成并保存项目初始化数据
*
* @param content 项目资料文本内容
* @param inputFiles 输入文件列表信息
* @return 项目初始化结果
*/
ProjectInitResult generateAndSaveProjectFromContent(String content, List<cn.yinlihupo.domain.entity.ProjectInitRecord.InputFile> inputFiles);
ProjectInitResult generateAndSaveProjectFromContent(String content);
}

View File

@@ -44,7 +44,6 @@ public class ProjectServiceImpl implements ProjectService {
private final ResourceMapper resourceMapper;
private final RiskMapper riskMapper;
private final ProjectTimelineMapper projectTimelineMapper;
private final ProjectInitRecordMapper projectInitRecordMapper;
/**
* 项目初始化系统提示词模板
@@ -214,17 +213,8 @@ public class ProjectServiceImpl implements ProjectService {
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);
// 3. 生成并保存项目数据
return generateAndSaveProjectFromContent(content);
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
@@ -234,22 +224,13 @@ public class ProjectServiceImpl implements ProjectService {
@Override
@Transactional(rollbackFor = Exception.class)
public ProjectInitResult generateAndSaveProjectFromContent(String content,
List<ProjectInitRecord.InputFile> inputFiles) {
public ProjectInitResult generateAndSaveProjectFromContent(String content) {
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;
ProjectInitResult result;
try {
// 2. 调用AI生成项目数据
// 1. 调用AI生成项目数据
String userPrompt = "请根据以下项目资料,生成完整的项目初始化结构化数据:\n\n" +
content + "\n\n" +
"请严格按照系统提示词中的JSON格式输出确保所有字段都包含合理的值。";
@@ -266,38 +247,15 @@ public class ProjectServiceImpl implements ProjectService {
// 使用 BeanOutputConverter 手动转换响应内容
String responseContent = chatResponse.getResult().getOutput().getText();
result = outputConverter.convert(responseContent);
usage = chatResponse.getMetadata().getUsage();
// 3. 保存项目数据到数据库
// 2. 保存项目数据到数据库
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);
}
}