feat(core): 完成AI项目管理平台基础模块开发
- 新增Spring Boot配置文件,支持多环境切换与数据库配置 - 集成PostgreSQL数据库和MyBatis Plus实现数据访问层 - 配置Spring AI和MinIO对象存储服务,支持文件上传下载功能 - 自定义错误码枚举,提供统一错误处理标准 - 实现MinIO客户端自动配置及服务端上传、下载和删除文件功能 - 开发OSS控制器及服务接口,实现文件管理API及文件存储操作 - 开发AI项目初始化模块,支持通过文本和文件生成结构化项目数据 - 设计项目初始化结果DTO,定义项目、里程碑、任务、成员、资源、风险等数据结构 - 实现项目初始化服务,调用AI聊天模型解析项目文档生成结构化输出 - 添加分页查询工具类,支持动态排序和分页参数构建 - 项目构建配置完善,集成必要依赖,支持Spring Boot 3和Java 17环境 - 代码结构规范,增加模块包说明及统一响应结果封装体系
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package cn.yinlihupo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class YlhpAiProjectManagerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(YlhpAiProjectManagerApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
44
src/main/java/cn/yinlihupo/common/config/MinioConfig.java
Normal file
44
src/main/java/cn/yinlihupo/common/config/MinioConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
23
src/main/java/cn/yinlihupo/common/config/SpringAiConfig.java
Normal file
23
src/main/java/cn/yinlihupo/common/config/SpringAiConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 公共配置模块
|
||||
* 包含系统全局配置类
|
||||
*/
|
||||
package cn.yinlihupo.ylhpaiprojectmanager.common.config;
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 常量定义模块
|
||||
* 包含系统常量、枚举等
|
||||
*/
|
||||
package cn.yinlihupo.ylhpaiprojectmanager.common.constant;
|
||||
44
src/main/java/cn/yinlihupo/common/enums/ErrorCode.java
Normal file
44
src/main/java/cn/yinlihupo/common/enums/ErrorCode.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 异常处理模块
|
||||
* 包含自定义异常和全局异常处理
|
||||
*/
|
||||
package cn.yinlihupo.ylhpaiprojectmanager.common.exception;
|
||||
81
src/main/java/cn/yinlihupo/common/page/PageQuery.java
Normal file
81
src/main/java/cn/yinlihupo/common/page/PageQuery.java
Normal 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;
|
||||
}
|
||||
}
|
||||
76
src/main/java/cn/yinlihupo/common/page/TableDataInfo.java
Normal file
76
src/main/java/cn/yinlihupo/common/page/TableDataInfo.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
89
src/main/java/cn/yinlihupo/common/result/Result.java
Normal file
89
src/main/java/cn/yinlihupo/common/result/Result.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 统一响应模块
|
||||
* 包含统一响应结果封装
|
||||
*/
|
||||
package cn.yinlihupo.ylhpaiprojectmanager.common.result;
|
||||
5
src/main/java/cn/yinlihupo/common/util/package-info.java
Normal file
5
src/main/java/cn/yinlihupo/common/util/package-info.java
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 工具类模块
|
||||
* 包含各种工具类
|
||||
*/
|
||||
package cn.yinlihupo.ylhpaiprojectmanager.common.util;
|
||||
89
src/main/java/cn/yinlihupo/controller/oss/OssController.java
Normal file
89
src/main/java/cn/yinlihupo/controller/oss/OssController.java
Normal file
@@ -0,0 +1,89 @@
|
||||
package cn.yinlihupo.controller.oss;
|
||||
|
||||
import cn.yinlihupo.common.result.Result;
|
||||
import cn.yinlihupo.service.oss.OssService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* OSS文件控制器
|
||||
* 提供文件上传、下载和管理功能
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/oss")
|
||||
@RequiredArgsConstructor
|
||||
public class OssController {
|
||||
|
||||
private final OssService ossService;
|
||||
|
||||
/**
|
||||
* 上传文件到OSS,返回文件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 = ossService.uploadFile(file, file.getOriginalFilename());
|
||||
return Result.success("文件上传成功", fileUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("文件上传失败: {}", e.getMessage(), e);
|
||||
return Result.error("文件上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到指定存储桶
|
||||
*
|
||||
* @param file 文件
|
||||
* @param bucketName 存储桶名称
|
||||
* @return 文件URL
|
||||
*/
|
||||
@PostMapping("/upload/{bucketName}")
|
||||
public Result<String> uploadFileToBucket(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@PathVariable String bucketName) {
|
||||
log.info("收到文件上传请求, 文件名: {}, 存储桶: {}", file.getOriginalFilename(), bucketName);
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return Result.error("上传文件不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
String fileUrl = ossService.uploadFile(file, file.getOriginalFilename(), bucketName);
|
||||
return Result.success("文件上传成功", fileUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("文件上传失败: {}", e.getMessage(), e);
|
||||
return Result.error("文件上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param fileUrl 文件URL
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/delete")
|
||||
public Result<Void> deleteFile(@RequestParam("fileUrl") String fileUrl) {
|
||||
log.info("收到文件删除请求, fileUrl: {}", fileUrl);
|
||||
|
||||
try {
|
||||
ossService.deleteFile(fileUrl);
|
||||
return Result.success("文件删除成功", null);
|
||||
} catch (Exception e) {
|
||||
log.error("文件删除失败: {}", e.getMessage(), e);
|
||||
return Result.error("文件删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cn.yinlihupo.controller.project;
|
||||
|
||||
import cn.yinlihupo.common.result.Result;
|
||||
import cn.yinlihupo.domain.dto.ProjectInitRequest;
|
||||
import cn.yinlihupo.domain.dto.ProjectInitResult;
|
||||
import cn.yinlihupo.service.oss.OssService;
|
||||
import cn.yinlihupo.service.project.ProjectService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* AI项目初始化控制器
|
||||
* 提供项目文档解析和结构化数据生成功能
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/project-init")
|
||||
@RequiredArgsConstructor
|
||||
public class ProjectController {
|
||||
|
||||
private final ProjectService projectService;
|
||||
private final OssService ossService;
|
||||
|
||||
/**
|
||||
* 根据文本内容生成项目初始化数据
|
||||
*
|
||||
* @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 = projectService.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 {
|
||||
// 上传文件到OSS
|
||||
String fileUrl = ossService.uploadFile(file, file.getOriginalFilename());
|
||||
log.info("文件上传成功,URL: {}", fileUrl);
|
||||
|
||||
// 根据文件生成项目初始化数据
|
||||
String fileType = getFileExtension(file.getOriginalFilename());
|
||||
ProjectInitResult result = projectService.generateProjectFromFile(fileUrl, fileType);
|
||||
|
||||
return Result.success("项目初始化成功", result);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
222
src/main/java/cn/yinlihupo/domain/dto/ProjectInitResult.java
Normal file
222
src/main/java/cn/yinlihupo/domain/dto/ProjectInitResult.java
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/main/java/cn/yinlihupo/service/oss/OssService.java
Normal file
71
src/main/java/cn/yinlihupo/service/oss/OssService.java
Normal file
@@ -0,0 +1,71 @@
|
||||
package cn.yinlihupo.service.oss;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* OSS文件服务接口
|
||||
* 用于文件上传、下载和管理
|
||||
*/
|
||||
public interface OssService {
|
||||
|
||||
/**
|
||||
* 上传文件到OSS
|
||||
*
|
||||
* @param file 文件
|
||||
* @param fileName 文件名
|
||||
* @return 文件访问URL
|
||||
*/
|
||||
String uploadFile(MultipartFile file, String fileName);
|
||||
|
||||
/**
|
||||
* 上传文件到OSS(指定存储桶)
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
213
src/main/java/cn/yinlihupo/service/oss/impl/OssServiceImpl.java
Normal file
213
src/main/java/cn/yinlihupo/service/oss/impl/OssServiceImpl.java
Normal file
@@ -0,0 +1,213 @@
|
||||
package cn.yinlihupo.service.oss.impl;
|
||||
|
||||
import cn.yinlihupo.common.config.MinioConfig;
|
||||
import cn.yinlihupo.service.oss.OssService;
|
||||
import io.minio.*;
|
||||
import io.minio.errors.*;
|
||||
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.util.UUID;
|
||||
|
||||
/**
|
||||
* OSS文件服务实现类(基于MinIO)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OssServiceImpl implements OssService {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.yinlihupo.service.project;
|
||||
|
||||
import cn.yinlihupo.domain.dto.ProjectInitResult;
|
||||
|
||||
/**
|
||||
* AI项目初始化服务接口
|
||||
* 使用Spring AI结构化输出能力,从项目文档中提取结构化信息
|
||||
*/
|
||||
public interface ProjectService {
|
||||
|
||||
/**
|
||||
* 根据项目资料内容生成项目初始化结构化数据
|
||||
*
|
||||
* @param content 项目资料文本内容
|
||||
* @return 项目初始化结果,包含项目信息、里程碑、任务、成员、资源、风险等
|
||||
*/
|
||||
ProjectInitResult generateProjectFromContent(String content);
|
||||
|
||||
/**
|
||||
* 根据OSS文件URL生成项目初始化结构化数据
|
||||
*
|
||||
* @param fileUrl OSS文件URL
|
||||
* @param fileType 文件类型
|
||||
* @return 项目初始化结果
|
||||
*/
|
||||
ProjectInitResult generateProjectFromFile(String fileUrl, String fileType);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package cn.yinlihupo.service.project.impl;
|
||||
|
||||
import cn.yinlihupo.domain.dto.ProjectInitResult;
|
||||
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.stereotype.Service;
|
||||
|
||||
/**
|
||||
* AI项目初始化服务实现类
|
||||
* 使用Spring AI结构化输出能力解析项目文档
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final OssService ossService;
|
||||
|
||||
/**
|
||||
* 项目初始化系统提示词模板
|
||||
*/
|
||||
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)).toString();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user