feat(content): 添加从目录导入Excel功能并优化内容管理
- 新增从指定目录批量导入Excel数据到CmsContent的功能 - 添加ImportPathDto请求参数实体类 - 实现importFromPath方法支持目录遍历和文件批量导入 - 添加truncateTable方法用于清空表数据 - 优化Excel导入逻辑增加异常处理和空值检查 - 调整批量处理大小从100改为10 - 更新审核状态和发布状态的描述文案 - 修复分享次数和官方标识字段的默认值设置 - 将Servlet API从javax迁移到jakarta - 更新README.md完善项目文档 - 优化技能解析逻辑支持多层级目录结构 - 修复AI模型生成中的标签选择和参数验证问题
This commit is contained in:
parent
59a44f9c53
commit
3df611f809
193
README.md
193
README.md
|
|
@ -1,3 +1,192 @@
|
||||||
# agent-skill-backend
|
# 可学AI-skills平台后端
|
||||||
|
|
||||||
agent-skill-backend
|
## 项目简介
|
||||||
|
|
||||||
|
可学AI-skills平台是一个基于Spring Boot的智能技能管理系统,提供技能生成、内容管理、用户认证、支付等功能。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **基础框架**:Spring Boot 3.2.2
|
||||||
|
- **持久层**:MyBatis 3.0.3
|
||||||
|
- **数据库**:MySQL
|
||||||
|
- **缓存**:Redis
|
||||||
|
- **认证**:Sa-Token 1.38.0
|
||||||
|
- **模板引擎**:Thymeleaf
|
||||||
|
- **API文档**:Swagger 3.0.0
|
||||||
|
- **文件处理**:sevenzipjbinding 16.02-2.01
|
||||||
|
- **短信服务**:SMS4J 3.3.5
|
||||||
|
- **分布式锁**:Redisson 3.23.5
|
||||||
|
- **AI集成**:DeepSeek、GLM-4.6v
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── .mvn/ # Maven包装器
|
||||||
|
├── db/ # 数据库脚本
|
||||||
|
├── src/
|
||||||
|
│ ├── main/
|
||||||
|
│ │ ├── java/com/kexue/skills/ # 主源码
|
||||||
|
│ │ │ ├── annotation/ # 自定义注解
|
||||||
|
│ │ │ ├── aspect/ # AOP切面
|
||||||
|
│ │ │ ├── common/ # 通用工具和常量
|
||||||
|
│ │ │ ├── config/ # 配置类
|
||||||
|
│ │ │ ├── controller/ # 控制器
|
||||||
|
│ │ │ ├── entity/ # 实体类
|
||||||
|
│ │ │ ├── exception/ # 异常处理
|
||||||
|
│ │ │ ├── interceptor/ # 拦截器
|
||||||
|
│ │ │ ├── mapper/ # 数据访问层
|
||||||
|
│ │ │ ├── service/ # 服务层
|
||||||
|
│ │ │ ├── task/ # 定时任务
|
||||||
|
│ │ │ ├── utils/ # 工具类
|
||||||
|
│ │ │ └── SkillsApp.java # 应用入口
|
||||||
|
│ │ └── resources/ # 资源文件
|
||||||
|
│ │ ├── mapper/ # MyBatis映射文件
|
||||||
|
│ │ ├── sql/ # SQL脚本
|
||||||
|
│ │ ├── static/ # 静态资源
|
||||||
|
│ │ ├── templates/ # Thymeleaf模板
|
||||||
|
│ │ ├── application-*.yml # 配置文件
|
||||||
|
│ │ └── logback-spring.xml # 日志配置
|
||||||
|
│ └── test/ # 测试代码
|
||||||
|
├── .gitignore # Git忽略文件
|
||||||
|
├── Dockerfile # Docker构建文件
|
||||||
|
├── README.md # 项目说明
|
||||||
|
├── mvnw.cmd # Maven包装器脚本
|
||||||
|
└── pom.xml # Maven依赖配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 1. 用户认证与授权
|
||||||
|
- 基于Sa-Token的认证系统
|
||||||
|
- 支持账号密码登录
|
||||||
|
- 支持手机验证码登录
|
||||||
|
- 角色权限管理
|
||||||
|
- 防重复提交
|
||||||
|
|
||||||
|
### 2. 内容管理系统
|
||||||
|
- 内容分类管理
|
||||||
|
- 内容标签管理
|
||||||
|
- 内容发布与管理
|
||||||
|
- 内容点赞与浏览统计
|
||||||
|
|
||||||
|
### 3. 技能生成系统
|
||||||
|
- 技能上传与解析(支持RAR等压缩格式)
|
||||||
|
- 技能结构分析
|
||||||
|
- 技能介绍生成
|
||||||
|
|
||||||
|
### 4. 支付系统
|
||||||
|
- 微信支付集成
|
||||||
|
- 支付宝集成
|
||||||
|
- 支付订单管理
|
||||||
|
|
||||||
|
### 5. 账户管理
|
||||||
|
- 账户余额管理
|
||||||
|
- 积分管理
|
||||||
|
- 交易记录
|
||||||
|
|
||||||
|
### 6. 系统管理
|
||||||
|
- 菜单管理
|
||||||
|
- 角色管理
|
||||||
|
- 字典管理
|
||||||
|
- 系统日志
|
||||||
|
|
||||||
|
### 7. AI集成
|
||||||
|
- DeepSeek模型集成
|
||||||
|
- GLM-4.6v模型集成
|
||||||
|
- 智能内容生成
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- JDK 17+
|
||||||
|
- Maven 3.6+
|
||||||
|
- MySQL 5.7+
|
||||||
|
- Redis 5.0+
|
||||||
|
|
||||||
|
### 配置说明
|
||||||
|
1. 修改 `application-dev.yml` 文件中的数据库连接信息
|
||||||
|
2. 修改 `application.yml` 文件中的Redis连接信息
|
||||||
|
3. 修改 `application.yml` 文件中的AI模型API密钥
|
||||||
|
4. 修改 `application.yml` 文件中的短信服务配置
|
||||||
|
|
||||||
|
### 数据库初始化
|
||||||
|
1. 执行 `db/create_tables.sql` 创建数据库表
|
||||||
|
2. 执行 `db/init_data.sql` 初始化基础数据
|
||||||
|
|
||||||
|
### 启动项目
|
||||||
|
```bash
|
||||||
|
# 编译项目
|
||||||
|
mvn clean compile
|
||||||
|
|
||||||
|
# 运行项目
|
||||||
|
mvn spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问地址
|
||||||
|
- 项目首页:http://localhost:8080
|
||||||
|
- Swagger文档:http://localhost:8080/doc.html
|
||||||
|
|
||||||
|
## 主要API
|
||||||
|
|
||||||
|
### 用户认证
|
||||||
|
- `POST /api/login` - 用户登录
|
||||||
|
- `POST /api/logout` - 用户登出
|
||||||
|
- `GET /api/currentUser` - 获取当前用户信息
|
||||||
|
|
||||||
|
### 内容管理
|
||||||
|
- `GET /api/cms/content/list` - 获取内容列表
|
||||||
|
- `POST /api/cms/content/save` - 保存内容
|
||||||
|
- `DELETE /api/cms/content/delete` - 删除内容
|
||||||
|
|
||||||
|
### 技能管理
|
||||||
|
- `POST /api/skill/upload` - 上传技能
|
||||||
|
- `POST /api/skill/analyze` - 分析技能结构
|
||||||
|
- `POST /api/skill/genIntroduce` - 生成技能介绍
|
||||||
|
|
||||||
|
### 支付管理
|
||||||
|
- `POST /api/pay/wx` - 微信支付
|
||||||
|
- `POST /api/pay/alipay` - 支付宝支付
|
||||||
|
- `GET /api/payment/order/list` - 获取支付订单列表
|
||||||
|
|
||||||
|
## 部署说明
|
||||||
|
|
||||||
|
### Docker部署
|
||||||
|
1. 构建Docker镜像
|
||||||
|
```bash
|
||||||
|
docker build -t agent-skills .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 运行Docker容器
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:8080 --name agent-skills agent-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产环境部署
|
||||||
|
1. 打包项目
|
||||||
|
```bash
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
配置文件直接打在jar包内
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 部署jar包
|
||||||
|
```bash
|
||||||
|
java -jar agentSkills.jar --spring.profiles.active=prod
|
||||||
|
或者执行脚本启动
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 项目使用Redis作为缓存,需要确保Redis服务正常运行
|
||||||
|
2. 项目使用阿里云短信服务,需要配置相关参数
|
||||||
|
3. 项目使用AI模型API,需要配置相关API密钥
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目仅供内部使用,未经授权不得用于商业用途。
|
||||||
|
|
||||||
|
## 联系方式
|
||||||
|
|
||||||
|
如有问题,请联系项目维护人员。
|
||||||
|
|
|
||||||
8
pom.xml
8
pom.xml
|
|
@ -146,11 +146,11 @@
|
||||||
<version>1.3.2</version>
|
<version>1.3.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Servlet API for javax.servlet.http -->
|
<!-- Servlet API for jakarta.servlet.http (Spring Boot 3.x uses Jakarta EE 9+) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>javax.servlet</groupId>
|
<groupId>jakarta.servlet</groupId>
|
||||||
<artifactId>javax.servlet-api</artifactId>
|
<artifactId>jakarta.servlet-api</artifactId>
|
||||||
<version>4.0.1</version>
|
<version>6.0.0</version>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,92 +84,6 @@ public class SkillZipParser {
|
||||||
return generateYaml(skillStructure);
|
return generateYaml(skillStructure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析skill zip包并生成yaml描述
|
|
||||||
* @param zipFilePath zip文件路径
|
|
||||||
* @param author 作者
|
|
||||||
* @param defaultSkillName 默认技能名称
|
|
||||||
* @return 包含技能信息和yaml描述的Map
|
|
||||||
* @throws IOException 解析过程中的IO异常
|
|
||||||
* @throws SevenZipException 解析RAR文件时的异常
|
|
||||||
*/
|
|
||||||
public static Map<String, Object> parseSkillZip(String zipFilePath, String author, String defaultSkillName) throws IOException, SevenZipException {
|
|
||||||
// 检查文件扩展名
|
|
||||||
String fileExtension = "";
|
|
||||||
if (zipFilePath.contains(".")) {
|
|
||||||
int dotIndex = zipFilePath.lastIndexOf(".");
|
|
||||||
fileExtension = zipFilePath.substring(dotIndex).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据文件类型选择不同的解析方法
|
|
||||||
if (".rar".equals(fileExtension)) {
|
|
||||||
return parseRarFile(zipFilePath, author, defaultSkillName);
|
|
||||||
} else {
|
|
||||||
// 默认为zip文件
|
|
||||||
try (ZipFile zipFile = new ZipFile(zipFilePath, StandardCharsets.UTF_8)) {
|
|
||||||
// 从压缩文件中提取技能信息
|
|
||||||
Map<String, Object> skillInfo = extractSkillInfo(zipFile, defaultSkillName);
|
|
||||||
String skillName = (String) skillInfo.get("name");
|
|
||||||
String skillDescription = (String) skillInfo.get("description");
|
|
||||||
List<String> skillTags = (List<String>) skillInfo.get("tags");
|
|
||||||
|
|
||||||
// 生成技能包结构
|
|
||||||
Map<String, Object> skillStructure = generateSkillStructure(zipFile, skillName, skillDescription, skillTags, author);
|
|
||||||
|
|
||||||
// 生成yaml
|
|
||||||
String yamlContent = generateYaml(skillStructure);
|
|
||||||
|
|
||||||
// 构建返回结果
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("name", skillName);
|
|
||||||
result.put("description", skillDescription);
|
|
||||||
result.put("tags", skillTags);
|
|
||||||
result.put("yamlContent", yamlContent);
|
|
||||||
// 包含md文件的完整内容
|
|
||||||
if (skillInfo.containsKey("skillMdText")) {
|
|
||||||
result.put("skillMdText", skillInfo.get("skillMdText"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析rar文件
|
|
||||||
* @param rarFilePath rar文件路径
|
|
||||||
* @param author 作者
|
|
||||||
* @param defaultSkillName 默认技能名称
|
|
||||||
* @return 技能信息
|
|
||||||
* @throws IOException 解析过程中的IO异常
|
|
||||||
* @throws SevenZipException 解析RAR文件时的异常
|
|
||||||
*/
|
|
||||||
private static Map<String, Object> parseRarFile(String rarFilePath, String author, String defaultSkillName) throws IOException, SevenZipException {
|
|
||||||
// 从rar文件中提取技能信息
|
|
||||||
Map<String, Object> skillInfo = extractSkillInfoFromRar(rarFilePath, defaultSkillName);
|
|
||||||
String skillName = (String) skillInfo.get("name");
|
|
||||||
String skillDescription = (String) skillInfo.get("description");
|
|
||||||
List<String> skillTags = (List<String>) skillInfo.get("tags");
|
|
||||||
|
|
||||||
// 生成技能包结构
|
|
||||||
Map<String, Object> skillStructure = generateSkillStructureFromRar(rarFilePath, skillName, skillDescription, skillTags, author);
|
|
||||||
|
|
||||||
// 生成yaml
|
|
||||||
String yamlContent = generateYaml(skillStructure);
|
|
||||||
|
|
||||||
// 构建返回结果
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("name", skillName);
|
|
||||||
result.put("description", skillDescription);
|
|
||||||
result.put("tags", skillTags);
|
|
||||||
result.put("yamlContent", yamlContent);
|
|
||||||
// 包含md文件的完整内容
|
|
||||||
if (skillInfo.containsKey("skillMdText")) {
|
|
||||||
result.put("skillMdText", skillInfo.get("skillMdText"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从rar文件中提取技能信息
|
* 从rar文件中提取技能信息
|
||||||
|
|
@ -197,7 +111,7 @@ public class SkillZipParser {
|
||||||
// 使用简单接口
|
// 使用简单接口
|
||||||
ISimpleInArchive simpleInArchive = archive.getSimpleInterface();
|
ISimpleInArchive simpleInArchive = archive.getSimpleInterface();
|
||||||
|
|
||||||
// 遍历所有文件条目
|
// 首先尝试在根目录查找md文件
|
||||||
for (ISimpleInArchiveItem item : simpleInArchive.getArchiveItems()) {
|
for (ISimpleInArchiveItem item : simpleInArchive.getArchiveItems()) {
|
||||||
// 检查是否是根目录下的md文件
|
// 检查是否是根目录下的md文件
|
||||||
String path = item.getPath();
|
String path = item.getPath();
|
||||||
|
|
@ -224,6 +138,55 @@ public class SkillZipParser {
|
||||||
break; // 找到一个md文件后就停止
|
break; // 找到一个md文件后就停止
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果根目录没有找到md文件,检查根目录下的文件夹
|
||||||
|
if (!foundMdFile) {
|
||||||
|
// 收集根目录下的文件夹
|
||||||
|
List<String> rootFolders = new ArrayList<>();
|
||||||
|
for (ISimpleInArchiveItem item : simpleInArchive.getArchiveItems()) {
|
||||||
|
if (item.isFolder()) {
|
||||||
|
String path = item.getPath();
|
||||||
|
// 确保是根目录下的文件夹(路径中不包含/)
|
||||||
|
if (!path.contains("/") && !path.contains("\\")) {
|
||||||
|
rootFolders.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每个根目录文件夹下的md文件
|
||||||
|
for (String folder : rootFolders) {
|
||||||
|
for (ISimpleInArchiveItem item : simpleInArchive.getArchiveItems()) {
|
||||||
|
if (!item.isFolder()) {
|
||||||
|
String path = item.getPath();
|
||||||
|
// 检查是否是该文件夹下的md文件
|
||||||
|
if (path.endsWith(".md") && (path.equals(folder + "/skill.md") || path.equals(folder + "/SKILL.md") ||
|
||||||
|
path.equals(folder + "/readme.md") || path.equals(folder + "/README.md"))) {
|
||||||
|
// 读取文件内容
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
item.extractSlow(data -> {
|
||||||
|
try {
|
||||||
|
outputStream.write(data);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return data.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
String content = outputStream.toString(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 存储md文件的完整内容
|
||||||
|
skillInfo.put("skillMdText", content);
|
||||||
|
|
||||||
|
// 解析md内容
|
||||||
|
parseSkillsMd(content, skillInfo);
|
||||||
|
foundMdFile = true;
|
||||||
|
break; // 找到一个md文件后就停止
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundMdFile) break; // 找到一个md文件后就停止
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有找到 md 文件,使用默认值
|
// 如果没有找到 md 文件,使用默认值
|
||||||
|
|
@ -277,16 +240,58 @@ public class SkillZipParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果根目录没有找到md文件,检查根目录下的文件夹
|
||||||
|
if (!foundMdFile) {
|
||||||
|
// 收集根目录下的文件夹
|
||||||
|
List<String> rootFolders = new ArrayList<>();
|
||||||
|
entries = zipFile.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
ZipEntry entry = entries.nextElement();
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
String path = entry.getName();
|
||||||
|
// 确保是根目录下的文件夹(路径中不包含/,或者以/结尾且前面没有/)
|
||||||
|
if (path.endsWith("/") && !path.substring(0, path.length() - 1).contains("/")) {
|
||||||
|
rootFolders.add(path.substring(0, path.length() - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每个根目录文件夹下的md文件
|
||||||
|
for (String folder : rootFolders) {
|
||||||
|
entries = zipFile.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
ZipEntry entry = entries.nextElement();
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
String path = entry.getName();
|
||||||
|
// 检查是否是该文件夹下的md文件
|
||||||
|
if (path.endsWith(".md") && (path.equals(folder + "/skill.md") || path.equals(folder + "/SKILL.md") ||
|
||||||
|
path.equals(folder + "/readme.md") || path.equals(folder + "/README.md"))) {
|
||||||
|
try (InputStream inputStream = zipFile.getInputStream(entry);
|
||||||
|
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
content.append(line).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储md文件的完整内容
|
||||||
|
skillInfo.put("skillMdText", content.toString());
|
||||||
|
|
||||||
|
// 解析md内容
|
||||||
|
parseSkillsMd(content.toString(), skillInfo);
|
||||||
|
foundMdFile = true;
|
||||||
|
break; // 找到一个md文件后就停止
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundMdFile) break; // 找到一个md文件后就停止
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有找到 md 文件,使用默认值
|
// 如果没有找到 md 文件,使用默认值
|
||||||
if (!foundMdFile) {
|
if (!foundMdFile) {
|
||||||
skillInfo.put("name", defaultSkillName);
|
skillInfo.put("name", defaultSkillName);
|
||||||
skillInfo.put("description", "Skill uploaded ");
|
|
||||||
skillInfo.put("tags", Arrays.asList("10001", "10002"));
|
|
||||||
} else {
|
|
||||||
// 确保 tags 不为 null
|
|
||||||
if (!skillInfo.containsKey("tags")) {
|
|
||||||
skillInfo.put("tags", Arrays.asList("10001"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillInfo;
|
return skillInfo;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import com.kexue.skills.entity.CmsContent;
|
||||||
import com.kexue.skills.entity.base.IdDto;
|
import com.kexue.skills.entity.base.IdDto;
|
||||||
import com.kexue.skills.entity.dto.CmsContentDto;
|
import com.kexue.skills.entity.dto.CmsContentDto;
|
||||||
import com.kexue.skills.entity.dto.QueryContentDto;
|
import com.kexue.skills.entity.dto.QueryContentDto;
|
||||||
|
import com.kexue.skills.entity.request.ImportPathDto;
|
||||||
import com.kexue.skills.service.CmsContentService;
|
import com.kexue.skills.service.CmsContentService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
@ -312,4 +313,24 @@ public class CmsContentController {
|
||||||
String title = cmsContentService.getTitle(contentId);
|
String title = cmsContentService.getTitle(contentId);
|
||||||
return CommonResult.success(title);
|
return CommonResult.success(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定目录导入Excel数据到CmsContent
|
||||||
|
*
|
||||||
|
* @param importPathDto 导入路径请求参数
|
||||||
|
* @param createBy 创建人
|
||||||
|
* @return 导入结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/importFromPath")
|
||||||
|
@Operation(summary = "从目录导入Excel数据", description = "从指定目录导入Excel数据到CmsContent")
|
||||||
|
@RequireAuth
|
||||||
|
public CommonResult<Integer> importFromPath(@RequestBody ImportPathDto importPathDto, @RequestParam("createBy") String createBy) {
|
||||||
|
try {
|
||||||
|
int successCount = cmsContentService.importFromPath(importPathDto, createBy);
|
||||||
|
return CommonResult.success(successCount);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return CommonResult.failed("导入失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,8 +10,8 @@ import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -42,18 +42,6 @@ public class SkillGenController {
|
||||||
return CommonResult.success(skillGenService.preGenerateV2(request));
|
return CommonResult.success(skillGenService.preGenerateV2(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成技能V2
|
|
||||||
*
|
|
||||||
* @param request 生成请求
|
|
||||||
* @return 生成结果
|
|
||||||
*/
|
|
||||||
@PostMapping("/preGenerateV2")
|
|
||||||
@Operation(summary = "预生成技能V2", description = "使用新模型生成技能")
|
|
||||||
public CommonResult<SkillResponse> preGenerateV2(@RequestBody SkillPreGenRequest request) {
|
|
||||||
return CommonResult.success(skillGenService.preGenerateV2(request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/generate")
|
@PostMapping("/generate")
|
||||||
@Operation(summary = "生成技能", description = "生成技能")
|
@Operation(summary = "生成技能", description = "生成技能")
|
||||||
public CommonResult<CmsContent> generate(@RequestBody SkillGenRequest request) {
|
public CommonResult<CmsContent> generate(@RequestBody SkillGenRequest request) {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ package com.kexue.skills.controller;
|
||||||
import com.kexue.skills.annotation.RequireAuth;
|
import com.kexue.skills.annotation.RequireAuth;
|
||||||
import com.kexue.skills.entity.SysUser;
|
import com.kexue.skills.entity.SysUser;
|
||||||
import com.kexue.skills.entity.dto.SysUserDto;
|
import com.kexue.skills.entity.dto.SysUserDto;
|
||||||
import com.kexue.skills.entity.request.ResetPasswordDto;
|
import com.kexue.skills.entity.request.*;
|
||||||
import com.kexue.skills.entity.request.ResetPwdDto;
|
|
||||||
import com.kexue.skills.entity.request.AdminResetPasswordDto;
|
|
||||||
import com.kexue.skills.exception.BizException;
|
import com.kexue.skills.exception.BizException;
|
||||||
import com.kexue.skills.service.SysUserService;
|
import com.kexue.skills.service.SysUserService;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
@ -17,7 +15,6 @@ import com.kexue.skills.common.CacheManager;
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
import com.kexue.skills.common.CommonResult;
|
import com.kexue.skills.common.CommonResult;
|
||||||
import com.kexue.skills.entity.base.IdDto;
|
import com.kexue.skills.entity.base.IdDto;
|
||||||
import com.kexue.skills.entity.request.LoginUserDto;
|
|
||||||
import org.redisson.api.RedissonClient;
|
import org.redisson.api.RedissonClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,7 +83,7 @@ public class SysUserController {
|
||||||
*/
|
*/
|
||||||
@PostMapping("/update")
|
@PostMapping("/update")
|
||||||
@Operation(summary = "更新用户", description = "更新用户")
|
@Operation(summary = "更新用户", description = "更新用户")
|
||||||
public CommonResult<SysUser> update(@RequestBody SysUser SysUser) {
|
public CommonResult<SysUser> update(@RequestBody SysUserUpdateDto SysUser) {
|
||||||
return CommonResult.success(sysUserService.update(SysUser));
|
return CommonResult.success(sysUserService.update(SysUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,14 +97,14 @@ public class CmsContent extends BaseEntity implements Serializable {
|
||||||
@Schema(description ="审核人名称")
|
@Schema(description ="审核人名称")
|
||||||
private String reviewerName;
|
private String reviewerName;
|
||||||
|
|
||||||
@Schema(description ="审核状态(1草稿,2待审核,3审核通过,4审核拒绝)")
|
@Schema(description ="审核状态(1未发布,2待审核,3审核通过,4审核未通过)")
|
||||||
private Integer auditStatus;
|
private Integer auditStatus;
|
||||||
|
|
||||||
@Schema(description ="审核意见")
|
@Schema(description ="审核意见")
|
||||||
private String auditComment;
|
private String auditComment;
|
||||||
|
|
||||||
@Schema(description ="发布状态(1未发布,2已发布,3已下架)")
|
@Schema(description ="发布状态(1未发布,2已发布,3已下架)--> 公有还是私有:1私有,2公有")
|
||||||
private Integer publishStatus;
|
private Integer publishStatus;
|
||||||
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
@Schema(description ="发布时间")
|
@Schema(description ="发布时间")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.kexue.skills.entity.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入路径请求参数
|
||||||
|
*
|
||||||
|
* @author 王志维
|
||||||
|
* @since 2025-02-21 23:01:48
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "ImportPathDto", description = "导入路径请求参数")
|
||||||
|
public class ImportPathDto implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "是否追加,true表示追加,false表示清空表")
|
||||||
|
private boolean append;
|
||||||
|
|
||||||
|
@Schema(description = "文件目录")
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.kexue.skills.entity.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户更新请求参数
|
||||||
|
*
|
||||||
|
* @author 王志维
|
||||||
|
* @since 2025-02-21 23:01:48
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "SysUserUpdateDto", description = "用户更新请求参数")
|
||||||
|
public class SysUserUpdateDto implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "用户ID")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "用户名")
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
@Schema(description = "密码")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "手机号")
|
||||||
|
private String tel;
|
||||||
|
|
||||||
|
@Schema(description = "状态(1-正常,0-禁用)")
|
||||||
|
private Integer enable;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -225,4 +225,11 @@ public interface CmsContentMapper {
|
||||||
* @return 总记录数
|
* @return 总记录数
|
||||||
*/
|
*/
|
||||||
int getPageListByUserCreatedCount(@Param("userId") Long userId, @Param("publishStatus") Integer publishStatus);
|
int getPageListByUserCreatedCount(@Param("userId") Long userId, @Param("publishStatus") Integer publishStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空表数据
|
||||||
|
*
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int truncateTable();
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.kexue.skills.service;
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
import com.kexue.skills.entity.CmsContent;
|
import com.kexue.skills.entity.CmsContent;
|
||||||
import com.kexue.skills.entity.dto.CmsContentDto;
|
import com.kexue.skills.entity.dto.CmsContentDto;
|
||||||
|
import com.kexue.skills.entity.request.ImportPathDto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
@ -191,4 +192,13 @@ public interface CmsContentService extends BaseService {
|
||||||
* @return title字段的内容
|
* @return title字段的内容
|
||||||
*/
|
*/
|
||||||
String getTitle(Long contentId);
|
String getTitle(Long contentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定目录导入Excel数据到CmsContent
|
||||||
|
*
|
||||||
|
* @param importPathDto 导入路径请求参数
|
||||||
|
* @param createBy 创建人
|
||||||
|
* @return 导入结果
|
||||||
|
*/
|
||||||
|
int importFromPath(ImportPathDto importPathDto, String createBy);
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package com.kexue.skills.service;
|
package com.kexue.skills.service;
|
||||||
|
|
||||||
import com.kexue.skills.entity.PaymentOrder;
|
import com.kexue.skills.entity.PaymentOrder;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.kexue.skills.entity.request.LoginDto;
|
||||||
import com.kexue.skills.entity.request.LoginUserDto;
|
import com.kexue.skills.entity.request.LoginUserDto;
|
||||||
import com.kexue.skills.entity.request.PhoneLoginDto;
|
import com.kexue.skills.entity.request.PhoneLoginDto;
|
||||||
import com.kexue.skills.entity.request.ResetPasswordDto;
|
import com.kexue.skills.entity.request.ResetPasswordDto;
|
||||||
|
import com.kexue.skills.entity.request.SysUserUpdateDto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
@ -59,6 +60,14 @@ public interface SysUserService extends BaseService {
|
||||||
*/
|
*/
|
||||||
SysUser update(SysUser sysUser);
|
SysUser update(SysUser sysUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户数据
|
||||||
|
*
|
||||||
|
* @param sysUserUpdateDto 用户更新请求参数
|
||||||
|
* @return 实例对象
|
||||||
|
*/
|
||||||
|
SysUser update(SysUserUpdateDto sysUserUpdateDto);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过主键删除数据
|
* 通过主键删除数据
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import com.kexue.skills.entity.CmsContentView;
|
||||||
import com.kexue.skills.entity.CmsContentLike;
|
import com.kexue.skills.entity.CmsContentLike;
|
||||||
import com.kexue.skills.entity.base.BaseQueryDto;
|
import com.kexue.skills.entity.base.BaseQueryDto;
|
||||||
import com.kexue.skills.entity.dto.CmsContentDto;
|
import com.kexue.skills.entity.dto.CmsContentDto;
|
||||||
|
import com.kexue.skills.entity.request.ImportPathDto;
|
||||||
import com.kexue.skills.mapper.CmsContentMapper;
|
import com.kexue.skills.mapper.CmsContentMapper;
|
||||||
import com.kexue.skills.mapper.CmsContentViewMapper;
|
import com.kexue.skills.mapper.CmsContentViewMapper;
|
||||||
import com.kexue.skills.mapper.CmsContentLikeMapper;
|
import com.kexue.skills.mapper.CmsContentLikeMapper;
|
||||||
|
|
@ -21,6 +22,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
@ -311,9 +314,16 @@ public class CmsContentServiceImpl implements CmsContentService {
|
||||||
if (cmsContent.getCommentCount() == null) {
|
if (cmsContent.getCommentCount() == null) {
|
||||||
cmsContent.setCommentCount(0);
|
cmsContent.setCommentCount(0);
|
||||||
}
|
}
|
||||||
|
if (cmsContent.getShareCount() == null) {
|
||||||
|
cmsContent.setShareCount(0);
|
||||||
|
}
|
||||||
if (cmsContent.getSort() == null) {
|
if (cmsContent.getSort() == null) {
|
||||||
cmsContent.setSort(0);
|
cmsContent.setSort(0);
|
||||||
}
|
}
|
||||||
|
if (cmsContent.getIsOfficial() == null) {
|
||||||
|
cmsContent.setIsOfficial(false);
|
||||||
|
}
|
||||||
|
cmsContent.setAuthorId(StpUtil.getLoginIdAsLong());
|
||||||
// 保存数据
|
// 保存数据
|
||||||
this.cmsContentMapper.insert(cmsContent);
|
this.cmsContentMapper.insert(cmsContent);
|
||||||
return cmsContent;
|
return cmsContent;
|
||||||
|
|
@ -729,7 +739,7 @@ public class CmsContentServiceImpl implements CmsContentService {
|
||||||
@Override
|
@Override
|
||||||
public int importFromExcel(byte[] fileBytes, String createBy) {
|
public int importFromExcel(byte[] fileBytes, String createBy) {
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
final int BATCH_SIZE = 100; // 批量处理大小
|
final int BATCH_SIZE = 10; // 批量处理大小
|
||||||
|
|
||||||
try (InputStream inputStream = new ByteArrayInputStream(fileBytes);
|
try (InputStream inputStream = new ByteArrayInputStream(fileBytes);
|
||||||
ExcelReader reader = ExcelUtil.getReader(inputStream)) {
|
ExcelReader reader = ExcelUtil.getReader(inputStream)) {
|
||||||
|
|
@ -757,76 +767,91 @@ public class CmsContentServiceImpl implements CmsContentService {
|
||||||
|
|
||||||
// 从第二行开始读取数据(第一行为标题行)
|
// 从第二行开始读取数据(第一行为标题行)
|
||||||
for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) {
|
for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) {
|
||||||
List<Object> rowList = reader.readRow(rowIndex);
|
try {
|
||||||
if (rowList == null || rowList.isEmpty()) {
|
List<Object> rowList = reader.readRow(rowIndex);
|
||||||
continue;
|
if (rowList == null || rowList.isEmpty()) {
|
||||||
}
|
continue;
|
||||||
|
|
||||||
// 转换为Map<String, Object>
|
|
||||||
Map<String, Object> row = new HashMap<>();
|
|
||||||
for (int i = 0; i < headerList.size() && i < rowList.size(); i++) {
|
|
||||||
row.put(headerList.get(i), rowList.get(i));
|
|
||||||
}
|
|
||||||
if (row.isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
CmsContent cmsContent = new CmsContent();
|
|
||||||
|
|
||||||
// 设置创建时间和更新时间
|
|
||||||
cmsContent.setCreateTime(now);
|
|
||||||
cmsContent.setUpdateTime(now);
|
|
||||||
cmsContent.setCreateBy(createBy);
|
|
||||||
cmsContent.setUpdateBy(createBy);
|
|
||||||
|
|
||||||
// 设置默认值
|
|
||||||
cmsContent.setDeleteFlag(0);
|
|
||||||
cmsContent.setAuditStatus(1); // 默认草稿状态
|
|
||||||
cmsContent.setPublishStatus(1); // 默认未发布状态
|
|
||||||
cmsContent.setViewCount(0);
|
|
||||||
cmsContent.setLikeCount(0);
|
|
||||||
cmsContent.setCommentCount(0);
|
|
||||||
cmsContent.setSort(0);
|
|
||||||
|
|
||||||
// 读取Excel中的字段
|
|
||||||
// content_id - 数据库自动生成,不需要读取
|
|
||||||
cmsContent.setTitle(getStringValue(row, "title"));
|
|
||||||
cmsContent.setTitleEn(getStringValue(row, "title_en"));
|
|
||||||
cmsContent.setOrigin(getStringValue(row, "origin"));
|
|
||||||
cmsContent.setTags(Objects.requireNonNull(getStringValue(row, "tags")).replaceAll(" ",""));
|
|
||||||
cmsContent.setIcon(getStringValue(row, "icon"));
|
|
||||||
cmsContent.setIsOfficial(getBooleanValue(row, "is_official"));
|
|
||||||
cmsContent.setPrice(getBigDecimalValue(row, "price"));
|
|
||||||
cmsContent.setLikeCount(getIntegerValue(row, "like_count"));
|
|
||||||
cmsContent.setShareCount(getIntegerValue(row, "share_count"));
|
|
||||||
cmsContent.setContentType(getIntegerValue(row, "content_type"));
|
|
||||||
cmsContent.setContent(getStringValue(row, "content"));
|
|
||||||
cmsContent.setContentEn(getStringValue(row, "content_en"));
|
|
||||||
cmsContent.setAuditStatus(getIntegerValue(row, "audit_status"));
|
|
||||||
cmsContent.setPublishStatus(getIntegerValue(row, "publish_status"));
|
|
||||||
cmsContent.setPublishTime(getDateValue(row, "publish_time"));
|
|
||||||
cmsContent.setViewCount(getIntegerValue(row, "view_count"));
|
|
||||||
cmsContent.setCommentCount(getIntegerValue(row, "comment_count"));
|
|
||||||
cmsContent.setIsPaid(getIntegerValue(row, "is_paid"));
|
|
||||||
cmsContent.setSupportPointsPay(getIntegerValue(row, "support_points_pay"));
|
|
||||||
cmsContent.setDescription(getStringValue(row, "description"));
|
|
||||||
cmsContent.setDescriptionEn(getStringValue(row, "description_en"));
|
|
||||||
cmsContent.setIntroduce(getStringValue(row, "introduce"));
|
|
||||||
cmsContent.setIntroduceEn(getStringValue(row, "introduce_en"));
|
|
||||||
// create_time - 由系统生成,不需要读取
|
|
||||||
// update_time - 由系统生成,不需要读取
|
|
||||||
cmsContent.setDeleteFlag(getIntegerValue(row, "delete_flag"));
|
|
||||||
|
|
||||||
batchList.add(cmsContent);
|
|
||||||
|
|
||||||
// 达到批量大小或最后一行时,执行批量插入
|
|
||||||
if (batchList.size() >= BATCH_SIZE || rowIndex == totalRows - 1) {
|
|
||||||
if (!batchList.isEmpty()) {
|
|
||||||
// 执行批量插入
|
|
||||||
this.cmsContentMapper.batchInsert(batchList);
|
|
||||||
successCount += batchList.size();
|
|
||||||
batchList.clear(); // 清空批次
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转换为Map<String, Object>
|
||||||
|
Map<String, Object> row = new HashMap<>();
|
||||||
|
for (int i = 0; i < headerList.size() && i < rowList.size(); i++) {
|
||||||
|
row.put(headerList.get(i), rowList.get(i));
|
||||||
|
}
|
||||||
|
if (row.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
CmsContent cmsContent = new CmsContent();
|
||||||
|
|
||||||
|
// 设置创建时间和更新时间
|
||||||
|
cmsContent.setCreateTime(now);
|
||||||
|
cmsContent.setUpdateTime(now);
|
||||||
|
cmsContent.setCreateBy(createBy);
|
||||||
|
cmsContent.setUpdateBy(createBy);
|
||||||
|
|
||||||
|
// 设置默认值
|
||||||
|
cmsContent.setDeleteFlag(0);
|
||||||
|
cmsContent.setAuditStatus(1); // 默认草稿状态
|
||||||
|
cmsContent.setPublishStatus(1); // 默认未发布状态
|
||||||
|
cmsContent.setViewCount(0);
|
||||||
|
cmsContent.setLikeCount(0);
|
||||||
|
cmsContent.setCommentCount(0);
|
||||||
|
cmsContent.setSort(0);
|
||||||
|
|
||||||
|
// 读取Excel中的字段
|
||||||
|
// content_id - 数据库自动生成,不需要读取
|
||||||
|
cmsContent.setTitle(getStringValue(row, "title"));
|
||||||
|
cmsContent.setTitleEn(getStringValue(row, "title_en"));
|
||||||
|
cmsContent.setOrigin(getStringValue(row, "origin"));
|
||||||
|
|
||||||
|
// 处理tags字段,避免NullPointerException
|
||||||
|
String tags = getStringValue(row, "tags");
|
||||||
|
if (tags == null) {
|
||||||
|
System.err.println("第 " + rowIndex + " 行数据的tags字段为null,跳过该条记录");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cmsContent.setTags(tags.replaceAll(" ",""));
|
||||||
|
|
||||||
|
cmsContent.setIcon(getStringValue(row, "icon"));
|
||||||
|
cmsContent.setIsOfficial(getBooleanValue(row, "is_official"));
|
||||||
|
cmsContent.setPrice(getBigDecimalValue(row, "price"));
|
||||||
|
cmsContent.setLikeCount(getIntegerValue(row, "like_count"));
|
||||||
|
cmsContent.setShareCount(getIntegerValue(row, "share_count"));
|
||||||
|
cmsContent.setContentType(getIntegerValue(row, "content_type"));
|
||||||
|
cmsContent.setContent(getStringValue(row, "content"));
|
||||||
|
cmsContent.setContentEn(getStringValue(row, "content_en"));
|
||||||
|
cmsContent.setAuditStatus(getIntegerValue(row, "audit_status"));
|
||||||
|
cmsContent.setPublishStatus(getIntegerValue(row, "publish_status"));
|
||||||
|
cmsContent.setPublishTime(getDateValue(row, "publish_time"));
|
||||||
|
cmsContent.setViewCount(getIntegerValue(row, "view_count"));
|
||||||
|
cmsContent.setCommentCount(getIntegerValue(row, "comment_count"));
|
||||||
|
cmsContent.setIsPaid(getIntegerValue(row, "is_paid"));
|
||||||
|
cmsContent.setSupportPointsPay(getIntegerValue(row, "support_points_pay"));
|
||||||
|
cmsContent.setDescription(getStringValue(row, "description"));
|
||||||
|
cmsContent.setDescriptionEn(getStringValue(row, "description_en"));
|
||||||
|
cmsContent.setIntroduce(getStringValue(row, "introduce"));
|
||||||
|
cmsContent.setIntroduceEn(getStringValue(row, "introduce_en"));
|
||||||
|
// create_time - 由系统生成,不需要读取
|
||||||
|
// update_time - 由系统生成,不需要读取
|
||||||
|
cmsContent.setDeleteFlag(getIntegerValue(row, "delete_flag"));
|
||||||
|
|
||||||
|
batchList.add(cmsContent);
|
||||||
|
|
||||||
|
// 达到批量大小或最后一行时,执行批量插入
|
||||||
|
if (batchList.size() >= BATCH_SIZE || rowIndex == totalRows - 1) {
|
||||||
|
if (!batchList.isEmpty()) {
|
||||||
|
// 执行批量插入
|
||||||
|
this.cmsContentMapper.batchInsert(batchList);
|
||||||
|
successCount += batchList.size();
|
||||||
|
batchList.clear(); // 清空批次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("处理第 " + rowIndex + " 行数据时出错: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
// 跳过当前行,继续处理下一行
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
@ -942,4 +967,66 @@ public class CmsContentServiceImpl implements CmsContentService {
|
||||||
}
|
}
|
||||||
return cmsContent.getTitle();
|
return cmsContent.getTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int importFromPath(ImportPathDto importPathDto, String createBy) {
|
||||||
|
int totalSuccessCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查目录是否存在
|
||||||
|
File directory = new File(importPathDto.getFilePath());
|
||||||
|
if (!directory.exists() || !directory.isDirectory()) {
|
||||||
|
System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是追加模式,清空表
|
||||||
|
if (!importPathDto.isAppend()) {
|
||||||
|
cmsContentMapper.truncateTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取目录下所有 Excel 文件(排除 Office 临时锁定文件)
|
||||||
|
File[] files = directory.listFiles((dir, name) -> {
|
||||||
|
// 跳过以 ~$ 开头的临时锁定文件
|
||||||
|
if (name.startsWith("~$")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return name.endsWith(".xls") || name.endsWith(".xlsx");
|
||||||
|
});
|
||||||
|
if (files == null || files.length == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录文件总数
|
||||||
|
int totalFiles = files.length;
|
||||||
|
System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要导入");
|
||||||
|
|
||||||
|
// 遍历所有 Excel 文件并导入
|
||||||
|
for (int i = 0; i < files.length; i++) {
|
||||||
|
File file = files[i];
|
||||||
|
System.out.println("当前处理第 " + (i + 1) + " 个文件,文件名称是:" + file.getName());
|
||||||
|
try (FileInputStream fis = new FileInputStream(file)) {
|
||||||
|
// 读取文件内容到字节数组
|
||||||
|
byte[] fileBytes = new byte[(int) file.length()];
|
||||||
|
fis.read(fileBytes);
|
||||||
|
|
||||||
|
// 调用现有的 importFromExcel 方法进行导入
|
||||||
|
int successCount = importFromExcel(fileBytes, createBy);
|
||||||
|
totalSuccessCount += successCount;
|
||||||
|
System.out.println("第 " + (i + 1) + " 个文件导入成功,导入了 " + successCount + " 条记录");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("导入文件失败: " + file.getAbsolutePath());
|
||||||
|
e.printStackTrace();
|
||||||
|
// 单个文件导入失败不影响其他文件
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("导入完成,共处理 " + totalFiles + " 个文件,成功导入 " + totalSuccessCount + " 条记录");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("导入操作失败: " + importPathDto.getFilePath());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSuccessCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,8 +14,8 @@ import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.collection.CollectionUtil;
|
import cn.hutool.core.collection.CollectionUtil;
|
||||||
import com.alibaba.fastjson.JSON;
|
import com.alibaba.fastjson.JSON;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import com.kexue.skills.common.Assert;
|
||||||
import com.kexue.skills.common.util.HttpUtil;
|
import com.kexue.skills.common.util.HttpUtil;
|
||||||
import com.kexue.skills.config.DeepSeekConfig;
|
import com.kexue.skills.config.DeepSeekConfig;
|
||||||
import com.kexue.skills.config.GlmConfig;
|
import com.kexue.skills.config.GlmConfig;
|
||||||
|
|
@ -140,14 +141,14 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
StringBuilder tagsList = new StringBuilder();
|
StringBuilder tagsList = new StringBuilder();
|
||||||
for (int i = 0; i < tags.size(); i++) {
|
for (int i = 0; i < tags.size(); i++) {
|
||||||
CmsTag tag = tags.get(i);
|
CmsTag tag = tags.get(i);
|
||||||
tagsList.append(tag.getTagName());
|
tagsList.append(tag.getTagId()+"."+tag.getTagName());
|
||||||
if (i < tags.size() - 1) {
|
if (i < tags.size() - 1) {
|
||||||
tagsList.append(",");
|
tagsList.append(",");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建系统消息内容
|
// 构建系统消息内容
|
||||||
String systemContent = "你是一个专业的AI技能设计助手。请根据agent skills撰写规范,按照用户提出的主题描述及参考文件,生成这个skill的名称、描述,并从以下标签列表中选择一个或者多个标签:\"" + tagsList.toString() + "\",并简述这个skill的具体价值点。输出json格式,仅输出以上所提到的名称、描述、标签、价值点,节点名称分别为name、description、tags、value_point,节点内容以中文形式返回。请严格按照指定的JSON格式输出,仅包含要求的字段,以中文形式返回。";
|
String systemContent = "你是一个专业的AI技能设计助手。请根据agent skills撰写规范,按照用户提出的主题描述及参考文件,生成这个skill的名称、描述,并从以下标签列表中选择至少3个标签:\"" + tagsList.toString() + "\",tags只需要返回序号数组,并简述这个skill的具体价值点。输出json格式,仅输出以上所提到的名称、描述、标签、价值点,节点名称分别为name、description、tags、value_point,节点内容以中文形式返回。请严格按照指定的JSON格式输出,仅包含要求的字段,以中文形式返回。";
|
||||||
|
|
||||||
// 准备文件URL列表
|
// 准备文件URL列表
|
||||||
List<String> fileUrls = new ArrayList<>();
|
List<String> fileUrls = new ArrayList<>();
|
||||||
|
|
@ -198,6 +199,11 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
@Override
|
@Override
|
||||||
public CmsContent generate(SkillGenRequest request) {
|
public CmsContent generate(SkillGenRequest request) {
|
||||||
log.info("生成技能请求: {}", request);
|
log.info("生成技能请求: {}", request);
|
||||||
|
// 参数验证,确保每个参数都必须传递
|
||||||
|
Assert.notEmpty(request.getName(), "技能名称不能为空");
|
||||||
|
Assert.notEmpty(request.getDescription(), "技能描述不能为空");
|
||||||
|
Assert.notEmpty(request.getTags(), "技能标签不能为空");
|
||||||
|
Assert.notEmpty(request.getRequirement(), "技能摘要不能为空");
|
||||||
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";
|
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";
|
||||||
|
|
||||||
// 从数据库中读取cms_tag表的标签信息
|
// 从数据库中读取cms_tag表的标签信息
|
||||||
|
|
@ -223,7 +229,7 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String systemContent = "你是一个专业的AI技能设计助手。请基于用户提供的Skill名称、描述、标签,按照skills目录结构输出完整的skills内容,包括skills.md本体内容、scripts目录中的脚本等,并打包成一个YAML文件技能包。请严格遵循以下规范:1. 包含必需的文件和目录;2. 多行内容使用 | 字面块;3. 内容从行首开始;4. 空目录用 children: [] 表示;5. 文件内容要实际有用;6.输出一个完整的YAML文档,概要含核心属性:name、version、description、author、created、tags 等,其中 structure 为核心节点,用于描述 skill 包的文件目录结构;structure 下的每个节点均含基础属性:name、type、path、format、description,content 和 children 为互选属性,由 type 决定:type 为 file 时,展示文件具体内容在 content 字段;type 为 directory 时,展示子目录 / 文件节点在 children [] 数组中,无需其他额外说明。";
|
String systemContent = "你是一个专业的AI技能设计助手。请基于用户提供的Skill名称、描述、标签,按照skills目录结构输出完整的skills内容,包括skills.md本体内容、scripts目录中的脚本等,并打包成一个YAML文件技能包。请严格遵循以下规范:1. 包含必需的文件和目录;2. 多行内容使用 | 字面块;3. 内容从行首开始;4. 空目录用 children: [] 表示;5. 文件内容要实际有用;6.输出一个完整的YAML文档,概要含核心属性:name、version、description、author、created、tags 等,其中 structure 为核心节点,用于描述 skill 包的文件目录结构;structure 下的每个节点均含基础属性:name、type、path、format、description,content 和 children 为互选属性,由 type 决定:type 为 file 时,展示文件具体内容在 content 字段,必须保证content内容的缩进为2个字符;type 为 directory 时,展示子目录 / 文件节点在 children [] 数组中,无需其他额外说明。";
|
||||||
String userContent = "请根据以下Skill信息生成skills.md文档内容:Skill名称:SKILL_NAME,Skill描述:DESCRIPTION,Skill标签:TAGS ,摘要:SUMMARY。";
|
String userContent = "请根据以下Skill信息生成skills.md文档内容:Skill名称:SKILL_NAME,Skill描述:DESCRIPTION,Skill标签:TAGS ,摘要:SUMMARY。";
|
||||||
userContent = userContent.replace("SKILL_NAME", request.getName()).replace("DESCRIPTION", request.getDescription()).replace("TAGS", tagsList.toString()).replace("SUMMARY", request.getRequirement());
|
userContent = userContent.replace("SKILL_NAME", request.getName()).replace("DESCRIPTION", request.getDescription()).replace("TAGS", tagsList.toString()).replace("SUMMARY", request.getRequirement());
|
||||||
SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(),systemContent,userContent,deepSeekConfig.getChat().getTemperature(), 8192,"text");
|
SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(),systemContent,userContent,deepSeekConfig.getChat().getTemperature(), 8192,"text");
|
||||||
|
|
@ -467,7 +473,7 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
request.setName(defaultSkillName);
|
request.setName(defaultSkillName);
|
||||||
request.setDescription("未知");
|
request.setDescription("未知");
|
||||||
request.setIntroduce("未知");
|
request.setIntroduce("未知");
|
||||||
request.setTags(Arrays.asList("10001", "10002"));
|
request.setTags(Arrays.asList("1001", "1000"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 使用SkillGenRequest中的信息生成yaml
|
// 3. 使用SkillGenRequest中的信息生成yaml
|
||||||
|
|
@ -482,9 +488,19 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
|
|
||||||
// 4. 生成CmsContent对象
|
// 4. 生成CmsContent对象
|
||||||
CmsContent cmsContent = getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), "");
|
CmsContent cmsContent = getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), "");
|
||||||
List<CmsTag> list = tags.stream().filter(tag -> tag.getTagId() == Long.parseLong(cmsContent.getTags().split(",")[0])).toList();
|
if (Objects.nonNull(cmsContent.getTags())) {
|
||||||
if (CollectionUtil.isNotEmpty(list)){
|
String s = cmsContent.getTags().split(",")[0];
|
||||||
cmsContent.setIcon(list.get(0).getIcon());
|
long l = 1000;// 默认值
|
||||||
|
try {
|
||||||
|
l = Long.parseLong(s);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// 异常是因为大模型返回的tag带中文了,忽略
|
||||||
|
}
|
||||||
|
long finalL = l;
|
||||||
|
List<CmsTag> list = tags.stream().filter(tag -> tag.getTagId() == finalL).toList();
|
||||||
|
if (CollectionUtil.isNotEmpty(list)){
|
||||||
|
cmsContent.setIcon(list.get(0).getIcon());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 删除临时文件
|
// 删除临时文件
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
|
|
@ -518,7 +534,7 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
- skill的核心描述(description);
|
- skill的核心描述(description);
|
||||||
- 详细功能介绍(introduce);
|
- 详细功能介绍(introduce);
|
||||||
- 从指定标签列表中选取至少3个适配标签(tagList),标签范围:TAG_LIST;
|
- 从指定标签列表中选取至少3个适配标签(tagList),标签范围:TAG_LIST;
|
||||||
- 选择标签的时候只需要输出对应的标签编号
|
- 选择标签的时候只需要输出对应的标签编号,特别注意:只需要标签编号
|
||||||
请将最终结果以JSON格式输出,JSON结构必须包含:name(技能名称),description(技能描述)、introduce(功能介绍)、tagList(标签列表),无需额外说明,仅输出符合要求的JSON内容。
|
请将最终结果以JSON格式输出,JSON结构必须包含:name(技能名称),description(技能描述)、introduce(功能介绍)、tagList(标签列表),无需额外说明,仅输出符合要求的JSON内容。
|
||||||
""";
|
""";
|
||||||
systemContent = systemContent.replace("TAG_LIST", tagsList.toString());
|
systemContent = systemContent.replace("TAG_LIST", tagsList.toString());
|
||||||
|
|
@ -554,7 +570,7 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||||
if (skillJson.containsKey("tagList")) {
|
if (skillJson.containsKey("tagList")) {
|
||||||
request.setTags(skillJson.getJSONArray("tagList").toJavaList(String.class));
|
request.setTags(skillJson.getJSONArray("tagList").toJavaList(String.class));
|
||||||
} else {
|
} else {
|
||||||
request.setTags(Arrays.asList("10001", "10002"));
|
request.setTags(Arrays.asList("1001", "1002"));
|
||||||
}
|
}
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.kexue.skills.entity.dto.ContentPurchaseDto;
|
||||||
import com.kexue.skills.entity.dto.SessionDto;
|
import com.kexue.skills.entity.dto.SessionDto;
|
||||||
import com.kexue.skills.entity.dto.SysUserDto;
|
import com.kexue.skills.entity.dto.SysUserDto;
|
||||||
import com.kexue.skills.entity.request.*;
|
import com.kexue.skills.entity.request.*;
|
||||||
|
import com.kexue.skills.entity.request.SysUserUpdateDto;
|
||||||
import com.kexue.skills.mapper.*;
|
import com.kexue.skills.mapper.*;
|
||||||
import com.kexue.skills.service.SysUserService;
|
import com.kexue.skills.service.SysUserService;
|
||||||
import com.kexue.skills.utils.MD5Util;
|
import com.kexue.skills.utils.MD5Util;
|
||||||
|
|
@ -194,6 +195,69 @@ public class SysUserServiceImpl implements SysUserService {
|
||||||
return queryById(sysUser.getUserId());
|
return queryById(sysUser.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户数据
|
||||||
|
*
|
||||||
|
* @param sysUserUpdateDto 用户更新请求参数
|
||||||
|
* @return 实例对象
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@CacheInvalidate(name = "sysUser:", key = "#sysUserUpdateDto.userId")
|
||||||
|
@CacheInvalidate(name = "sysUser:username:", key = "#sysUserUpdateDto.userName")
|
||||||
|
public SysUser update(SysUserUpdateDto sysUserUpdateDto) {
|
||||||
|
if (Objects.nonNull(sysUserUpdateDto.getUserId())){
|
||||||
|
// 查询用户信息
|
||||||
|
SysUser sysUser = sysUserMapper.queryById(sysUserUpdateDto.getUserId());
|
||||||
|
Assert.notNull(sysUser, "用户不存在");
|
||||||
|
|
||||||
|
// 校验用户名是否已经存在
|
||||||
|
if (sysUserUpdateDto.getUserName() != null && !sysUserUpdateDto.getUserName().isEmpty()) {
|
||||||
|
SysUser existingUser = sysUserMapper.getByUsername(sysUserUpdateDto.getUserName());
|
||||||
|
Assert.isTrue(existingUser == null || existingUser.getUserId().equals(sysUserUpdateDto.getUserId()), "用户名已存在");
|
||||||
|
sysUser.setUserName(sysUserUpdateDto.getUserName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新邮箱
|
||||||
|
if (sysUserUpdateDto.getEmail() != null) {
|
||||||
|
sysUser.setEmail(sysUserUpdateDto.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新手机号
|
||||||
|
if (sysUserUpdateDto.getTel() != null) {
|
||||||
|
sysUser.setTel(sysUserUpdateDto.getTel());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
if (sysUserUpdateDto.getEnable() != null) {
|
||||||
|
sysUser.setEnable(sysUserUpdateDto.getEnable());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新密码(如果有)
|
||||||
|
if (sysUserUpdateDto.getPassword() != null && !sysUserUpdateDto.getPassword().isEmpty()) {
|
||||||
|
try {
|
||||||
|
// 假设客户端已经对密码进行了一次MD5加密,服务端使用双重加密验证
|
||||||
|
String encryptedPwd = MD5Util.doubleEncrypt(sysUserUpdateDto.getPassword(), sysUser.getSalt());
|
||||||
|
sysUser.setPwd(encryptedPwd);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
sysUserMapper.update(sysUser);
|
||||||
|
}else {
|
||||||
|
// 如果没有用户ID,创建新用户
|
||||||
|
SysUser sysUser = new SysUser();
|
||||||
|
sysUser.setUserName(sysUserUpdateDto.getUserName());
|
||||||
|
sysUser.setEmail(sysUserUpdateDto.getEmail());
|
||||||
|
sysUser.setTel(sysUserUpdateDto.getTel());
|
||||||
|
sysUser.setEnable(sysUserUpdateDto.getEnable());
|
||||||
|
sysUser.setPwd(sysUserUpdateDto.getPassword());
|
||||||
|
insert(sysUser);
|
||||||
|
}
|
||||||
|
return queryById(sysUserUpdateDto.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过主键删除数据
|
* 通过主键删除数据
|
||||||
*
|
*
|
||||||
|
|
@ -980,7 +1044,7 @@ public class SysUserServiceImpl implements SysUserService {
|
||||||
sessionDto.setNew(false);
|
sessionDto.setNew(false);
|
||||||
} else {
|
} else {
|
||||||
// 没有会话,创建新的会话ID
|
// 没有会话,创建新的会话ID
|
||||||
sessionId = java.util.UUID.randomUUID().toString().replaceAll("-","");
|
sessionId = java.util.UUID.randomUUID().toString();
|
||||||
sysUser.setSessionId(sessionId);
|
sysUser.setSessionId(sessionId);
|
||||||
sysUserMapper.update(sysUser);
|
sysUserMapper.update(sysUser);
|
||||||
sessionDto.setSessionId(sessionId);
|
sessionDto.setSessionId(sessionId);
|
||||||
|
|
|
||||||
|
|
@ -593,4 +593,9 @@
|
||||||
</if>
|
</if>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--清空表数据-->
|
||||||
|
<update id="truncateTable">
|
||||||
|
truncate table cms_content
|
||||||
|
</update>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue