feat(common): 新增基础模块及AI项目初始化功能

- 新增基础响应类BaseResponse,实现统一返回格式
- 定义自定义异常BusinessException及全局异常处理机制
- 构建错误码枚举ErrorCode,包含常见HTTP及业务错误码
- 实现返回工具类ResultUtils提供成功与失败响应构建方法
- 添加分页查询支持PageQuery和TableDataInfo数据结构
- 配置MinIO客户端及服务类MinioFileService实现文件上传、下载、删除功能
- 实现AI项目初始化控制器ProjectInitController,支持文本、文件及URL输入生成项目结构化数据
- 定义项目初始化相关DTO,包括请求参数和复杂的结果结构体
- 补充项目开发环境配置文件,集成Spring AI、PostgreSQL和MinIO配置
- 项目主启动类YlhpAiProjectManagerApplication添加启动入口
- 更新Maven依赖,集成Spring AI、MyBatis Plus、MinIO及AWS S3客户端依赖
- 规范代码包结构,新增多模块package-info.java文件进行模块说明文档编写
This commit is contained in:
2026-03-26 16:50:18 +08:00
parent 9b840f887d
commit e82b5c2f0b
26 changed files with 1463 additions and 3 deletions

View File

@@ -34,6 +34,13 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<!-- PostgreSQL 数据库驱动 -->
<dependency>
<groupId>org.postgresql</groupId>
@@ -86,7 +93,7 @@
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

View File

@@ -1,4 +1,4 @@
package cn.yinlihupo.ylhpaiprojectmanager;
package cn.yinlihupo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

View File

@@ -0,0 +1,35 @@
package cn.yinlihupo.common.base;
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,44 @@
package cn.yinlihupo.common.config;
import io.minio.MinioClient;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MinIO 配置类
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
/**
* MinIO 服务端点
*/
private String endpoint;
/**
* 访问密钥
*/
private String accessKey;
/**
* 秘密密钥
*/
private String secretKey;
/**
* 默认存储桶名称
*/
private String bucketName;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}

View File

@@ -0,0 +1,23 @@
package cn.yinlihupo.common.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Spring AI 配置类
*/
@Configuration
public class SpringAiConfig {
/**
* 配置ChatClient
*
* @param builder ChatClient.Builder
* @return ChatClient
*/
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}

View File

@@ -0,0 +1,44 @@
package cn.yinlihupo.common.enums;
/**
* 自定义错误码
*
*/
public enum ErrorCode {
SUCCESS(200, "ok"),
PARAMS_ERROR(400, "请求参数错误"),
NOT_LOGIN_ERROR(401, "未登录"),
NO_AUTH_ERROR(402, "无权限"),
NOT_FOUND_ERROR(404, "请求数据不存在"),
FORBIDDEN_ERROR(403, "禁止访问"),
SYSTEM_ERROR(500, "系统内部异常"),
TOO_MANY_REQUEST(429, "请求过于频繁"),
TOKEN_INVALID(401, "token已失效"),
OPERATION_ERROR(500, "操作失败");
/**
* 状态码
*/
private final int code;
/**
* 信息
*/
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}

View File

@@ -0,0 +1,28 @@
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,94 @@
package cn.yinlihupo.common.exception;
import cn.yinlihupo.common.base.BaseResponse;
import cn.yinlihupo.common.enums.ErrorCode;
import cn.yinlihupo.common.util.ResultUtils;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
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(OrderException.class)
public BaseResponse<?> orderExceptionHandler(OrderException e) {
log.error("OrderException", e);
// 如果没有设置错误码,使用默认的业务错误码
Integer code = e.getCode();
if (code == null) {
return ResultUtils.error(ErrorCode.OPERATION_ERROR, e.getMessage());
}
return ResultUtils.error(code, e.getMessage());
}
@ExceptionHandler(ServiceException.class)
public BaseResponse<?> serviceExceptionHandler(ServiceException e) {
log.error("ServiceException", e);
// 如果没有设置错误码,使用默认的业务错误码
Integer code = e.getCode();
if (code == null) {
return ResultUtils.error(ErrorCode.OPERATION_ERROR, e.getMessage());
}
return ResultUtils.error(code, 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(ConstraintViolationException.class)
public BaseResponse<?> constraintViolationExceptionHandler(ConstraintViolationException e) {
log.error("ConstraintViolationException", e);
String errorMsg = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.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

@@ -0,0 +1,81 @@
package cn.yinlihupo.common.page;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 分页查询实体类
*/
@Data
public class PageQuery implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 分页大小
*/
private Integer pageSize;
/**
* 当前页数
*/
private Integer pageNum;
/**
* 排序列
*/
private String orderByColumn;
/**
* 排序的方向desc或者asc
*/
private String isAsc;
/**
* 当前记录起始索引 默认值
*/
public static final int DEFAULT_PAGE_NUM = 1;
/**
* 每页显示记录数 默认值 默认查全部
*/
public static final int DEFAULT_PAGE_SIZE = 10;
public <T> Page<T> build() {
Integer pageNum = this.pageNum == null ? DEFAULT_PAGE_NUM : this.pageNum;
Integer pageSize = this.pageSize == null ? DEFAULT_PAGE_SIZE : this.pageSize;
if (pageNum <= 0) {
pageNum = DEFAULT_PAGE_NUM;
}
return new Page<>(pageNum, pageSize);
}
/**
* 构建排序
*/
public List<OrderItem> buildOrderItem() {
if (orderByColumn == null || orderByColumn.isEmpty() || isAsc == null || isAsc.isEmpty()) {
return null;
}
List<OrderItem> list = new ArrayList<>();
String[] orderByArr = orderByColumn.split(",");
String[] isAscArr = isAsc.split(",");
for (int i = 0; i < orderByArr.length; i++) {
String orderByStr = orderByArr[i].trim();
String isAscStr = isAscArr.length == 1 ? isAscArr[0].trim() : isAscArr[i].trim();
if ("asc".equalsIgnoreCase(isAscStr)) {
list.add(OrderItem.asc(orderByStr));
} else if ("desc".equalsIgnoreCase(isAscStr)) {
list.add(OrderItem.desc(orderByStr));
}
}
return list;
}
}

View File

@@ -0,0 +1,76 @@
package cn.yinlihupo.common.page;
import cn.hutool.http.HttpStatus;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
/**
* 表格分页数据对象
*/
@Data
@NoArgsConstructor
public class TableDataInfo<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 总记录数
*/
private long total;
/**
* 列表数据
*/
private List<T> rows;
/**
* 消息状态码
*/
private int code;
/**
* 消息内容
*/
private String msg;
/**
* 分页
*
* @param list 列表数据
* @param total 总记录数
*/
public TableDataInfo(List<T> list, long total) {
this.rows = list;
this.total = total;
}
public static <T> TableDataInfo<T> build(IPage<T> page) {
TableDataInfo<T> rspData = new TableDataInfo<>();
rspData.setCode(HttpStatus.HTTP_OK);
rspData.setMsg("查询成功");
rspData.setRows(page.getRecords());
rspData.setTotal(page.getTotal());
return rspData;
}
public static <T> TableDataInfo<T> build(List<T> list) {
TableDataInfo<T> rspData = new TableDataInfo<>();
rspData.setCode(HttpStatus.HTTP_OK);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(list.size());
return rspData;
}
public static <T> TableDataInfo<T> build() {
TableDataInfo<T> rspData = new TableDataInfo<>();
rspData.setCode(HttpStatus.HTTP_OK);
rspData.setMsg("查询成功");
return rspData;
}
}

View File

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,54 @@
package cn.yinlihupo.common.util;
import cn.yinlihupo.common.base.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 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
* @return
*/
public static BaseResponse error(ErrorCode errorCode, String message) {
return new BaseResponse(errorCode.getCode(), null, message);
}
}

View File

@@ -0,0 +1,140 @@
package cn.yinlihupo.controller;
import cn.yinlihupo.common.result.Result;
import cn.yinlihupo.domain.dto.ProjectInitRequest;
import cn.yinlihupo.domain.dto.ProjectInitResult;
import cn.yinlihupo.service.ai.MinioFileService;
import cn.yinlihupo.service.ai.ProjectInitService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* AI项目初始化控制器
* 提供项目文档解析和结构化数据生成功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/project-init")
@RequiredArgsConstructor
public class ProjectInitController {
private final ProjectInitService projectInitService;
private final MinioFileService minioFileService;
/**
* 根据文本内容生成项目初始化数据
*
* @param request 包含项目资料内容的请求
* @return 项目初始化结构化数据
*/
@PostMapping("/from-content")
public Result<ProjectInitResult> generateFromContent(@RequestBody ProjectInitRequest request) {
log.info("收到项目初始化请求(文本内容)");
if (request.getContent() == null || request.getContent().trim().isEmpty()) {
return Result.error("项目资料内容不能为空");
}
try {
ProjectInitResult result = projectInitService.generateProjectFromContent(request.getContent());
return Result.success("项目初始化成功", result);
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
return Result.error("项目初始化失败: " + e.getMessage());
}
}
/**
* 上传文件并生成项目初始化数据
*
* @param file 项目资料文件
* @return 项目初始化结构化数据
*/
@PostMapping("/from-file")
public Result<ProjectInitResult> generateFromFile(@RequestParam("file") MultipartFile file) {
log.info("收到项目初始化请求(文件上传), 文件名: {}", file.getOriginalFilename());
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
}
try {
// 上传文件到MinIO
String fileUrl = minioFileService.uploadFile(file, file.getOriginalFilename());
log.info("文件上传成功URL: {}", fileUrl);
// 根据文件生成项目初始化数据
String fileType = getFileExtension(file.getOriginalFilename());
ProjectInitResult result = projectInitService.generateProjectFromFile(fileUrl, fileType);
return Result.success("项目初始化成功", result);
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
return Result.error("项目初始化失败: " + e.getMessage());
}
}
/**
* 根据已上传的文件URL生成项目初始化数据
*
* @param request 包含文件URL的请求
* @return 项目初始化结构化数据
*/
@PostMapping("/from-url")
public Result<ProjectInitResult> generateFromUrl(@RequestBody ProjectInitRequest request) {
log.info("收到项目初始化请求文件URL");
if (request.getFileUrl() == null || request.getFileUrl().trim().isEmpty()) {
return Result.error("文件URL不能为空");
}
try {
ProjectInitResult result = projectInitService.generateProjectFromFile(
request.getFileUrl(),
request.getFileType()
);
return Result.success("项目初始化成功", result);
} catch (Exception e) {
log.error("项目初始化失败: {}", e.getMessage(), e);
return Result.error("项目初始化失败: " + e.getMessage());
}
}
/**
* 仅上传文件到MinIO返回文件URL
*
* @param file 文件
* @return 文件URL
*/
@PostMapping("/upload")
public Result<String> uploadFile(@RequestParam("file") MultipartFile file) {
log.info("收到文件上传请求, 文件名: {}", file.getOriginalFilename());
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
}
try {
String fileUrl = minioFileService.uploadFile(file, file.getOriginalFilename());
return Result.success("文件上传成功", fileUrl);
} catch (Exception e) {
log.error("文件上传失败: {}", e.getMessage(), e);
return Result.error("文件上传失败: " + e.getMessage());
}
}
/**
* 获取文件扩展名
*
* @param fileName 文件名
* @return 文件扩展名
*/
private String getFileExtension(String fileName) {
if (fileName == null || fileName.lastIndexOf('.') == -1) {
return "";
}
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}
}

View File

@@ -0,0 +1,25 @@
package cn.yinlihupo.domain.dto;
import lombok.Data;
/**
* 项目初始化请求DTO
*/
@Data
public class ProjectInitRequest {
/**
* 项目资料文本内容(直接输入)
*/
private String content;
/**
* MinIO文件URL已上传的文件
*/
private String fileUrl;
/**
* 文件类型text, pdf, word等
*/
private String fileType;
}

View File

@@ -0,0 +1,222 @@
package cn.yinlihupo.domain.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* AI项目初始化结果DTO
* 对应系统提示词中定义的JSON输出格式
*/
@Data
public class ProjectInitResult {
/**
* 项目基本信息
*/
@JsonProperty("project")
private ProjectInfo project;
/**
* 里程碑列表
*/
@JsonProperty("milestones")
private List<MilestoneInfo> milestones;
/**
* 任务清单
*/
@JsonProperty("tasks")
private List<TaskInfo> tasks;
/**
* 项目成员
*/
@JsonProperty("members")
private List<MemberInfo> members;
/**
* 资源需求
*/
@JsonProperty("resources")
private List<ResourceInfo> resources;
/**
* 风险识别
*/
@JsonProperty("risks")
private List<RiskInfo> risks;
/**
* 时间节点
*/
@JsonProperty("timeline_nodes")
private List<TimelineNodeInfo> timelineNodes;
// ==================== 内部类定义 ====================
@Data
public static class ProjectInfo {
@JsonProperty("project_name")
private String projectName;
@JsonProperty("project_type")
private String projectType;
@JsonProperty("description")
private String description;
@JsonProperty("objectives")
private String objectives;
@JsonProperty("plan_start_date")
private LocalDate planStartDate;
@JsonProperty("plan_end_date")
private LocalDate planEndDate;
@JsonProperty("budget")
private BigDecimal budget;
@JsonProperty("currency")
private String currency;
@JsonProperty("priority")
private String priority;
@JsonProperty("tags")
private List<String> tags;
}
@Data
public static class MilestoneInfo {
@JsonProperty("milestone_name")
private String milestoneName;
@JsonProperty("description")
private String description;
@JsonProperty("plan_date")
private LocalDate planDate;
@JsonProperty("deliverables")
private String deliverables;
@JsonProperty("owner_role")
private String ownerRole;
}
@Data
public static class TaskInfo {
@JsonProperty("task_name")
private String taskName;
@JsonProperty("parent_task_id")
private String parentTaskId;
@JsonProperty("description")
private String description;
@JsonProperty("plan_start_date")
private LocalDate planStartDate;
@JsonProperty("plan_end_date")
private LocalDate planEndDate;
@JsonProperty("estimated_hours")
private Integer estimatedHours;
@JsonProperty("priority")
private String priority;
@JsonProperty("assignee_role")
private String assigneeRole;
@JsonProperty("dependencies")
private List<String> dependencies;
@JsonProperty("deliverables")
private String deliverables;
}
@Data
public static class MemberInfo {
@JsonProperty("name")
private String name;
@JsonProperty("role_code")
private String roleCode;
@JsonProperty("responsibility")
private String responsibility;
@JsonProperty("department")
private String department;
@JsonProperty("weekly_hours")
private Integer weeklyHours;
}
@Data
public static class ResourceInfo {
@JsonProperty("resource_name")
private String resourceName;
@JsonProperty("resource_type")
private String resourceType;
@JsonProperty("quantity")
private BigDecimal quantity;
@JsonProperty("unit")
private String unit;
@JsonProperty("unit_price")
private BigDecimal unitPrice;
@JsonProperty("supplier")
private String supplier;
}
@Data
public static class RiskInfo {
@JsonProperty("risk_name")
private String riskName;
@JsonProperty("category")
private String category;
@JsonProperty("description")
private String description;
@JsonProperty("probability")
private Integer probability;
@JsonProperty("impact")
private Integer impact;
@JsonProperty("mitigation_plan")
private String mitigationPlan;
}
@Data
public static class TimelineNodeInfo {
@JsonProperty("node_name")
private String nodeName;
@JsonProperty("node_type")
private String nodeType;
@JsonProperty("plan_date")
private LocalDate planDate;
@JsonProperty("description")
private String description;
@JsonProperty("kb_scope")
private List<String> kbScope;
}
}

View File

@@ -0,0 +1,71 @@
package cn.yinlihupo.service.ai;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
/**
* MinIO文件服务接口
* 用于文件上传、下载和管理
*/
public interface MinioFileService {
/**
* 上传文件到MinIO
*
* @param file 文件
* @param fileName 文件名
* @return 文件访问URL
*/
String uploadFile(MultipartFile file, String fileName);
/**
* 上传文件到MinIO指定存储桶
*
* @param file 文件
* @param fileName 文件名
* @param bucketName 存储桶名称
* @return 文件访问URL
*/
String uploadFile(MultipartFile file, String fileName, String bucketName);
/**
* 从URL读取文件内容为字符串
*
* @param fileUrl 文件URL
* @return 文件内容
*/
String readFileAsString(String fileUrl);
/**
* 从URL获取文件输入流
*
* @param fileUrl 文件URL
* @return 文件输入流
*/
InputStream getFileInputStream(String fileUrl);
/**
* 删除文件
*
* @param fileUrl 文件URL
*/
void deleteFile(String fileUrl);
/**
* 获取文件URL
*
* @param objectName 对象名称
* @return 文件URL
*/
String getFileUrl(String objectName);
/**
* 获取文件URL指定存储桶
*
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @return 文件URL
*/
String getFileUrl(String bucketName, String objectName);
}

View File

@@ -0,0 +1,27 @@
package cn.yinlihupo.service.ai;
import cn.yinlihupo.domain.dto.ProjectInitResult;
/**
* AI项目初始化服务接口
* 使用Spring AI结构化输出能力从项目文档中提取结构化信息
*/
public interface ProjectInitService {
/**
* 根据项目资料内容生成项目初始化结构化数据
*
* @param content 项目资料文本内容
* @return 项目初始化结果,包含项目信息、里程碑、任务、成员、资源、风险等
*/
ProjectInitResult generateProjectFromContent(String content);
/**
* 根据MinIO文件URL生成项目初始化结构化数据
*
* @param fileUrl MinIO文件URL
* @param fileType 文件类型
* @return 项目初始化结果
*/
ProjectInitResult generateProjectFromFile(String fileUrl, String fileType);
}

View File

@@ -0,0 +1,217 @@
package cn.yinlihupo.service.ai.impl;
import cn.yinlihupo.common.config.MinioConfig;
import cn.yinlihupo.service.ai.MinioFileService;
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* MinIO文件服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MinioFileServiceImpl implements MinioFileService {
private final MinioClient minioClient;
private final MinioConfig minioConfig;
@Override
public String uploadFile(MultipartFile file, String fileName) {
return uploadFile(file, fileName, minioConfig.getBucketName());
}
@Override
public String uploadFile(MultipartFile file, String fileName, String bucketName) {
try {
// 确保存储桶存在
ensureBucketExists(bucketName);
// 生成唯一的对象名称
String objectName = generateObjectName(fileName);
// 上传文件
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
log.info("文件上传成功: {}/{}", bucketName, objectName);
// 返回文件URL
return getFileUrl(bucketName, objectName);
} catch (Exception e) {
log.error("文件上传失败: {}", e.getMessage(), e);
throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
}
}
@Override
public String readFileAsString(String fileUrl) {
try (InputStream inputStream = getFileInputStream(fileUrl);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
return outputStream.toString(StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("读取文件内容失败: {}", e.getMessage(), e);
throw new RuntimeException("读取文件内容失败: " + e.getMessage(), e);
}
}
@Override
public InputStream getFileInputStream(String fileUrl) {
try {
// 解析URL获取bucket和object名称
URL url = new URL(fileUrl);
String path = url.getPath();
// 去掉开头的/
if (path.startsWith("/")) {
path = path.substring(1);
}
// 解析bucket和object
int slashIndex = path.indexOf('/');
String bucketName;
String objectName;
if (slashIndex > 0) {
bucketName = path.substring(0, slashIndex);
objectName = path.substring(slashIndex + 1);
} else {
bucketName = minioConfig.getBucketName();
objectName = path;
}
return minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
} catch (Exception e) {
log.error("获取文件输入流失败: {}", e.getMessage(), e);
throw new RuntimeException("获取文件输入流失败: " + e.getMessage(), e);
}
}
@Override
public void deleteFile(String fileUrl) {
try {
// 解析URL获取bucket和object名称
URL url = new URL(fileUrl);
String path = url.getPath();
if (path.startsWith("/")) {
path = path.substring(1);
}
int slashIndex = path.indexOf('/');
String bucketName;
String objectName;
if (slashIndex > 0) {
bucketName = path.substring(0, slashIndex);
objectName = path.substring(slashIndex + 1);
} else {
bucketName = minioConfig.getBucketName();
objectName = path;
}
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
log.info("文件删除成功: {}/{}", bucketName, objectName);
} catch (Exception e) {
log.error("删除文件失败: {}", e.getMessage(), e);
throw new RuntimeException("删除文件失败: " + e.getMessage(), e);
}
}
@Override
public String getFileUrl(String objectName) {
return getFileUrl(minioConfig.getBucketName(), objectName);
}
@Override
public String getFileUrl(String bucketName, String objectName) {
try {
// 构建文件URL
String endpoint = minioConfig.getEndpoint();
if (endpoint.endsWith("/")) {
endpoint = endpoint.substring(0, endpoint.length() - 1);
}
return endpoint + "/" + bucketName + "/" + objectName;
} catch (Exception e) {
log.error("获取文件URL失败: {}", e.getMessage(), e);
throw new RuntimeException("获取文件URL失败: " + e.getMessage(), e);
}
}
/**
* 确保存储桶存在
*
* @param bucketName 存储桶名称
*/
private void ensureBucketExists(String bucketName) throws Exception {
boolean exists = minioClient.bucketExists(
BucketExistsArgs.builder()
.bucket(bucketName)
.build()
);
if (!exists) {
minioClient.makeBucket(
MakeBucketArgs.builder()
.bucket(bucketName)
.build()
);
log.info("创建存储桶成功: {}", bucketName);
}
}
/**
* 生成对象名称
*
* @param originalFileName 原始文件名
* @return 对象名称
*/
private String generateObjectName(String originalFileName) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = String.valueOf(System.currentTimeMillis());
// 提取文件扩展名
String extension = "";
int dotIndex = originalFileName.lastIndexOf('.');
if (dotIndex > 0) {
extension = originalFileName.substring(dotIndex);
}
return timestamp + "_" + uuid + extension;
}
}

View File

@@ -0,0 +1,174 @@
package cn.yinlihupo.service.ai.impl;
import cn.yinlihupo.domain.dto.ProjectInitResult;
import cn.yinlihupo.service.ai.ProjectInitService;
import cn.yinlihupo.service.ai.MinioFileService;
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.core.io.Resource;
import org.springframework.stereotype.Service;
/**
* AI项目初始化服务实现类
* 使用Spring AI结构化输出能力解析项目文档
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProjectInitServiceImpl implements ProjectInitService {
private final ChatClient chatClient;
private final MinioFileService minioFileService;
/**
* 项目初始化系统提示词模板
*/
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("开始根据内容生成项目初始化数据");
PromptTemplate promptTemplate = new PromptTemplate(USER_PROMPT_TEMPLATE);
String userPrompt = promptTemplate.createMessage(java.util.Map.of("content", content)).getContent();
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);
// 从MinIO下载文件内容
String content = minioFileService.readFileAsString(fileUrl);
if (content == null || content.isEmpty()) {
throw new RuntimeException("无法读取文件内容: " + fileUrl);
}
return generateProjectFromContent(content);
}
}

View File

@@ -30,6 +30,16 @@ spring:
logic-delete-value: 1
logic-not-delete-value: 0
# Spring AI 配置
ai:
openai:
api-key: ${OPENAI_API_KEY:your-api-key}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${OPENAI_MODEL:gpt-4o}
temperature: 0.3
# MinIO 对象存储配置
minio:
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
@@ -37,7 +47,6 @@ minio:
secret-key: ${MINIO_SECRET_KEY:minioadmin}
bucket-name: ${MINIO_BUCKET_NAME:ylhp-files}
# 日志配置
logging:
level: